api_spa.rs
⎇
Raw
1//! SPA serving: static assets, client-route fallback, API 404s, method
2//! checks. Uses $FBNG_DIST (the dev-mode asset directory) via a tempdir so
3//! the test is independent of any built frontend.
4
5mod common;
6
7use axum::http::StatusCode;
8use common::*;
9
10#[tokio::test]
11async fn spa_fallback_and_api_guards() {
12 let env = Env::new().await;
13 let c = Client::new(env.app.clone());
14
15 // Unknown /api/ endpoints get a plain 404, not the SPA page.
16 let r = c.get("/api/unknown/endpoint").await;
17 assert_eq!(r.status, StatusCode::NOT_FOUND);
18 assert_eq!(r.text(), "unknown endpoint");
19
20 // Non-GET to a non-API path → 405.
21 let r = c
22 .raw(axum::http::Method::POST, "/some/page", &[], b"x".to_vec())
23 .await;
24 assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED);
25
26 // Point the dev asset dir at a controlled tempdir.
27 //
28 // `FBNG_DIST` is process-global; only this test (the sole test in this
29 // binary) touches it, and other test binaries are separate processes.
30 let dist = tempfile::tempdir().unwrap();
31 std::fs::write(dist.path().join("index.html"), "DIST-INDEX").unwrap();
32 std::fs::write(dist.path().join("app.css"), "body{}").unwrap();
33 unsafe { std::env::set_var("FBNG_DIST", dist.path()) };
34
35 // Root serves index.html.
36 let r = c.get("/").await;
37 assert_eq!(r.status, StatusCode::OK);
38 assert_eq!(r.text(), "DIST-INDEX");
39 assert_eq!(r.header("content-type").as_deref(), Some("text/html"));
40 assert_eq!(r.header("cache-control").as_deref(), Some("no-cache"));
41
42 // Hard security headers on every response.
43 let csp = r.header("content-security-policy").unwrap();
44 assert!(csp.starts_with("default-src 'self'"), "CSP: {csp}");
45 assert!(csp.contains("frame-ancestors 'none'"), "CSP: {csp}");
46 assert_eq!(
47 r.header("x-content-type-options").as_deref(),
48 Some("nosniff")
49 );
50 assert_eq!(r.header("x-frame-options").as_deref(), Some("DENY"));
51 assert_eq!(r.header("referrer-policy").as_deref(), Some("no-referrer"));
52
53 // A known asset is served with the right type.
54 let r = c.get("/app.css").await;
55 assert_eq!(r.status, StatusCode::OK);
56 assert_eq!(r.text(), "body{}");
57 assert_eq!(r.header("content-type").as_deref(), Some("text/css"));
58
59 // Unknown paths fall back to index.html (SPA client routes, deep links).
60 let r = c.get("/some/deep/client/route").await;
61 assert_eq!(r.status, StatusCode::OK);
62 assert_eq!(r.text(), "DIST-INDEX");
63 assert_eq!(r.header("cache-control").as_deref(), Some("no-cache"));
64 let r = c.get("/s/abc123token/deeper/path").await;
65 assert_eq!(r.status, StatusCode::OK);
66 assert_eq!(r.text(), "DIST-INDEX");
67
68 // Now with an *empty* dist dir → the friendly "build the frontend" hint.
69 let empty = tempfile::tempdir().unwrap();
70 unsafe { std::env::set_var("FBNG_DIST", empty.path()) };
71 let r = c.get("/").await;
72 assert_eq!(r.status, StatusCode::OK);
73 assert!(r.text().contains("frontend has not been built"));
74
75 unsafe { std::env::remove_var("FBNG_DIST") };
76}
77