feat(vorlagen): Editor, Liste und Versionsgeschichte (#24)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #24.
This commit is contained in:
2026-09-25 08:23:22 +00:00
parent d8d5444e9f
commit e3d80e5982
65 changed files with 5454 additions and 103 deletions
+160
View File
@@ -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");
});
});