fabro/lib/crates/fabro-server/tests/it/api/install.rs
Bryan Helmkamp 9c3c66c59a
test(http): improve HTTP test failure diagnostics
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.
2026-04-20 08:06:14 -04:00

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}")));
}