Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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);
}