Files
craftvia/scripts/test-incident-links.ts
msolarczekandClaude Opus 5 c8e6f30a27
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s
Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:05:39 +02:00

213 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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());