import { readFileSync, readdirSync, statSync } from 'node:fs' import { join, relative, sep } from 'node:path' import { describe, expect, it } from 'vitest' /** * Die MFA-Pflicht als Grenze, nicht als Behauptung. * * "Der zweite Faktor ist Pflicht" stand lange im Kopf von src/auth.ts, * durchgesetzt wurde es aber nur im App-Layout. Ein Layout schuetzt, was * durch das Layout gerendert wird - Server Actions und Route Handler laufen * daran vorbei. Wer eine Passwort-Sitzung hatte, die TOTP-Einrichtung aber * nie abgeschlossen hat, kam trotzdem an die Daten. * * Seitdem steckt die Pruefung in erfordereBenutzer() selbst. Damit sie dort * bleibt, prueft dieser Test die Bauform statt des Verhaltens: Er liest den * Quelltext und besteht darauf, dass die Umgehungen aufgezaehlt bleiben. * * Ein Integrationstest waere schoener, braeuchte aber eine Sitzung mit * halbfertigem zweitem Faktor - also genau den Zustand, den die Anwendung * nicht mehr herstellen laesst. Diese Pruefung greift dafuer bei jedem * neuen Endpunkt, auch bei dem, an den beim Testeschreiben niemand denkt. */ const WURZEL = join(__dirname, '..', 'src') /** * Wer an aktuellerBenutzer() oder erfordereBenutzerOhneZweitenFaktor() * vorbei darf - und warum. * * Gemeinsam ist allen: Sie geben keine Daten einer betreuten Person heraus. * Wer hier etwas ergaenzt, hebelt die MFA-Pflicht fuer diesen Pfad aus und * schuldet eine Begruendung. */ const ERLAUBT = new Map([ ['lib/auth/sitzung.ts', 'definiert die Funktionen'], ['app/(auth)/anmelden/page.tsx', 'leitet bereits Angemeldete weiter, zeigt nichts an'], ['app/(auth)/ersteinrichtung/page.tsx', 'laeuft, bevor es einen zweiten Faktor geben kann'], ['app/(auth)/zwei-faktor/einrichten/page.tsx', 'ist die Einrichtung selbst'], ['server/actions/zwei-faktor.ts', 'Vorbereiten und Bestaetigen der Einrichtung'], ]) /** Route Handler, die absichtlich ohne Anmeldung erreichbar sind. */ const OEFFENTLICHE_ROUTEN = new Map([ ['app/api/auth/[...nextauth]/route.ts', 'die Anmeldung selbst'], ['app/api/gesundheit/route.ts', 'Statusabfrage fuer die Ueberwachung, ohne Daten'], ['app/api/kalender/[signatur]/route.ts', 'signierter Abo-Link, eigene Pruefung'], ['app/api/wartung/route.ts', 'Aufruf per Geheimnis aus dem Systemdienst'], ]) /** Alles, was einen angemeldeten Benutzer mit fertigem zweitem Faktor sichert. */ const WAECHTER = [ 'benutzerFuerRoute', 'erfordereZugang', 'erfordereBenutzer(', 'finanzKontext', 'berichtsKontext', ] function dateienUnter(verzeichnis: string, endungen: string[]): string[] { const gefunden: string[] = [] for (const eintrag of readdirSync(verzeichnis)) { if (eintrag === 'node_modules' || eintrag === '.next') continue const pfad = join(verzeichnis, eintrag) if (statSync(pfad).isDirectory()) { gefunden.push(...dateienUnter(pfad, endungen)) } else if (endungen.some((endung) => eintrag.endsWith(endung))) { gefunden.push(pfad) } } return gefunden } /** Pfad relativ zu src/, immer mit Schraegstrich - auch unter Windows. */ function kennung(pfad: string): string { return relative(WURZEL, pfad).split(sep).join('/') } describe('MFA-Grenze', () => { it('umgeht die Pflicht nur an den aufgezaehlten Stellen', () => { const umgehungen = dateienUnter(WURZEL, ['.ts', '.tsx']) .filter((pfad) => { const quelltext = readFileSync(pfad, 'utf-8') return ( quelltext.includes('aktuellerBenutzer') || quelltext.includes('erfordereBenutzerOhneZweitenFaktor') ) }) .map(kennung) .sort() expect(umgehungen).toEqual([...ERLAUBT.keys()].sort()) }) it('sichert jeden Route Handler oder erklaert, warum nicht', () => { const ungesichert = dateienUnter(join(WURZEL, 'app'), ['route.ts']) .map(kennung) .filter((datei) => !OEFFENTLICHE_ROUTEN.has(datei)) .filter((datei) => { const quelltext = readFileSync(join(WURZEL, datei), 'utf-8') return !WAECHTER.some((waechter) => quelltext.includes(waechter)) }) expect(ungesichert).toEqual([]) }) it('weist einen Abruf ohne zweiten Faktor ab, statt ihn umzuleiten', () => { // Ein Redirect auf die Anmeldeseite waere bei einer Datei die falsche // Antwort: Der Aufrufer bekaeme HTML, wo er ein Dokument erwartet, und // ein Skript wertete das womoeglich als Erfolg. const quelltext = readFileSync(join(WURZEL, 'lib', 'auth', 'sitzung.ts'), 'utf-8') const abschnitt = quelltext.slice(quelltext.indexOf('export async function benutzerFuerRoute')) expect(abschnitt).toContain('status: 401') expect(abschnitt).toContain('status: 403') }) it('prueft den zweiten Faktor am Datenbankstand, nicht am Sitzungstoken', () => { const quelltext = readFileSync(join(WURZEL, 'lib', 'auth', 'sitzung.ts'), 'utf-8') const funktion = quelltext.slice( quelltext.indexOf('export function zweiterFaktorFertig'), quelltext.indexOf('export async function erfordereBenutzer'), ) // Die Felder kommen aus dem User-Datensatz. Stuende hier etwas aus der // Sitzung, liesse es sich ueber session.update() vom Client setzen. expect(funktion).toContain('benutzer.totpAktiviertAm') expect(funktion).toContain('benutzer.totpSecretVerschluesselt') expect(funktion).not.toContain('sitzung') }) })