Files
Florian WildeandClaude Opus 5 75c335df23
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
test: Zusagen pruefbar machen, die nur von Hand hielten (#038, #037)
#038: Die E2E-Ablaeufe laufen ab jetzt vor jedem Merge nach main, nicht vor
jedem Commit. Gemessen: Bau 14 s, Start 7 s, Ablaeufe 256 s — zu den vier
Minuten des bestehenden Tores. Neun Minuten vor jedem Commit ist ein Tor, das
umgangen wird. Vor jedem Merge waeren es in Phase 2 acht Laeufe gewesen, und
jeder der vier Befunde aus #027 waere am Tag seiner Entstehung aufgefallen.

Geprueft wurde jeder der 16 Ablaeufe gegen die Frage, ob es ein
Vitest-Gegenstueck gibt. Neun Zusagen haengen ausschliesslich dort, und keine
laesst sich holen: alle brauchen einen echten Browser. Die Liste steht in
CLAUDE.md.

Der eigentliche Fund dabei war keine Testluecke, sondern eine Konvention ohne
Pruefer: "jede Seite prueft selbst" steht seit #007 in CLAUDE.md, und geprueft
hat es bis hierher nur ein Mensch, der nachsieht. Der Review von Phase 2 hat
alle 27 Seiten durchgesehen — ein Ergebnis, das fuer genau einen Tag gilt.
tests/zugang.test.ts liest sie jetzt, samt jedem Route Handler; die Ausnahmen
(Auth, Healthcheck) stehen namentlich mit Begruendung, und ein Test prueft auch
die Gegenrichtung.

#037: Das Lesemodell der Anhaenge liegt in queries/ und engt selbst ein — vorher
hing das daran, dass beide Aufrufer den Lauf vorher pruefen. /meine-geraete wird
bei 390 px gemessen, mit einem echten MITARBEITER-Konto aus dem E2E-Vorlauf,
dazu ein Test, dass dort kein Link in die Verwaltung angeboten wird.

Dabei selbst einen Fehler gemacht, der die neue Regel begruendet: fuer das
zweite Konto brauchte `anmelden` eine Variante, die nicht auf / wartet, und ich
habe das Warten fuer alle gelockert. Das machte zwei unverwandte Ablaeufe
unzuverlaessig — sichtbar nur im vollen Durchgang, einzeln waren beide gruen.
Ein gemeinsamer Helfer in einer Testsuite ist geteilter Zustand. Ohne vollen
Durchgang haette ich zwei rote Ablaeufe gemergt.

1457 Vitest-Tests (vorher 1454), 115 E2E-Ablaeufe (vorher 113). Offen bleibt nur
noch #024, und das verlangt einen echten Proxmox-Host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-11 06:05:29 +02:00

382 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { SignJWT } from "jose";
import { sicheresZiel } from "@/server/auth/anmeldung";
import { OEFFENTLICH, istOeffentlich } from "@/proxy";
import { kurzzeitTokenErzeugen, kurzzeitTokenLesen } from "@/lib/kurzzeit-token";
import { sitzungErzeugen, sitzungLesen } from "@/lib/sitzung";
/**
* Die Zugangswege in die Anwendung (#007).
*
* Diese Datei haelt die Zusicherungen fest, die ein unabhaengiger Review als
* Luecken benannt hat: das Ruecksprungziel gegen Steuerzeichen, die Allowlist des
* Proxys, die Trennung der beiden Token-Arten und dass wirklich jede Server
* Action mit einer Rechtepruefung beginnt.
*/
const UMGEBUNG = { ...process.env };
afterEach(() => {
process.env = { ...UMGEBUNG };
});
describe("Ruecksprungziel gegen Steuerzeichen", () => {
// Browser entfernen Tabulator, Zeilenumbruch und Wagenruecklauf beim
// Verarbeiten einer URL. Aus "/<TAB>/fremd.example" wird dabei
// "//fremd.example" – also ein fremder Host. Eine Pruefung auf Praefixe
// erkennt das nicht.
const angriffe = [
"/\t/fremd.example",
"/\n//fremd.example",
"/\r/fremd.example",
"/\t\t//fremd.example",
"//fremd.example",
"/\\fremd.example",
"/..//fremd.example",
"/../..//fremd.example",
"https://fremd.example",
"//FREMD.example",
"javascript:alert(1)",
];
it.each(angriffe)("weist %j ab", (angriff) => {
const ergebnis = sicheresZiel(angriff);
expect(ergebnis).toBe("/");
});
it("gibt nie etwas zurueck, das als Adresse einen fremden Host ergaebe", () => {
for (const angriff of angriffe) {
const aufgeloest = new URL(sicheresZiel(angriff), "http://laufzettel.example");
expect(aufgeloest.origin, `bei ${JSON.stringify(angriff)}`).toBe("http://laufzettel.example");
}
});
it("laesst gewoehnliche Ziele unveraendert durch", () => {
expect(sicheresZiel("/t/LZ-7K3F9Q")).toBe("/t/LZ-7K3F9Q");
expect(sicheresZiel("/laeufe?filter=offen#abschnitt-2")).toBe("/laeufe?filter=offen#abschnitt-2");
});
it("laesst prozentkodierte Steuerzeichen als Pfad stehen, ohne den Host zu wechseln", () => {
// %09 wird beim Aufloesen nicht dekodiert, bleibt also ein eigenartiger Pfad
// auf der eigenen Herkunft – harmlos, im Gegensatz zum echten Tabulator.
const ergebnis = sicheresZiel("/%09/fremd.example");
expect(new URL(ergebnis, "http://laufzettel.example").origin).toBe("http://laufzettel.example");
});
});
describe("Allowlist des Proxys", () => {
/**
* Jeder oeffentliche Pfad, einzeln aufgezaehlt — **und zwar absichtlich von
* Hand.** Diese Liste ist die Behauptung „genau das soll ohne Anmeldung
* gehen“; aus `OEFFENTLICH` abgeleitet wuerde sie nichts mehr behaupten.
* Dass sie vollstaendig ist, prueft der Test darunter.
*/
const ERWARTET_OEFFENTLICH = [
"/anmelden",
"/api/auth",
"/api/gesundheit",
// Der SCIM-Endpunkt (#029): ein Identitaetsanbieter hat kein Cookie, er
// weist sich mit einem Bearer-Token aus, und jeder Handler darunter prueft
// es selbst. Ohne diesen Eintrag leitet der Proxy jede Zustellung auf
// /anmelden um, und das Provisioning hoert still auf zu arbeiten.
"/api/scim",
"/manifest.webmanifest",
"/icon.svg",
] as const;
it("laesst genau die Pfade durch, die ohne Anmeldung gehen muessen", () => {
for (const pfad of [
...ERWARTET_OEFFENTLICH,
// Und die Unterpfade, auf die es im Betrieb ankommt.
"/api/auth/oidc/start",
"/api/auth/oidc/rueckruf",
"/api/auth/abmelden",
"/api/scim/v2/Users",
"/api/scim/v2/ServiceProviderConfig",
]) {
expect(istOeffentlich(pfad), pfad).toBe(true);
}
});
it("hat keinen oeffentlichen Pfad, den dieser Test nicht kennt", () => {
/*
* **Der Test, der gefehlt hat.** `/api/scim` kam mit #029 in die Allowlist
* und stand in keiner Pruefung — die Zeile liess sich entfernen, ohne dass
* ein einziger Test rot wurde (Reviewbefund). Dabei ist der Eintrag beides:
* noetig, damit das Provisioning ueberhaupt ankommt, und eine
* Sicherheitsentscheidung, weil darunter jeder Handler sein Token selbst
* pruefen **muss**.
*
* Deshalb hier der Vergleich in die andere Richtung: wer `OEFFENTLICH`
* erweitert, muss es oben aufschreiben. Eine Liste, die nur in einer
* Richtung geprueft wird, laeuft dem Code hinterher.
*/
expect([...OEFFENTLICH].sort()).toEqual([...ERWARTET_OEFFENTLICH].sort());
});
it("laesst sich nicht durch einen angehaengten Namen austricksen", () => {
// Ein reiner startsWith-Vergleich haette /anmeldenX durchgelassen.
for (const pfad of [
"/anmeldenX",
"/anmelden-geheim",
"/api/authX",
"/api/gesundheitlich",
"/admin/benutzer",
"/",
"/laeufe",
"/t/LZ-7K3F9Q",
]) {
expect(istOeffentlich(pfad), pfad).toBe(false);
}
});
});
describe("Trennung von Sitzung und Kurzzeit-Token", () => {
it("laesst ein Kurzzeit-Token nicht als Sitzung durchgehen", async () => {
const kurz = await kurzzeitTokenErzeugen("oidc-fluss", { ziel: "/" }, 60);
expect(await sitzungLesen(kurz)).toBeUndefined();
});
it("laesst eine Sitzung nicht als Kurzzeit-Token durchgehen", async () => {
const sitzung = await sitzungErzeugen({
benutzerId: "b1",
email: "a@b.example",
name: "A",
herkunft: "passwort",
});
expect(await kurzzeitTokenLesen("oidc-fluss", sitzung)).toBeUndefined();
});
it("laesst ein Kurzzeit-Token fuer einen anderen Zweck nicht durchgehen", async () => {
const kurz = await kurzzeitTokenErzeugen("ganz-anderer-zweck", { ziel: "/" }, 60);
expect(await kurzzeitTokenLesen("oidc-fluss", kurz)).toBeUndefined();
});
it("weist ein Token ab, das mit dem rohen APP_SECRET signiert wurde", async () => {
// Das Kurzzeit-Modul leitet seinen Schluessel per scrypt ab. Wer nur das
// Geheimnis kennt, kann damit kein gueltiges Fluss-Token bauen.
const roh = new TextEncoder().encode(process.env["APP_SECRET"] ?? "");
const gefaelscht = await new SignJWT({ ziel: "/" })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setIssuer("laufzettel")
.setAudience("oidc-fluss")
.setExpirationTime("60s")
.sign(roh);
expect(await kurzzeitTokenLesen("oidc-fluss", gefaelscht)).toBeUndefined();
});
});
describe("Rechtepruefung an den Einstiegspunkten", () => {
const verzeichnis = "src/server/actions";
/** Aktionen, die absichtlich ohne Anmeldung erreichbar sind. */
const OEFFENTLICHE_AKTIONEN = new Set(["anmeldenAktion"]);
it("beginnt jede Server Action mit einer Rechtepruefung", () => {
// Strukturell geprueft, weil eine vergessene Pruefung sonst erst auffiele,
// wenn jemand sie ausnutzt. Die Rechtematrix selbst deckt tests/rollen.test.ts ab.
//
// Anerkannt wird auch ein Helfer **in derselben Datei**, der seinerseits
// `rechtErforderlich` ruft: seit #010 fangen die Vorlagen-Aktionen den
// RechteFehler ab und geben eine Meldung zurueck, statt die Seite
// auszuhaengen — im Editor waere sonst jede ungespeicherte Arbeit verloren.
// Die Pruefung findet trotzdem statt, nur eine Ebene tiefer.
const befunde: string[] = [];
for (const name of readdirSync(verzeichnis).filter((n) => n.endsWith(".ts"))) {
const quelle = readFileSync(join(verzeichnis, name), "utf8");
expect(quelle.startsWith('"use server";'), `${name} fehlt "use server"`).toBe(true);
// Helfer, die selbst pruefen – Name und Rumpf aus derselben Datei.
const helfer = [...quelle.matchAll(/(?:async )?function (\w+)\(([\s\S]*?)\n\}/g)]
.filter((treffer) => treffer[2]?.includes("rechtErforderlich("))
.map((treffer) => treffer[1])
.filter((funktion): funktion is string => funktion !== undefined);
for (const treffer of quelle.matchAll(/export async function (\w+)\(([\s\S]*?)\n\}/g)) {
const [, funktion, rumpf] = treffer;
if (!funktion || OEFFENTLICHE_AKTIONEN.has(funktion)) continue;
const prueft =
rumpf?.includes("rechtErforderlich(") === true ||
helfer.some((helfername) => rumpf?.includes(`${helfername}(`) === true);
if (!prueft) {
befunde.push(`${name}: ${funktion} prueft kein Recht`);
}
}
}
expect(befunde).toEqual([]);
});
it("laesst keine Server Action auf dem Recht der Selbstauskunft stehen", () => {
/*
* Die Zusage „ein MITARBEITER kann nichts schreiben“ (#035) steht nicht in
* einer einzelnen Pruefung, sondern in der **Abwesenheit** einer Aktion mit
* diesem Recht. Das ist strukturell: `selbstauskunft` ist ein reines
* Leserecht, und sobald eine Server Action es verlangte, haette diese Rolle
* einen Schreibweg — ohne dass irgendein anderer Test rot wuerde.
*
* Deshalb hier und nicht in `tests/rollen.test.ts`: dort geht es um die
* Tabelle, hier um das, was mit ihr gebaut wird.
*/
const befunde: string[] = [];
for (const name of readdirSync(verzeichnis).filter((n) => n.endsWith(".ts"))) {
const quelle = readFileSync(join(verzeichnis, name), "utf8");
if (quelle.includes('rechtErforderlich("selbstauskunft")')) {
befunde.push(`${name} verlangt "selbstauskunft" – das ist ein reines Leserecht`);
}
}
expect(befunde).toEqual([]);
});
/**
* Alle `page.tsx` unter `(app)`, rekursiv.
*
* Von Hand aufzuzaehlen waere dasselbe Problem wie bei der Proxy-Allowlist:
* eine Liste, die dem Code hinterherlaeuft. Eine neue Seite soll **ohne**
* Zutun in diesem Test landen.
*/
function seitenDateien(ordner: string): string[] {
const gefunden: string[] = [];
for (const eintrag of readdirSync(ordner, { withFileTypes: true })) {
const pfad = join(ordner, eintrag.name);
if (eintrag.isDirectory()) gefunden.push(...seitenDateien(pfad));
else if (eintrag.name === "page.tsx") gefunden.push(pfad);
}
return gefunden;
}
it("laesst jede Seite ihr Recht selbst pruefen", () => {
/*
* **Die Huelle ist das zweite Netz, nicht das erste** (CLAUDE.md). Next
* rendert Layouts bei einer Navigation innerhalb desselben Layouts nicht
* neu — eine Seite, die sich auf `src/app/(app)/layout.tsx` verlaesst, ist
* nach dem ersten Seitenwechsel ungeschuetzt.
*
* Dass das heute an allen 27 Seiten stimmt, hat der Review von Phase 2 **von
* Hand** nachgesehen (Reviewbefund zu #029). Eine Pruefung von Hand ist kein
* Regressionstest: sie gilt fuer den Tag, an dem sie lief. Dieser Test gilt
* fuer jede kuenftige Seite — und genau dort liegt sein Wert, nicht im
* heutigen gruenen Ergebnis.
*/
const befunde: string[] = [];
const seiten = seitenDateien("src/app/(app)");
for (const datei of seiten) {
const quelle = readFileSync(datei, "utf8");
const prueft = quelle.includes("sitzungErforderlich(") || quelle.includes("rechtErforderlich(");
if (!prueft) befunde.push(`${datei} prueft weder Sitzung noch Recht`);
}
expect(befunde).toEqual([]);
// Ohne diese Zeile waere eine leere Liste ein gruener Test.
expect(seiten.length).toBeGreaterThan(20);
});
it("laesst jeden Route Handler sein Recht selbst pruefen", () => {
/*
* **Die Huelle deckt Route Handler gar nicht ab**, und der Proxy prueft das
* Cookie absichtlich nur auf Vorhandensein (CLAUDE.md). Alles unter
* `src/app/api/`, das mehr tut als Auth und Healthcheck, prueft deshalb
* selbst.
*
* Die Ausnahmen stehen **namentlich** darunter, wie bei den oeffentlichen
* Aktionen: ein neuer Handler ohne Pruefung soll jemanden zwingen, ihn hier
* einzutragen — und damit zu begruenden.
*
* Dass das noetig ist, hat der Review gezeigt: beim Anhang-Handler liess
* sich die ganze Pruefung entfernen, ohne dass ein Test rot wurde. Jetzt
* faehrt `tests/anhang-route.test.ts` ihn echt; dieser Test hier faengt den
* **naechsten**.
*/
const OHNE_PRUEFUNG = new Map([
[
"src/app/api/gesundheit/route.ts",
"Healthcheck fuer Docker und den Reverse Proxy – muss ohne Anmeldung antworten",
],
["src/app/api/auth/abmelden/route.ts", "Abmelden kann keine gueltige Sitzung verlangen"],
["src/app/api/auth/oidc/start/route.ts", "Beginn des Anmeldevorgangs"],
["src/app/api/auth/oidc/rueckruf/route.ts", "Rueckruf des Anbieters, vor der Sitzung"],
]);
function handlerDateien(ordner: string): string[] {
const gefunden: string[] = [];
for (const eintrag of readdirSync(ordner, { withFileTypes: true })) {
const pfad = join(ordner, eintrag.name);
if (eintrag.isDirectory()) gefunden.push(...handlerDateien(pfad));
else if (eintrag.name === "route.ts") gefunden.push(pfad);
}
return gefunden;
}
const befunde: string[] = [];
const handler = handlerDateien("src/app/api");
for (const datei of handler) {
const quelle = readFileSync(datei, "utf8");
const prueft =
quelle.includes("rechtErforderlich(") ||
quelle.includes("sitzungErforderlich(") ||
// Der SCIM-Weg: das Bearer-Token wird zur Quelle und damit zum Kunden.
quelle.includes("quelleAusAnfrage(");
if (!prueft && !OHNE_PRUEFUNG.has(datei)) {
befunde.push(`${datei} prueft nichts und steht nicht in der Ausnahmeliste`);
}
}
expect(befunde).toEqual([]);
expect(handler.length).toBeGreaterThan(8);
// Und die Gegenrichtung: eine Ausnahme fuer einen Handler, den es nicht mehr
// gibt, verdeckt beim naechsten Mal einen echten Befund.
for (const datei of OHNE_PRUEFUNG.keys()) {
expect(handler, `${datei} steht in der Ausnahmeliste, existiert aber nicht`).toContain(datei);
}
});
it("erkennt eine Aktion ohne jede Pruefung", () => {
// Der Test oben ist nachsichtiger geworden; dieser hier zeigt, dass er
// trotzdem noch anschlaegt. Geprueft wird die Regel, nicht die Datei.
const quelle = [
'"use server";',
"async function mitPruefung() {",
' await rechtErforderlich("vorlagenBearbeiten");',
"}",
"",
"export async function gut() {",
" await mitPruefung();",
"}",
"",
"export async function schlecht() {",
" return 42;",
"}",
].join("\n");
const helfer = [...quelle.matchAll(/(?:async )?function (\w+)\(([\s\S]*?)\n\}/g)]
.filter((treffer) => treffer[2]?.includes("rechtErforderlich("))
.map((treffer) => treffer[1]);
const ohnePruefung = [...quelle.matchAll(/export async function (\w+)\(([\s\S]*?)\n\}/g)]
.filter(
([, , rumpf]) =>
rumpf?.includes("rechtErforderlich(") !== true &&
!helfer.some((name) => rumpf?.includes(`${name}(`) === true),
)
.map(([, funktion]) => funktion);
expect(ohnePruefung).toEqual(["schlecht"]);
});
it("findet ueberhaupt Aktionen, sonst prueft der Test oben nichts", () => {
const aktionen = readdirSync(verzeichnis)
.filter((n) => n.endsWith(".ts"))
.flatMap((n) => [
...readFileSync(join(verzeichnis, n), "utf8").matchAll(/export async function (\w+)/g),
])
.map((t) => t[1]);
expect(aktionen.length).toBeGreaterThanOrEqual(4);
});
});