//! 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(())
}