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