crates/monetize-ui-server/tests/screens_csp.rs
//! **The served Content-Security-Policy follows the bundle on disk, with no
//! restart** — asserted on the header the ROUTER sends for `/`, not on the
//! `Csp` unit (serve.rs already holds that one).
//!
//! The blank-page regression of 2026-09-04: a new bundle copied into the ui
//! dir, the service not restarted for an hour, the header advertising the old
//! inline script's hash while the page served the new script. The browser
//! blocked the console's own bootstrap, nothing mounted, every route answered
//! 200. This test swaps the bundle under a running router and reads the header
//! and the body of the same response: they must agree.
#[path = "../../../uitest/src/fixture.rs"]
mod fixture;
#[path = "../../../uitest/src/mock.rs"]
mod mock;
mod screens_rig;
use base64::Engine as _;
use http::{Method, StatusCode};
use monetize_ui_server::role::Role;
use screens_rig::{Auth, Rig};
use sha2::{Digest, Sha256};
/// The shape trunk emits: an inline module that names the content-hashed bundle.
fn bundle(hash: &str) -> String {
format!(
r#"<!DOCTYPE html><html><head><title>monetize</title>
<script type="module">
import init, * as bindings from '/monetize-ui-web-{hash}.js';
const wasm = await init({{ module_or_path: '/monetize-ui-web-{hash}_bg.wasm' }});
window.wasmBindings = bindings;
dispatchEvent(new CustomEvent("TrunkApplicationStarted", {{detail: {{wasm}}}}));
</script>
</head><body></body></html>"#
)
}
/// `sha256-…` of the ONE inline script in `html`, computed here and not by the
/// code under test.
fn inline_hash(html: &str) -> String {
let start = html.find("<script").unwrap();
let body_start = start + html[start..].find('>').unwrap() + 1;
let body_end = body_start + html[body_start..].find("</script>").unwrap();
format!("sha256-{}", base64::engine::general_purpose::STANDARD.encode(Sha256::digest(&html.as_bytes()[body_start..body_end])))
}
fn script_src(csp: &str) -> String {
csp.split(';').find(|d| d.trim().starts_with("script-src")).unwrap_or("").trim().to_owned()
}
/// (r) RED first: against a router built with the hashes read ONCE (the
/// 2026-09-04 shape), the second half fails — the header still names the
/// first bundle's hash after the swap.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_served_csp_follows_a_new_bundle_without_a_restart_and_matches_the_body_it_ships() {
let dir = tempfile::tempdir().unwrap();
let index = dir.path().join("index.html");
let v1 = bundle("aaaaaaaaaaaaaaaa");
std::fs::write(&index, &v1).unwrap();
let rig = Rig::up_with(mock::Mock::new(), Some(dir.path().to_path_buf())).await;
let viewer = rig.cookie(Role::Viewer);
let (status, headers, body) = rig.raw(Method::GET, "/", Auth::Cookie(&viewer), None, &[("accept", "text/html")]).await;
assert_eq!(status, StatusCode::OK);
let csp1 = headers.get("content-security-policy").expect("a CSP on the page").to_str().unwrap().to_owned();
let want1 = inline_hash(&v1);
assert!(script_src(&csp1).contains(&format!("'{want1}'")), "the first bundle's script must be allowed:\n{csp1}");
assert_eq!(String::from_utf8_lossy(&body), v1, "the body is the bundle on disk");
assert!(!script_src(&csp1).contains("unsafe-inline"), "script-src is the strict half: {csp1}");
// SHIP A NEW BUNDLE under the running router. Same length, so only the
// mtime distinguishes the two files — which is why this sleeps.
let v2 = bundle("bbbbbbbbbbbbbbbb");
assert_eq!(v1.len(), v2.len());
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
std::fs::write(&index, &v2).unwrap();
let want2 = inline_hash(&v2);
assert_ne!(want1, want2, "or nothing is being tested");
let (status, headers, body) = rig.raw(Method::GET, "/", Auth::Cookie(&viewer), None, &[("accept", "text/html")]).await;
assert_eq!(status, StatusCode::OK);
let csp2 = headers.get("content-security-policy").unwrap().to_str().unwrap().to_owned();
assert_eq!(String::from_utf8_lossy(&body), v2, "the NEW bundle is what is served…");
assert!(script_src(&csp2).contains(&format!("'{want2}'")), "…and its script is what the header allows, with no restart:\n{csp2}");
assert!(!script_src(&csp2).contains(&format!("'{want1}'")), "the stale hash is gone:\n{csp2}");
// A deep link falls back to the same index, under the same header: a
// reload on /e/gunnar.rs/customers must not be the one page that gets blocked.
let (status, headers, body) = rig.raw(Method::GET, "/e/gunnar.rs/customers", Auth::Cookie(&viewer), None, &[("accept", "text/html")]).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(String::from_utf8_lossy(&body), v2);
assert_eq!(headers.get("content-security-policy").unwrap().to_str().unwrap(), csp2);
}
/// (r) **The bundle's own assets need no session, and everything else does.**
/// trunk marks its scripts `crossorigin="anonymous"`, so the browser fetches
/// them without the cookie; a console whose JS needed a cookie would be a
/// blank page for everyone, and one whose API did not would be open.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_bundles_assets_are_public_and_the_page_itself_sends_a_stranger_to_login() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("index.html"), bundle("cccccccccccccccc")).unwrap();
std::fs::write(dir.path().join("monetize-ui-web-cccccccccccccccc.js"), "export default async function init() {}").unwrap();
std::fs::write(dir.path().join("console-cccc.css"), "body{}").unwrap();
let rig = Rig::up_with(mock::Mock::new(), Some(dir.path().to_path_buf())).await;
for asset in ["/monetize-ui-web-cccccccccccccccc.js", "/console-cccc.css"] {
let (status, headers, _) = rig.raw(Method::GET, asset, Auth::None, None, &[]).await;
assert_eq!(status, StatusCode::OK, "{asset} must load without a session");
assert!(headers.get("content-security-policy").is_some(), "{asset}: the headers layer is outermost; even an asset carries the policy");
}
// The page: a stranger with a browser is redirected to /login, not shown a 401.
let (status, headers, _) = rig.raw(Method::GET, "/", Auth::None, None, &[("accept", "text/html")]).await;
assert!(status.is_redirection(), "{status}");
assert_eq!(headers.get("location").and_then(|v| v.to_str().ok()), Some("/login"));
// …and /login itself is served (the SPA route the shell needs before a session exists).
let (status, _, body) = rig.raw(Method::GET, "/login", Auth::None, None, &[("accept", "text/html")]).await;
assert_eq!(status, StatusCode::OK);
assert!(String::from_utf8_lossy(&body).contains("cccccccccccccccc"), "the bundle, not a 401 page");
// A fetch (no text/html) gets the 401 it can act on.
let (status, ..) = rig.raw(Method::GET, "/api/products", Auth::None, None, &[]).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}