#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>
382 lines
15 KiB
TypeScript
382 lines
15 KiB
TypeScript
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);
|
||
});
|
||
});
|