import { describe, expect, it } from "vitest"; import { buildCspDirectives, buildCspHeader, buildServeJson, injectCspMetaIntoHtml, serializeCsp, toOrigin } from "./vite-plugin-csp"; describe("toOrigin", () => { it("renvoie null pour une entrée vide ou non string", () => { expect(toOrigin(null)).toBeNull(); expect(toOrigin(undefined)).toBeNull(); expect(toOrigin("")).toBeNull(); expect(toOrigin(" ")).toBeNull(); }); it("extrait le protocole + host (+ port)", () => { expect(toOrigin("https://analytics.example.com/")).toBe("https://analytics.example.com"); expect(toOrigin("https://analytics.example.com")).toBe("https://analytics.example.com"); expect(toOrigin("https://analytics.example.com:8443/foo/bar?x=1")).toBe( "https://analytics.example.com:8443" ); }); it("refuse les protocoles non http(s)", () => { expect(toOrigin("file:///etc/passwd")).toBeNull(); expect(toOrigin("javascript:alert(1)")).toBeNull(); expect(toOrigin("data:text/plain,foo")).toBeNull(); }); it("renvoie null pour une URL invalide", () => { expect(toOrigin("not a url")).toBeNull(); expect(toOrigin("http://")).toBeNull(); }); }); describe("serializeCsp", () => { it("joint les directives par `; ` et quote chaque source", () => { const out = serializeCsp([ { name: "default-src", sources: ["self"] }, { name: "script-src", sources: ["self", "https://x.test"] } ]); expect(out).toBe("default-src 'self'; script-src 'self' 'https://x.test'"); }); it("préserve les tokens spéciaux (unsafe-inline, etc.)", () => { const out = serializeCsp([{ name: "script-src", sources: ["self", "unsafe-inline", "unsafe-eval"] }]); expect(out).toBe("script-src 'self' 'unsafe-inline' 'unsafe-eval'"); }); }); describe("buildCspDirectives", () => { it("en prod sans Matomo : aucune origine externe, pas de unsafe-eval, frame-ancestors none", () => { const dirs = buildCspDirectives("production", null); const find = (name: string) => dirs.find((d) => d.name === name); expect(find("default-src")?.sources).toEqual(["self"]); expect(find("script-src")?.sources).toEqual(["self"]); // `style-src` inclut `unsafe-inline` même en prod (styles inline Solid). expect(find("style-src")?.sources).toEqual(["self", "unsafe-inline"]); expect(find("img-src")?.sources).toEqual(["self"]); expect(find("font-src")?.sources).toEqual(["self"]); expect(find("connect-src")?.sources).toEqual(["self"]); expect(find("media-src")?.sources).toEqual(["self"]); expect(find("object-src")?.sources).toEqual(["none"]); expect(find("base-uri")?.sources).toEqual(["self"]); expect(find("form-action")?.sources).toEqual(["self"]); expect(find("frame-ancestors")?.sources).toEqual(["none"]); expect(find("frame-src")?.sources).toEqual(["none"]); }); it("en prod avec Matomo : autorise l'origine sur script/connect/img", () => { const dirs = buildCspDirectives("production", "https://analytics.example.com"); expect(dirs.find((d) => d.name === "script-src")?.sources).toEqual([ "self", "https://analytics.example.com" ]); expect(dirs.find((d) => d.name === "connect-src")?.sources).toEqual([ "self", "https://analytics.example.com" ]); expect(dirs.find((d) => d.name === "img-src")?.sources).toEqual([ "self", "https://analytics.example.com" ]); // Pas de Matomo sur font-src / default-src. expect(dirs.find((d) => d.name === "font-src")?.sources).toEqual(["self"]); }); it("en dev : autorise unsafe-eval (HMR), ws:/wss:, et unsafe-inline sur style-src", () => { const dirs = buildCspDirectives("development", null); expect(dirs.find((d) => d.name === "script-src")?.sources).toEqual([ "self", "unsafe-inline", "unsafe-eval" ]); expect(dirs.find((d) => d.name === "style-src")?.sources).toEqual(["self", "unsafe-inline"]); expect(dirs.find((d) => d.name === "connect-src")?.sources).toEqual(["self", "ws:", "wss:"]); // frame-ancestors reste 'none' en dev aussi. expect(dirs.find((d) => d.name === "frame-ancestors")?.sources).toEqual(["none"]); }); it("en dev avec Matomo : cumule unsafe-* et l'origine Matomo", () => { const dirs = buildCspDirectives("development", "https://analytics.example.com"); expect(dirs.find((d) => d.name === "script-src")?.sources).toEqual([ "self", "unsafe-inline", "unsafe-eval", "https://analytics.example.com" ]); }); }); describe("buildCspHeader", () => { it("inclut toutes les directives attendues en prod", () => { const csp = buildCspHeader("production", null); expect(csp).toContain("default-src 'self'"); expect(csp).toContain("frame-ancestors 'none'"); expect(csp).toContain("object-src 'none'"); // `unsafe-inline` reste sur `style-src` (styles inline Solid) — seul `unsafe-eval` est // strictement exclu en prod (HMR Vite ne sert qu'en dev). expect(csp).not.toContain("unsafe-eval"); }); }); describe("injectCspMetaIntoHtml", () => { const sample = ` Test `; it("insère la balise CSP juste après le charset", () => { const out = injectCspMetaIntoHtml(sample, "default-src 'self'"); expect(out).toMatch( /\s*/ ); }); it("insère après si pas de charset", () => { const html = "X"; const out = injectCspMetaIntoHtml(html, "default-src 'self'"); expect(out).toMatch(/\s* { const out = injectCspMetaIntoHtml(sample, "default-src 'self'; script-src 'self'"); expect(out).toContain(`content="default-src 'self'; script-src 'self'"`); }); }); describe("buildServeJson", () => { it("déclare une règle HTML avec CSP + en-têtes statiques, et une règle catch-all sans CSP", () => { const sj = buildServeJson(null); expect(sj.headers).toHaveLength(2); const htmlRule = sj.headers.find((r) => r.source === "**/*.html"); expect(htmlRule).toBeDefined(); const htmlKeys = htmlRule!.headers.map((h) => h.key); expect(htmlKeys).toEqual( expect.arrayContaining([ "Content-Security-Policy", "X-Content-Type-Options", "Referrer-Policy", "X-Frame-Options", "Permissions-Policy" ]) ); const cspEntry = htmlRule!.headers.find((h) => h.key === "Content-Security-Policy"); expect(cspEntry?.value).not.toContain("unsafe-eval"); expect(cspEntry?.value).toContain("frame-ancestors 'none'"); const allRule = sj.headers.find((r) => r.source === "**/*"); expect(allRule).toBeDefined(); const allKeys = allRule!.headers.map((h) => h.key); expect(allKeys).not.toContain("Content-Security-Policy"); expect(allKeys).toEqual( expect.arrayContaining([ "X-Content-Type-Options", "Referrer-Policy", "X-Frame-Options", "Permissions-Policy" ]) ); }); it("la CSP s'adapte à l'origine Matomo fournie", () => { const sj = buildServeJson("https://analytics.example.com"); const cspEntry = sj.headers .find((r) => r.source === "**/*.html")! .headers.find((h) => h.key === "Content-Security-Policy"); expect(cspEntry?.value).toContain("https://analytics.example.com"); }); it("la Permissions-Policy désactive les capacités sensibles", () => { const sj = buildServeJson(null); const allHeaders = sj.headers.flatMap((r) => r.headers); const pp = allHeaders.find((h) => h.key === "Permissions-Policy"); expect(pp?.value).toContain("camera=()"); expect(pp?.value).toContain("microphone=()"); expect(pp?.value).toContain("geolocation=()"); }); });