// Test der Error-Boundary `withActionErrors` (Sicherheitsbefund F-16). // // Prüft: // 1. Bei `ForbiddenError` wirft die Boundary nach außen die GENERISCHE Meldung // ("Vorgang nicht möglich.") — kein Leak interner Details ("Fehlende // Berechtigung: …"). // 2. Intern wird der denied-Pfad genommen: ein Audit-Eintrag `action:"denied"` // landet für Mandant + Entität in der DB. // 3. Ein generischer Fehler wird ebenfalls generisch gekappt, aber NICHT // auditiert (keine Zugriffsverweigerung). // 4. Der Erfolgsfall reicht den Rückgabewert unverändert durch. // // Lauf: npx tsx scripts/test-action-error.ts // Nutzt die lokale Postgres-DB (wie test-tenant-isolation.ts); .env liegt im Worktree. import "dotenv/config"; import { prisma } from "../src/server/db"; import { withActionErrors, GENERIC_ACTION_ERROR } from "../src/server/action-error"; import { ForbiddenError, type Permission } from "../src/server/rbac"; let failures = 0; const ok = (cond: boolean, msg: string) => { console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); if (!cond) failures++; }; const SLUG = "zz-actionerr-test"; const ENTITY = "customer_ae_test"; async function cleanup() { const tenant = await prisma.tenant.findFirst({ where: { slug: SLUG }, select: { id: true }, }); if (tenant) { await prisma.auditLog.deleteMany({ where: { tenantId: tenant.id } }); await prisma.tenant.deleteMany({ where: { id: tenant.id } }); } } async function main() { await cleanup(); const tenant = await prisma.tenant.create({ data: { name: "AE-Test", slug: SLUG }, }); const ctx = { tenantId: tenant.id, actorId: undefined, entity: ENTITY }; // (1) + (2) ForbiddenError → generische Meldung + denied-Audit. let outward = ""; try { await withActionErrors(async () => { throw new ForbiddenError("customer:write" as Permission); }, ctx); } catch (e) { outward = e instanceof Error ? e.message : String(e); } ok(outward === GENERIC_ACTION_ERROR, `ForbiddenError → nach außen "${GENERIC_ACTION_ERROR}"`); ok( !/Fehlende Berechtigung/.test(outward), "ForbiddenError → interne Meldung leakt NICHT nach außen", ); const deniedRows = await prisma.auditLog.findMany({ where: { tenantId: tenant.id, entity: ENTITY, action: "denied" }, }); ok(deniedRows.length === 1, `denied-Audit geschrieben (gefunden: ${deniedRows.length})`); // (3) Generischer Fehler → generisch gekappt, KEIN zusätzliches Audit. let outward2 = ""; try { await withActionErrors(async () => { throw new Error("interne DB-Details XYZ"); }, ctx); } catch (e) { outward2 = e instanceof Error ? e.message : String(e); } ok(outward2 === GENERIC_ACTION_ERROR, "generischer Fehler → generisch gekappt"); ok(!/XYZ/.test(outward2), "generischer Fehler → interne Details leaken NICHT"); const afterRows = await prisma.auditLog.count({ where: { tenantId: tenant.id, entity: ENTITY, action: "denied" }, }); ok(afterRows === 1, `kein zusätzliches denied-Audit für generischen Fehler (weiterhin ${afterRows})`); // (4) Erfolgsfall reicht Rückgabewert durch. const result = await withActionErrors(async () => 42, ctx); ok(result === 42, "Erfolgsfall → Rückgabewert unverändert durchgereicht"); await cleanup(); console.log(failures === 0 ? "\nOK" : `\nFEHLGESCHLAGEN (${failures})`); await prisma.$disconnect(); process.exit(failures === 0 ? 0 : 1); } main().catch(async (e) => { console.error(e); await prisma.$disconnect(); process.exit(1); });