1
25import { describe, expect, it } from "vitest";
26import { readFileSync, readdirSync } from "node:fs";
27import { fileURLToPath } from "node:url";
28import { webSecurityHeaders, webContentSecurityPolicy } from "@factory/shared/security-headers";
29import { checkLiveApp, apiFetch, webFetch } from "./helpers/app.js";
30
31const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
32const WEB = fileURLToPath(new URL("../../apps/web", import.meta.url));
33const SHARED_POLICY = fileURLToPath(new URL("../../packages/shared/src/security-headers.ts", import.meta.url));
34
35
36
37const DEV = { apiOrigin: "http://localhost:4000", wsOrigin: "ws://localhost:4001", production: false };
38const EXPECTED = webSecurityHeaders(DEV);
39const EXPECTED_CSP = EXPECTED["content-security-policy"]!;
40
41
42
43
44
45
46
47
48
49
50const LIVE_API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000/api/v1";
51const LIVE_WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:4001";
52const LIVE = {
53 apiOrigin: LIVE_API_URL.replace(/\/api\/v1\/?$/, ""),
54 wsOrigin: LIVE_WS_URL,
55 production: false,
56};
57const EXPECTED_LIVE = webSecurityHeaders(LIVE);
58
59const live = await checkLiveApp();
60const itLive = it.skipIf(!live);
61
62
63
64
65describe("headers / policy shape (static)", () => {
66 function directives(csp: string): Record<string, string[]> {
67 const out: Record<string, string[]> = {};
68 for (const part of csp.split(";").map((s) => s.trim()).filter(Boolean)) {
69 const [name, ...rest] = part.split(/\s+/);
70 out[name!] = rest;
71 }
72 return out;
73 }
74
75 it("the CSP has a strict default and no third-party script origin; 'unsafe-eval' is dev-only", () => {
76 const d = directives(EXPECTED_CSP);
77 expect(d["default-src"]).toEqual(["'self'"]);
78 const script = d["script-src"] ?? [];
79 expect(script).toContain("'self'");
80
81
82
83
84 const prodCsp = webSecurityHeaders({ ...DEV, production: true })["content-security-policy"]!;
85 expect(directives(prodCsp)["script-src"], "no eval in production")
86 .not.toContain("'unsafe-eval'");
87
88
89
90 expect(script, "dev hydration requires 'unsafe-eval'").toContain("'unsafe-eval'");
91
92
93 expect(script.filter((s) => /^https?:\/\
94 for (const k of ["object-src", "base-uri", "form-action", "frame-ancestors"]) {
95 expect(d[k], `directive ${k} present`).toBeDefined();
96 }
97 expect(d["object-src"]).toEqual(["'none'"]);
98 expect(d["frame-ancestors"]).toEqual(["'self'"]);
99 });
100
101 it("connect-src allows only same-origin + the configured API/WS origins", () => {
102 const d = directives(webContentSecurityPolicy(DEV));
103 const connect = d["connect-src"]!;
104 expect(connect).toContain("'self'");
105 for (const c of connect) {
106 if (c === "'self'") continue;
107 expect(c, `connect-src entry "${c}" is a configured origin`).toMatch(
108 /^(https?|wss?):\/\/(localhost|127\.0\.0\.1)(:\d+)?$/
109 );
110 }
111 });
112
113 it("HSTS is production-only (absent in dev)", () => {
114 expect(EXPECTED["strict-transport-security"]).toBeUndefined();
115 const prod = webSecurityHeaders({ ...DEV, production: true });
116 expect(prod["strict-transport-security"]).toBe("max-age=31536000; includeSubDomains");
117 });
118
119 it("the non-CSP headers are present with the canonical values", () => {
120 expect(EXPECTED["x-content-type-options"]).toBe("nosniff");
121 expect(EXPECTED["x-frame-options"]).toBe("DENY");
122 expect(EXPECTED["referrer-policy"]).toBe("no-referrer");
123 expect(EXPECTED["permissions-policy"]).toBe("geolocation=(), microphone=(), camera=()");
124 });
125
126 it("next.config.mjs wires the policy through headers() (the fix is in place)", () => {
127 const cfg = readFileSync(WEB + "/next.config.mjs", "utf8");
128 expect(cfg).toContain("@factory/shared/security-headers");
129 expect(cfg).toContain("webSecurityHeaders(");
130 expect(cfg).toContain("async headers()");
131
132
133 expect(cfg).toContain("source: " + JSON.stringify("/(.*)"));
134
135
136 expect(cfg).toContain("NEXT_PUBLIC_API_URL");
137 expect(cfg).toContain("NEXT_PUBLIC_WS_URL");
138 });
139
140 it("the app has no third-party script/style/font/img origin (CSP completeness)", () => {
141
142
143
144
145 const files: string[] = [];
146 const walk = (dir: string): void => {
147 for (const entry of readdirSync(dir, { withFileTypes: true })) {
148 if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
149 const p = dir + "/" + entry.name;
150 if (entry.isDirectory()) walk(p);
151 else if (/\.(tsx?|css)$/.test(entry.name)) files.push(p);
152 }
153 };
154 walk(WEB + "/src");
155 const refRe = /(?:src|href)=["'](https?:\/\/[^"'\s]+)/g;
156 const external: string[] = [];
157 for (const f of files) {
158 let m: RegExpExecArray | null;
159 refRe.lastIndex = 0;
160 while ((m = refRe.exec(readFileSync(f, "utf8"))) !== null) external.push(`${f}:${m[1]}`);
161 }
162 for (const line of external) {
163 const url = line.slice(line.indexOf("http"));
164 expect(
165 url,
166 `external asset "${url}" would be blocked by the 'self'-only CSP (or is a test fixture)`
167 ).toMatch(/localhost|127\.0\.0\.1|example|w3\.org|__tests__|\.test\./);
168 }
169 });
170});
171
172
173
174
175describe("headers / live web responses (the fixed gap)", () => {
176 async function assertWebHeaders(label: string, res: Response): Promise<void> {
177
178
179
180 for (const [name, value] of Object.entries(EXPECTED_LIVE)) {
181 const got = res.headers.get(name.toLowerCase());
182 expect(got, `${label}: header ${name}`).toBe(value);
183 }
184 }
185
186 itLive("the login page carries the full security header set", async () => {
187 const res = await webFetch("/login");
188 expect(res.status).toBe(200);
189 await assertWebHeaders("login", res);
190 }, { timeout: 60_000 });
191
192 itLive("the root redirect carries the headers (redirects too, not just 200s)", async () => {
193 const res = await webFetch("/", { redirect: "manual" });
194 expect([301, 302, 307, 308]).toContain(res.status);
195 await assertWebHeaders("root redirect", res);
196 }, { timeout: 60_000 });
197
198 itLive("a 404 page carries the headers (error responses are not exempt)", async () => {
199
200
201
202
203 const res = await webFetch("/definitely-not-a-page-404", {
204 headers: { cookie: "factory_refresh=garbage" }
205 });
206 expect(res.status).toBe(404);
207 await assertWebHeaders("404", res);
208 }, { timeout: 60_000 });
209});
210
211
212
213
214describe("headers / live API responses (unchanged stricter policy)", () => {
215 itLive("a JSON API response carries default-src 'none' + nosniff + XFO + no-referrer", async () => {
216 const res = await apiFetch("/health");
217 expect(res.status).toBeLessThan(500);
218 const csp = res.headers.get("content-security-policy") ?? "";
219 expect(csp).toMatch(/^default-src 'none'/);
220 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
221 expect(res.headers.get("x-frame-options")).toBe("DENY");
222 expect(res.headers.get("referrer-policy")).toBe("no-referrer");
223 expect(res.headers.get("permissions-policy")).toBe("geolocation=(), microphone=(), camera=()");
224
225 if (process.env.NODE_ENV !== "production") {
226 expect(res.headers.get("strict-transport-security")).toBeNull();
227 }
228 }, { timeout: 60_000 });
229
230 itLive("the API docs route keeps its relaxed (swagger-ui) CSP, not the web CSP", async () => {
231 const res = await fetch(`${process.env.SEC_API_URL ?? "http://localhost:4000"}/api/v1/docs`, {
232 signal: AbortSignal.timeout(20_000)
233 });
234 expect(res.status).toBe(200);
235 const csp = res.headers.get("content-security-policy") ?? "";
236 expect(csp, "docs CSP allows the vendored swagger-ui bundle").toMatch(/script-src 'self' 'unsafe-inline'/);
237
238 expect(res.headers.get("x-frame-options")).toBe("DENY");
239 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
240 }, { timeout: 60_000 });
241});
242