Files
Laufzettel/tests/zugang.test.ts
T
Florian WildeandClaude Opus 5 80d97467da
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
fix: Reviewbefunde aus der Durchsicht von Phase 2 (#036, #027)
Acht Meilensteine lagen ohne fremden Blick auf main. Ein Review mit eigener
Datenbank, Gegenproben und vollem Suitenlauf hat sie gegen die
Akzeptanzkriterien und gegen CLAUDE.md geprueft.

Die Kernregeln halten: fuenf von fuenf Gegenproben gegen Versionierung,
Mandantengrenze, Abschlussregel und Selbstauskunft wurden rot. Alle Befunde
lagen am Rand — und die drei wertvollsten waren Gegenproben, die gruen blieben.

Schwer:

- Der Link in der Mail "Verzeichnis-Abgleich fehlgeschlagen" war ein 404
  (/verwaltung/verzeichnis statt /admin/verzeichnis). Die eine Meldung, die an
  alle Administratoren geht, im Moment eines Ausfalls. Ein Test prueft jetzt
  jeden Meldungspfad gegen die vorhandenen Routen.
- Der Export eines Entwurfs mit Baustein lieferte einen leeren Abschnitt unter
  dem Platzhalter "wird ersetzt" — der Zweck des Formats ist, eine Vorlage in
  eine zweite Installation zu bringen. Bausteine werden jetzt aufgeloest.

Mittel:

- Der Anhang-Handler hatte keinen Test; die ganze Rechtepruefung liess sich
  durch einen hartcodierten ADMIN ersetzen, Suite gruen. Es ist die einzige
  Stelle, die rohe Dateibytes ausliefert. tests/anhang-route.test.ts faehrt ihn
  jetzt echt, acht Pruefungen.
- /api/scim in der Proxy-Allowlist stand in keinem Test. OEFFENTLICH ist
  exportiert, der Test vergleicht beide Richtungen: eine Liste, die nur in einer
  Richtung geprueft wird, laeuft dem Code hinterher.
- Ein offener Ereignisstrom ueberlebte Abmelden, Deaktivieren und
  Sitzungswiderruf. Der Herzschlag prueft die Sitzung jetzt nach. Dass es kein
  Test zeigte, lag an einer Attrappe `sitzung: {}` — eine Attrappe ohne die Form
  der Wirklichkeit prueft etwas anderes als die Wirklichkeit.
- README kannte MITARBEITER nicht und behauptete, unbekannte Benutzer wuerden
  angelegt. Wer auf v0.2.0 hebt und nur das README liest, haette genau diese
  Leute ausgesperrt.
- sicherung.md nannte einen Anhaenge-Pfad, den es nirgends gibt — als
  Rueckfallwert in einem Skript mit rm -rf. Jetzt bricht es ab statt zu raten.
- Attributnamen wurden nur in der Server Action geprueft, waehrend der
  Graph-Leser sich im Kommentar darauf berief.

Dazu #027: die Ende-zu-Ende-Ablaeufe raeumen jetzt zentral auf, vor und nach
dem Lauf, und ein struktureller Test bewacht es. Beim ersten echten Lauf seit
dem 26. September waren sieben Ablaeufe rot, vier Ursachen, keine ein
Produktfehler — unbemerkt, weil pnpm test:e2e in keinem Tor steht. Das ist jetzt
#038 und keine Fussnote.

Ein Befund des Reviews war falsch (der Zustands-Handler habe keinen Test; er hat
acht direkte Aufrufe). Nachgeprueft, bevor etwas geaendert wurde.

1454 Vitest-Tests (vorher 1432), 113 E2E-Ablaeufe gruen.

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

277 lines
11 KiB
TypeScript
Raw 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([]);
});
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);
});
});