api_spa.rs
⎇
Raw
1//! SPA serving (dev mode): static assets, client-route fallback, API 404s,
2//! method checks. Uses $DOVENEST_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//! $DOVENEST_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 // `DOVENEST_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("DOVENEST_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("same-origin"));
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 // Traversal: the dev reader joins the request path onto the dist dir, and
64 // hyper does not normalize literal `..` segments. Such a path must fall
65 // through to the SPA page, never to a file outside the dist dir.
66 std::fs::write(dist.path().parent().unwrap().join("outside.txt"), "SECRET").unwrap();
67 for p in [
68 "/../outside.txt",
69 "/../../etc/passwd",
70 "/sub/../../outside.txt",
71 ] {
72 let r = c.get(p).await;
73 let body = r.text();
74 assert!(
75 !body.contains("SECRET") && !body.contains("root:x:"),
76 "{p} leaked a file outside the dist dir: {body}"
77 );
78 }
79
80 // Unknown paths fall back to index.html (SPA client routes, deep links).
81 let r = c.get("/some/deep/client/route").await;
82 assert_eq!(r.status, StatusCode::OK);
83 assert_eq!(r.text(), "DIST-INDEX");
84 assert_eq!(r.header("cache-control").as_deref(), Some("no-cache"));
85 let r = c.get("/s/abc123token/deeper/path").await;
86 assert_eq!(r.status, StatusCode::OK);
87 assert_eq!(r.text(), "DIST-INDEX");
88
89 // Now with an *empty* dist dir → the friendly "build the frontend" hint.
90 let empty = tempfile::tempdir().unwrap();
91 unsafe { std::env::set_var("DOVENEST_DIST", empty.path()) };
92 let r = c.get("/").await;
93 assert_eq!(r.status, StatusCode::OK);
94 assert!(r.text().contains("frontend has not been built"));
95
96 unsafe { std::env::remove_var("DOVENEST_DIST") };
97}
98