/** * Phase 7.2 R1 Agent C (H1) — CSP nonce tests for Fast Refresh preamble. * * Three code paths covered: * * A — nonce resolver (env opt-out, explicit string, auto-generate, off) * B — preamble tag emitter (nonce attribute injection, opt-out) * C — response header (renderSSR / renderStreamingResponse emit the * right `Content-Security-Policy` value when a nonce is in play) * * Dependencies: none that touch fs.watch / bun:build — tests use * in-memory manifests only, so they run in any environment. * * References: * docs/security/phase-7-1-audit.md §2 M-01 * docs/bun/phase-7-2-team-plan.md §3 Agent C H1 */ import { describe, expect, test, afterEach, beforeEach } from "bun:test"; import React from "react"; import type { BundleManifest } from "../types"; import { renderSSR, renderToHTML, _testOnly_buildFastRefreshCspHeader, _testOnly_generateCspNonce, _testOnly_generateFastRefreshPreambleTag, _testOnly_getAttachedCspNonce, _testOnly_resolveFastRefreshCspNonce, } from "../../runtime/ssr"; /** * Minimal dev-mode manifest carrying the same Fast Refresh URLs the * bundler emits. We deliberately keep this a literal so unit tests * never invoke `Bun.build` — the plumbing under test is purely string * manipulation + header emission. */ const DEV_MANIFEST: BundleManifest = { version: 1, buildTime: "2026-04-19T00:00:00.000Z", env: "development", bundles: { page: { js: "/.mandu/client/page.js", dependencies: [], priority: "visible", }, }, shared: { runtime: "/.mandu/client/_runtime.js", vendor: "/.mandu/client/_vendor-react.js", fastRefresh: { runtime: "/.mandu/client/_fast-refresh-runtime.js", glue: "/.mandu/client/_vendor-react-refresh.js", }, }, }; // Preserve env across tests so MANDU_CSP_NONCE toggling doesn't leak. let prevEnv: string | undefined; beforeEach(() => { prevEnv = process.env.MANDU_CSP_NONCE; }); afterEach(() => { if (prevEnv === undefined) { delete process.env.MANDU_CSP_NONCE; } else { process.env.MANDU_CSP_NONCE = prevEnv; } }); // ═══════════════════════════════════════════════════════════════════ // Section A — resolveFastRefreshCspNonce // ═══════════════════════════════════════════════════════════════════ describe("csp-nonce — resolveFastRefreshCspNonce", () => { test("[A1] generates a fresh 128-bit base64 nonce on each call (entropy > 120 unique of 200)", () => { const seen = new Set(); for (let i = 0; i < 200; i++) { seen.add(_testOnly_generateCspNonce()); } // 200 draws from 2^128 should produce 200 distinct values; accept // ≥120 unique to survive any pathological RNG quirks. expect(seen.size).toBeGreaterThan(120); const sample = [...seen][0]; // 16 bytes → 24 base64 chars incl. `=` padding expect(sample.length).toBeGreaterThanOrEqual(20); expect(sample.length).toBeLessThanOrEqual(32); // base64 alphabet only (no slashes / plus are allowed but not typical // for nonces; we accept standard base64) expect(/^[A-Za-z0-9+/=]+$/.test(sample)).toBe(true); }); test("[A2] opt-out via MANDU_CSP_NONCE=0 forces undefined even when cspNonce=true", () => { process.env.MANDU_CSP_NONCE = "0"; expect(_testOnly_resolveFastRefreshCspNonce(true)).toBeUndefined(); expect(_testOnly_resolveFastRefreshCspNonce("fixed")).toBeUndefined(); expect(_testOnly_resolveFastRefreshCspNonce(false)).toBeUndefined(); }); test("[A3] explicit string value is passed through verbatim", () => { delete process.env.MANDU_CSP_NONCE; expect(_testOnly_resolveFastRefreshCspNonce("abc123")).toBe("abc123"); }); test("[A4] undefined / false produce undefined (legacy byte-identical path)", () => { delete process.env.MANDU_CSP_NONCE; expect(_testOnly_resolveFastRefreshCspNonce(undefined)).toBeUndefined(); expect(_testOnly_resolveFastRefreshCspNonce(false)).toBeUndefined(); }); }); // ═══════════════════════════════════════════════════════════════════ // Section B — generateFastRefreshPreambleTag // ═══════════════════════════════════════════════════════════════════ describe("csp-nonce — generateFastRefreshPreambleTag", () => { test("[B1] injects nonce attribute onto