// Akzeptanztest Modul „Vorfälle" (IM-A). // // Prüft: // 1. Mandantenisolation: ein Vorfall aus Mandant A ist über dbForTenant(B) NICHT // sichtbar (RLS/Guard) — und der Eigenzugriff funktioniert. // 2. refNo-Format `INC--` + Fortlauf je Mandant/Jahr. // 3. Statusübergang mit Pflichtfeld: nach „behoben"/„abgeschlossen" fehlen die // Pflichtfelder → Übergang unzulässig; mit gesetzten Feldern → zulässig. // 4. Vertraulichkeit: ein restricted-Vorfall wird nur für owner/manage sichtbar, // für andere über den serverseitigen Filter ausgeblendet. // // Lauf: npx tsx scripts/test-incidents.ts (lokale isms-DB, .env im Repo). import "dotenv/config"; import { prisma, dbForTenant } from "../src/server/db"; import { nextIncidentRefNo } from "../src/server/incident-refno"; import { canTransition, missingRequiredFields } from "../src/lib/incident"; import { computeSeverity, impactFromCia } from "../src/lib/incident-severity"; import type { Prisma } from "@prisma/client"; let failures = 0; const ok = (cond: boolean, msg: string) => { console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); if (!cond) failures++; }; async function expectNull(fn: () => Promise, msg: string) { const r = await fn(); ok(r === null, `${msg}${r === null ? "" : ` — statt null: ${JSON.stringify(r)}`}`); } const SLUG_A = "zz-inc-test-a"; const SLUG_B = "zz-inc-test-b"; async function cleanup() { const tenants = await prisma.tenant.findMany({ where: { slug: { in: [SLUG_A, SLUG_B] } }, select: { id: true }, }); const ids = tenants.map((t) => t.id); if (ids.length) { await prisma.incident.deleteMany({ where: { tenantId: { in: ids } } }); await prisma.user.deleteMany({ where: { tenantId: { in: ids } } }); await prisma.tenant.deleteMany({ where: { id: { in: ids } } }); } await prisma.identity.deleteMany({ where: { email: { in: ["zz-inc-owner@test.example", "zz-inc-other@test.example"] } } }); } async function main() { await cleanup(); const tenantA = await prisma.tenant.create({ data: { name: "INC-Test A", slug: SLUG_A } }); const tenantB = await prisma.tenant.create({ data: { name: "INC-Test B", slug: SLUG_B } }); // Nutzer (owner + anderer) im Mandant A für die Vertraulichkeitsprüfung. const idOwner = await prisma.identity.create({ data: { email: "zz-inc-owner@test.example", passwordHash: "x" } }); const idOther = await prisma.identity.create({ data: { email: "zz-inc-other@test.example", passwordHash: "x" } }); const owner = await prisma.user.create({ data: { tenantId: tenantA.id, identityId: idOwner.id, email: "zz-inc-owner@test.example", name: "Owner" } }); const other = await prisma.user.create({ data: { tenantId: tenantA.id, identityId: idOther.id, email: "zz-inc-other@test.example", name: "Other" } }); const dbA = dbForTenant(tenantA.id); const dbB = dbForTenant(tenantB.id); // ── 2. refNo-Format + Fortlauf ──────────────────────────────────────────── const ref1 = await nextIncidentRefNo(prisma, tenantA.id); ok(/^INC-\d{4}-\d{4}$/.test(ref1), `refNo-Format korrekt (${ref1})`); const year = new Date().getFullYear(); ok(ref1 === `INC-${year}-0001`, `erste refNo ist INC-${year}-0001 (${ref1})`); const incA1 = await dbA.incident.create({ data: { tenantId: tenantA.id, refNo: ref1, title: "Vorfall A1", category: "phishing", severity: "mittel", status: "neu" }, }); const ref2 = await nextIncidentRefNo(prisma, tenantA.id); ok(ref2 === `INC-${year}-0002`, `zweite refNo läuft fort → INC-${year}-0002 (${ref2})`); await dbA.incident.create({ data: { tenantId: tenantA.id, refNo: ref2, title: "Vorfall A2", category: "outage", severity: "niedrig", status: "neu" }, }); // Mandant B startet unabhängig wieder bei 0001. const refB1 = await nextIncidentRefNo(prisma, tenantB.id); ok(refB1 === `INC-${year}-0001`, `Fortlauf ist mandantenlokal (B startet bei 0001: ${refB1})`); const incB1 = await dbB.incident.create({ data: { tenantId: tenantB.id, refNo: refB1, title: "Vorfall B1", category: "malware", severity: "hoch", status: "neu" }, }); // ── 1. Mandantenisolation ───────────────────────────────────────────────── const ownA = await dbA.incident.findFirst({ where: { id: incA1.id } }); ok(ownA?.id === incA1.id, "Eigenzugriff: Mandant A sieht seinen Vorfall"); await expectNull( () => dbB.incident.findFirst({ where: { id: incA1.id } }), "Isolation: Mandant B sieht den Vorfall von A NICHT (findFirst → null)", ); const bCount = await dbB.incident.count({ where: {} }); ok(bCount === 1, `Isolation: Mandant B zählt nur seine eigenen Vorfälle (${bCount} === 1)`); // Gegenrichtung await expectNull( () => dbA.incident.findFirst({ where: { id: incB1.id } }), "Isolation: Mandant A sieht den Vorfall von B NICHT", ); // ── 3. Statusübergang mit Pflichtfeld ───────────────────────────────────── ok(canTransition("in_bearbeitung", "behoben"), "Übergang in_bearbeitung → behoben ist erlaubt"); ok(!canTransition("neu", "abgeschlossen"), "Übergang neu → abgeschlossen ist NICHT erlaubt"); // ohne Pflichtfelder → fehlend gemeldet const missBehoben = missingRequiredFields("behoben", { rootCause: null, resolution: "" }); ok(missBehoben.includes("rootCause") && missBehoben.includes("resolution"), "behoben verlangt rootCause + resolution (fehlen erkannt)"); const missClose = missingRequiredFields("abgeschlossen", { rootCause: "x", resolution: "y", closingNote: "", lessonsLearned: null }); ok(missClose.includes("closingNote") && missClose.includes("lessonsLearned"), "abgeschlossen verlangt zusätzlich closingNote + lessonsLearned"); // mit allen Feldern → nichts fehlt const missOk = missingRequiredFields("abgeschlossen", { rootCause: "Ursache", resolution: "Lösung", closingNote: "Notiz", lessonsLearned: "LL" }); ok(missOk.length === 0, "abgeschlossen mit allen Pflichtfeldern → zulässig (nichts fehlt)"); // Severity-Matrix (Default, §5) ok(computeSeverity(impactFromCia(4, 0, 0), 4) === "kritisch", "Severity-Matrix: hohe Auswirkung + hohe Dringlichkeit → kritisch"); ok(computeSeverity(impactFromCia(0, 0, 0), 0) === "niedrig", "Severity-Matrix: keine Auswirkung + keine Dringlichkeit → niedrig"); // ── 4. Vertraulichkeit (restricted) ─────────────────────────────────────── const restricted = await dbA.incident.create({ data: { tenantId: tenantA.id, refNo: await nextIncidentRefNo(prisma, tenantA.id), title: "Vertraulicher Vorfall", category: "unauthorized_access", severity: "hoch", status: "neu", restricted: true, ownerId: owner.id }, }); // Filter wie in der Liste/Detail (page.tsx): manage/close sieht alles; sonst nur // unrestricted + eigene (ownerId == userId). const restrictedWhere = (canSee: boolean, userId: string): Prisma.IncidentWhereInput => canSee ? {} : { OR: [{ restricted: false }, { ownerId: userId }] }; // manage/close → sichtbar const seenByManager = await dbA.incident.findFirst({ where: { AND: [{ id: restricted.id }, restrictedWhere(true, other.id)] } }); ok(seenByManager?.id === restricted.id, "restricted: Rolle mit manage/close sieht den vertraulichen Vorfall"); // owner → sichtbar const seenByOwner = await dbA.incident.findFirst({ where: { AND: [{ id: restricted.id }, restrictedWhere(false, owner.id)] } }); ok(seenByOwner?.id === restricted.id, "restricted: der owner sieht seinen vertraulichen Vorfall"); // anderer ohne manage/close → NICHT sichtbar await expectNull( () => dbA.incident.findFirst({ where: { AND: [{ id: restricted.id }, restrictedWhere(false, other.id)] } }), "restricted: anderer Nutzer ohne manage/close sieht ihn NICHT", ); await cleanup(); if (failures) { console.error(`\n✗ ${failures} Prüfung(en) fehlgeschlagen.`); process.exit(1); } console.log("\n✓ Alle Incident-Prüfungen bestanden."); } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(() => prisma.$disconnect());