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>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
// 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());
|
||||
Reference in New Issue
Block a user