| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321 |
- //! 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"),
- "<html><body>spa-shell</body></html>",
- )
- .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<axum::http::Response<Body>, Box<dyn std::error::Error>> {
- Ok(
- app
- .oneshot(Request::builder().uri(uri).body(Body::empty())?)
- .await?,
- )
- }
- async fn body_text(
- response: axum::http::Response<Body>,
- ) -> Result<String, Box<dyn std::error::Error>> {
- 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<String>,
- ) -> (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"),
- "<html><body>spa-shell</body></html>",
- )
- .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<dyn std::error::Error>> {
- 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<dyn std::error::Error>> {
- 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<dyn std::error::Error>> {
- 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<dyn std::error::Error>> {
- 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<dyn std::error::Error>> {
- 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<dyn std::error::Error>>
- {
- 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<dyn std::error::Error>> {
- 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<dyn std::error::Error>> {
- 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(())
- }
|