Files
ben-to/apps/web/vite-plugin-csp.unit.test.ts
2026-07-21 16:50:58 +02:00

209 lines
7.9 KiB
TypeScript

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 = `<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<title>Test</title>
</head>
<body></body>
</html>
`;
it("insère la balise CSP juste après le charset", () => {
const out = injectCspMetaIntoHtml(sample, "default-src 'self'");
expect(out).toMatch(
/<meta\s+charset="UTF-8"\s*\/>\s*<meta\s+http-equiv="Content-Security-Policy"\s+content="default-src 'self'">/
);
});
it("insère après <head> si pas de charset", () => {
const html = "<html><head><title>X</title></head><body/></html>";
const out = injectCspMetaIntoHtml(html, "default-src 'self'");
expect(out).toMatch(/<head>\s*<meta\s+http-equiv="Content-Security-Policy"/);
});
it("quote correctement la valeur CSP", () => {
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=()");
});
});