//! SPA serving (dev mode): static assets, client-route fallback, API 404s, //! method checks. Uses $DOVENEST_DIST (the dev-mode asset directory) via a //! tempdir so the test is independent of any built frontend. //! //! Disabled under `--features embedded` (assets come from rust-embed, not //! $DOVENEST_DIST) — see `api_embedded.rs` for the production variant. #![cfg(not(feature = "embedded"))] mod common; use axum::http::StatusCode; use common::*; #[tokio::test] async fn spa_fallback_and_api_guards() { let env = Env::new().await; let c = Client::new(env.app.clone()); // Unknown /api/ endpoints get a plain 404, not the SPA page. let r = c.get("/api/unknown/endpoint").await; assert_eq!(r.status, StatusCode::NOT_FOUND); assert_eq!(r.text(), "unknown endpoint"); // Non-GET to a non-API path → 405. let r = c .raw(axum::http::Method::POST, "/some/page", &[], b"x".to_vec()) .await; assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED); // Point the dev asset dir at a controlled tempdir. // // `DOVENEST_DIST` is process-global; only this test (the sole test in this // binary) touches it, and other test binaries are separate processes. let dist = tempfile::tempdir().unwrap(); std::fs::write(dist.path().join("index.html"), "DIST-INDEX").unwrap(); std::fs::write(dist.path().join("app.css"), "body{}").unwrap(); unsafe { std::env::set_var("DOVENEST_DIST", dist.path()) }; // Root serves index.html. let r = c.get("/").await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.text(), "DIST-INDEX"); assert_eq!(r.header("content-type").as_deref(), Some("text/html")); assert_eq!(r.header("cache-control").as_deref(), Some("no-cache")); // Hard security headers on every response. let csp = r.header("content-security-policy").unwrap(); assert!(csp.starts_with("default-src 'self'"), "CSP: {csp}"); assert!(csp.contains("frame-ancestors 'none'"), "CSP: {csp}"); assert_eq!( r.header("x-content-type-options").as_deref(), Some("nosniff") ); assert_eq!(r.header("x-frame-options").as_deref(), Some("DENY")); assert_eq!(r.header("referrer-policy").as_deref(), Some("same-origin")); // A known asset is served with the right type. let r = c.get("/app.css").await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.text(), "body{}"); assert_eq!(r.header("content-type").as_deref(), Some("text/css")); // Traversal: the dev reader joins the request path onto the dist dir, and // hyper does not normalize literal `..` segments. Such a path must fall // through to the SPA page, never to a file outside the dist dir. std::fs::write(dist.path().parent().unwrap().join("outside.txt"), "SECRET").unwrap(); for p in [ "/../outside.txt", "/../../etc/passwd", "/sub/../../outside.txt", ] { let r = c.get(p).await; let body = r.text(); assert!( !body.contains("SECRET") && !body.contains("root:x:"), "{p} leaked a file outside the dist dir: {body}" ); } // Unknown paths fall back to index.html (SPA client routes, deep links). let r = c.get("/some/deep/client/route").await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.text(), "DIST-INDEX"); assert_eq!(r.header("cache-control").as_deref(), Some("no-cache")); let r = c.get("/s/abc123token/deeper/path").await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.text(), "DIST-INDEX"); // Now with an *empty* dist dir → the friendly "build the frontend" hint. let empty = tempfile::tempdir().unwrap(); unsafe { std::env::set_var("DOVENEST_DIST", empty.path()) }; let r = c.get("/").await; assert_eq!(r.status, StatusCode::OK); assert!(r.text().contains("frontend has not been built")); unsafe { std::env::remove_var("DOVENEST_DIST") }; }