use std::path::PathBuf; use axum::body::Body; use axum::http::{Method, Request, StatusCode}; use fabro_config::ServerSettingsBuilder; use fabro_server::jwt_auth::{AuthMode, resolve_auth_mode_with_lookup}; use fabro_server::server::RouterOptions; use fabro_server::test_support::{ TEST_DEV_TOKEN, TEST_SESSION_SECRET, test_app_state, test_app_state_with_runtime_settings_and_options, }; use tower::ServiceExt; use crate::helpers::{ checked_response, response_json, response_status, response_text, settings_from_toml, }; fn dev_token_enabled_auth_mode() -> AuthMode { let resolved = ServerSettingsBuilder::from_toml( r#" _version = 1 [server.auth] methods = ["dev-token"] "#, ) .expect("settings should resolve") .server; resolve_auth_mode_with_lookup(&resolved, |name| match name { "SESSION_SECRET" => Some(TEST_SESSION_SECRET.to_string()), "FABRO_DEV_TOKEN" => Some(TEST_DEV_TOKEN.to_string()), _ => None, }) .expect("auth mode should resolve") } fn spa_fixture_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/spa") } #[tokio::test] async fn old_unversioned_routes_return_404() { let app = fabro_server::test_support::build_test_router(test_app_state()); let cases = [(Method::POST, "/completions")]; for (method, path) in cases { let req = Request::builder() .method(method.clone()) .uri(path) .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); response_status(response, StatusCode::NOT_FOUND, format!("{method} {path}")).await; } } #[tokio::test] async fn root_and_health_stay_at_root() { let app = fabro_server::test_support::build_test_router_with_options( test_app_state(), RouterOptions { static_asset_root: Some(spa_fixture_root()), ..RouterOptions::default() }, ); let root_req = Request::builder() .method("GET") .uri("/") .body(Body::empty()) .unwrap(); let root_response = app.clone().oneshot(root_req).await.unwrap(); let root_html = response_text(root_response, StatusCode::OK, "GET /").await; assert!(root_html.contains("
")); let health_req = Request::builder() .method("GET") .uri("/health") .body(Body::empty()) .unwrap(); let health_response = app.oneshot(health_req).await.unwrap(); let health_body = response_json(health_response, StatusCode::OK, "GET /health").await; assert_eq!(health_body["status"], "ok"); assert!( health_body.get("version").is_none(), "health endpoint should not expose version" ); } #[tokio::test] async fn install_routes_are_absent_in_normal_mode() { let app = fabro_server::test_support::build_test_router(test_app_state()); let response = app .oneshot( Request::builder() .method("GET") .uri("/install") .header("accept", "text/html,application/xhtml+xml") .body(Body::empty()) .unwrap(), ) .await .unwrap(); response_status(response, StatusCode::NOT_FOUND, "GET /install").await; } #[tokio::test] async fn api_v1_root_is_not_routed() { let app = fabro_server::test_support::build_test_router(test_app_state()); let req = Request::builder() .method("GET") .uri("/api/v1/") .body(Body::empty()) .unwrap(); let response = app.oneshot(req).await.unwrap(); response_status(response, StatusCode::NOT_FOUND, "GET /api/v1/").await; } #[tokio::test] async fn health_responds_at_versioned_path() { let app = fabro_server::test_support::build_test_router(test_app_state()); let req = Request::builder() .method("GET") .uri("/api/v1/health") .body(Body::empty()) .unwrap(); let response = app.oneshot(req).await.unwrap(); let body = response_json(response, StatusCode::OK, "GET /api/v1/health").await; assert_eq!(body["status"], "ok"); } #[tokio::test] async fn source_maps_are_not_served() { let app = fabro_server::test_support::build_test_router(test_app_state()); let request = Request::builder() .method("GET") .uri("/assets/entry-abc123.js.map") .body(Body::empty()) .unwrap(); let response = app.oneshot(request).await.unwrap(); response_status( response, StatusCode::NOT_FOUND, "GET /assets/entry-abc123.js.map", ) .await; } #[tokio::test] async fn web_enabled_serves_web_only_routes() { let auth_mode = dev_token_enabled_auth_mode(); let app = fabro_server::server::build_router_with_options( test_app_state(), &auth_mode, RouterOptions { static_asset_root: Some(spa_fixture_root()), ..RouterOptions::default() }, ); let auth_me_request = Request::builder() .method("GET") .uri("/api/v1/auth/me") .body(Body::empty()) .unwrap(); let auth_me_response = app.clone().oneshot(auth_me_request).await.unwrap(); response_status( auth_me_response, StatusCode::UNAUTHORIZED, "GET /api/v1/auth/me", ) .await; // Browser-style navigation to an SPA route falls back to index.html. let setup_request = Request::builder() .method("GET") .uri("/setup") .header("accept", "text/html,application/xhtml+xml") .body(Body::empty()) .unwrap(); let setup_response = app.clone().oneshot(setup_request).await.unwrap(); response_status(setup_response, StatusCode::OK, "GET /setup").await; // Same path without `Accept: text/html` (e.g. curl, fetch default) is // not a browser navigation and must not get the SPA HTML fallback. let setup_no_accept = Request::builder() .method("GET") .uri("/setup") .body(Body::empty()) .unwrap(); let setup_no_accept_response = app.clone().oneshot(setup_no_accept).await.unwrap(); response_status( setup_no_accept_response, StatusCode::NOT_FOUND, "GET /setup", ) .await; let setup_status_request = Request::builder() .method("GET") .uri("/api/v1/setup/status") .body(Body::empty()) .unwrap(); let setup_status_response = app.clone().oneshot(setup_status_request).await.unwrap(); response_status( setup_status_response, StatusCode::NOT_FOUND, "GET /api/v1/setup/status", ) .await; let setup_complete_request = Request::builder() .method("GET") .uri("/setup/complete") .body(Body::empty()) .unwrap(); let setup_complete_response = app.clone().oneshot(setup_complete_request).await.unwrap(); response_status( setup_complete_response, StatusCode::NOT_FOUND, "GET /setup/complete", ) .await; // Unregistered /api/* paths must always 404, even for browser-style // `Accept: text/html` requests — the SPA fallback never applies to // /api/. Guards against API typos silently rendering the UI shell. let api_miss = Request::builder() .method("GET") .uri("/api/v2/nonexistent") .header("accept", "text/html") .body(Body::empty()) .unwrap(); let api_miss_response = app.oneshot(api_miss).await.unwrap(); response_status( api_miss_response, StatusCode::NOT_FOUND, "GET /api/v2/nonexistent", ) .await; } #[tokio::test] async fn security_headers_are_applied_to_all_responses() { let app = fabro_server::test_support::build_test_router_with_options( test_app_state(), RouterOptions { static_asset_root: Some(spa_fixture_root()), ..RouterOptions::default() }, ); // Plain HTTP: HSTS must NOT be present. let api_response = checked_response( app.clone() .oneshot( Request::builder() .method("GET") .uri("/health") .body(Body::empty()) .unwrap(), ) .await .unwrap(), StatusCode::OK, "GET /health", ) .await; let headers = api_response.headers(); assert_eq!(headers.get("x-content-type-options").unwrap(), "nosniff"); assert_eq!(headers.get("x-frame-options").unwrap(), "DENY"); assert_eq!( headers.get("referrer-policy").unwrap(), "strict-origin-when-cross-origin" ); assert_eq!( headers.get("cross-origin-opener-policy").unwrap(), "same-origin" ); assert!(headers.contains_key("permissions-policy")); assert_eq!(headers.get("x-xss-protection").unwrap(), "0"); assert_eq!(headers.get("pragma").unwrap(), "no-cache"); assert!( !headers.contains_key("strict-transport-security"), "HSTS must not be emitted over plain HTTP" ); // X-Forwarded-Proto: https signals the request reached an HTTPS edge. let https_response = checked_response( app.clone() .oneshot( Request::builder() .method("GET") .uri("/health") .header("x-forwarded-proto", "https") .body(Body::empty()) .unwrap(), ) .await .unwrap(), StatusCode::OK, "GET /health with x-forwarded-proto=https", ) .await; assert_eq!( https_response .headers() .get("strict-transport-security") .unwrap(), "max-age=63072000; includeSubDomains" ); // SPA fallback path must also get the headers. let spa_response = checked_response( app.oneshot( Request::builder() .method("GET") .uri("/runs/abc123") .header("accept", "text/html") .body(Body::empty()) .unwrap(), ) .await .unwrap(), StatusCode::OK, "GET /runs/abc123", ) .await; assert_eq!( spa_response.headers().get("x-frame-options").unwrap(), "DENY" ); // Static files set their own cache-control (no-cache for index.html); // the middleware default must not stomp on it. assert_eq!( spa_response.headers().get("cache-control").unwrap(), "no-cache" ); // CSP must cover the sources the embedded SPA actually loads: same-origin // scripts, Google Fonts, WASM instantiation (viz-js), data: and blob: // images, blob: workers, and terminal WebSockets. // Inline hashes are optional because the current SPA ships only // external module scripts. let csp = spa_response .headers() .get("content-security-policy-report-only") .expect("CSP report-only header should be emitted") .to_str() .expect("CSP should be ASCII"); assert!( !spa_response .headers() .contains_key("content-security-policy"), "CSP should be report-only while tuning, not enforced" ); assert!(csp.contains("default-src 'self'"), "got: {csp}"); assert!(csp.contains("script-src 'self'"), "got: {csp}"); assert!(csp.contains("'wasm-unsafe-eval'"), "got: {csp}"); assert!( csp.contains("style-src 'self' https://fonts.googleapis.com 'unsafe-inline'"), "got: {csp}" ); assert!( csp.contains("font-src 'self' https://fonts.gstatic.com"), "got: {csp}" ); assert!( csp.contains("img-src 'self' data: blob: https://avatars.githubusercontent.com"), "got: {csp}" ); assert!(csp.contains("connect-src 'self' ws: wss:"), "got: {csp}"); assert!(csp.contains("worker-src 'self' blob:"), "got: {csp}"); assert!(csp.contains("frame-ancestors 'none'"), "got: {csp}"); assert!(csp.contains("object-src 'none'"), "got: {csp}"); } #[tokio::test] async fn web_disabled_returns_404_for_web_routes_and_keeps_machine_api() { let settings = settings_from_toml( r" _version = 1 [server.web] enabled = false ", ); let app = fabro_server::test_support::build_test_router_with_options( test_app_state_with_runtime_settings_and_options( settings.server_settings, settings.manifest_run_defaults, 5, ), RouterOptions { web_enabled: false, ..RouterOptions::default() }, ); for (method, path, body) in [ ("GET", "/", Body::empty()), ("GET", "/setup", Body::empty()), ("GET", "/runs/abc", Body::empty()), ("GET", "/auth/login/github", Body::empty()), ("GET", "/api/v1/auth/me", Body::empty()), ("GET", "/api/v1/setup/status", Body::empty()), ] { let request = Request::builder() .method(method) .uri(path) .header("content-type", "application/json") .body(body) .unwrap(); let response = app.clone().oneshot(request).await.unwrap(); response_status(response, StatusCode::NOT_FOUND, format!("{method} {path}")).await; } let settings_request = Request::builder() .method("GET") .uri("/api/v1/settings") .body(Body::empty()) .unwrap(); let settings_response = app.clone().oneshot(settings_request).await.unwrap(); response_status(settings_response, StatusCode::OK, "GET /api/v1/settings").await; let health_request = Request::builder() .method("GET") .uri("/health") .body(Body::empty()) .unwrap(); let health_response = app.oneshot(health_request).await.unwrap(); response_status(health_response, StatusCode::OK, "GET /health").await; } #[tokio::test] async fn web_disabled_ignores_demo_header_dispatch() { let settings = settings_from_toml( r" _version = 1 [server.web] enabled = false ", ); let app = fabro_server::test_support::build_test_router_with_options( test_app_state_with_runtime_settings_and_options( settings.server_settings, settings.manifest_run_defaults, 5, ), RouterOptions { web_enabled: false, ..RouterOptions::default() }, ); let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; let request = Request::builder() .method("GET") .uri(format!("/api/v1/runs/{run_id}")) .header("X-Fabro-Demo", "1") .body(Body::empty()) .unwrap(); let response = app.oneshot(request).await.unwrap(); response_status(response, StatusCode::NOT_FOUND, "GET /api/v1/runs/{id}").await; }