// Akzeptanztest Modul „Vorfälle" (IM-C): Verknüpfungen, Abschluss-Pflicht & Export. // // Prüft: // 1. Maßnahme direkt aus dem Vorfall anlegen → landet im ZENTRALEN Maßnahmen-Modul // (measures-Tabelle) UND ist mit dem Vorfall verknüpft (IncidentMeasure). // 2. Risiko neu aus dem Vorfall erzeugen → im Risiko-Register + verknüpft (IncidentRisk). // 3. Nachweis anlegen + verknüpfen (Evidence + IncidentEvidence). // 4. Abschluss-Pflichtfelder (§8): abgeschlossen erzwingt Ursache/Lösung/Lessons // Learned (+ Abschlussnotiz); Post-Incident-Review/Wirksamkeit sind NICHT Pflicht. // 5. Register-Export (CSV) enthält die erwarteten Spalten/Werte. // 6. NIS2- und DSGVO-Meldevorlagen sind vorbefüllt (Zeiten, Kategorie, Fristen). // // Lauf: npx tsx scripts/test-incident-links.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 { missingRequiredFields } from "../src/lib/incident"; import { INCIDENT_REGISTER_COLUMNS, toRegisterCsv, buildNis2Template, buildDsgvoTemplate, type IncidentRegisterInput, type IncidentTemplateInput, } from "../src/lib/incident-export"; let failures = 0; const ok = (cond: boolean, msg: string) => { console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); if (!cond) failures++; }; const SLUG = "zz-inc-links-a"; const EMAIL = "zz-inc-links-owner@test.example"; async function cleanup() { const t = await prisma.tenant.findFirst({ where: { slug: SLUG }, select: { id: true } }); if (t) { await prisma.incident.deleteMany({ where: { tenantId: t.id } }); await prisma.measure.deleteMany({ where: { tenantId: t.id } }); await prisma.risk.deleteMany({ where: { tenantId: t.id } }); await prisma.evidence.deleteMany({ where: { tenantId: t.id } }); await prisma.user.deleteMany({ where: { tenantId: t.id } }); await prisma.tenant.delete({ where: { id: t.id } }); } await prisma.identity.deleteMany({ where: { email: EMAIL } }); } async function nextMeasureRef(tenantId: string) { const last = await prisma.measure.aggregate({ where: { tenantId }, _max: { refNo: true } }); return (last._max.refNo ?? 0) + 1; } async function nextRiskRef(tenantId: string) { const last = await prisma.risk.aggregate({ where: { tenantId }, _max: { refNo: true } }); return (last._max.refNo ?? 0) + 1; } async function main() { await cleanup(); const tenant = await prisma.tenant.create({ data: { name: "IM-C Test", slug: SLUG } }); const identity = await prisma.identity.create({ data: { email: EMAIL, passwordHash: "x" } }); const user = await prisma.user.create({ data: { tenantId: tenant.id, identityId: identity.id, email: EMAIL, name: "Owner" } }); const db = dbForTenant(tenant.id); const occurredAt = new Date("2026-08-15T08:00:00Z"); const detectedAt = new Date("2026-08-16T09:30:00Z"); const incident = await db.incident.create({ data: { tenantId: tenant.id, refNo: await nextIncidentRefNo(prisma, tenant.id), title: "Ransomware auf Fileserver", description: "Verschlüsselte Freigaben; Verdacht auf Datenabfluss.", category: "malware", severity: "hoch", status: "in_bearbeitung", occurredAt, detectedAt, impactC: 3, impactI: 4, impactA: 4, urgency: 4, affectedDataCategories: ["Kundendaten", "Zugangsdaten"], personalData: true, dsgvoRelevant: true, nis2Relevant: true, ownerId: user.id, // Meldefristen wie von IM-B aus dem Kenntniszeitpunkt gesetzt (24h/72h/1M). erstmeldungDueAt: new Date(detectedAt.getTime() + 24 * 3600_000), folgemeldungDueAt: new Date(detectedAt.getTime() + 72 * 3600_000), dsgvoDueAt: new Date(detectedAt.getTime() + 72 * 3600_000), reportStatus: "pruefung", }, }); // ── 1. Maßnahme direkt aus dem Vorfall anlegen + verknüpfen ──────────────── const measure = await db.measure.create({ data: { tenantId: tenant.id, refNo: await nextMeasureRef(tenant.id), title: "Systeme isolieren & Backups prüfen", priority: "HIGH", ownerId: user.id, createdBy: user.id }, }); await db.incidentMeasure.create({ data: { tenantId: tenant.id, incidentId: incident.id, measureId: measure.id } }); const centralMeasure = await db.measure.findFirst({ where: { id: measure.id } }); ok(!!centralMeasure && centralMeasure.tenantId === tenant.id, "Maßnahme liegt im zentralen Maßnahmen-Modul (measures-Tabelle)"); const linkedMeasures = await db.incidentMeasure.findMany({ where: { incidentId: incident.id }, include: { measure: true } }); ok(linkedMeasures.length === 1 && linkedMeasures[0].measure.title === measure.title, "Maßnahme ist mit dem Vorfall verknüpft (IncidentMeasure, Titel/Status aus dem Modul)"); ok(linkedMeasures[0].measure.status === "OPEN" && linkedMeasures[0].measure.priority === "HIGH", "Verknüpfte Maßnahme trägt Status/Priorität aus dem Maßnahmen-Modul"); // ── 2. Risiko neu erzeugen + verknüpfen ──────────────────────────────────── const risk = await db.risk.create({ data: { tenantId: tenant.id, refNo: await nextRiskRef(tenant.id), title: "Unzureichende Backup-Isolierung", likelihood: 3, impact: 5, score: 15, createdBy: user.id }, }); await db.incidentRisk.create({ data: { tenantId: tenant.id, incidentId: incident.id, riskId: risk.id } }); const centralRisk = await db.risk.findFirst({ where: { id: risk.id } }); ok(!!centralRisk && centralRisk.score === 15, "Risiko im Register angelegt (score = E×S)"); const linkedRisks = await db.incidentRisk.count({ where: { incidentId: incident.id } }); ok(linkedRisks === 1, "Risiko ist mit dem Vorfall verknüpft (IncidentRisk)"); // ── 3. Nachweis anlegen + verknüpfen ─────────────────────────────────────── const ev = await db.evidence.create({ data: { tenantId: tenant.id, title: "Forensik-Report Erstsichtung", kind: "record", createdById: user.id } }); await db.incidentEvidence.create({ data: { tenantId: tenant.id, incidentId: incident.id, evidenceId: ev.id, note: "PDF im DMS" } }); const linkedEv = await db.incidentEvidence.findMany({ where: { incidentId: incident.id }, include: { evidence: true } }); ok(linkedEv.length === 1 && linkedEv[0].evidence.title === ev.title, "Nachweis verknüpft (IncidentEvidence)"); // ── 4. Abschluss-Pflichtfelder (§8) ──────────────────────────────────────── const missWithout = missingRequiredFields("abgeschlossen", { rootCause: "x", resolution: "y", closingNote: "z", lessonsLearned: "" }); ok(missWithout.includes("lessonsLearned"), "Abschluss erzwingt Lessons Learned (fehlt → blockiert)"); const missAll = missingRequiredFields("abgeschlossen", { rootCause: "Ursache", resolution: "Lösung", closingNote: "Notiz", lessonsLearned: "LL", postIncidentReview: null, measuresEffectiveness: null }); ok(missAll.length === 0, "Abschluss mit Ursache/Lösung/Abschlussnotiz/Lessons Learned zulässig — Review/Wirksamkeit NICHT erzwungen"); // ── 5. Register-Export (CSV) ─────────────────────────────────────────────── const regInput: IncidentRegisterInput = { refNo: incident.refNo, title: incident.title, category: incident.category, severity: incident.severity, priority: incident.priority, status: incident.status, source: incident.source, occurredAt: incident.occurredAt, detectedAt: incident.detectedAt, reportedAt: incident.reportedAt, ownerName: user.name, assigneeName: null, nis2Relevant: incident.nis2Relevant, dsgvoRelevant: incident.dsgvoRelevant, personalData: incident.personalData, prototypeData: incident.prototypeData, reportStatus: incident.reportStatus, erstmeldungDueAt: incident.erstmeldungDueAt, dsgvoDueAt: incident.dsgvoDueAt, abschlussDueAt: incident.abschlussDueAt, measureCount: 1, riskCount: 1, assetCount: 0, controlCount: 0, createdAt: incident.createdAt, }; const csv = toRegisterCsv([regInput]); const header = csv.split("\r\n")[0]; ok(INCIDENT_REGISTER_COLUMNS.every((c) => header.includes(c)), "Register-CSV enthält alle erwarteten Spalten (Kennung … Maßnahmen … Erstellt)"); ok(csv.includes(incident.refNo) && csv.includes("Ransomware auf Fileserver"), "Register-CSV-Zeile enthält refNo + Titel"); ok(csv.includes("Schadsoftware"), "Register-CSV übersetzt die Kategorie (Schadsoftware)"); // ── 6. Meldevorlagen (NIS2 / DSGVO) ──────────────────────────────────────── const tmpl: IncidentTemplateInput = { refNo: incident.refNo, title: incident.title, description: incident.description, category: incident.category, severity: incident.severity, organisation: tenant.name, occurredAt: incident.occurredAt, detectedAt: incident.detectedAt, reportedAt: incident.reportedAt, impactC: incident.impactC, impactI: incident.impactI, impactA: incident.impactA, affectedDataCategories: incident.affectedDataCategories, personalData: incident.personalData, nis2Relevant: incident.nis2Relevant, reporterName: incident.reporterName, reporterContact: incident.reporterContact, erstmeldungDueAt: incident.erstmeldungDueAt, folgemeldungDueAt: incident.folgemeldungDueAt, abschlussDueAt: incident.abschlussDueAt, dsgvoDueAt: incident.dsgvoDueAt, }; const nis2 = buildNis2Template(tmpl); ok(nis2.includes("NIS2") && nis2.includes(incident.refNo) && nis2.includes("Schadsoftware"), "NIS2-Vorlage vorbefüllt (refNo + Kategorie)"); ok(nis2.includes("24 h") && nis2.includes("72 h") && nis2.includes("1 Monat"), "NIS2-Vorlage nennt die Meldefristen 24 h / 72 h / 1 Monat"); ok(nis2.includes("IM-C Test"), "NIS2-Vorlage trägt die meldende Organisation"); const dsgvo = buildDsgvoTemplate(tmpl); ok(dsgvo.includes("Art. 33") && dsgvo.includes("72 h"), "DSGVO-Vorlage referenziert Art. 33 (72 h)"); ok(dsgvo.includes("Kundendaten") && dsgvo.includes("Art. 34"), "DSGVO-Vorlage enthält betroffene Datenkategorien + Art.-34-Hinweis"); await cleanup(); if (failures) { console.error(`\n✗ ${failures} Prüfung(en) fehlgeschlagen.`); process.exit(1); } console.log("\n✓ Alle IM-C-Verknüpfungs-/Export-Prüfungen bestanden."); } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(() => prisma.$disconnect());