mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-29 01:42:21 +00:00
Add shared axum/reqwest response assertion helpers in fabro-test, migrate the Rust HTTP test surface to use them, and document the new rule in the testing strategy.
1420 lines
45 KiB
Rust
1420 lines
45 KiB
Rust
#![expect(
|
|
clippy::disallowed_methods,
|
|
reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
|
|
)]
|
|
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::time::Duration;
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode};
|
|
use fabro_config::{Storage, parse_settings_layer, resolve_server_from_file};
|
|
use fabro_model::Provider;
|
|
use fabro_server::install::{InstallAppState, build_install_router};
|
|
use fabro_util::{Home, dev_token};
|
|
use fabro_vault::Vault;
|
|
use httpmock::MockServer;
|
|
use tokio::time::sleep;
|
|
use tower::ServiceExt;
|
|
|
|
use crate::helpers::{checked_response, response_json, response_status, response_text};
|
|
|
|
async fn configure_token_install(app: &axum::Router, token: &str) {
|
|
let llm_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("PUT")
|
|
.uri("/install/llm")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"providers":[{"provider":"anthropic","api_key":"anthropic-test-key"}]}"#,
|
|
))
|
|
.expect("LLM install request should build"),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response_status(llm_response, StatusCode::NO_CONTENT, "PUT /install/llm").await;
|
|
|
|
let server_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("PUT")
|
|
.uri("/install/server")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"canonical_url":"https://fabro.example.com"}"#,
|
|
))
|
|
.expect("server install request should build"),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response_status(
|
|
server_response,
|
|
StatusCode::NO_CONTENT,
|
|
"PUT /install/server",
|
|
)
|
|
.await;
|
|
|
|
let github_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("PUT")
|
|
.uri("/install/github/token")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"token":"ghp_test_token","username":"brynary"}"#,
|
|
))
|
|
.expect("GitHub token install request should build"),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response_status(
|
|
github_response,
|
|
StatusCode::NO_CONTENT,
|
|
"PUT /install/github/token",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_router_isolated_from_normal_api_surface() {
|
|
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
|
|
|
let health_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri("/health")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let health_body = response_json(health_response, StatusCode::OK, "GET /health").await;
|
|
assert_eq!(health_body["status"], "ok");
|
|
assert_eq!(health_body["mode"], "install");
|
|
|
|
let root_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri("/")
|
|
.header("accept", "text/html,application/xhtml+xml")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let root_html = response_text(root_response, StatusCode::OK, "GET /").await;
|
|
assert!(
|
|
root_html.contains("__FABRO_MODE__ = \"install\""),
|
|
"install shell should mark the SPA boot mode"
|
|
);
|
|
|
|
let api_response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri("/api/v1/auth/me")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response_status(api_response, StatusCode::NOT_FOUND, "GET /api/v1/auth/me").await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_session_requires_valid_install_token() {
|
|
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
|
|
|
let unauthorized = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri("/install/session")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response_status(
|
|
unauthorized,
|
|
StatusCode::UNAUTHORIZED,
|
|
"GET /install/session",
|
|
)
|
|
.await;
|
|
|
|
let authorized = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri("/install/session")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.header("x-forwarded-proto", "https")
|
|
.header("x-forwarded-host", "fabro.example.com")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let body = response_json(authorized, StatusCode::OK, "GET /install/session").await;
|
|
assert_eq!(
|
|
body["prefill"]["canonical_url"],
|
|
"https://fabro.example.com"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_endpoints_reject_missing_and_wrong_tokens() {
|
|
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
|
let cases = [
|
|
("GET", "/install/session", None),
|
|
(
|
|
"POST",
|
|
"/install/llm/test",
|
|
Some(r#"{"provider":"anthropic","api_key":"anthropic-test-key"}"#),
|
|
),
|
|
(
|
|
"PUT",
|
|
"/install/llm",
|
|
Some(r#"{"providers":[{"provider":"anthropic","api_key":"anthropic-test-key"}]}"#),
|
|
),
|
|
(
|
|
"PUT",
|
|
"/install/server",
|
|
Some(r#"{"canonical_url":"https://fabro.example.com"}"#),
|
|
),
|
|
(
|
|
"POST",
|
|
"/install/github/token/test",
|
|
Some(r#"{"token":"ghp_test_token"}"#),
|
|
),
|
|
(
|
|
"PUT",
|
|
"/install/github/token",
|
|
Some(r#"{"token":"ghp_test_token","username":"octocat"}"#),
|
|
),
|
|
(
|
|
"POST",
|
|
"/install/github/app/manifest",
|
|
Some(
|
|
r#"{"owner":{"kind":"personal"},"app_name":"Fabro","allowed_username":"octocat"}"#,
|
|
),
|
|
),
|
|
("POST", "/install/finish", None),
|
|
];
|
|
|
|
for (method, path, body) in cases {
|
|
let mut missing_token = Request::builder().method(method).uri(path);
|
|
let mut wrong_token = Request::builder()
|
|
.method(method)
|
|
.uri(path)
|
|
.header("authorization", "Bearer wrong-token");
|
|
if body.is_some() {
|
|
missing_token = missing_token.header("content-type", "application/json");
|
|
wrong_token = wrong_token.header("content-type", "application/json");
|
|
}
|
|
|
|
let missing_token_response = app
|
|
.clone()
|
|
.oneshot(
|
|
missing_token
|
|
.body(Body::from(body.unwrap_or_default().to_string()))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response_status(
|
|
missing_token_response,
|
|
StatusCode::UNAUTHORIZED,
|
|
format!("{method} {path} without install token"),
|
|
)
|
|
.await;
|
|
|
|
let wrong_token_response = app
|
|
.clone()
|
|
.oneshot(
|
|
wrong_token
|
|
.body(Body::from(body.unwrap_or_default().to_string()))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response_status(
|
|
wrong_token_response,
|
|
StatusCode::UNAUTHORIZED,
|
|
format!("{method} {path} with wrong install token"),
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_endpoints_accept_query_token_when_authorization_header_is_wrong() {
|
|
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri("/install/session?token=test-install-token")
|
|
.header("authorization", "Bearer wrong-token")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
response_status(response, StatusCode::OK, "GET /install/session?token=...").await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn token_install_finish_persists_settings_env_and_vault() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let config_path = temp_dir.path().join("settings.toml");
|
|
let app = build_install_router(InstallAppState::for_test_with_paths(
|
|
"test-install-token",
|
|
temp_dir.path(),
|
|
&config_path,
|
|
))
|
|
.await;
|
|
|
|
let llm_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("PUT")
|
|
.uri("/install/llm")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"providers":[{"provider":"anthropic","api_key":"anthropic-test-key"}]}"#,
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response_status(llm_response, StatusCode::NO_CONTENT, "PUT /install/llm").await;
|
|
|
|
let server_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("PUT")
|
|
.uri("/install/server")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"canonical_url":"https://fabro.example.com"}"#,
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response_status(
|
|
server_response,
|
|
StatusCode::NO_CONTENT,
|
|
"PUT /install/server",
|
|
)
|
|
.await;
|
|
|
|
let github_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("PUT")
|
|
.uri("/install/github/token")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"token":"ghp_test_token","username":"brynary"}"#,
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response_status(
|
|
github_response,
|
|
StatusCode::NO_CONTENT,
|
|
"PUT /install/github/token",
|
|
)
|
|
.await;
|
|
|
|
let finish_response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/install/finish")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let finish_body = response_json(
|
|
finish_response,
|
|
StatusCode::ACCEPTED,
|
|
"POST /install/finish",
|
|
)
|
|
.await;
|
|
assert_eq!(finish_body["status"], "completing");
|
|
assert_eq!(finish_body["restart_url"], "https://fabro.example.com");
|
|
assert!(
|
|
finish_body["dev_token"]
|
|
.as_str()
|
|
.is_some_and(|value| !value.is_empty())
|
|
);
|
|
|
|
let settings = std::fs::read_to_string(&config_path).unwrap();
|
|
assert!(settings.contains("https://fabro.example.com"));
|
|
assert!(settings.contains("strategy = \"token\""));
|
|
let parsed = parse_settings_layer(&settings).expect("settings should parse");
|
|
let resolved = resolve_server_from_file(&parsed).expect("settings should resolve");
|
|
assert_eq!(
|
|
match resolved.listen {
|
|
fabro_types::settings::server::ServerListenSettings::Tcp { address, .. } => {
|
|
address.to_string()
|
|
}
|
|
fabro_types::settings::server::ServerListenSettings::Unix { .. } => {
|
|
String::new()
|
|
}
|
|
},
|
|
"127.0.0.1:32276"
|
|
);
|
|
|
|
let server_env = std::fs::read_to_string(
|
|
fabro_config::Storage::new(temp_dir.path())
|
|
.server_state()
|
|
.env_path(),
|
|
)
|
|
.unwrap();
|
|
assert!(server_env.contains("FABRO_JWT_PRIVATE_KEY="));
|
|
assert!(server_env.contains("FABRO_JWT_PUBLIC_KEY="));
|
|
assert!(server_env.contains("SESSION_SECRET="));
|
|
assert!(server_env.contains("FABRO_DEV_TOKEN="));
|
|
|
|
let vault = Vault::load(fabro_config::Storage::new(temp_dir.path()).secrets_path()).unwrap();
|
|
assert!(vault.get("anthropic").is_some());
|
|
assert_eq!(vault.get("GITHUB_TOKEN"), Some("ghp_test_token"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn app_install_finish_omits_dev_token_and_does_not_write_it() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let home_root = tempfile::tempdir().unwrap();
|
|
let home = Home::new(home_root.path().join(".fabro"));
|
|
let config_path = temp_dir.path().join("settings.toml");
|
|
let github_mock = MockServer::start_async().await;
|
|
github_mock
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/app-manifests/stub-code/conversions");
|
|
then.status(200)
|
|
.header("content-type", "application/json")
|
|
.body(
|
|
r#"{
|
|
"id": 42,
|
|
"slug": "fabro-test-app",
|
|
"client_id": "Iv1.test-client-id",
|
|
"client_secret": "test-client-secret",
|
|
"webhook_secret": "test-webhook-secret",
|
|
"pem": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n"
|
|
}"#,
|
|
);
|
|
})
|
|
.await;
|
|
let app = build_install_router(
|
|
InstallAppState::for_test_with_paths("test-install-token", temp_dir.path(), &config_path)
|
|
.with_home(home.clone())
|
|
.with_github_api_base_url(github_mock.url("")),
|
|
)
|
|
.await;
|
|
|
|
let llm_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("PUT")
|
|
.uri("/install/llm")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"providers":[{"provider":"anthropic","api_key":"anthropic-test-key"}]}"#,
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response_status(llm_response, StatusCode::NO_CONTENT, "PUT /install/llm").await;
|
|
|
|
let server_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("PUT")
|
|
.uri("/install/server")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"canonical_url":"https://fabro.example.com"}"#,
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response_status(
|
|
server_response,
|
|
StatusCode::NO_CONTENT,
|
|
"PUT /install/server",
|
|
)
|
|
.await;
|
|
|
|
let manifest_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/install/github/app/manifest")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"owner":{"kind":"personal"},"app_name":"Fabro Test","allowed_username":"octocat"}"#,
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let manifest_body = response_json(
|
|
manifest_response,
|
|
StatusCode::OK,
|
|
"POST /install/github/app/manifest",
|
|
)
|
|
.await;
|
|
let redirect_url = manifest_body["manifest"]["redirect_url"]
|
|
.as_str()
|
|
.expect("redirect_url should be present");
|
|
let redirect_uri = fabro_http::Url::parse(redirect_url).unwrap();
|
|
let state = redirect_uri
|
|
.query_pairs()
|
|
.find(|(key, _)| key == "state")
|
|
.map(|(_, value)| value.into_owned())
|
|
.expect("state should be embedded in redirect_url");
|
|
|
|
let callback_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri(format!(
|
|
"/install/github/app/redirect?code=stub-code&state={state}"
|
|
))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response_status(
|
|
callback_response,
|
|
StatusCode::FOUND,
|
|
"GET /install/github/app/redirect?code=stub-code&state=...",
|
|
)
|
|
.await;
|
|
|
|
let finish_response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/install/finish")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let finish_body = response_json(
|
|
finish_response,
|
|
StatusCode::ACCEPTED,
|
|
"POST /install/finish",
|
|
)
|
|
.await;
|
|
assert_eq!(finish_body["status"], "completing");
|
|
assert_eq!(finish_body["restart_url"], "https://fabro.example.com");
|
|
assert!(
|
|
finish_body.get("dev_token").is_none(),
|
|
"App installs must not expose a dev token"
|
|
);
|
|
|
|
let server_env =
|
|
std::fs::read_to_string(Storage::new(temp_dir.path()).server_state().env_path()).unwrap();
|
|
assert!(!server_env.contains("FABRO_DEV_TOKEN="));
|
|
|
|
assert!(
|
|
!home.dev_token_path().exists(),
|
|
"home dev token file should not be created for App installs"
|
|
);
|
|
assert!(
|
|
!Storage::new(temp_dir.path())
|
|
.server_state()
|
|
.dev_token_path()
|
|
.exists(),
|
|
"storage dev token file should not be created for App installs"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn token_install_finish_invokes_shutdown_callback_after_accepting() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let home_root = tempfile::tempdir().unwrap();
|
|
let config_path = temp_dir.path().join("settings.toml");
|
|
let callback_invoked = Arc::new(AtomicBool::new(false));
|
|
let callback_flag = Arc::clone(&callback_invoked);
|
|
let app = build_install_router(
|
|
InstallAppState::for_test_with_paths("test-install-token", temp_dir.path(), &config_path)
|
|
.with_home(Home::new(home_root.path().join(".fabro")))
|
|
.with_finish_callback(Arc::new(move || {
|
|
callback_flag.store(true, Ordering::Release);
|
|
})),
|
|
)
|
|
.await;
|
|
|
|
configure_token_install(&app, "test-install-token").await;
|
|
|
|
let finish_response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/install/finish")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response_status(
|
|
finish_response,
|
|
StatusCode::ACCEPTED,
|
|
"POST /install/finish",
|
|
)
|
|
.await;
|
|
assert!(!callback_invoked.load(Ordering::Acquire));
|
|
|
|
sleep(Duration::from_millis(650)).await;
|
|
assert!(callback_invoked.load(Ordering::Acquire));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_validation_endpoints_validate_credentials_and_github_token() {
|
|
let llm_mock = MockServer::start_async().await;
|
|
llm_mock
|
|
.mock_async(|when, then| {
|
|
when.method("GET").path("/v1/models");
|
|
then.status(200)
|
|
.header("content-type", "application/json")
|
|
.body(
|
|
serde_json::to_string(&serde_json::json!({
|
|
"data": [
|
|
{
|
|
"id": "claude-sonnet-4-5",
|
|
"type": "model"
|
|
}
|
|
]
|
|
}))
|
|
.unwrap(),
|
|
);
|
|
})
|
|
.await;
|
|
let github_mock = MockServer::start_async().await;
|
|
github_mock
|
|
.mock_async(|when, then| {
|
|
when.method("GET").path("/user");
|
|
then.status(200)
|
|
.header("content-type", "application/json")
|
|
.body(r#"{"login":"octocat"}"#);
|
|
})
|
|
.await;
|
|
|
|
let app = build_install_router(
|
|
InstallAppState::for_test("test-install-token")
|
|
.with_provider_base_url(Provider::Anthropic, format!("{}/v1", llm_mock.url("")))
|
|
.with_github_api_base_url(github_mock.url("")),
|
|
)
|
|
.await;
|
|
|
|
let llm_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/install/llm/test")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"provider":"anthropic","api_key":"anthropic-test-key"}"#,
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let llm_body = response_json(llm_response, StatusCode::OK, "POST /install/llm/test").await;
|
|
assert_eq!(llm_body["ok"], true);
|
|
|
|
let github_response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/install/github/token/test")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(r#"{"token":"ghp_test_token"}"#))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let github_body = response_json(
|
|
github_response,
|
|
StatusCode::OK,
|
|
"POST /install/github/token/test",
|
|
)
|
|
.await;
|
|
assert_eq!(github_body["username"], "octocat");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn github_app_manifest_round_trip_updates_install_session() {
|
|
let github_mock = MockServer::start_async().await;
|
|
let conversion_mock = github_mock
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/app-manifests/stub-code/conversions");
|
|
then.status(200)
|
|
.header("content-type", "application/json")
|
|
.body(
|
|
r#"{
|
|
"id": 42,
|
|
"slug": "fabro-test-app",
|
|
"client_id": "Iv1.test-client-id",
|
|
"client_secret": "test-client-secret",
|
|
"webhook_secret": "test-webhook-secret",
|
|
"pem": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n"
|
|
}"#,
|
|
);
|
|
})
|
|
.await;
|
|
let app = build_install_router(
|
|
InstallAppState::for_test("test-install-token")
|
|
.with_github_api_base_url(github_mock.url("")),
|
|
)
|
|
.await;
|
|
|
|
let server_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("PUT")
|
|
.uri("/install/server")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"canonical_url":"https://fabro.example.com"}"#,
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response_status(
|
|
server_response,
|
|
StatusCode::NO_CONTENT,
|
|
"PUT /install/server",
|
|
)
|
|
.await;
|
|
|
|
let manifest_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/install/github/app/manifest")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"owner":{"kind":"personal"},"app_name":"Fabro Test","allowed_username":"octocat"}"#,
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let manifest_body = response_json(
|
|
manifest_response,
|
|
StatusCode::OK,
|
|
"POST /install/github/app/manifest",
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
manifest_body["github_form_action"],
|
|
"https://github.com/settings/apps/new"
|
|
);
|
|
assert_eq!(
|
|
manifest_body["manifest"]["callback_urls"][0],
|
|
"https://fabro.example.com/auth/callback/github"
|
|
);
|
|
|
|
let redirect_url = manifest_body["manifest"]["redirect_url"]
|
|
.as_str()
|
|
.expect("redirect_url should be present");
|
|
let redirect_uri = fabro_http::Url::parse(redirect_url).unwrap();
|
|
let state = redirect_uri
|
|
.query_pairs()
|
|
.find(|(key, _)| key == "state")
|
|
.map(|(_, value)| value.into_owned())
|
|
.expect("state should be embedded in redirect_url");
|
|
|
|
let callback_response = checked_response(
|
|
app.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri(format!(
|
|
"/install/github/app/redirect?code=stub-code&state={state}"
|
|
))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap(),
|
|
StatusCode::FOUND,
|
|
"GET /install/github/app/redirect?code=stub-code&state=...",
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
callback_response
|
|
.headers()
|
|
.get("location")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("/install/github/done?token=test-install-token")
|
|
);
|
|
conversion_mock.assert_async().await;
|
|
|
|
let session_response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri("/install/session")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let session_body =
|
|
response_json(session_response, StatusCode::OK, "GET /install/session").await;
|
|
assert_eq!(session_body["github"]["strategy"], "app");
|
|
assert_eq!(session_body["github"]["slug"], "fabro-test-app");
|
|
assert_eq!(session_body["github"]["allowed_username"], "octocat");
|
|
assert!(
|
|
session_body["completed_steps"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|value| value == "github")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn github_app_manifest_rejects_retry_while_pending_and_preserves_prior_token_strategy() {
|
|
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
|
|
|
let server_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("PUT")
|
|
.uri("/install/server")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"canonical_url":"https://fabro.example.com"}"#,
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response_status(
|
|
server_response,
|
|
StatusCode::NO_CONTENT,
|
|
"PUT /install/server",
|
|
)
|
|
.await;
|
|
|
|
let github_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("PUT")
|
|
.uri("/install/github/token")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"token":"ghp_test_token","username":"brynary"}"#,
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response_status(
|
|
github_response,
|
|
StatusCode::NO_CONTENT,
|
|
"PUT /install/github/token",
|
|
)
|
|
.await;
|
|
|
|
let manifest_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/install/github/app/manifest")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"owner":{"kind":"personal"},"app_name":"Fabro Test","allowed_username":"octocat"}"#,
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response_status(
|
|
manifest_response,
|
|
StatusCode::OK,
|
|
"POST /install/github/app/manifest",
|
|
)
|
|
.await;
|
|
|
|
let session_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri("/install/session")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let session_body =
|
|
response_json(session_response, StatusCode::OK, "GET /install/session").await;
|
|
assert_eq!(session_body["github"]["strategy"], "token");
|
|
assert_eq!(session_body["github"]["username"], "brynary");
|
|
assert!(
|
|
session_body["completed_steps"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|value| value == "github")
|
|
);
|
|
|
|
let retry_response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/install/github/app/manifest")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"owner":{"kind":"personal"},"app_name":"Fabro Retry","allowed_username":"octocat"}"#,
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let retry_body = response_json(
|
|
retry_response,
|
|
StatusCode::CONFLICT,
|
|
"POST /install/github/app/manifest",
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
retry_body["errors"][0]["detail"],
|
|
"GitHub App setup is already pending; finish it or wait for it to expire."
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn github_app_redirect_rejects_invalid_or_missing_state_without_mutating_session() {
|
|
let github_mock = MockServer::start_async().await;
|
|
let conversion_mock = github_mock
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/app-manifests/stub-code/conversions");
|
|
then.status(200)
|
|
.header("content-type", "application/json")
|
|
.body(
|
|
r#"{
|
|
"id": 42,
|
|
"slug": "fabro-test-app",
|
|
"client_id": "Iv1.test-client-id",
|
|
"client_secret": "test-client-secret",
|
|
"webhook_secret": "test-webhook-secret",
|
|
"pem": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n"
|
|
}"#,
|
|
);
|
|
})
|
|
.await;
|
|
let app = build_install_router(
|
|
InstallAppState::for_test("test-install-token")
|
|
.with_github_api_base_url(github_mock.url("")),
|
|
)
|
|
.await;
|
|
|
|
let server_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("PUT")
|
|
.uri("/install/server")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"canonical_url":"https://fabro.example.com"}"#,
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response_status(
|
|
server_response,
|
|
StatusCode::NO_CONTENT,
|
|
"PUT /install/server",
|
|
)
|
|
.await;
|
|
|
|
let manifest_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/install/github/app/manifest")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"owner":{"kind":"personal"},"app_name":"Fabro Test","allowed_username":"octocat"}"#,
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let manifest_body = response_json(
|
|
manifest_response,
|
|
StatusCode::OK,
|
|
"POST /install/github/app/manifest",
|
|
)
|
|
.await;
|
|
let redirect_url = manifest_body["manifest"]["redirect_url"]
|
|
.as_str()
|
|
.expect("redirect_url should be present");
|
|
let redirect_uri = fabro_http::Url::parse(redirect_url).unwrap();
|
|
let state = redirect_uri
|
|
.query_pairs()
|
|
.find(|(key, _)| key == "state")
|
|
.map(|(_, value)| value.into_owned())
|
|
.expect("state should be embedded in redirect_url");
|
|
|
|
let wrong_state_response = checked_response(
|
|
app.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri("/install/github/app/redirect?code=stub-code&state=wrong-state")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap(),
|
|
StatusCode::FOUND,
|
|
"GET /install/github/app/redirect?code=stub-code&state=wrong-state",
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
wrong_state_response
|
|
.headers()
|
|
.get("location")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("/install/github?token=test-install-token&error=invalid-install-github-app-state")
|
|
);
|
|
conversion_mock.assert_calls_async(0).await;
|
|
|
|
let session_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri("/install/session")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let session_body =
|
|
response_json(session_response, StatusCode::OK, "GET /install/session").await;
|
|
assert!(session_body["github"].is_null());
|
|
assert!(
|
|
!session_body["completed_steps"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|value| value == "github")
|
|
);
|
|
|
|
let missing_state_response = checked_response(
|
|
app.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri("/install/github/app/redirect?code=stub-code")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap(),
|
|
StatusCode::FOUND,
|
|
"GET /install/github/app/redirect?code=stub-code",
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
missing_state_response
|
|
.headers()
|
|
.get("location")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("/install/github?token=test-install-token&error=missing-install-github-app-state")
|
|
);
|
|
conversion_mock.assert_calls_async(0).await;
|
|
|
|
let valid_state_response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri(format!(
|
|
"/install/github/app/redirect?code=stub-code&state={state}"
|
|
))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response_status(
|
|
valid_state_response,
|
|
StatusCode::FOUND,
|
|
"GET /install/github/app/redirect?code=stub-code&state=...",
|
|
)
|
|
.await;
|
|
conversion_mock.assert_calls_async(1).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn github_app_redirect_exchange_failure_returns_to_wizard_and_keeps_pending_state() {
|
|
let github_mock = MockServer::start_async().await;
|
|
let conversion_mock = github_mock
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/app-manifests/stub-code/conversions");
|
|
then.status(502).body("upstream exploded");
|
|
})
|
|
.await;
|
|
let app = build_install_router(
|
|
InstallAppState::for_test("test-install-token")
|
|
.with_github_api_base_url(github_mock.url("")),
|
|
)
|
|
.await;
|
|
|
|
let server_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("PUT")
|
|
.uri("/install/server")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"canonical_url":"https://fabro.example.com"}"#,
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response_status(
|
|
server_response,
|
|
StatusCode::NO_CONTENT,
|
|
"PUT /install/server",
|
|
)
|
|
.await;
|
|
|
|
let manifest_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/install/github/app/manifest")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"owner":{"kind":"personal"},"app_name":"Fabro Test","allowed_username":"octocat"}"#,
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let manifest_body = response_json(
|
|
manifest_response,
|
|
StatusCode::OK,
|
|
"POST /install/github/app/manifest",
|
|
)
|
|
.await;
|
|
let redirect_url = manifest_body["manifest"]["redirect_url"]
|
|
.as_str()
|
|
.expect("redirect_url should be present");
|
|
let redirect_uri = fabro_http::Url::parse(redirect_url).unwrap();
|
|
let state = redirect_uri
|
|
.query_pairs()
|
|
.find(|(key, _)| key == "state")
|
|
.map(|(_, value)| value.into_owned())
|
|
.expect("state should be embedded in redirect_url");
|
|
|
|
let callback_response = checked_response(
|
|
app.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri(format!(
|
|
"/install/github/app/redirect?code=stub-code&state={state}"
|
|
))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap(),
|
|
StatusCode::FOUND,
|
|
"GET /install/github/app/redirect?code=stub-code&state=...",
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
callback_response
|
|
.headers()
|
|
.get("location")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some(
|
|
"/install/github?token=test-install-token&error=github-app-manifest-conversion-failed"
|
|
)
|
|
);
|
|
conversion_mock.assert_calls_async(1).await;
|
|
|
|
let retry_response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri(format!(
|
|
"/install/github/app/redirect?code=stub-code&state={state}"
|
|
))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response_status(
|
|
retry_response,
|
|
StatusCode::FOUND,
|
|
"GET /install/github/app/redirect?code=stub-code&state=...",
|
|
)
|
|
.await;
|
|
conversion_mock.assert_calls_async(2).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_server_rejects_trailing_slash_canonical_urls() {
|
|
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("PUT")
|
|
.uri("/install/server")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"canonical_url":"https://fabro.example.com/"}"#,
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let body = response_json(
|
|
response,
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"PUT /install/server",
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
body["errors"][0]["detail"],
|
|
"canonical_url must not end with a trailing slash"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_finish_failure_restores_settings_and_vault_but_leaves_env_keys() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let home_root = tempfile::tempdir().unwrap();
|
|
let config_path = temp_dir.path().join("settings.toml");
|
|
std::fs::write(&config_path, "_version = 1\n[project]\nname = \"keep\"\n").unwrap();
|
|
|
|
let storage = Storage::new(temp_dir.path());
|
|
let vault_path = storage.secrets_path();
|
|
std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
|
|
std::fs::write(&vault_path, "{ not valid json").unwrap();
|
|
let callback_invoked = Arc::new(AtomicBool::new(false));
|
|
let callback_flag = Arc::clone(&callback_invoked);
|
|
|
|
let app = build_install_router(
|
|
InstallAppState::for_test_with_paths("test-install-token", temp_dir.path(), &config_path)
|
|
.with_home(Home::new(home_root.path().join(".fabro")))
|
|
.with_finish_callback(Arc::new(move || {
|
|
callback_flag.store(true, Ordering::Release);
|
|
})),
|
|
)
|
|
.await;
|
|
|
|
configure_token_install(&app, "test-install-token").await;
|
|
|
|
let finish_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/install/finish")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let finish_body = response_json(
|
|
finish_response,
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"POST /install/finish",
|
|
)
|
|
.await;
|
|
assert!(
|
|
finish_body["errors"][0]["detail"]
|
|
.as_str()
|
|
.is_some_and(|value| value.contains("persisting install outputs directly"))
|
|
);
|
|
let leftover_env_keys = finish_body["leftover_env_keys"]
|
|
.as_array()
|
|
.expect("leftover_env_keys should be present");
|
|
assert!(
|
|
leftover_env_keys
|
|
.iter()
|
|
.any(|value| value == "SESSION_SECRET")
|
|
);
|
|
assert!(
|
|
leftover_env_keys
|
|
.iter()
|
|
.any(|value| value == "FABRO_DEV_TOKEN")
|
|
);
|
|
|
|
assert_eq!(
|
|
std::fs::read_to_string(&config_path).unwrap(),
|
|
"_version = 1\n[project]\nname = \"keep\"\n"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_to_string(&vault_path).unwrap(),
|
|
"{ not valid json"
|
|
);
|
|
|
|
let server_env = std::fs::read_to_string(storage.server_state().env_path()).unwrap();
|
|
assert!(server_env.contains("SESSION_SECRET="));
|
|
assert!(server_env.contains("FABRO_DEV_TOKEN="));
|
|
assert!(!callback_invoked.load(Ordering::Acquire));
|
|
|
|
let session_response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri("/install/session")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let session_body =
|
|
response_json(session_response, StatusCode::OK, "GET /install/session").await;
|
|
assert!(
|
|
session_body["completed_steps"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|value| value == "github")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_finish_failure_leaves_home_dev_token_mirror_written() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let home_root = tempfile::tempdir().unwrap();
|
|
let home = Home::new(home_root.path().join(".fabro"));
|
|
let config_path = temp_dir.path().join("settings.toml");
|
|
std::fs::write(&config_path, "_version = 1\n[project]\nname = \"keep\"\n").unwrap();
|
|
|
|
let storage = Storage::new(temp_dir.path());
|
|
let vault_path = storage.secrets_path();
|
|
std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
|
|
std::fs::write(&vault_path, "{ not valid json").unwrap();
|
|
|
|
let app = build_install_router(
|
|
InstallAppState::for_test_with_paths("test-install-token", temp_dir.path(), &config_path)
|
|
.with_home(home.clone()),
|
|
)
|
|
.await;
|
|
|
|
configure_token_install(&app, "test-install-token").await;
|
|
|
|
let finish_response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/install/finish")
|
|
.header("authorization", "Bearer test-install-token")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response_status(
|
|
finish_response,
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"POST /install/finish",
|
|
)
|
|
.await;
|
|
|
|
let home_dev_token = dev_token::read_dev_token_file(&home.dev_token_path())
|
|
.expect("home dev token should exist");
|
|
let storage_dev_token =
|
|
dev_token::read_dev_token_file(&storage.server_state().dev_token_path())
|
|
.expect("storage dev token should exist");
|
|
assert_eq!(home_dev_token, storage_dev_token);
|
|
|
|
let server_env = std::fs::read_to_string(storage.server_state().env_path()).unwrap();
|
|
assert!(server_env.contains(&format!("FABRO_DEV_TOKEN={home_dev_token}")));
|
|
}
|