fabro/lib/apps/fabro-server/tests/it/api/routing.rs
2026-07-23 17:59:34 -04:00

474 lines
15 KiB
Rust

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("<div id=\"root\"></div>"));
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;
}