feat(vorlagen): Editor, Liste und Versionsgeschichte (#24)
This commit was merged in pull request #24.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { istOeffentlich, proxy, sicherheitsrichtlinie } from "@/proxy";
|
||||
|
||||
/**
|
||||
* Die Content-Security-Policy (#005, korrigiert in #010).
|
||||
*
|
||||
* Sie war in beiden Betriebsarten gleich streng — und genau deshalb hydrierte
|
||||
* React im Entwicklungsmodus **nie**: `script-src` ohne `unsafe-eval` verbietet
|
||||
* das, was React und Turbopack dort tun. Die Seiten sahen richtig aus, aber kein
|
||||
* Knopf tat etwas, und ein Screenshot zeigte davon nichts. Gefunden erst, als der
|
||||
* Editor aus #010 im Browser bedient werden sollte.
|
||||
*
|
||||
* Diese Tests halten beide Seiten fest: dass die Lockerung im Entwicklungsmodus
|
||||
* da ist, und dass sie im ausgelieferten Stand fehlt.
|
||||
*/
|
||||
|
||||
const NONCE = "abc123";
|
||||
|
||||
function teile(richtlinie: string): Map<string, string> {
|
||||
return new Map(
|
||||
richtlinie.split("; ").map((eintrag) => {
|
||||
const [name, ...rest] = eintrag.split(" ");
|
||||
return [name ?? "", rest.join(" ")];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("Ausgelieferter Stand", () => {
|
||||
const richtlinie = sicherheitsrichtlinie(NONCE, false);
|
||||
|
||||
it("erlaubt kein unsafe-eval", () => {
|
||||
expect(richtlinie).not.toContain("unsafe-eval");
|
||||
});
|
||||
|
||||
it("erlaubt keine WebSocket-Verbindungen", () => {
|
||||
expect(teile(richtlinie).get("connect-src")).toBe("'self'");
|
||||
});
|
||||
|
||||
it("bindet Skripte an die Nonce dieser Anfrage", () => {
|
||||
expect(teile(richtlinie).get("script-src")).toBe(`'self' 'nonce-${NONCE}' 'strict-dynamic'`);
|
||||
});
|
||||
|
||||
it("verbietet das Einbetten in fremde Seiten und Objekte", () => {
|
||||
const werte = teile(richtlinie);
|
||||
expect(werte.get("frame-ancestors")).toBe("'none'");
|
||||
expect(werte.get("object-src")).toBe("'none'");
|
||||
expect(werte.get("base-uri")).toBe("'self'");
|
||||
expect(werte.get("form-action")).toBe("'self'");
|
||||
});
|
||||
|
||||
it("erlaubt Bilder als data: und blob: – QR-Codes und Kamerabilder", () => {
|
||||
expect(teile(richtlinie).get("img-src")).toBe("'self' data: blob:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Entwicklungsmodus", () => {
|
||||
const richtlinie = sicherheitsrichtlinie(NONCE, true);
|
||||
|
||||
it("erlaubt unsafe-eval, sonst hydriert React nicht", () => {
|
||||
expect(teile(richtlinie).get("script-src")).toContain("'unsafe-eval'");
|
||||
});
|
||||
|
||||
it("erlaubt die WebSocket-Verbindung von Fast Refresh", () => {
|
||||
expect(teile(richtlinie).get("connect-src")).toBe("'self' ws: wss:");
|
||||
});
|
||||
|
||||
it("lockert sonst nichts", () => {
|
||||
// Namentlich verglichen: was hier gleich bleiben soll, bleibt gleich.
|
||||
const streng = teile(sicherheitsrichtlinie(NONCE, false));
|
||||
const locker = teile(richtlinie);
|
||||
for (const name of [...streng.keys()]) {
|
||||
if (name === "script-src" || name === "connect-src") continue;
|
||||
expect(locker.get(name), name).toBe(streng.get(name));
|
||||
}
|
||||
expect([...locker.keys()]).toEqual([...streng.keys()]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Oeffentliche Pfade", () => {
|
||||
it("laesst Anmeldung, Auth-Rueckruf und Healthcheck ohne Cookie durch", () => {
|
||||
for (const pfad of [
|
||||
"/anmelden",
|
||||
"/api/auth/oidc/start",
|
||||
"/api/gesundheit",
|
||||
"/manifest.webmanifest",
|
||||
"/icon.svg",
|
||||
]) {
|
||||
expect(istOeffentlich(pfad), pfad).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("laesst nichts anderes durch", () => {
|
||||
for (const pfad of ["/", "/vorlagen", "/admin", "/api/laeufe/1/zustand", "/anmeldenX"]) {
|
||||
expect(istOeffentlich(pfad), pfad).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Die Entscheidung im Proxy selbst", () => {
|
||||
/**
|
||||
* Die beiden Tests oben pruefen die Funktion mit einem Argument, das der Test
|
||||
* setzt — genau das hat der Review beanstandet: die eigentliche Entscheidung
|
||||
* `process.env.NODE_ENV !== "production"` blieb ungeprueft. Wer dort `true`
|
||||
* einsetzt, haette gruene Tests und lieferte `unsafe-eval` aus.
|
||||
*
|
||||
* Hier laeuft deshalb `proxy()` selbst, mit gesetztem NODE_ENV.
|
||||
*/
|
||||
function anfrageMitCookie(): NextRequest {
|
||||
const anfrage = new NextRequest(new URL("http://127.0.0.1:3000/vorlagen"));
|
||||
anfrage.cookies.set("lz_sitzung", "egal");
|
||||
return anfrage;
|
||||
}
|
||||
|
||||
function mitNodeEnv<T>(wert: string, was: () => T): T {
|
||||
const vorher = process.env.NODE_ENV;
|
||||
// NODE_ENV ist in den Typen schreibgeschuetzt, zur Laufzeit ist es eine
|
||||
// gewoehnliche Variable – und genau die liest der Proxy.
|
||||
const umgebung = process.env as Record<string, string | undefined>;
|
||||
umgebung["NODE_ENV"] = wert;
|
||||
try {
|
||||
return was();
|
||||
} finally {
|
||||
umgebung["NODE_ENV"] = vorher;
|
||||
}
|
||||
}
|
||||
|
||||
it("liefert im gebauten Stand keine Lockerung aus", () => {
|
||||
const richtlinie = mitNodeEnv("production", () =>
|
||||
proxy(anfrageMitCookie()).headers.get("Content-Security-Policy"),
|
||||
);
|
||||
expect(richtlinie).toBeTruthy();
|
||||
expect(richtlinie).not.toContain("unsafe-eval");
|
||||
expect(richtlinie).toContain("connect-src 'self';");
|
||||
expect(richtlinie).toContain("'strict-dynamic'");
|
||||
});
|
||||
|
||||
it("lockert im Entwicklungsmodus", () => {
|
||||
const richtlinie = mitNodeEnv("development", () =>
|
||||
proxy(anfrageMitCookie()).headers.get("Content-Security-Policy"),
|
||||
);
|
||||
expect(richtlinie).toContain("'unsafe-eval'");
|
||||
expect(richtlinie).toContain("ws: wss:");
|
||||
});
|
||||
|
||||
it("gibt jeder Antwort eine eigene Nonce", () => {
|
||||
// Eine wiederverwendete Nonce waere so gut wie keine.
|
||||
const erste = proxy(anfrageMitCookie()).headers.get("Content-Security-Policy");
|
||||
const zweite = proxy(anfrageMitCookie()).headers.get("Content-Security-Policy");
|
||||
expect(erste).not.toBe(zweite);
|
||||
});
|
||||
|
||||
it("schickt ohne Sitzungscookie auf die Anmeldeseite, mit dem Ziel im Gepäck", () => {
|
||||
const antwort = proxy(new NextRequest(new URL("http://127.0.0.1:3000/vorlagen?suche=pc")));
|
||||
expect(antwort.status).toBe(307);
|
||||
const ziel = new URL(antwort.headers.get("location") ?? "");
|
||||
expect(ziel.pathname).toBe("/anmelden");
|
||||
expect(ziel.searchParams.get("ziel")).toBe("/vorlagen?suche=pc");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user