import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; /** * Prueft die auth_lookup_*-Migration (Aufgabe 2, WINDOWS #18/#20, T-EOR-01/ * T-EOR-02) rein textuell gegen die generierte migration.sql — keine * Datenbank noetig. Baut die Hilfsfunktion aus rls-app-role.spec.ts bewusst * nach, statt sie zu importieren (dieselbe Begruendung wie dort: die * vorhandene ist in ihrer Datei privat). * * Zaehlbedingungen ueber den Migrationstext filtern Kommentarzeilen vorher * heraus, sonst zaehlt die Begruendung im Kopf der Datei als Treffer mit. */ const MIGRATIONS_DIR = join(__dirname, '../../prisma/migrations'); const FUNCTION_NAMES = [ 'auth_lookup_user_by_username', 'auth_lookup_user_by_email', 'auth_lookup_reset_token', ]; function readMigrationSql(suffix: string): string { const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && entry.name.endsWith(suffix)) .map((entry) => entry.name); if (dirs.length !== 1) { throw new Error( `Expected exactly one migration directory ending in "${suffix}", found ${dirs.length}: ${dirs.join(', ')}`, ); } return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8'); } function stripSqlComments(sql: string): string { // Migrations verwenden ausschliesslich `--`-Zeilenkommentare, keine // Blockkommentare — eine einfache Zeilenfilterung reicht. return sql .split('\n') .filter((line) => !line.trim().startsWith('--')) .join('\n'); } describe('auth_lookup_functions migration.sql (Aufgabe 2, WINDOWS #18/#20)', () => { const rawSql = readMigrationSql('_auth_lookup_functions'); const sql = stripSqlComments(rawSql); it('legt alle drei Funktionen an', () => { for (const name of FUNCTION_NAMES) { expect(sql).toContain(`CREATE OR REPLACE FUNCTION ${name}(`); } }); it('jede Funktion traegt SECURITY DEFINER', () => { const occurrences = sql.match(/SECURITY DEFINER/g) ?? []; expect(occurrences.length).toBe(FUNCTION_NAMES.length); }); it('jede Funktion traegt STABLE', () => { const occurrences = sql.match(/\bSTABLE\b/g) ?? []; expect(occurrences.length).toBe(FUNCTION_NAMES.length); }); it('jede Funktion heftet den Suchpfad fest auf public, pg_temp — der eigentliche Schutz bei SECURITY DEFINER', () => { const occurrences = sql.match(/SET search_path = public, pg_temp/g) ?? []; expect(occurrences.length).toBe(FUNCTION_NAMES.length); }); it('jede Funktion begrenzt das Ergebnis auf LIMIT 1', () => { const occurrences = sql.match(/LIMIT 1;/g) ?? []; expect(occurrences.length).toBe(FUNCTION_NAMES.length); }); it('entzieht fuer jede Funktion zuerst alle Rechte von PUBLIC, bevor tessera_app das Ausfuehrungsrecht bekommt', () => { for (const name of FUNCTION_NAMES) { const revokeIdx = sql.indexOf(`REVOKE ALL ON FUNCTION ${name}(`); const grantIdx = sql.indexOf(`GRANT EXECUTE ON FUNCTION ${name}(`); expect(revokeIdx).toBeGreaterThanOrEqual(0); expect(grantIdx).toBeGreaterThan(revokeIdx); expect(sql.slice(revokeIdx, revokeIdx + 200)).toContain('FROM PUBLIC'); } }); it('erteilt das Ausfuehrungsrecht ausschliesslich an tessera_app — keine andere Rolle taucht in einem GRANT EXECUTE auf', () => { const grantLines = sql .split('\n') .filter((line) => line.trim().startsWith('GRANT EXECUTE ON FUNCTION')); expect(grantLines.length).toBe(FUNCTION_NAMES.length); for (const line of grantLines) { expect(line).toContain('TO tessera_app'); } }); it('enthaelt kein Kennwort — kein PASSWORD gefolgt von einem Hochkomma', () => { expect(sql).not.toMatch(/PASSWORD\s*'/); }); it('keine der drei Funktionen schreibt — kein INSERT/UPDATE/DELETE/DROP im Funktionskoerper', () => { for (const verb of ['INSERT', 'UPDATE', 'DELETE', 'DROP']) { expect(sql).not.toContain(`${verb} `); } }); it('prueft vorab, dass tessera_app existiert, statt still zu ueberspringen (wiederholbares Muster wie 20260909130000)', () => { expect(sql).toContain("pg_roles WHERE rolname = 'tessera_app'"); expect(sql).toContain('RAISE EXCEPTION'); }); it('auth_lookup_user_by_username liefert keinen der drei Textblock-Kandidaten fuer Kennwortfelder ausserhalb passwordHash', () => { // Feste Regressionspruefung: der Spaltensatz ist genau der im Plan // benannte, kein SELECT *. expect(sql).not.toMatch(/SELECT\s+\*\s+FROM\s+"User"/); expect(sql).not.toMatch(/SELECT\s+\*\s+FROM\s+"PasswordResetToken"/); }); });