Files
craftvia/scripts/test-incidents.ts
T
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

154 lines
8.2 KiB
TypeScript

// 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-<JJJJ>-<lfd>` + 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<unknown>, 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());