//! Integration tests for static SPA serving and `GET /api/config`. //! //! These drive the [`Router`] through `tower::ServiceExt::oneshot`: no ports //! are bound and no servers start. The SPA fixture and the config files live //! in a `tempfile` directory, never in the real `config/` or `dist/`. use std::collections::HashMap; use axum::body::Body; use axum::http::{Request, StatusCode}; use server::app::build_app; use server::config::ServerConfig; use server::state::AppState; use tower::ServiceExt as _; /// Build an app backed by a temp dist dir (with `index.html` + one asset) /// and temp config files loaded through the real layered loader. fn fixture_app() -> (axum::Router, tempfile::TempDir) { let dir = tempfile::tempdir().expect("temp dir is creatable"); let dist = dir.path().join("dist"); std::fs::create_dir_all(dist.join("assets")).expect("fixture dirs are creatable"); std::fs::write( dist.join("index.html"), "spa-shell", ) .expect("fixture index is writable"); std::fs::write(dist.join("assets/app.js"), "console.log('app');") .expect("fixture asset is writable"); let config_json = format!( r#"{{ "server": {{ "dist_dir": {} }}, "oauth": {{ "issuer": "https://as.example", "redirect_uri": "http://127.0.0.1:3000/callback", "scope": "demo.read", "capabilities": {{ "par": true, "dpop": true, "introspection": true }} }} }}"#, serde_json::to_string(&dist).expect("path serializes") ); std::fs::write(dir.path().join("config.json"), config_json).expect("config is writable"); std::fs::write( dir.path().join("client.json"), r#"{"client_id": "demo-client", "client_secret": "FILE-S3CRET"}"#, ) .expect("client fixture is writable"); let empty: HashMap<&str, &str> = HashMap::new(); let config = ServerConfig::load_from_paths_with( Some(&dir.path().join("config.json")), Some(&dir.path().join("client.json")), &|key| empty.get(key).map(|value| (*value).to_owned()), ) .expect("fixture config loads"); (build_app(AppState::new(config)), dir) } async fn get( app: axum::Router, uri: &str, ) -> Result, Box> { Ok( app .oneshot(Request::builder().uri(uri).body(Body::empty())?) .await?, ) } async fn body_text( response: axum::http::Response, ) -> Result> { let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await?; Ok(std::str::from_utf8(&bytes)?.to_owned()) } /// Build an app whose profile points at `issuer` with no static capabilities /// (all false), so any `true` flag in `/api/config` must come from discovery. fn discovery_fixture_app( issuer: &str, lookup: &dyn Fn(&str) -> Option, ) -> (axum::Router, tempfile::TempDir) { let dir = tempfile::tempdir().expect("temp dir is creatable"); let dist = dir.path().join("dist"); std::fs::create_dir_all(&dist).expect("fixture dist is creatable"); std::fs::write( dist.join("index.html"), "spa-shell", ) .expect("fixture index is writable"); let config_json = format!( r#"{{ "server": {{ "dist_dir": {} }}, "oauth": {{ "issuer": "{issuer}", "redirect_uri": "http://127.0.0.1:3000/callback", "scope": "demo.read" }} }}"#, serde_json::to_string(&dist).expect("path serializes") ); std::fs::write(dir.path().join("config.json"), config_json).expect("config is writable"); std::fs::write( dir.path().join("client.json"), r#"{"client_id": "demo-client", "client_secret": "FILE-S3CRET"}"#, ) .expect("client fixture is writable"); let config = ServerConfig::load_from_paths_with( Some(&dir.path().join("config.json")), Some(&dir.path().join("client.json")), lookup, ) .expect("fixture config loads"); ( build_app(AppState::with_client(config, reqwest::Client::new())), dir, ) } #[tokio::test] async fn root_serves_index_html_with_static_csp() -> Result<(), Box> { let (app, _dir) = fixture_app(); let response = get(app, "/").await?; assert_eq!(response.status(), StatusCode::OK); let csp = response .headers() .get("content-security-policy") .and_then(|value| value.to_str().ok()) .unwrap_or_default() .to_owned(); assert!(csp.contains("script-src 'self'"), "unexpected CSP: {csp}"); assert!( csp.contains("'wasm-unsafe-eval'"), "WASM must be allowed for the Leptos app to boot: {csp}" ); assert!( !csp.contains("unsafe-inline"), "inline scripts forbidden: {csp}" ); assert!( !csp.contains("'unsafe-eval'"), "full eval forbidden (wasm-unsafe-eval only): {csp}" ); assert_eq!( response .headers() .get("x-content-type-options") .and_then(|value| value.to_str().ok()), Some("nosniff") ); let body = body_text(response).await?; assert!(body.contains("spa-shell"), "unexpected body: {body:?}"); Ok(()) } #[tokio::test] async fn missing_asset_is_404_not_fallback() -> Result<(), Box> { let (app, _dir) = fixture_app(); let response = get(app, "/assets/missing.js").await?; assert_eq!(response.status(), StatusCode::NOT_FOUND); Ok(()) } #[tokio::test] async fn existing_asset_is_served() -> Result<(), Box> { let (app, _dir) = fixture_app(); let response = get(app, "/assets/app.js").await?; assert_eq!(response.status(), StatusCode::OK); let content_type = response .headers() .get("content-type") .and_then(|value| value.to_str().ok()) .unwrap_or_default() .to_owned(); assert!( content_type.contains("javascript"), "unexpected MIME: {content_type}" ); Ok(()) } #[tokio::test] async fn client_route_spa_falls_back_to_index() -> Result<(), Box> { let (app, _dir) = fixture_app(); let response = get(app, "/dashboard").await?; assert_eq!(response.status(), StatusCode::OK); let body = body_text(response).await?; assert!(body.contains("spa-shell"), "unexpected body: {body:?}"); Ok(()) } #[tokio::test] async fn api_config_returns_public_display_config_only() -> Result<(), Box> { let (app, _dir) = fixture_app(); let response = get(app, "/api/config").await?; assert_eq!(response.status(), StatusCode::OK); let csp = response .headers() .get("content-security-policy") .and_then(|value| value.to_str().ok()) .unwrap_or_default() .to_owned(); assert!( csp.contains("frame-ancestors 'none'"), "unexpected API CSP: {csp}" ); assert_eq!( response .headers() .get("x-content-type-options") .and_then(|value| value.to_str().ok()), Some("nosniff") ); assert_eq!( response .headers() .get("referrer-policy") .and_then(|value| value.to_str().ok()), Some("same-origin") ); let body = body_text(response).await?; let json: serde_json::Value = serde_json::from_str(&body)?; assert_eq!(json["issuer"], "https://as.example"); assert_eq!(json["client_id"], "demo-client"); assert_eq!(json["scope"], "demo.read"); assert_eq!(json["provider_logout_url"], "https://as.example/logout"); assert_eq!(json["capabilities"]["par"], true); assert_eq!(json["capabilities"]["userinfo"], false); for forbidden in ["S3CRET", "client_secret", "dpop_key", "private_key"] { assert!(!body.contains(forbidden), "leak of {forbidden} in {body}"); } Ok(()) } #[tokio::test] async fn api_config_derives_capabilities_from_discovery() -> Result<(), Box> { use wiremock::{ Mock, MockServer, ResponseTemplate, matchers::{method, path}, }; let fake = MockServer::start().await; let issuer = fake.uri(); let doc = serde_json::json!({ "issuer": issuer, "authorization_endpoint": format!("{issuer}/authorize"), "token_endpoint": format!("{issuer}/token"), "pushed_authorization_request_endpoint": format!("{issuer}/par"), "introspection_endpoint": format!("{issuer}/introspect"), "registration_endpoint": format!("{issuer}/register"), "grant_types_supported": ["authorization_code", "refresh_token"], "dpop_signing_alg_values_supported": ["ES256"], }); Mock::given(method("GET")) .and(path("/.well-known/oauth-authorization-server")) .respond_with(ResponseTemplate::new(200).set_body_json(&doc)) .mount(&fake) .await; let (app, _dir) = discovery_fixture_app(&issuer, &|_| None); let response = get(app, "/api/config").await?; assert_eq!(response.status(), StatusCode::OK); let body = body_text(response).await?; let json: serde_json::Value = serde_json::from_str(&body)?; assert_eq!(json["capabilities"]["par"], true); assert_eq!(json["capabilities"]["dpop"], true); assert_eq!(json["capabilities"]["introspection"], true); assert_eq!(json["capabilities"]["userinfo"], false); assert_eq!(json["start_url"], "/oauth/start"); assert_eq!(json["provider_logout_url"], format!("{issuer}/logout")); for forbidden in ["S3CRET", "client_secret", "dpop_key", "private_key"] { assert!(!body.contains(forbidden), "leak of {forbidden} in {body}"); } Ok(()) } #[tokio::test] async fn api_config_falls_back_to_profile_caps_when_discovery_is_down() -> Result<(), Box> { let lookup = |key: &str| { (key == server::config::START_URL_ENV).then(|| "http://127.0.0.1:3001/oauth/start".to_owned()) }; let (app, _dir) = discovery_fixture_app("http://127.0.0.1:9", &lookup); let response = get(app, "/api/config").await?; assert_eq!(response.status(), StatusCode::OK); let body = body_text(response).await?; let json: serde_json::Value = serde_json::from_str(&body)?; assert_eq!(json["capabilities"]["par"], false); assert_eq!(json["capabilities"]["dpop"], false); assert_eq!(json["capabilities"]["introspection"], false); assert_eq!(json["start_url"], "http://127.0.0.1:3001/oauth/start"); Ok(()) } #[tokio::test] async fn path_traversal_does_not_escape_dist_root() -> Result<(), Box> { let (app, _dir) = fixture_app(); let response = get(app, "/../etc/passwd").await?; // The stack normalizes `/../etc/passwd` to the extensionless `/etc/passwd`, // which hits the SPA fallback: safe as long as the body is the app shell // and never the target file. let body = body_text(response).await?; assert!(!body.contains("root:"), "traversal escaped dist root"); assert!(body.contains("spa-shell"), "unexpected body: {body:?}"); let (app, _dir) = fixture_app(); let encoded = get(app, "/assets/%2e%2e/%2e%2e/etc/passwd").await?; // The fallback only ever serves the app shell, so an encoded traversal // cannot escape either; assert the body, not the status. let encoded_body = body_text(encoded).await?; assert!(!encoded_body.contains("root:"), "encoded traversal escaped"); assert!( encoded_body.contains("spa-shell"), "unexpected body: {encoded_body:?}" ); Ok(()) }