Basis: Certvia dev@a48c5fb als Fundament für Craftvia
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s

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:
2026-09-14 11:05:39 +02:00
co-authored by Claude Opus 5
commit c8e6f30a27
720 changed files with 140143 additions and 0 deletions
+212
View File
@@ -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());