Fundament: ISMS-Module entfernt; Craftvia-Rollen, Module, Navigation, i18n-Split
- ISMS-Routen, Actions, Server-/Lib-Code, Komponenten, Prisma-Modelle, Seeds, Importer, Skripte und ISMS-Tests entfernt (Fundament bleibt: Auth, Identity, MFA/WebAuthn, RBAC, Audit, Mail, Storage, Backup/DSGVO, Plattform-Admin) - Schema auf Fundament-Modelle reduziert; TenantSettings generisch (+phone/email) - TENANT_MODELS (db.ts, backup/topology.ts) und PII-Felder ausgedünnt - RBAC: Rollen tenant-admin/backoffice/team-lead/technician + Craftvia-Permissions - Modul-Katalog (customers, sites, teams, work_orders, imports, field, reports, emergency, documents, notifications, lotse) + Navigation aus src/lib/nav.ts - Modul-Routen mit requireModule-Layout und Platzhalterseite - Message-Katalog je Namespace (messages/<locale>/<namespace>.json), fs-Loader - check-module-guards: Modul-Key aus src/server/actions/<moduleKey>/ - Provisionierung, Admin-Konsole, Einstellungen, Files-Route, Mail entkoppelt - Seed minimal (demo/demo2, Nutzer je Rolle); Fundament-Tests auf Role/ NotificationPreference-Fixtures umgestellt Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,61 +0,0 @@
|
||||
import "dotenv/config";
|
||||
import { prisma } from "../src/server/db";
|
||||
|
||||
/**
|
||||
* Backfill 5×5-Risikokriterien für Bestandsmandanten: ergänzt die 5. Eintritts-
|
||||
* wahrscheinlichkeits-Stufe und die 5. Schadensstufe je Dimension, falls noch nicht
|
||||
* vorhanden. Die Bewertung (Formular/Matrix/Score) war schon 5×5 — nur die Kriterien-
|
||||
* daten waren 4-stufig geseedet. Idempotent (mehrfach ausführbar).
|
||||
*
|
||||
* npx tsx scripts/backfill-risk-5x5.ts # alle Mandanten
|
||||
* TENANT_SLUG=gefim npx tsx scripts/backfill-risk-5x5.ts # nur einer
|
||||
*/
|
||||
|
||||
// Sinnvolle Default-Texte für Stufe 5 der Standard-Schadensdimensionen (Abgleich per Name).
|
||||
const LEVEL5: Record<string, string> = {
|
||||
"Gesetzes-/Vertragsverstöße": "existenzbedrohende Rechtsfolgen (Lizenzentzug/Haftung)",
|
||||
"Datenschutz": "besonders schützenswerte Daten in großem Umfang / existenzielle Betroffenheit",
|
||||
"Persönliche Unversehrtheit": "Lebensgefahr für viele / Todesfolge",
|
||||
"Aufgabenerfüllung": "vollständiger, dauerhafter Ausfall der Organisation",
|
||||
"Innen-/Außenwirkung (Reputation)": "existenzbedrohender, dauerhafter Reputationsverlust",
|
||||
"Störungs-/Ausfallzeit": "> 1 Monat / dauerhaft",
|
||||
"Finanzielle Auswirkungen": "existenzbedrohend (> 1 Mio €)",
|
||||
};
|
||||
const FALLBACK5 = "(bitte definieren)";
|
||||
|
||||
async function main() {
|
||||
const slug = process.env.TENANT_SLUG?.trim().toLowerCase();
|
||||
const tenants = await prisma.tenant.findMany({ where: slug ? { slug } : {}, select: { id: true, slug: true } });
|
||||
if (!tenants.length) { console.log("Keine Mandanten gefunden."); return; }
|
||||
|
||||
for (const t of tenants) {
|
||||
let ew5 = 0, dim5 = 0;
|
||||
|
||||
// EW-Stufe 5 ergänzen, falls nicht vorhanden
|
||||
const hasEw5 = await prisma.riskEwLevel.findFirst({ where: { tenantId: t.id, level: 5 } });
|
||||
if (!hasEw5) {
|
||||
// Nur ergänzen, wenn überhaupt EW-Stufen existieren (sonst provisioniert der Mandant sie neu).
|
||||
const anyEw = await prisma.riskEwLevel.count({ where: { tenantId: t.id } });
|
||||
if (anyEw > 0) {
|
||||
await prisma.riskEwLevel.create({ data: { tenantId: t.id, level: 5, label: "Nahezu sicher", definition: "Ereignis ist praktisch dauerhaft gegeben oder tritt ständig ein." } });
|
||||
ew5 = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Schadensdimensionen: je Dimension "5" ergänzen, falls fehlt
|
||||
const dims = await prisma.riskDamageDimension.findMany({ where: { tenantId: t.id } });
|
||||
for (const d of dims) {
|
||||
const levels = (d.levels ?? {}) as Record<string, string>;
|
||||
if (levels["5"] == null || levels["5"] === "") {
|
||||
levels["5"] = LEVEL5[d.name] ?? FALLBACK5;
|
||||
await prisma.riskDamageDimension.update({ where: { id: d.id }, data: { levels } });
|
||||
dim5++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Mandant ${t.slug}: EW-Stufe 5 ${ew5 ? "ergänzt" : "bereits vorhanden/übersprungen"}, ${dim5} Schadensdimension(en) um Stufe 5 ergänzt.`);
|
||||
}
|
||||
console.log("Fertig.");
|
||||
}
|
||||
|
||||
main().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -1,63 +0,0 @@
|
||||
// Generator (Story A2-2): parst die C1-Scoping-Tabelle (Fachcontent) in die
|
||||
// maschinenlesbare Scope-Datengrundlage `seed/scoping/c1-scope.json`.
|
||||
// Lauf: npx tsx scripts/build-c1-scope.ts
|
||||
//
|
||||
// Spalten der C1-Tabelle: Control | Anforderungs-ID | Typ | AL2 | AL3 | Prüfziel | Scope-Bedingung(Flag)
|
||||
// `condition` wird normalisiert: exaktes Feature-Flag (FLAG_*) → Flag-Name, sonst null
|
||||
// ("immer im Scope" bzw. "Prüfziel … aktiv" werden allein über das Prüfziel gegatet).
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const SRC = resolve("docs/wizard-uebergabe/02_Fachcontent_C1-C9/C1_Scoping-AL-Pruefziel.md");
|
||||
const OUT = resolve("seed/scoping/c1-scope.json");
|
||||
|
||||
const PRUEFZIEL: Record<string, string> = {
|
||||
Informationssicherheit: "informationssicherheit",
|
||||
Prototypenschutz: "prototypenschutz",
|
||||
Datenschutz: "datenschutz",
|
||||
};
|
||||
|
||||
const FLAG_RE = /^FLAG_[A-Z_]+$/;
|
||||
|
||||
interface C1Row {
|
||||
id: string;
|
||||
control: string;
|
||||
type: "MUSS" | "SOLL" | "HOCH" | "SEHR HOCH";
|
||||
al2: boolean;
|
||||
al3: boolean;
|
||||
pruefziel: string;
|
||||
condition: string | null;
|
||||
}
|
||||
|
||||
const rows: C1Row[] = [];
|
||||
for (const line of readFileSync(SRC, "utf8").split("\n")) {
|
||||
if (!line.startsWith("| ")) continue;
|
||||
const cells = line.split("|").map((c) => c.trim());
|
||||
// cells[0] = "" (vor dem ersten |); Nutzdaten ab cells[1]
|
||||
const [, control, id, type, al2, al3, pruefzielText, condRaw] = cells;
|
||||
if (!id || id === "Anforderungs-ID" || control.startsWith("---")) continue; // Header/Separator
|
||||
const pruefziel = PRUEFZIEL[pruefzielText];
|
||||
if (!pruefziel) continue; // keine gültige Datenzeile
|
||||
const condClean = (condRaw ?? "").replace(/`/g, "").trim();
|
||||
const condition = FLAG_RE.test(condClean) ? condClean : null;
|
||||
rows.push({
|
||||
id,
|
||||
control,
|
||||
type: type as C1Row["type"],
|
||||
al2: al2 === "Ja",
|
||||
al3: al3 === "Ja",
|
||||
pruefziel,
|
||||
condition,
|
||||
});
|
||||
}
|
||||
|
||||
mkdirSync(dirname(OUT), { recursive: true });
|
||||
writeFileSync(OUT, JSON.stringify(rows, null, 2) + "\n");
|
||||
|
||||
// Kurzstatistik zur Kontrolle
|
||||
const by = (f: (r: C1Row) => string) => rows.reduce<Record<string, number>>((a, r) => ((a[f(r)] = (a[f(r)] ?? 0) + 1), a), {});
|
||||
console.log(`✓ ${rows.length} Anforderungen → seed/scoping/c1-scope.json`);
|
||||
console.log(" Prüfziele:", JSON.stringify(by((r) => r.pruefziel)));
|
||||
console.log(" Typen:", JSON.stringify(by((r) => r.type)));
|
||||
console.log(" Bedingungen:", JSON.stringify(by((r) => r.condition ?? "(immer/Prüfziel)")));
|
||||
@@ -1,67 +0,0 @@
|
||||
// Generator (Story A7-2): parst die C5-Control-Belegtabelle (Fachcontent §5, IS-Controls)
|
||||
// in die maschinenlesbare Reifegrad-Datengrundlage `seed/scoping/c5-controls.json`.
|
||||
// Lauf: npx tsx scripts/build-c5-controls.ts
|
||||
//
|
||||
// Quelle §5-Zeilen: | **Control** Titel | P / V / N (mit (**A**)/(**R**)) | R2-Bed. | R3-Bed. | Ziel |
|
||||
// - policy (P): L00 | R01..R14 (zuständige Richtlinie[n], „—" = keine)
|
||||
// - verfahren (V): VA-01..VA-19 (geforderte Verfahren, „—" = in Richtlinie verankert)
|
||||
// - needsAsset/needsRisk: Belegzelle enthält (**A**) bzw. (**R**)
|
||||
// - target: Standard-Zielreifegrad der Tabelle (informativ; effektiv wird er zur Laufzeit
|
||||
// aus dem Scope nach C5 §3 abgeleitet).
|
||||
// §6 (Proto 8.x / Datenschutz 9.x) ist gruppenbasiert und wird NICHT je Control abgebildet;
|
||||
// dafür greift zur Laufzeit ein generischer Fallback-Spec.
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const SRC = resolve("docs/wizard-uebergabe/02_Fachcontent_C1-C9/C5_Reifegradlogik.md");
|
||||
const OUT = resolve("seed/scoping/c5-controls.json");
|
||||
|
||||
export interface C5ControlSpec {
|
||||
control: string;
|
||||
title: string;
|
||||
policy: string[];
|
||||
verfahren: string[];
|
||||
needsAsset: boolean;
|
||||
needsRisk: boolean;
|
||||
target: number;
|
||||
}
|
||||
|
||||
const CONTROL_RE = /^\|\s*\*\*([0-9]+\.[0-9]+\.[0-9]+(?:-[A-Z]+)?)\*\*\s*([^|]*?)\s*\|(.+)$/;
|
||||
|
||||
function codes(cell: string, re: RegExp): string[] {
|
||||
const out = new Set<string>();
|
||||
for (const m of cell.matchAll(re)) out.add(m[0]);
|
||||
return [...out];
|
||||
}
|
||||
|
||||
function parse(md: string): C5ControlSpec[] {
|
||||
const rows: C5ControlSpec[] = [];
|
||||
for (const line of md.split("\n")) {
|
||||
const m = CONTROL_RE.exec(line.trim());
|
||||
if (!m) continue;
|
||||
const [, control, title, rest] = m;
|
||||
// rest = "Belege | R2 | R3 | Ziel |"
|
||||
const cells = rest.split("|").map((c) => c.trim());
|
||||
const belege = cells[0] ?? "";
|
||||
const zielCell = cells[3] ?? cells[cells.length - 2] ?? "";
|
||||
const [pPart = "", vPart = ""] = belege.split(" / ");
|
||||
const target = Number.parseInt(zielCell.replace(/[^0-9]/g, "").charAt(0) || "3", 10);
|
||||
rows.push({
|
||||
control,
|
||||
title: title.trim(),
|
||||
policy: codes(pPart, /L00|R[0-9]{2}/g),
|
||||
verfahren: codes(vPart, /VA-[0-9]{2}/g),
|
||||
needsAsset: /\*\*A\*\*/.test(belege),
|
||||
needsRisk: /\*\*R\*\*/.test(belege),
|
||||
target: Number.isFinite(target) ? target : 3,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const specs = parse(readFileSync(SRC, "utf8"));
|
||||
if (specs.length < 40) throw new Error(`C5 §5 unerwartet wenige Controls geparst: ${specs.length}`);
|
||||
mkdirSync(dirname(OUT), { recursive: true });
|
||||
writeFileSync(OUT, JSON.stringify(specs, null, 2) + "\n");
|
||||
console.log(`c5-controls.json geschrieben: ${specs.length} Controls`);
|
||||
@@ -1,145 +1,136 @@
|
||||
/**
|
||||
* Vollständigkeitscheck der serverseitigen Modul-Durchsetzung (§3.4, Phase-1-Härtung).
|
||||
* Vollständigkeitscheck der serverseitigen Modul-Durchsetzung.
|
||||
*
|
||||
* Jede mutierende Server-Action eines gegateten Moduls MUSS über einen
|
||||
* `moduleGuard("<key>")`-Guard laufen (siehe src/server/action-guard.ts), damit ein
|
||||
* `moduleGuard("<key>")`-Guard laufen (src/server/action-guard.ts), damit ein
|
||||
* für den Mandanten deaktiviertes Modul auch Writes serverseitig abweist.
|
||||
*
|
||||
* Dieses Script erzwingt das statisch: Es kennt die Zuordnung Action-Datei → Modul
|
||||
* und schlägt fehl (Exit 1 → Build/Test rot), sobald
|
||||
* - eine neue Action-Datei nicht zugeordnet ist ("vergessener Endpoint"),
|
||||
* ── Konvention (Craftvia) ────────────────────────────────────────────────────
|
||||
*
|
||||
* 1) Modul-Actions liegen in einem Unterordner je Modul:
|
||||
* src/server/actions/<moduleKey>/*.ts (auch tiefer verschachtelt)
|
||||
* Der Modul-Key wird aus dem ERSTEN Ordnernamen abgeleitet und muss in MODULE_KEYS
|
||||
* (src/lib/modules.ts) stehen, z. B. src/server/actions/work_orders/assign.ts →
|
||||
* Modul „work_orders". Keine Eintragung in diesem Skript nötig.
|
||||
* Jede solche Datei muss
|
||||
* - `moduleGuard("<moduleKey>")` verwenden und
|
||||
* - jede `export async function` über `await guard(` laufen lassen.
|
||||
* Reine Hilfsdateien ohne exportierte async functions (z. B. schemas.ts) sind erlaubt;
|
||||
* Dateien, die mit `_` beginnen, werden als intern übersprungen.
|
||||
*
|
||||
* 2) Top-Level-Dateien src/server/actions/*.ts sind Fundament (Auth, Plattform,
|
||||
* Einstellungen …) und stehen in der expliziten Map ACTION_MODULE — entweder mit
|
||||
* Modul-Key (dann gelten die Regeln aus 1) oder "EXEMPT" (eigene Auth-Prüfung
|
||||
* erforderlich: require…-Guard oder auth()).
|
||||
*
|
||||
* Schlägt fehl (Exit 1 → prebuild/Gate rot), sobald
|
||||
* - eine Top-Level-Datei nicht zugeordnet ist ("vergessener Endpoint"),
|
||||
* - ein Modulordner keinen gültigen Modul-Key trägt,
|
||||
* - eine gegatete Datei den erwarteten moduleGuard nicht verwendet, oder
|
||||
* - eine exportierte Action nicht über `await guard(...)` läuft.
|
||||
*
|
||||
* Neue Action-Datei anlegen ⇒ hier eintragen (Modul-Key oder "EXEMPT").
|
||||
*/
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, dirname, relative, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { MODULE_KEYS } from "../src/lib/modules";
|
||||
|
||||
const ACTIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "server", "actions");
|
||||
|
||||
/** Zuordnung Action-Datei → Modul-Key. "EXEMPT" = kein gegatetes Fachmodul (eigene Auth). */
|
||||
/** Top-Level-Action-Datei → Modul-Key oder "EXEMPT" (Fundament mit eigener Auth). */
|
||||
const ACTION_MODULE: Record<string, string> = {
|
||||
"assets.ts": "assets",
|
||||
// M2 Strukturanalyse: primäre Informations-Assets (Dedup/Autocomplete) → Asset-Modul.
|
||||
"structure.ts": "assets",
|
||||
"processes.ts": "bia",
|
||||
"risks.ts": "risk",
|
||||
"risk-catalog.ts": "risk",
|
||||
"measures.ts": "measures",
|
||||
"tasks.ts": "tasks",
|
||||
"incidents.ts": "incidents",
|
||||
// IM-D: mandantenseitige Pflege der E-Mail-Intake-Konfiguration (moduleGuard("incidents") + tenant:manage).
|
||||
"incident-intake.ts": "incidents",
|
||||
"suppliers.ts": "suppliers",
|
||||
"services.ts": "suppliers",
|
||||
"software.ts": "suppliers",
|
||||
"projects.ts": "assets",
|
||||
"onboarding.ts": "onboarding",
|
||||
"onboarding-facts.ts": "onboarding",
|
||||
"onboarding-steps.ts": "onboarding",
|
||||
"onboarding-team.ts": "onboarding",
|
||||
"soa.ts": "onboarding",
|
||||
// AP3: ISO-Anwendbarkeitserklärung (eigenes Modul „soa").
|
||||
"soa-entries.ts": "soa",
|
||||
// AP4: Managementklauseln (Kennzahlen/Managementbewertung/CAPA) im Modul „review".
|
||||
"review.ts": "review",
|
||||
"gap.ts": "onboarding",
|
||||
// Audit-Vorbereitung — Modul `audit`.
|
||||
"audits.ts": "audit",
|
||||
"audit-evidence.ts": "audit",
|
||||
"control-descriptions.ts": "audit",
|
||||
"policies.ts": "policies",
|
||||
"policy-package.ts": "policies",
|
||||
// AP5: Dokumentenlenkung (Prüfzyklus, Neuversion/Historie, Lesebestätigung).
|
||||
"policy-control.ts": "policies",
|
||||
"policy-upload.ts": "policies",
|
||||
"hints.ts": "policies",
|
||||
"register.ts": "policies",
|
||||
// Plattform-Betrieb (eigene Auth) und Kunden-Einstellungen (tenant:manage) sind
|
||||
// keine per TenantModule gegateten Fachmodule — eigene Autorisierung, kein moduleGuard.
|
||||
// Plattform-Betrieb (Auth über die Plattform-Session)
|
||||
"admin.ts": "EXEMPT",
|
||||
// SEC1: Mail-Betriebsfunktionen der Plattform-Administration (Auth über die
|
||||
// Plattform-Session), kein per TenantModule gegatetes Fachmodul.
|
||||
"mail.ts": "EXEMPT",
|
||||
// Backup-Portal: Enqueue-Actions für Portal-Restore/Export/DSGVO-Zustellung.
|
||||
// Betreiber-/Plattform-Fähigkeit (Auth über requirePlatformFullAdmin + MFA-Step-up),
|
||||
// kein per TenantModule gegatetes Fachmodul.
|
||||
"backup-admin.ts": "EXEMPT",
|
||||
"backup-settings.ts": "EXEMPT",
|
||||
// IM-D: Betreiber-Provisionierung/Verifizierung der Intake-Konfiguration + Inbound-Review.
|
||||
// Plattform-Fähigkeit (requirePlatformFullAdmin), kein per TenantModule gegatetes Fachmodul.
|
||||
"incident-intake-admin.ts": "EXEMPT",
|
||||
// SEC2: Passwort-Self-Service. Die Reset-Abläufe laufen bewusst OHNE Session
|
||||
// (der Nutzer ist ausgesperrt); abgesichert über Rate-Limit, Enumeration-
|
||||
// Neutralität und single-use-Tokens. Die angemeldeten Abläufe nutzen
|
||||
// requireSession bzw. requirePlatformSession.
|
||||
"auth-recovery.ts": "EXEMPT",
|
||||
"platform.ts": "EXEMPT",
|
||||
"platform-users.ts": "EXEMPT",
|
||||
"platform-admins.ts": "EXEMPT",
|
||||
// SEC2: Passwort-Self-Service. Reset-Abläufe laufen bewusst OHNE Session; abgesichert
|
||||
// über Rate-Limit, Enumeration-Neutralität und single-use-Tokens.
|
||||
"auth-recovery.ts": "EXEMPT",
|
||||
// Mandanten-Fundament (requireSession/requirePermission)
|
||||
"tenant-users.ts": "EXEMPT",
|
||||
"tenant-settings.ts": "EXEMPT",
|
||||
"account.ts": "EXEMPT",
|
||||
"tenant-switch.ts": "EXEMPT",
|
||||
"webauthn.ts": "EXEMPT",
|
||||
"platform-admins.ts": "EXEMPT",
|
||||
"policy-templates.ts": "EXEMPT",
|
||||
"tenant-settings.ts": "EXEMPT",
|
||||
};
|
||||
|
||||
const errors: string[] = [];
|
||||
const files = readdirSync(ACTIONS_DIR).filter((f) => f.endsWith(".ts"));
|
||||
let checked = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const mapped = ACTION_MODULE[file];
|
||||
if (!mapped) {
|
||||
errors.push(
|
||||
`Nicht zugeordnete Action-Datei: ${file} — in scripts/check-module-guards.ts eintragen (Modul-Key oder "EXEMPT").`
|
||||
);
|
||||
continue;
|
||||
function checkGatedFile(label: string, src: string, moduleKey: string) {
|
||||
if (!(MODULE_KEYS as readonly string[]).includes(moduleKey)) {
|
||||
errors.push(`${label}: unbekannter Modul-Key "${moduleKey}" (nicht in src/lib/modules.ts).`);
|
||||
}
|
||||
|
||||
const src = readFileSync(join(ACTIONS_DIR, file), "utf8");
|
||||
|
||||
if (mapped === "EXEMPT") {
|
||||
// Auth-Nachweis: entweder ein require*-Guard ODER ein direkter auth()-Aufruf
|
||||
// (z. B. tenant-switch.ts, das im No-Tenant-Zustand kein requireSession nutzen
|
||||
// kann, aber die Identity + Mitgliedschaftszugehörigkeit selbst prüft).
|
||||
if (!/require(Session|Platform\w*|Permission)|\bauth\(\)/.test(src)) {
|
||||
errors.push(`${file}: als EXEMPT markiert, aber keine erkennbare Auth-Prüfung.`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!MODULE_KEYS.includes(mapped)) {
|
||||
errors.push(`${file}: unbekannter Modul-Key "${mapped}" (nicht in src/lib/modules.ts).`);
|
||||
}
|
||||
if (!src.includes(`moduleGuard("${mapped}")`)) {
|
||||
errors.push(`${file}: erwartet moduleGuard("${mapped}") — Modul-Gating fehlt oder falscher Key.`);
|
||||
}
|
||||
|
||||
// Jede exportierte Server-Action muss über await guard(...) laufen.
|
||||
const exportRe = /export async function (\w+)\s*\(/g;
|
||||
const positions: { name: string; index: number }[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = exportRe.exec(src))) positions.push({ name: m[1], index: m.index });
|
||||
if (positions.length === 0) return; // Hilfsdatei ohne Actions
|
||||
|
||||
if (!src.includes(`moduleGuard("${moduleKey}")`)) {
|
||||
errors.push(`${label}: erwartet moduleGuard("${moduleKey}") — Modul-Gating fehlt oder falscher Key.`);
|
||||
}
|
||||
for (let i = 0; i < positions.length; i++) {
|
||||
const start = positions[i].index;
|
||||
const end = i + 1 < positions.length ? positions[i + 1].index : src.length;
|
||||
if (!/await guard\(/.test(src.slice(start, end))) {
|
||||
errors.push(
|
||||
`${file}: Action "${positions[i].name}" läuft nicht über await guard(...) — Modul-/Rechte-Guard fehlt.`
|
||||
);
|
||||
errors.push(`${label}: Action "${positions[i].name}" läuft nicht über await guard(...) — Modul-/Rechte-Guard fehlt.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const full = join(dir, name);
|
||||
return statSync(full).isDirectory() ? walk(full) : [full];
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of readdirSync(ACTIONS_DIR)) {
|
||||
const full = join(ACTIONS_DIR, entry);
|
||||
|
||||
if (statSync(full).isDirectory()) {
|
||||
// (1) Modulordner: Key = Ordnername.
|
||||
const moduleKey = entry;
|
||||
for (const file of walk(full).filter((f) => f.endsWith(".ts"))) {
|
||||
const rel = relative(ACTIONS_DIR, file).split(sep).join("/");
|
||||
if (rel.split("/").some((seg) => seg.startsWith("_"))) continue;
|
||||
checked++;
|
||||
checkGatedFile(rel, readFileSync(file, "utf8"), moduleKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.endsWith(".ts")) continue;
|
||||
// (2) Top-Level-Fundament-Datei.
|
||||
checked++;
|
||||
const mapped = ACTION_MODULE[entry];
|
||||
if (!mapped) {
|
||||
errors.push(
|
||||
`Nicht zugeordnete Action-Datei: ${entry} — Modul-Actions gehören nach src/server/actions/<moduleKey>/; ` +
|
||||
`Fundament-Dateien in scripts/check-module-guards.ts eintragen (Modul-Key oder "EXEMPT").`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const src = readFileSync(full, "utf8");
|
||||
if (mapped === "EXEMPT") {
|
||||
// Auth-Nachweis: ein require*-Guard ODER ein direkter auth()-Aufruf.
|
||||
if (!/require(Session|Platform\w*|Permission)|\bauth\(\)/.test(src)) {
|
||||
errors.push(`${entry}: als EXEMPT markiert, aber keine erkennbare Auth-Prüfung.`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
checkGatedFile(entry, src, mapped);
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
console.error("✗ Modul-Guard-Vollständigkeitscheck fehlgeschlagen:");
|
||||
for (const e of errors) console.error(" - " + e);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`✓ Modul-Guard-Vollständigkeitscheck: ${files.length} Action-Dateien geprüft — alle mutierenden Actions sind modul- und rechtegegated.`
|
||||
`✓ Modul-Guard-Vollständigkeitscheck: ${checked} Action-Dateien geprüft — alle mutierenden Actions sind modul- und rechtegegated.`,
|
||||
);
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import "dotenv/config";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { importImplementationHints } from "../prisma/import-hints";
|
||||
|
||||
// Standalone-Import der C6-Umsetzungshinweise (Story B5-1). Idempotent.
|
||||
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) });
|
||||
|
||||
async function main() {
|
||||
const n = await importImplementationHints(prisma);
|
||||
const total = await prisma.implementationHint.count();
|
||||
const proc = await prisma.implementationHint.count({ where: { procurement: true } });
|
||||
console.log(JSON.stringify({ parsed: n, inDb: total, procurement: proc }));
|
||||
}
|
||||
main().finally(() => prisma.$disconnect());
|
||||
@@ -1,15 +0,0 @@
|
||||
import "dotenv/config";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { importRiskCatalog } from "../prisma/import-risks";
|
||||
|
||||
// Standalone-Import des C4-Risikokatalogs (Story A6-1). Idempotent.
|
||||
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) });
|
||||
|
||||
async function main() {
|
||||
const n = await importRiskCatalog(prisma);
|
||||
const total = await prisma.riskCatalogEntry.count();
|
||||
const cats = await prisma.riskCatalogEntry.groupBy({ by: ["category"], _count: true });
|
||||
console.log(JSON.stringify({ parsed: n, inDb: total, categories: cats.length }));
|
||||
}
|
||||
main().finally(() => prisma.$disconnect());
|
||||
@@ -1,205 +0,0 @@
|
||||
import "dotenv/config";
|
||||
import { parseInbound, type RawHeaders } from "../src/server/incident-inbound/parse";
|
||||
import { processInbound } from "../src/server/incident-inbound/process";
|
||||
|
||||
/**
|
||||
* IM-D — Einstiegspunkt des Inbound-Mail-Workers (`npm run worker:incident-inbound`).
|
||||
*
|
||||
* Eigener Prozess/Container neben der App (analog Mail-/Backup-Worker). Holt Mails
|
||||
* vom Catch-all-Postfach (`vorfall-<token>@in.certvia.de`, KONZEPT §2/§14.3) per
|
||||
* IMAP ab, parst sie (rein, parse.ts) und verarbeitet sie (process.ts): Vorfall im
|
||||
* richtigen Mandanten anlegen ODER in die Betreiber-Review geben.
|
||||
*
|
||||
* IMAP-Bibliotheken (imapflow/mailparser) werden BEWUSST dynamisch importiert:
|
||||
* - der Web-/Build-Pfad zieht sie nie mit ein (reiner Ops-Prozess),
|
||||
* - `tsc --noEmit` bleibt unabhängig von den Paket-Typdefinitionen grün.
|
||||
*
|
||||
* Ohne IMAP-Env (INCIDENT_IMAP_HOST/USER/PASSWORD) gibt es keinen Betrieb — der
|
||||
* Worker meldet „nicht konfiguriert" und geht in den LEERLAUF (Prozess bleibt am
|
||||
* Leben), statt sich zu beenden. Grund: mit `restart: unless-stopped` würde ein
|
||||
* Exit einen Crash-Loop erzeugen, den Coolify als unhealthy wertet und deshalb den
|
||||
* GESAMTEN Stack (inkl. gesunder App) wieder abreißt. Idle → Container „running",
|
||||
* Deploy bleibt grün; bei nachträglicher IMAP-Konfig aktiviert ein Neustart den Betrieb.
|
||||
*/
|
||||
|
||||
interface ImapEnv {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
password: string;
|
||||
tls: boolean;
|
||||
mailbox: string;
|
||||
pollMs: number;
|
||||
}
|
||||
|
||||
function readEnv(): ImapEnv | null {
|
||||
const host = process.env.INCIDENT_IMAP_HOST?.trim();
|
||||
const user = process.env.INCIDENT_IMAP_USER?.trim();
|
||||
const password = process.env.INCIDENT_IMAP_PASSWORD;
|
||||
if (!host || !user || !password) return null;
|
||||
return {
|
||||
host,
|
||||
port: Number(process.env.INCIDENT_IMAP_PORT ?? "993"),
|
||||
user,
|
||||
password,
|
||||
// Default TLS an; nur bei explizit "false"/"0" abschalten (STARTTLS/Plain).
|
||||
tls: !["false", "0", "no"].includes((process.env.INCIDENT_IMAP_TLS ?? "true").toLowerCase()),
|
||||
mailbox: process.env.INCIDENT_IMAP_MAILBOX?.trim() || "INBOX",
|
||||
pollMs: Math.max(15_000, Number(process.env.INCIDENT_IMAP_POLL_MS ?? "60000")),
|
||||
};
|
||||
}
|
||||
|
||||
/** mailparser-Header (Map) → flache RawHeaders für parse.ts. */
|
||||
function toRawHeaders(headerLines: Array<{ key: string; line: string }> | undefined): RawHeaders {
|
||||
const out: RawHeaders = {};
|
||||
for (const h of headerLines ?? []) {
|
||||
// `line` ist die vollständige "Key: Value"-Zeile; Wert hinter dem ersten ":".
|
||||
const idx = h.line.indexOf(":");
|
||||
const value = idx >= 0 ? h.line.slice(idx + 1).trim() : h.line.trim();
|
||||
const key = h.key;
|
||||
const existing = out[key];
|
||||
if (existing === undefined) out[key] = value;
|
||||
else if (Array.isArray(existing)) existing.push(value);
|
||||
else out[key] = [existing, value];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hält den Prozess am Leben, bis SIGTERM/SIGINT kommt — statt Crash-Loop bei fehlender
|
||||
* IMAP-Konfig. Der Heartbeat-Timer hält den Event-Loop offen (Signal-Listener allein
|
||||
* genügen dafür nicht); bei Signal wird er gestoppt und der Prozess endet sauber (Exit 0).
|
||||
*/
|
||||
function idleUntilSignal(): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
const beat = setInterval(() => {}, 60_000);
|
||||
const done = (signal: string) => {
|
||||
clearInterval(beat);
|
||||
console.info(`[incident-inbound] ${signal} — Leerlauf beendet.`);
|
||||
resolve();
|
||||
};
|
||||
process.on("SIGTERM", () => done("SIGTERM"));
|
||||
process.on("SIGINT", () => done("SIGINT"));
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const env = readEnv();
|
||||
if (!env) {
|
||||
console.warn(
|
||||
"[incident-inbound] IMAP ist nicht konfiguriert (INCIDENT_IMAP_HOST/USER/PASSWORD fehlen). " +
|
||||
"Ohne Postfach kein E-Mail-to-Ticket-Betrieb — Worker läuft im Leerlauf (kein Crash-Loop). " +
|
||||
"Setze die INCIDENT_IMAP_*-Variablen und starte den Container neu, um den Betrieb zu aktivieren.",
|
||||
);
|
||||
await idleUntilSignal();
|
||||
return;
|
||||
}
|
||||
|
||||
// Dynamischer Import: hält Web-/Build-Pfad + tsc frei von IMAP-Typen.
|
||||
let ImapFlow: unknown;
|
||||
let simpleParser: unknown;
|
||||
try {
|
||||
({ ImapFlow } = (await import("imapflow")) as { ImapFlow: unknown });
|
||||
({ simpleParser } = (await import("mailparser")) as { simpleParser: unknown });
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[incident-inbound] Pakete 'imapflow'/'mailparser' nicht installiert. " +
|
||||
"`npm install imapflow mailparser` im Worker-Image sicherstellen.",
|
||||
err,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const ClientCtor = ImapFlow as any;
|
||||
const parse = simpleParser as any;
|
||||
|
||||
const client = new ClientCtor({
|
||||
host: env.host,
|
||||
port: env.port,
|
||||
secure: env.tls,
|
||||
auth: { user: env.user, pass: env.password },
|
||||
logger: false,
|
||||
});
|
||||
|
||||
let shuttingDown = false;
|
||||
const stop = async (signal: string) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
console.info(`[incident-inbound] ${signal} — fahre herunter…`);
|
||||
try {
|
||||
await client.logout();
|
||||
} catch {
|
||||
/* egal beim Herunterfahren */
|
||||
}
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGTERM", () => void stop("SIGTERM"));
|
||||
process.on("SIGINT", () => void stop("SIGINT"));
|
||||
|
||||
await client.connect();
|
||||
console.info(
|
||||
`[incident-inbound] verbunden mit ${env.host} (${env.mailbox}) — Intake-Domain ` +
|
||||
`${process.env.INCIDENT_INTAKE_DOMAIN ?? "in.certvia.de"}, Poll ${env.pollMs}ms.`,
|
||||
);
|
||||
|
||||
async function drainOnce(): Promise<void> {
|
||||
const lock = await client.getMailboxLock(env!.mailbox);
|
||||
try {
|
||||
// Nur ungelesene Mails; nach erfolgreicher Verarbeitung als \Seen markieren
|
||||
// (Idempotenz zusätzlich über Message-ID in process.ts).
|
||||
for await (const message of client.fetch({ seen: false }, { source: true, uid: true })) {
|
||||
let handled = false;
|
||||
try {
|
||||
const mail = await parse(message.source);
|
||||
const parsed = parseInbound({
|
||||
headers: toRawHeaders(mail.headerLines),
|
||||
from: mail.from?.text,
|
||||
subject: mail.subject,
|
||||
text: mail.text ?? "",
|
||||
messageId: mail.messageId,
|
||||
});
|
||||
const result = await processInbound(parsed);
|
||||
const decision = result.decision;
|
||||
const detail =
|
||||
decision.action === "incident"
|
||||
? `Vorfall ${result.refNo}`
|
||||
: decision.action === "review"
|
||||
? `Review (${decision.review.reason})`
|
||||
: `ignoriert (${decision.reason})`;
|
||||
console.info(`[incident-inbound] UID ${message.uid}: ${detail}`);
|
||||
handled = true;
|
||||
} catch (err) {
|
||||
// Verarbeitungsfehler: NICHT als gelesen markieren → nächster Lauf erneut.
|
||||
console.error(`[incident-inbound] Fehler bei UID ${message.uid}:`, err);
|
||||
}
|
||||
if (handled) {
|
||||
try {
|
||||
await client.messageFlagsAdd({ uid: message.uid }, ["\\Seen"], { uid: true });
|
||||
} catch (err) {
|
||||
console.error(`[incident-inbound] Konnte UID ${message.uid} nicht als gelesen markieren:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
// Poll-Schleife (robust gegen einzelne Fehlläufe). IMAP-IDLE wäre latenzärmer, das
|
||||
// Polling ist aber einfacher, ausreichend und übersteht Verbindungsabbrüche.
|
||||
while (!shuttingDown) {
|
||||
try {
|
||||
await drainOnce();
|
||||
} catch (err) {
|
||||
console.error("[incident-inbound] Abholung fehlgeschlagen (nächster Versuch folgt):", err);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, env.pollMs));
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[incident-inbound] Start fehlgeschlagen:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,58 +0,0 @@
|
||||
// Einmalige Synchronisierung des Datei-Vorlagenpakets in die globale DB-Vorlagenablage.
|
||||
// Überführt seed/isms-vorlagenpaket-v2/ (deutsch) in eine veröffentlichte
|
||||
// PolicyTemplateVersion. Danach ist die DB die Master-Quelle; der Plattform-Admin
|
||||
// bearbeitet Vorlagen/Versionen dort, der Mandanten-Import liest die veröffentlichte
|
||||
// Version. Idempotent: erneuter Lauf ersetzt die deutschen Inhalte der Version.
|
||||
//
|
||||
// Lauf (lokal): npx tsx scripts/sync-policy-templates.ts
|
||||
// Lauf (Deploy): im Container/Coolify mit gesetzter DATABASE_URL ausführen (einmalig
|
||||
// bzw. nach Paket-Updates, solange die Pflege noch datei-basiert erfolgt).
|
||||
|
||||
import "dotenv/config";
|
||||
import { join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { PrismaClient, type Framework } from "@prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { parsePackageFiles, mappingFileFor } from "../prisma/import-policies";
|
||||
import { syncTemplatesFromParsed } from "../prisma/template-store";
|
||||
|
||||
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) });
|
||||
const ROOT = join(process.cwd(), "seed");
|
||||
// Sprache → Seed-Verzeichnis. EN nur, wenn vorhanden (Übersetzungspaket).
|
||||
const SOURCES: Array<{ locale: string; dir: string }> = [
|
||||
{ locale: "de", dir: join(ROOT, "isms-vorlagenpaket-v2") },
|
||||
{ locale: "en", dir: join(ROOT, "isms-vorlagenpaket-v2-en") },
|
||||
];
|
||||
// AP1: beide Frameworks teilen den Dokumentensatz, nur das Mapping wechselt. Ein Sync
|
||||
// je (Framework × Sprache) — das ISO-Mapping nur, wenn die Datei existiert.
|
||||
const FRAMEWORKS: Framework[] = ["TISAX", "ISO_27001"];
|
||||
|
||||
async function main() {
|
||||
for (const { locale, dir } of SOURCES) {
|
||||
if (!existsSync(dir)) {
|
||||
console.log(`Sprache ${locale}: kein Verzeichnis (${dir}) — übersprungen.`);
|
||||
continue;
|
||||
}
|
||||
for (const framework of FRAMEWORKS) {
|
||||
const mappingFile = mappingFileFor(framework);
|
||||
if (!existsSync(join(dir, mappingFile))) {
|
||||
console.log(` ${framework}/${locale}: kein ${mappingFile} — übersprungen.`);
|
||||
continue;
|
||||
}
|
||||
const pkg = parsePackageFiles(dir, mappingFile);
|
||||
const { version } = await syncTemplatesFromParsed(prisma, pkg, locale, framework, { publish: true });
|
||||
console.log(
|
||||
`Vorlagen-Sync (${framework}/${locale}) → Version ${version}: ` +
|
||||
`${pkg.documents.length} Dokumente, ${pkg.requirements.length} Anforderungen, ` +
|
||||
`${pkg.variables.length} Variablen, ${pkg.baseline.length} Baseline, ${pkg.evidence.length} Nachweise.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error("Vorlagen-Sync fehlgeschlagen:", e.message);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -25,7 +25,7 @@ const ok = (cond: boolean, msg: string) => {
|
||||
};
|
||||
|
||||
const SLUG = "zz-actionerr-test";
|
||||
const ENTITY = "risk_ae_test";
|
||||
const ENTITY = "customer_ae_test";
|
||||
|
||||
async function cleanup() {
|
||||
const tenant = await prisma.tenant.findFirst({
|
||||
@@ -49,7 +49,7 @@ async function main() {
|
||||
let outward = "";
|
||||
try {
|
||||
await withActionErrors(async () => {
|
||||
throw new ForbiddenError("risk:write" as Permission);
|
||||
throw new ForbiddenError("customer:write" as Permission);
|
||||
}, ctx);
|
||||
} catch (e) {
|
||||
outward = e instanceof Error ? e.message : String(e);
|
||||
|
||||
@@ -21,7 +21,7 @@ const ok = (cond: boolean, msg: string) => {
|
||||
|
||||
const SLUG = "zz-f06-authz-test";
|
||||
const EMAIL = "f06-user@zz-authz.test";
|
||||
const PERM = "asset:write"; // existiert im globalen Permissionskatalog
|
||||
const PERM = "customer:write"; // existiert im globalen Permissionskatalog
|
||||
|
||||
/**
|
||||
* Repliziert die autoritative Prüfung aus moduleGuard (Option C): Membership-Status +
|
||||
@@ -94,7 +94,7 @@ async function main() {
|
||||
const a1 = await authorize(user.id, identity.id);
|
||||
ok(a1 !== null, "(1) aktives Konto wird gefunden");
|
||||
ok(a1?.perms.has(PERM) === true, `(1) effektive Rechte enthalten ${PERM}`);
|
||||
ok(a1?.perms.has("risk:accept") === false, "(1) nicht vergebenes Recht fehlt korrekt");
|
||||
ok(a1?.perms.has("customer:merge") === false, "(1) nicht vergebenes Recht fehlt korrekt");
|
||||
|
||||
// (2) Membership deaktiviert → Query liefert null → Guard wirft „Konto ist nicht aktiv".
|
||||
await prisma.user.update({ where: { id: user.id }, data: { status: "DEACTIVATED" } });
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
// B1 Schritt 5/6 — ISO-Bewertung über buildAssessment("ISO_27001"). Prüft Scope (SoA-
|
||||
// Anwendbarkeit + Klauseln 4–10), Umsetzungsgrad-Kennzahl, leere-SoA-Hinweis und dass
|
||||
// KEIN TISAX-Vokabular (Reifegrad/AL) in der ISO-Zusammenfassung auftaucht.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-assessment-iso.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { prisma, dbForTenant } from "../src/server/db";
|
||||
import { buildAssessment } from "../src/server/assessment";
|
||||
import { ensureSoaEntries } from "../src/server/soa-statement";
|
||||
|
||||
let failures = 0;
|
||||
const ok = (c: boolean, m: string) => { console.log(`${c ? "✓" : "✗ FEHLER"} ${m}`); if (!c) failures++; };
|
||||
const SLUG = "b1-test-iso";
|
||||
|
||||
async function cleanup() {
|
||||
const t = await prisma.tenant.findUnique({ where: { slug: SLUG }, select: { id: true } });
|
||||
if (!t) return;
|
||||
await prisma.soaEntry.deleteMany({ where: { tenantId: t.id } });
|
||||
await prisma.tenant.delete({ where: { id: t.id } });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await cleanup();
|
||||
const tenant = await prisma.tenant.create({ data: { name: "B1 ISO", slug: SLUG } });
|
||||
const t = tenant.id;
|
||||
const db = dbForTenant(t);
|
||||
|
||||
// ── A) Leere SoA → „Anwendbarkeit offen", nicht 0 % ─────────────────────────
|
||||
const empty = await buildAssessment(db, t, "ISO_27001");
|
||||
ok(empty.summary.band === "Anwendbarkeit offen" && empty.summary.metricValue === "—", "leere SoA meldet Anwendbarkeit-noch-nicht-erklaert (nicht 0 Prozent)");
|
||||
|
||||
// ── B) SoA vorbefüllen → Bewertung rechnet ──────────────────────────────────
|
||||
await ensureSoaEntries(db, t);
|
||||
const asm = await buildAssessment(db, t, "ISO_27001");
|
||||
const clauses = asm.rows.filter((r) => !r.control.startsWith("A."));
|
||||
const annex = asm.rows.filter((r) => r.control.startsWith("A."));
|
||||
ok(clauses.length >= 20, `Klauseln 4–10 immer im Scope (${clauses.length})`);
|
||||
ok(annex.length > 0, `anwendbare Annex-A-Controls im Scope (${annex.length})`);
|
||||
ok(asm.rows.every((r) => r.verdict.kind === "status"), "jede ISO-Bewertung ist ein Status-Verdict (kein Reifegrad)");
|
||||
ok(asm.summary.metricLabel === "Umsetzungsgrad" && asm.summary.metricValue.endsWith("%"), `Kennzahl ist der Umsetzungsgrad (${asm.summary.metricValue})`);
|
||||
|
||||
// ── C) Einige anwendbare Controls „umgesetzt" → fulfilled steigt ────────────
|
||||
const applicableAnnex = await prisma.soaEntry.findMany({ where: { tenantId: t, framework: "ISO_27001", applicable: true }, select: { id: true }, take: 5 });
|
||||
await prisma.soaEntry.updateMany({ where: { id: { in: applicableAnnex.map((s) => s.id) } }, data: { implementationStatus: "umgesetzt" } });
|
||||
const asm2 = await buildAssessment(db, t, "ISO_27001");
|
||||
ok(asm2.summary.fulfilled >= 5, `bestätigte Umsetzungen zählen in den Umsetzungsgrad (${asm2.summary.fulfilled} umgesetzt)`);
|
||||
|
||||
// ── Kein TISAX-Vokabular in der ISO-Sicht ───────────────────────────────────
|
||||
const vocab = `${asm2.summary.band} ${asm2.summary.metricLabel} ${asm2.summary.text}`;
|
||||
ok(!/Reifegrad|AL2|AL3|Prüfziel|Assessment-reif/.test(vocab), "keine Reifegrad-/AL-/Prüfziel-Begriffe in der ISO-Zusammenfassung");
|
||||
|
||||
await cleanup();
|
||||
console.log("\n✓ aufgeräumt (Wegwerf-Mandant entfernt)");
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => { console.log(failures === 0 ? "\nISO-Bewertung grün." : `\n${failures} Prüfung(en) fehlgeschlagen.`); process.exit(failures === 0 ? 0 : 1); })
|
||||
.catch(async (e) => { console.error(e); await cleanup().catch(() => {}); process.exit(1); })
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -30,7 +30,7 @@ async function cleanup() {
|
||||
await prisma.tombstoneEntry.deleteMany({ where: { tenantId: t.id } });
|
||||
await prisma.deletionCertificate.deleteMany({ where: { tenantId: t.id } });
|
||||
await prisma.auditLog.deleteMany({ where: { tenantId: t.id } });
|
||||
await prisma.asset.deleteMany({ where: { tenantId: t.id } });
|
||||
await prisma.notificationPreference.deleteMany({ where: { tenantId: t.id } });
|
||||
await prisma.userRole.deleteMany({ where: { user: { tenantId: t.id } } });
|
||||
await prisma.user.deleteMany({ where: { tenantId: t.id } });
|
||||
await prisma.role.deleteMany({ where: { tenantId: t.id } });
|
||||
@@ -49,7 +49,7 @@ async function cleanup() {
|
||||
async function main() {
|
||||
await cleanup();
|
||||
|
||||
// Fixture: Mandant + Identity + Mitgliedschaft + Rolle + Asset + AuditLog.
|
||||
// Fixture: Mandant + Identity + Mitgliedschaft + Rolle + NotificationPreference + AuditLog.
|
||||
const tenant = await prisma.tenant.create({ data: { name: "DSGVO Test", slug: SLUG } });
|
||||
const identity = await prisma.identity.create({
|
||||
data: { email: EMAIL, passwordHash: "x", status: "ACTIVE" },
|
||||
@@ -59,11 +59,11 @@ async function main() {
|
||||
});
|
||||
const role = await prisma.role.create({ data: { tenantId: tenant.id, key: "member", name: "Member" } });
|
||||
await prisma.userRole.create({ data: { userId: user.id, roleId: role.id } });
|
||||
await prisma.asset.create({
|
||||
data: { tenantId: tenant.id, name: "Server der Person", type: "SYSTEM", ownerId: user.id, createdBy: user.id },
|
||||
await prisma.notificationPreference.create({
|
||||
data: { tenantId: tenant.id, userId: user.id, eventType: "work_order_assigned", email: false },
|
||||
});
|
||||
await prisma.auditLog.create({
|
||||
data: { tenantId: tenant.id, actorId: user.id, action: "create", entity: "asset" },
|
||||
data: { tenantId: tenant.id, actorId: user.id, action: "create", entity: "user" },
|
||||
});
|
||||
|
||||
// 1. Auskunft (Art. 15/20).
|
||||
@@ -71,7 +71,7 @@ async function main() {
|
||||
ok(subj.identity?.email === EMAIL, "Auskunft enthält Identity-Metadaten (E-Mail)");
|
||||
ok(!("passwordHash" in (subj.identity ?? {})), "Auskunft enthält KEINE Secrets (passwordHash)");
|
||||
ok(subj.memberships.length === 1, "Auskunft enthält die Mitgliedschaft");
|
||||
ok(!!subj.references["Asset.ownerId"], "Auskunft findet Asset über ownerId");
|
||||
ok(!!subj.references["NotificationPreference.userId"], "Auskunft findet NotificationPreference über userId");
|
||||
ok(!!subj.references["AuditLog.actorId"], "Auskunft findet AuditLog über actorId");
|
||||
|
||||
// Snapshot MIT Original-PII (für den Tombstone-on-Restore-Beweis).
|
||||
|
||||
@@ -43,7 +43,7 @@ async function cleanup() {
|
||||
await prisma.tombstoneEntry.deleteMany({ where: { tenantId: t.id } });
|
||||
await prisma.deletionCertificate.deleteMany({ where: { tenantId: t.id } });
|
||||
await prisma.auditLog.deleteMany({ where: { tenantId: t.id } });
|
||||
await prisma.asset.deleteMany({ where: { tenantId: t.id } });
|
||||
await prisma.notificationPreference.deleteMany({ where: { tenantId: t.id } });
|
||||
await prisma.userRole.deleteMany({ where: { user: { tenantId: t.id } } });
|
||||
await prisma.user.deleteMany({ where: { tenantId: t.id } });
|
||||
await prisma.role.deleteMany({ where: { tenantId: t.id } });
|
||||
@@ -109,7 +109,7 @@ async function main() {
|
||||
const tenant = await prisma.tenant.create({ data: { name: "Portal Test", slug: SLUG } });
|
||||
const identity = await prisma.identity.create({ data: { email: EMAIL, passwordHash: "x", status: "ACTIVE" } });
|
||||
const user = await prisma.user.create({ data: { tenantId: tenant.id, identityId: identity.id, email: EMAIL, name: "Portal Person" } });
|
||||
await prisma.asset.create({ data: { tenantId: tenant.id, name: "Asset der Person", type: "SYSTEM", ownerId: user.id, createdBy: user.id } });
|
||||
await prisma.notificationPreference.create({ data: { tenantId: tenant.id, userId: user.id, eventType: "work_order_assigned", email: false } });
|
||||
|
||||
// ── (4a) DSGVO-Paket (Per-Person) direkt ────────────────────────────────────
|
||||
console.log("\n(4) DSGVO-Zustellpaket:");
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
// AP5 — Dokumentenlenkung: Prüfzyklus/-termin, Neuversion + Historie, Lesebestätigung
|
||||
// je Version. Prüft die reine Zyklus-Logik und die Daten-Invarianten (Wegwerf-Mandant).
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-doc-control.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { prisma } from "../src/server/db";
|
||||
import { computeNextReview, isReviewDue, bumpMinorVersion } from "../src/lib/review-cycle";
|
||||
|
||||
let failures = 0;
|
||||
const ok = (c: boolean, m: string) => { console.log(`${c ? "✓" : "✗ FEHLER"} ${m}`); if (!c) failures++; };
|
||||
const SLUG = "ap5-test-doc";
|
||||
|
||||
async function cleanup() {
|
||||
const t = await prisma.tenant.findUnique({ where: { slug: SLUG }, select: { id: true } });
|
||||
if (!t) return;
|
||||
await prisma.policyAcknowledgement.deleteMany({ where: { tenantId: t.id } });
|
||||
await prisma.policyDocumentVersion.deleteMany({ where: { tenantId: t.id } });
|
||||
await prisma.policyDocument.deleteMany({ where: { tenantId: t.id } });
|
||||
await prisma.tenant.delete({ where: { id: t.id } });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await cleanup();
|
||||
|
||||
// ── Reine Zyklus-Logik ──────────────────────────────────────────────────────
|
||||
const from = new Date("2026-01-15T00:00:00Z");
|
||||
const next = computeNextReview(from, "jährlich");
|
||||
ok(next?.getUTCFullYear() === 2027 && next?.getUTCMonth() === 0, "computeNextReview: jährlich → +12 Monate");
|
||||
ok(computeNextReview(from, null) === null, "kein Zyklus → kein Termin");
|
||||
ok(isReviewDue(new Date("2020-01-01")) === true && isReviewDue(new Date("2999-01-01")) === false, "isReviewDue erkennt Überfälligkeit");
|
||||
ok(bumpMinorVersion("1.0") === "1.1" && bumpMinorVersion("2.9") === "2.10" && bumpMinorVersion("3") === "3.1", "bumpMinorVersion korrekt");
|
||||
|
||||
// ── Daten-Invarianten ───────────────────────────────────────────────────────
|
||||
const tenant = await prisma.tenant.create({ data: { name: "AP5", slug: SLUG } });
|
||||
const t = tenant.id;
|
||||
const doc = await prisma.policyDocument.create({
|
||||
data: { tenantId: t, code: "R08", type: "RICHTLINIE", title: "Zugriffskontrolle", version: "1.0", rawMarkdown: "# Zugriffskontrolle" },
|
||||
select: { id: true, version: true },
|
||||
});
|
||||
|
||||
// Lesebestätigung v1.0 durch zwei Nutzer; Duplikat (gleicher Nutzer/Version) wird verhindert.
|
||||
await prisma.policyAcknowledgement.create({ data: { tenantId: t, policyDocumentId: doc.id, userId: "user-1", version: "1.0" } });
|
||||
await prisma.policyAcknowledgement.create({ data: { tenantId: t, policyDocumentId: doc.id, userId: "user-2", version: "1.0" } });
|
||||
let dupBlocked = false;
|
||||
try {
|
||||
await prisma.policyAcknowledgement.create({ data: { tenantId: t, policyDocumentId: doc.id, userId: "user-1", version: "1.0" } });
|
||||
} catch { dupBlocked = true; }
|
||||
ok(dupBlocked, "Lesebestätigung je (Dokument, Nutzer, Version) nur einmal (Unique)");
|
||||
ok((await prisma.policyAcknowledgement.count({ where: { policyDocumentId: doc.id, version: "1.0" } })) === 2, "2 Lesebestätigungen für v1.0");
|
||||
|
||||
// „Als geprüft markieren": Momentaufnahme + Minor-Bump.
|
||||
await prisma.policyDocumentVersion.create({ data: { tenantId: t, policyDocumentId: doc.id, version: "1.0", title: "Zugriffskontrolle", changeNote: "Turnusmäßige Überprüfung", createdBy: "user-1" } });
|
||||
const newVersion = bumpMinorVersion(doc.version);
|
||||
await prisma.policyDocument.update({ where: { id: doc.id }, data: { version: newVersion, reviewCycle: "jährlich", nextReviewAt: computeNextReview(new Date(), "jährlich") } });
|
||||
|
||||
const after = await prisma.policyDocument.findUnique({ where: { id: doc.id }, include: { versionHistory: true } });
|
||||
ok(after?.version === "1.1", "Neuversion nach Prüfung (v1.0 → v1.1)");
|
||||
ok((after?.versionHistory.length ?? 0) >= 1, "Versionshistorie enthält die frühere Version (≥2 Versionen sichtbar: Historie + aktuell)");
|
||||
|
||||
// Version-Scoping: alte Bestätigungen (v1.0) zählen NICHT für v1.1.
|
||||
ok((await prisma.policyAcknowledgement.count({ where: { policyDocumentId: doc.id, version: "1.1" } })) === 0, "Lesebestätigungen sind versions-scoped (v1.1 startet bei 0)");
|
||||
|
||||
// Prüftermin gesetzt.
|
||||
ok(after?.reviewCycle === "jährlich" && !!after?.nextReviewAt, "Prüfzyklus + nächster Prüftermin gesetzt");
|
||||
|
||||
await cleanup();
|
||||
console.log("\n✓ aufgeräumt (Wegwerf-Mandant entfernt)");
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => { console.log(failures === 0 ? "\nAP5-Dokumentenlenkung grün." : `\n${failures} Prüfung(en) fehlgeschlagen.`); process.exit(failures === 0 ? 0 : 1); })
|
||||
.catch(async (e) => { console.error(e); await cleanup().catch(() => {}); process.exit(1); });
|
||||
@@ -1,103 +0,0 @@
|
||||
// B1 Schritt 1 — Snapshot-Regressionsnetz der heutigen TISAX-Bewertung.
|
||||
//
|
||||
// Hält die Ausgabe von buildControlRows (Reifegradvorschlag, Zielwert, Belegstatus und
|
||||
// offene Punkte je Control) für den Demo-Mandanten als JSON im Repo fest. JEDE spätere
|
||||
// Abweichung (ControlSpec-Umbau, Strategie-Extraktion) ist eine Regression, kein
|
||||
// „ist besser geworden" (Feindesign §7, §7a: Schritt 1 ist die wichtigste Maßnahme).
|
||||
//
|
||||
// Baseline erzeugen/aktualisieren: UPDATE_SNAPSHOT=1 npx tsx scripts/test-framework-assessment.ts
|
||||
// Prüfen (Merge-Gate): npx tsx scripts/test-framework-assessment.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { prisma, dbForTenant } from "../src/server/db";
|
||||
import { buildControlRows } from "../src/server/soa-context";
|
||||
import { buildAssessment } from "../src/server/assessment";
|
||||
|
||||
const SNAPSHOT = join(dirname(fileURLToPath(import.meta.url)), "snapshots", "framework-assessment-tisax.json");
|
||||
|
||||
/** Kanonisches JSON (rekursiv sortierte Objekt-Schlüssel) für stabilen Vergleich. */
|
||||
function canonical(v: unknown): unknown {
|
||||
if (Array.isArray(v)) return v.map(canonical);
|
||||
if (v && typeof v === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of Object.keys(v as Record<string, unknown>).sort()) out[k] = canonical((v as Record<string, unknown>)[k]);
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
const stable = (v: unknown) => JSON.stringify(canonical(v), null, 2);
|
||||
|
||||
async function build() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { slug: "demo" }, select: { id: true } });
|
||||
if (!tenant) throw new Error("Demo-Mandant fehlt (prisma/seed.ts ausführen).");
|
||||
const { rows, level } = await buildControlRows(dbForTenant(tenant.id), tenant.id);
|
||||
// Nur die bewertungsrelevanten, deterministischen Felder je Control festhalten.
|
||||
const controls = rows.map((r) => ({
|
||||
control: r.control,
|
||||
target: r.target,
|
||||
confirmed: r.confirmed,
|
||||
suggestion: r.suggestion,
|
||||
evidence: r.evidence,
|
||||
gaps: r.gaps,
|
||||
}));
|
||||
return { level, count: controls.length, controls };
|
||||
}
|
||||
|
||||
/** §7.2: buildAssessment("TISAX") auf dieselbe Snapshot-Form abbilden. */
|
||||
async function buildViaStrategy() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { slug: "demo" }, select: { id: true } });
|
||||
const { rows } = await buildAssessment(dbForTenant(tenant!.id), tenant!.id, "TISAX");
|
||||
const controls = rows.map((r) => {
|
||||
if (r.verdict.kind !== "maturity") throw new Error("TISAX-Verdict ist nicht 'maturity'.");
|
||||
return { control: r.control, target: r.verdict.target, confirmed: r.verdict.confirmed, suggestion: r.verdict.suggestion, evidence: r.evidence, gaps: r.gaps };
|
||||
});
|
||||
return { level: "AL2", count: controls.length, controls };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const current = await build();
|
||||
|
||||
// Strategie-Äquivalenz (Feindesign §7.2): buildAssessment("TISAX") == buildControlRows.
|
||||
const viaStrategy = await buildViaStrategy();
|
||||
if (stable(viaStrategy.controls) !== stable(current.controls)) {
|
||||
console.error("✗ buildAssessment(\"TISAX\") weicht von buildControlRows ab — die Strategie-Schicht verändert TISAX.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (process.env.UPDATE_SNAPSHOT === "1" || !existsSync(SNAPSHOT)) {
|
||||
mkdirSync(dirname(SNAPSHOT), { recursive: true });
|
||||
writeFileSync(SNAPSHOT, stable(current) + "\n", "utf-8");
|
||||
console.log(`✓ Snapshot ${existsSync(SNAPSHOT) ? "aktualisiert" : "erstellt"}: ${current.count} Controls (Level ${current.level}).`);
|
||||
console.log(" → Baseline committen; künftige Läufe prüfen gegen diesen Stand.");
|
||||
return;
|
||||
}
|
||||
|
||||
const expected = readFileSync(SNAPSHOT, "utf-8").trim();
|
||||
const actual = stable(current).trim();
|
||||
if (expected === actual) {
|
||||
console.log(`✓ TISAX-Snapshot unverändert: ${current.count} Controls, Level ${current.level} — 0 Abweichungen.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Erste abweichende Control-Zeile für die Diagnose zeigen.
|
||||
const exp = JSON.parse(expected) as { controls: { control: string }[] };
|
||||
const expByControl = new Map(exp.controls.map((c) => [c.control, stable(c)]));
|
||||
const diffs: string[] = [];
|
||||
for (const c of current.controls) {
|
||||
const e = expByControl.get(c.control);
|
||||
const a = stable(c);
|
||||
if (e !== a) diffs.push(c.control);
|
||||
}
|
||||
const onlyExpected = exp.controls.map((c) => c.control).filter((id) => !current.controls.some((c) => c.control === id));
|
||||
console.error(`✗ TISAX-Snapshot weicht ab. Betroffene Controls (${diffs.length}): ${diffs.slice(0, 20).join(", ")}`);
|
||||
if (onlyExpected.length) console.error(` Fehlend gegenüber Baseline: ${onlyExpected.join(", ")}`);
|
||||
console.error(" → Wenn die Änderung beabsichtigt ist: UPDATE_SNAPSHOT=1 erneut ausführen. Sonst Regression.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => { console.error(e); process.exit(1); })
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -1,93 +0,0 @@
|
||||
// AP1 — Framework-Dimension: Kern-Invarianten (docs/UEBERGABE-framework-iso27001.md §1).
|
||||
//
|
||||
// Prüft gegen die lokale DB (Demo-Mandant):
|
||||
// 1. Backfill: jeder Mandant hat ≥1 TenantFramework; Bestands-Anforderungen sind TISAX;
|
||||
// getTenantFrameworks(demo) beginnt mit TISAX; Fallback ["TISAX"] ohne Zeile.
|
||||
// 2. Beide Mappings parsen: mapping.json (VDA ISA) und mapping-iso.json (ISO) — gemeinsamer
|
||||
// Dokumentensatz, unterschiedliche Anforderungen.
|
||||
// 3. Doppel-Framework-Reconcile: ISO-Import in einen TISAX-Mandanten legt die ISO-
|
||||
// Anforderungen an und archiviert KEINE TISAX-Anforderung (Falle 1.1); Dokumente
|
||||
// werden nicht dupliziert (reconcileShared).
|
||||
// 4. Rück-Reconcile TISAX archiviert die ISO-Anforderungen NICHT.
|
||||
// Räumt die ISO-Testdaten am Ende wieder ab.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-framework-core.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { join } from "node:path";
|
||||
import { prisma } from "../src/server/db";
|
||||
import { parsePackageFiles, reconcilePackage } from "../prisma/import-policies";
|
||||
import { getTenantFrameworks, getTenantPrimaryFramework } from "../prisma/template-store";
|
||||
|
||||
const SEED_DIR = join(process.cwd(), "seed", "isms-vorlagenpaket-v2");
|
||||
let failures = 0;
|
||||
const ok = (c: boolean, m: string) => { console.log(`${c ? "✓" : "✗ FEHLER"} ${m}`); if (!c) failures++; };
|
||||
|
||||
const activeReqs = (tenantId: string, framework: "TISAX" | "ISO_27001") =>
|
||||
prisma.policyRequirement.count({ where: { tenantId, framework, archivedAt: null } });
|
||||
const archivedReqs = (tenantId: string, framework: "TISAX" | "ISO_27001") =>
|
||||
prisma.policyRequirement.count({ where: { tenantId, framework, archivedAt: { not: null } } });
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { slug: "demo" } });
|
||||
if (!tenant) throw new Error("Demo-Mandant fehlt (prisma/seed.ts ausführen).");
|
||||
const t = tenant.id;
|
||||
|
||||
// ── 1. Backfill / Framework-Zuordnung ───────────────────────────────────────
|
||||
const allTenants = await prisma.tenant.count();
|
||||
const tenantsWithFw = await prisma.tenantFramework.groupBy({ by: ["tenantId"] });
|
||||
ok(tenantsWithFw.length === allTenants, `jeder Mandant hat ≥1 TenantFramework (${tenantsWithFw.length}/${allTenants})`);
|
||||
|
||||
const demoFw = await getTenantFrameworks(prisma, t);
|
||||
ok(demoFw[0] === "TISAX", "getTenantFrameworks(demo) beginnt mit TISAX");
|
||||
ok((await getTenantPrimaryFramework(prisma, t)) === "TISAX", "Primär-Framework = TISAX");
|
||||
ok((await getTenantFrameworks(prisma, "does-not-exist"))[0] === "TISAX", "Fallback ohne Zeile → [\"TISAX\"]");
|
||||
|
||||
const isoBaseline = await prisma.policyRequirement.count({ where: { tenantId: t, framework: "ISO_27001" } });
|
||||
ok(isoBaseline === 0, "Demo-Mandant hat vor dem Test keine ISO-Anforderungen");
|
||||
|
||||
// ── 2. Beide Mappings parsen ────────────────────────────────────────────────
|
||||
const tisaxPkg = parsePackageFiles(SEED_DIR, "mapping.json");
|
||||
const isoPkg = parsePackageFiles(SEED_DIR, "mapping-iso.json");
|
||||
ok(tisaxPkg.requirements.length > 300, `mapping.json: ${tisaxPkg.requirements.length} Anforderungen (VDA ISA)`);
|
||||
ok(isoPkg.requirements.length === 120, `mapping-iso.json: ${isoPkg.requirements.length} Anforderungen (erwartet 120)`);
|
||||
ok(tisaxPkg.documents.length === isoPkg.documents.length, "gemeinsamer Dokumentensatz (gleiche Dokumentanzahl)");
|
||||
|
||||
// ── 3. Doppel-Framework: ISO in TISAX-Mandant ───────────────────────────────
|
||||
const tisaxActiveBefore = await activeReqs(t, "TISAX");
|
||||
const tisaxArchivedBefore = await archivedReqs(t, "TISAX");
|
||||
const docsBefore = await prisma.policyDocument.count({ where: { tenantId: t } });
|
||||
|
||||
const isoRun = await reconcilePackage(prisma, t, isoPkg, { framework: "ISO_27001", reconcileShared: false });
|
||||
ok(isoRun.report.requirements.added === isoPkg.requirements.length,
|
||||
`ISO-Import legt ${isoRun.report.requirements.added} Anforderungen an (= ${isoPkg.requirements.length})`);
|
||||
ok(isoRun.report.documents.added === 0 && isoRun.report.documents.updated === 0,
|
||||
"ISO-Import fasst die geteilten Dokumente NICHT an (reconcileShared=false)");
|
||||
|
||||
const isoActive = await activeReqs(t, "ISO_27001");
|
||||
ok(isoActive === isoPkg.requirements.length, `ISO-Anforderungen aktiv: ${isoActive}`);
|
||||
|
||||
const tisaxActiveAfter = await activeReqs(t, "TISAX");
|
||||
const tisaxArchivedAfter = await archivedReqs(t, "TISAX");
|
||||
ok(tisaxActiveAfter === tisaxActiveBefore, `KEINE TISAX-Anforderung archiviert (aktiv ${tisaxActiveBefore} → ${tisaxActiveAfter})`);
|
||||
ok(tisaxArchivedAfter === tisaxArchivedBefore, "ISO-Import erzeugt keine neu archivierten TISAX-Anforderungen (Falle 1.1)");
|
||||
|
||||
const docsAfter = await prisma.policyDocument.count({ where: { tenantId: t } });
|
||||
ok(docsAfter === docsBefore, `Dokumente nicht dupliziert (${docsBefore} → ${docsAfter})`);
|
||||
|
||||
const coexist = tisaxActiveAfter + isoActive;
|
||||
ok(tisaxActiveAfter > 0 && isoActive === 120, `Doppel-Framework: ${tisaxActiveAfter} TISAX + ${isoActive} ISO koexistieren (${coexist})`);
|
||||
|
||||
// ── 4. Rück-Reconcile TISAX archiviert ISO NICHT ────────────────────────────
|
||||
await reconcilePackage(prisma, t, tisaxPkg, { framework: "TISAX", reconcileShared: true });
|
||||
const isoStillActive = await activeReqs(t, "ISO_27001");
|
||||
ok(isoStillActive === isoPkg.requirements.length, "TISAX-Re-Import archiviert die ISO-Anforderungen NICHT");
|
||||
|
||||
// ── Aufräumen: ISO-Testdaten entfernen (Demo-Mandant zurücksetzen) ──────────
|
||||
const removed = await prisma.policyRequirement.deleteMany({ where: { tenantId: t, framework: "ISO_27001" } });
|
||||
console.log(`\n✓ aufgeräumt (${removed.count} ISO-Test-Anforderungen entfernt)`);
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => { console.log(failures === 0 ? "\nAP1-Framework-Kern grün." : `\n${failures} Prüfung(en) fehlgeschlagen.`); process.exit(failures === 0 ? 0 : 1); })
|
||||
.catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -1,118 +0,0 @@
|
||||
// Abnahmetest der Mehr-Framework-Fähigkeit (ISO 27001 neben TISAX) — reiner Trockenlauf.
|
||||
//
|
||||
// Prüft die Zusagen aus docs/UEBERGABE-framework-iso27001.md, ohne etwas zu schreiben:
|
||||
// 1. Paketebene: beide Mappings lesen denselben Dokumentensatz; kein ISO-Umsetzungstext
|
||||
// ist leer; die Anforderungs-IDs beider Frameworks überschneiden sich nicht.
|
||||
// 2. Falle 1.1: Ein ISO-Import in einen Mandanten mit TISAX archiviert KEINE
|
||||
// TISAX-Anforderung (`report.requirements.archived === 0`).
|
||||
// 3. Geteilte Inhalte (Dokumente, Variablen, Baseline, Nachweisregister) werden bei einem
|
||||
// Mandanten, der das Paket bereits hat, nicht doppelt angelegt.
|
||||
// 4. Der Trockenlauf hinterlässt keine Spuren: Zählstände vor und nach dem Lauf identisch.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-framework-dryrun.ts
|
||||
// Nutzt die lokale Postgres-DB; .env liegt im Worktree. Ohne Mandanten läuft nur Teil 1.
|
||||
|
||||
import "dotenv/config";
|
||||
import { join } from "node:path";
|
||||
import { prisma } from "../src/server/db";
|
||||
import { parsePackageFiles, reconcilePackage } from "../prisma/import-policies";
|
||||
|
||||
const SEED = join(process.cwd(), "seed", "isms-vorlagenpaket-v2");
|
||||
let failed = 0;
|
||||
|
||||
function check(ok: boolean, label: string, detail = "") {
|
||||
if (!ok) failed++;
|
||||
console.log(` ${ok ? "✓" : "✗"} ${label}${detail ? " — " + detail : ""}`);
|
||||
}
|
||||
|
||||
async function counts(tenantId: string) {
|
||||
return {
|
||||
tisax: await prisma.policyRequirement.count({ where: { tenantId, framework: "TISAX", archivedAt: null } }),
|
||||
tisaxArch: await prisma.policyRequirement.count({ where: { tenantId, framework: "TISAX", archivedAt: { not: null } } }),
|
||||
iso: await prisma.policyRequirement.count({ where: { tenantId, framework: "ISO_27001", archivedAt: null } }),
|
||||
docs: await prisma.policyDocument.count({ where: { tenantId, archivedAt: null } }),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("1. Paketebene");
|
||||
const iso = parsePackageFiles(SEED, "mapping-iso.json");
|
||||
const tis = parsePackageFiles(SEED, "mapping.json");
|
||||
const isoCodes = iso.documents.map((d) => d.code).sort();
|
||||
const tisCodes = tis.documents.map((d) => d.code).sort();
|
||||
|
||||
check(JSON.stringify(isoCodes) === JSON.stringify(tisCodes),
|
||||
"beide Mappings lesen denselben Dokumentensatz", `${isoCodes.length} Dokumente`);
|
||||
check(iso.requirements.length === 120,
|
||||
"ISO-Mapping enthält 120 Anforderungen", `${iso.requirements.length}`);
|
||||
check(iso.requirements.filter((r) => !r.implementation.trim()).length === 0,
|
||||
"kein ISO-Umsetzungstext ist leer");
|
||||
check(iso.requirements.every((r) => !!r.control),
|
||||
"jede ISO-Anforderung trägt eine Control-Referenz");
|
||||
const overlap = iso.requirements.map((r) => r.reqId).filter((id) => tis.requirements.some((t) => t.reqId === id));
|
||||
check(overlap.length === 0, "Anforderungs-IDs beider Frameworks überschneiden sich nicht",
|
||||
overlap.length ? overlap.slice(0, 3).join(", ") : "");
|
||||
|
||||
// Englische Fassung: gleiche Struktur, übersetzte Texte.
|
||||
const SEED_EN = join(process.cwd(), "seed", "isms-vorlagenpaket-v2-en");
|
||||
const isoEn = parsePackageFiles(SEED_EN, "mapping-iso.json");
|
||||
check(isoEn.requirements.length === iso.requirements.length,
|
||||
"EN-Fassung enthält gleich viele ISO-Anforderungen", `${isoEn.requirements.length}`);
|
||||
check(JSON.stringify(isoEn.requirements.map((r) => r.reqId).sort())
|
||||
=== JSON.stringify(iso.requirements.map((r) => r.reqId).sort()),
|
||||
"EN- und DE-Fassung haben identische Anforderungs-IDs");
|
||||
check(isoEn.requirements.filter((r) => !r.implementation.trim()).length === 0,
|
||||
"kein EN-Umsetzungstext ist leer");
|
||||
check(isoEn.documents.length === iso.documents.length,
|
||||
"EN-Fassung hat denselben Dokumentenumfang", `${isoEn.documents.length}`);
|
||||
|
||||
const tenants = await prisma.tenant.findMany({ select: { id: true, name: true, slug: true } });
|
||||
if (!tenants.length) {
|
||||
console.log("\n(keine Mandanten in der DB — Teil 2 übersprungen)");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const t of tenants) {
|
||||
console.log(`\n2. Trockenlauf ISO_27001 gegen „${t.name}" (${t.slug})`);
|
||||
const before = await counts(t.id);
|
||||
console.log(` vorher: TISAX=${before.tisax} (archiviert ${before.tisaxArch}) · ISO=${before.iso} · Dokumente=${before.docs}`);
|
||||
|
||||
const { report } = await reconcilePackage(prisma, t.id, iso, { dryRun: true, framework: "ISO_27001" });
|
||||
const r = report.requirements, d = report.documents;
|
||||
console.log(` Anforderungen: +${r.added} ~${r.updated} =${r.unchanged} archiviert=${r.archived}`);
|
||||
console.log(` Dokumente: +${d.added} ~${d.updated} =${d.unchanged} reaktiviert=${d.reactivated} archiviert=${d.archived}`);
|
||||
|
||||
check(r.archived === 0, "Falle 1.1 — keine fremde Anforderung archiviert", `archived=${r.archived}`);
|
||||
check(r.added + r.unchanged + r.updated === 120, "alle 120 ISO-Anforderungen erfasst");
|
||||
check(d.archived === 0, "kein geteiltes Dokument wird stillgelegt", `archived=${d.archived}`);
|
||||
check(report.variables.obsolete === 0 && report.baseline.obsolete === 0 && report.evidence.obsolete === 0,
|
||||
"geteilte Inhalte verlieren nichts (Variablen, Baseline, Nachweisregister)");
|
||||
// Mandant hat das Paket bereits: jedes vorhandene Paket-Dokument muss wiedererkannt
|
||||
// werden; angelegt wird nur, was im Mandanten wirklich fehlt.
|
||||
const vorhanden = await prisma.policyDocument.findMany({
|
||||
where: { tenantId: t.id, code: { in: iso.documents.map((x) => x.code) } },
|
||||
select: { code: true },
|
||||
});
|
||||
// Ein vorhandenes Dokument zählt je nach Zustand als unchanged, updated ODER
|
||||
// reactivated (wenn es beim Mandanten stillgelegt war) — alle drei sind „wiedererkannt".
|
||||
const wiedererkannt = d.unchanged + d.updated + d.reactivated;
|
||||
check(wiedererkannt === vorhanden.length,
|
||||
"jedes vorhandene Paket-Dokument wird wiedererkannt",
|
||||
`vorhanden=${vorhanden.length} unchanged=${d.unchanged} updated=${d.updated} reaktiviert=${d.reactivated}`);
|
||||
check(d.added === iso.documents.length - vorhanden.length,
|
||||
"angelegt wird nur, was im Mandanten fehlt",
|
||||
`added=${d.added} erwartet=${iso.documents.length - vorhanden.length}`);
|
||||
|
||||
const after = await counts(t.id);
|
||||
check(JSON.stringify(before) === JSON.stringify(after),
|
||||
"Trockenlauf hat nichts geschrieben", JSON.stringify(after));
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => { console.error(e); failed++; })
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
console.log(`\n${failed === 0 ? "OK — alle Prüfungen bestanden" : `FEHLGESCHLAGEN — ${failed} Prüfung(en)`}`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
// AP2 — Provisionierung & Sichtbarkeits-Flags (docs/UEBERGABE-framework-iso27001.md §2).
|
||||
//
|
||||
// Provisioniert je einen Mandanten als reines TISAX, reines ISO und Doppel-Framework
|
||||
// und prüft:
|
||||
// - TenantFramework-Zeilen (Primär zuerst),
|
||||
// - Import je Framework (Anforderungszahlen, keine Cross-Archivierung, Dokumente
|
||||
// nicht dupliziert),
|
||||
// - FLAG_FW_TISAX / FLAG_FW_ISO27001 NACH dem Import korrekt gesetzt,
|
||||
// - AL-/Schutzbedarf-Flags nur bei TISAX (reiner ISO-Mandant: nicht gesetzt).
|
||||
// Räumt die Test-Mandanten (inkl. Identitäten) vor und nach dem Lauf ab.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-framework-provision.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { join } from "node:path";
|
||||
import { prisma } from "../src/server/db";
|
||||
import { provisionTenant } from "../src/server/provision";
|
||||
|
||||
const SEED_DIR = join(process.cwd(), "seed", "isms-vorlagenpaket-v2");
|
||||
let failures = 0;
|
||||
const ok = (c: boolean, m: string) => { console.log(`${c ? "✓" : "✗ FEHLER"} ${m}`); if (!c) failures++; };
|
||||
|
||||
const T = {
|
||||
tisax: { slug: "ap2-test-tisax", email: "admin@ap2-test-tisax.example" },
|
||||
iso: { slug: "ap2-test-iso", email: "admin@ap2-test-iso.example" },
|
||||
dual: { slug: "ap2-test-dual", email: "admin@ap2-test-dual.example" },
|
||||
};
|
||||
const SLUGS = Object.values(T).map((t) => t.slug);
|
||||
const EMAILS = Object.values(T).map((t) => t.email);
|
||||
|
||||
async function cleanup() {
|
||||
const tenants = await prisma.tenant.findMany({ where: { slug: { in: SLUGS } }, select: { id: true } });
|
||||
const ids = tenants.map((t) => t.id);
|
||||
if (ids.length) {
|
||||
// FK-sichere Reihenfolge: Verknüpfungen → Kinder → Stamm.
|
||||
await prisma.userRole.deleteMany({ where: { user: { tenantId: { in: ids } } } });
|
||||
await prisma.rolePermission.deleteMany({ where: { role: { tenantId: { in: ids } } } });
|
||||
await prisma.user.deleteMany({ where: { tenantId: { in: ids } } });
|
||||
await prisma.role.deleteMany({ where: { tenantId: { in: ids } } });
|
||||
for (const model of [
|
||||
"policyRequirement", "policyVariable", "policyDocument", "policyBaselineParam",
|
||||
"policyEvidence", "policyPackageState", "tenantFramework", "tenantModule",
|
||||
"managedRegister", "auditLog",
|
||||
] as const) {
|
||||
// @ts-expect-error dynamischer Modellzugriff (alle tragen tenantId)
|
||||
await prisma[model].deleteMany({ where: { tenantId: { in: ids } } });
|
||||
}
|
||||
await prisma.tenantSettings.deleteMany({ where: { tenantId: { in: ids } } });
|
||||
await prisma.tenant.deleteMany({ where: { id: { in: ids } } });
|
||||
}
|
||||
await prisma.identity.deleteMany({ where: { email: { in: EMAILS } } });
|
||||
}
|
||||
|
||||
const flag = async (tenantId: string, key: string) =>
|
||||
(await prisma.policyVariable.findFirst({ where: { tenantId, key }, select: { value: true } }))?.value ?? null;
|
||||
const reqCount = (tenantId: string, framework: "TISAX" | "ISO_27001") =>
|
||||
prisma.policyRequirement.count({ where: { tenantId, framework, archivedAt: null } });
|
||||
|
||||
async function provision(slug: string, email: string, frameworks: ("TISAX" | "ISO_27001")[]) {
|
||||
return provisionTenant(prisma, {
|
||||
name: slug, slug, admin: { email, name: "AP2 Test", password: "Str0ng-Passw0rt!" },
|
||||
frameworks, seedPoliciesDir: SEED_DIR, actorId: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await cleanup();
|
||||
|
||||
// ── Reines TISAX ────────────────────────────────────────────────────────────
|
||||
const tisax = await provision(T.tisax.slug, T.tisax.email, ["TISAX"]);
|
||||
const tisaxFw = await prisma.tenantFramework.findMany({ where: { tenantId: tisax.id }, select: { framework: true, isPrimary: true } });
|
||||
ok(tisaxFw.length === 1 && tisaxFw[0].framework === "TISAX" && tisaxFw[0].isPrimary, "TISAX-Mandant: genau eine TenantFramework-Zeile (TISAX, primär)");
|
||||
ok((await reqCount(tisax.id, "TISAX")) === 321 && (await reqCount(tisax.id, "ISO_27001")) === 0, "TISAX-Mandant: 321 TISAX-, 0 ISO-Anforderungen");
|
||||
ok((await flag(tisax.id, "FLAG_FW_TISAX")) === "true" && (await flag(tisax.id, "FLAG_FW_ISO27001")) === "false", "TISAX-Mandant: FLAG_FW_TISAX=true, FLAG_FW_ISO27001=false");
|
||||
ok((await flag(tisax.id, "FLAG_HIGH_PROTECTION")) === "true", "TISAX-Mandant: AL-Flag FLAG_HIGH_PROTECTION gesetzt");
|
||||
|
||||
// ── Reines ISO ──────────────────────────────────────────────────────────────
|
||||
const iso = await provision(T.iso.slug, T.iso.email, ["ISO_27001"]);
|
||||
const isoFw = await prisma.tenantFramework.findMany({ where: { tenantId: iso.id }, select: { framework: true, isPrimary: true } });
|
||||
ok(isoFw.length === 1 && isoFw[0].framework === "ISO_27001" && isoFw[0].isPrimary, "ISO-Mandant: genau eine TenantFramework-Zeile (ISO, primär)");
|
||||
ok((await reqCount(iso.id, "ISO_27001")) === 120 && (await reqCount(iso.id, "TISAX")) === 0, "ISO-Mandant: 120 ISO-, 0 TISAX-Anforderungen");
|
||||
ok((await flag(iso.id, "FLAG_FW_ISO27001")) === "true" && (await flag(iso.id, "FLAG_FW_TISAX")) === "false", "ISO-Mandant: FLAG_FW_ISO27001=true, FLAG_FW_TISAX=false");
|
||||
ok((await flag(iso.id, "FLAG_HIGH_PROTECTION")) === "false", "ISO-Mandant: KEIN AL-Flag gesetzt (Default false, Übergabe §1.3)");
|
||||
const isoDocs = await prisma.policyDocument.count({ where: { tenantId: iso.id } });
|
||||
const isoDocCodes = (await prisma.policyDocument.findMany({ where: { tenantId: iso.id }, select: { code: true } })).map((d) => d.code);
|
||||
ok(isoDocs === new Set(isoDocCodes).size, "ISO-Mandant: Dokumente nicht dupliziert (eindeutige Codes)");
|
||||
|
||||
// ── Doppel-Framework ─────────────────────────────────────────────────────────
|
||||
const dual = await provision(T.dual.slug, T.dual.email, ["TISAX", "ISO_27001"]);
|
||||
const dualFw = await prisma.tenantFramework.findMany({ where: { tenantId: dual.id }, orderBy: { isPrimary: "desc" }, select: { framework: true, isPrimary: true } });
|
||||
ok(dualFw.length === 2 && dualFw[0].framework === "TISAX" && dualFw[0].isPrimary && !dualFw[1].isPrimary, "Doppel-Mandant: zwei Zeilen, TISAX primär");
|
||||
const dTisax = await reqCount(dual.id, "TISAX");
|
||||
const dIso = await reqCount(dual.id, "ISO_27001");
|
||||
ok(dTisax === 321 && dIso === 120, `Doppel-Mandant: 321 TISAX + 120 ISO koexistieren (${dTisax}+${dIso})`);
|
||||
ok((await flag(dual.id, "FLAG_FW_TISAX")) === "true" && (await flag(dual.id, "FLAG_FW_ISO27001")) === "true", "Doppel-Mandant: beide FLAG_FW_* = true");
|
||||
const dualDocs = await prisma.policyDocument.count({ where: { tenantId: dual.id } });
|
||||
const dualCodes = (await prisma.policyDocument.findMany({ where: { tenantId: dual.id }, select: { code: true } })).map((d) => d.code);
|
||||
ok(dualDocs === new Set(dualCodes).size, `Doppel-Mandant: Dokumente NICHT dupliziert (${dualDocs} eindeutige Codes)`);
|
||||
|
||||
await cleanup();
|
||||
console.log("\n✓ aufgeräumt (Test-Mandanten + Identitäten entfernt)");
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => { console.log(failures === 0 ? "\nAP2-Provisionierung grün." : `\n${failures} Prüfung(en) fehlgeschlagen.`); process.exit(failures === 0 ? 0 : 1); })
|
||||
.catch(async (e) => { console.error(e); await cleanup().catch(() => {}); process.exit(1); });
|
||||
@@ -1,62 +0,0 @@
|
||||
// Vorlagen-Versionierung je Framework (Falle 1.2, docs/UEBERGABE-framework-iso27001.md §1).
|
||||
//
|
||||
// Sichert die Daten-Invariante, auf der der framework-fähige Vorlagen-Editor
|
||||
// (policy-templates.ts) aufsetzt — ohne die session-geschützten Actions aufzurufen:
|
||||
// 1. Dieselbe Versionsnummer darf je Framework existieren (@@unique([framework, version])):
|
||||
// (TISAX, "99.1") UND (ISO_27001, "99.1") koexistieren.
|
||||
// 2. Das Publish-Archivieren ist framework-scoped: ein `updateMany` mit
|
||||
// `framework=ISO_27001` archiviert NUR die ISO-Version, die TISAX-Version bleibt
|
||||
// PUBLISHED (sonst archivierte ein ISO-Publish die TISAX-Vorlage und umgekehrt).
|
||||
// Nutzt eindeutige Wegwerf-Versionen ("99.1") und räumt sie wieder ab.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-framework-templates.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { prisma } from "../src/server/db";
|
||||
|
||||
let failures = 0;
|
||||
const ok = (c: boolean, m: string) => { console.log(`${c ? "✓" : "✗ FEHLER"} ${m}`); if (!c) failures++; };
|
||||
const V = "99.1"; // Wegwerf-Version, kollidiert nicht mit echten Paketständen
|
||||
|
||||
async function cleanup() {
|
||||
await prisma.policyTemplateVersion.deleteMany({ where: { version: V } });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await cleanup();
|
||||
|
||||
// ── 1. Gleiche Version je Framework koexistiert (Unique-Constraint) ──────────
|
||||
const tisax = await prisma.policyTemplateVersion.create({ data: { framework: "TISAX", version: V, status: "PUBLISHED", publishedAt: new Date() } });
|
||||
let isoCreated = false;
|
||||
try {
|
||||
await prisma.policyTemplateVersion.create({ data: { framework: "ISO_27001", version: V, status: "PUBLISHED", publishedAt: new Date() } });
|
||||
isoCreated = true;
|
||||
} catch {
|
||||
isoCreated = false;
|
||||
}
|
||||
ok(isoCreated, "gleiche Versionsnummer je Framework erlaubt (@@unique([framework, version]))");
|
||||
|
||||
const both = await prisma.policyTemplateVersion.findMany({ where: { version: V }, select: { framework: true } });
|
||||
ok(both.length === 2, "beide Versionen (TISAX + ISO) existieren nebeneinander");
|
||||
|
||||
// ── 2. Framework-scopes Archivieren (wie publishDraft) ──────────────────────
|
||||
const ids = (await prisma.policyTemplateVersion.findMany({ where: { version: V }, select: { id: true } })).map((r) => r.id);
|
||||
// Repliziert die Publish-Bedingung `where { status: PUBLISHED, framework }` — zusätzlich
|
||||
// auf die Testzeilen eingegrenzt, um echte Paketstände nicht anzufassen.
|
||||
await prisma.policyTemplateVersion.updateMany({
|
||||
where: { status: "PUBLISHED", framework: "ISO_27001", id: { in: ids } },
|
||||
data: { status: "ARCHIVED" },
|
||||
});
|
||||
const tisaxAfter = await prisma.policyTemplateVersion.findFirst({ where: { framework: "TISAX", version: V }, select: { status: true } });
|
||||
const isoAfter = await prisma.policyTemplateVersion.findFirst({ where: { framework: "ISO_27001", version: V }, select: { status: true } });
|
||||
ok(tisaxAfter?.status === "PUBLISHED", "TISAX-Version bleibt PUBLISHED, wenn ISO archiviert wird");
|
||||
ok(isoAfter?.status === "ARCHIVED", "ISO-Version wird archiviert (framework-scoped)");
|
||||
|
||||
void tisax;
|
||||
await cleanup();
|
||||
console.log("\n✓ aufgeräumt (Wegwerf-Versionen entfernt)");
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => { console.log(failures === 0 ? "\nVorlagen-Versionierung je Framework grün." : `\n${failures} Prüfung(en) fehlgeschlagen.`); process.exit(failures === 0 ? 0 : 1); })
|
||||
.catch(async (e) => { console.error(e); await cleanup().catch(() => {}); process.exit(1); });
|
||||
@@ -1,150 +0,0 @@
|
||||
// Test der nachträglichen Framework-Umschaltung (Admin: setTenantFrameworks).
|
||||
//
|
||||
// Spiegelt die Datenoperationen der Server-Action gegen die lokale DB und stellt den
|
||||
// Ausgangszustand danach wieder her. Der Auth-Guard (`requirePlatformFullAdmin`) wird
|
||||
// dabei NICHT durchlaufen — er ist identisch zu den sechs übrigen Admin-Aktionen.
|
||||
//
|
||||
// Geprüft wird:
|
||||
// 1. Aktivieren — Framework-Zeile entsteht, 120 ISO-Anforderungen werden angelegt,
|
||||
// die 321 TISAX-Anforderungen bleiben unangetastet, Flags stehen richtig.
|
||||
// 2. Deaktivieren — Zugehörigkeit fällt weg, ISO-Anforderungen werden STILLGELEGT
|
||||
// (archivedAt), nicht gelöscht; SoA-Einträge bleiben vollständig erhalten.
|
||||
// 3. Wiederherstellung — der Mandant steht am Ende exakt wie vorher.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-framework-toggle.ts
|
||||
// Nutzt die lokale Postgres-DB; .env liegt im Worktree.
|
||||
|
||||
import "dotenv/config";
|
||||
import { join } from "node:path";
|
||||
import type { Framework } from "@prisma/client";
|
||||
import { prisma, dbForTenant } from "../src/server/db";
|
||||
import { reconcilePackage, stampPackageVersion } from "../prisma/import-policies";
|
||||
import { resolvePackageForTenant, getTenantFrameworks } from "../prisma/template-store";
|
||||
|
||||
const SEED = join(process.cwd(), "seed", "isms-vorlagenpaket-v2");
|
||||
let failed = 0;
|
||||
|
||||
function check(ok: boolean, label: string, detail = "") {
|
||||
if (!ok) failed++;
|
||||
console.log(` ${ok ? "✓" : "✗"} ${label}${detail ? " — " + detail : ""}`);
|
||||
}
|
||||
|
||||
async function state(tenantId: string) {
|
||||
const db = dbForTenant(tenantId);
|
||||
const [fw, tisax, tisaxArch, iso, isoArch, soa, flags] = await Promise.all([
|
||||
getTenantFrameworks(prisma, tenantId),
|
||||
db.policyRequirement.count({ where: { framework: "TISAX", archivedAt: null } }),
|
||||
db.policyRequirement.count({ where: { framework: "TISAX", archivedAt: { not: null } } }),
|
||||
db.policyRequirement.count({ where: { framework: "ISO_27001", archivedAt: null } }),
|
||||
db.policyRequirement.count({ where: { framework: "ISO_27001", archivedAt: { not: null } } }),
|
||||
db.soaEntry.count(),
|
||||
db.policyVariable.findMany({
|
||||
where: { key: { in: ["FLAG_FW_TISAX", "FLAG_FW_ISO27001"] } },
|
||||
select: { key: true, value: true }, orderBy: { key: "asc" },
|
||||
}),
|
||||
]);
|
||||
return { fw, tisax, tisaxArch, iso, isoArch, soa, flags: flags.map((f) => `${f.key}=${f.value}`).join(" ") };
|
||||
}
|
||||
|
||||
/** Datenoperationen aus `setTenantFrameworks` (ohne Auth-Guard und revalidatePath). */
|
||||
async function apply(tenantId: string, wanted: Framework[]) {
|
||||
const current = await getTenantFrameworks(prisma, tenantId);
|
||||
const added = wanted.filter((f) => !current.includes(f));
|
||||
const removed = current.filter((f) => !wanted.includes(f));
|
||||
const db = dbForTenant(tenantId);
|
||||
|
||||
for (const [i, framework] of wanted.entries()) {
|
||||
await prisma.tenantFramework.upsert({
|
||||
where: { tenantId_framework: { tenantId, framework } },
|
||||
update: { isPrimary: i === 0 },
|
||||
create: { tenantId, framework, isPrimary: i === 0 },
|
||||
});
|
||||
}
|
||||
for (const [i, framework] of added.entries()) {
|
||||
const { pkg } = await resolvePackageForTenant(prisma, tenantId, SEED, framework);
|
||||
await reconcilePackage(prisma, tenantId, pkg, { framework, reconcileShared: i === 0 });
|
||||
await stampPackageVersion(prisma, tenantId, pkg.version, framework);
|
||||
}
|
||||
if (removed.length > 0) {
|
||||
await prisma.tenantFramework.deleteMany({ where: { tenantId, framework: { in: removed } } });
|
||||
await db.policyRequirement.updateMany({
|
||||
where: { framework: { in: removed }, archivedAt: null },
|
||||
data: { archivedAt: new Date() },
|
||||
});
|
||||
}
|
||||
await db.policyVariable.updateMany({ where: { key: "FLAG_FW_TISAX" }, data: { value: String(wanted.includes("TISAX")) } });
|
||||
await db.policyVariable.updateMany({ where: { key: "FLAG_FW_ISO27001" }, data: { value: String(wanted.includes("ISO_27001")) } });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { slug: "demo" }, select: { id: true, name: true } });
|
||||
if (!tenant) { console.log("Mandant demo nicht gefunden — Test uebersprungen."); return; }
|
||||
|
||||
const before = await state(tenant.id);
|
||||
console.log(`Mandant: ${tenant.name}`);
|
||||
console.log(` vorher: ${before.fw.join("+")} · TISAX=${before.tisax}/${before.tisaxArch} · ISO=${before.iso}/${before.isoArch} · SoA=${before.soa} · ${before.flags}`);
|
||||
|
||||
try {
|
||||
console.log("\n1. ISO aktivieren");
|
||||
await apply(tenant.id, [...before.fw, "ISO_27001"] as Framework[]);
|
||||
const on = await state(tenant.id);
|
||||
console.log(` ${on.fw.join("+")} · TISAX=${on.tisax}/${on.tisaxArch} · ISO=${on.iso}/${on.isoArch} · ${on.flags}`);
|
||||
check(on.fw.includes("ISO_27001"), "Framework-Zugehörigkeit angelegt");
|
||||
check(on.iso === 120, "120 ISO-Anforderungen aktiv", `${on.iso}`);
|
||||
check(on.tisax === before.tisax && on.tisaxArch === before.tisaxArch,
|
||||
"TISAX-Anforderungen unangetastet", `${on.tisax} aktiv / ${on.tisaxArch} archiviert`);
|
||||
check(on.flags.includes("FLAG_FW_ISO27001=true") && on.flags.includes("FLAG_FW_TISAX=true"),
|
||||
"Sichtbarkeits-Flags gesetzt", on.flags);
|
||||
|
||||
// Gepflegte SoA-Inhalte anlegen — sonst liefe die Erhaltungsprüfung unten über 0 → 0.
|
||||
const db0 = dbForTenant(tenant.id);
|
||||
for (const control of ["A.5.15", "A.8.5", "A.7.7"]) {
|
||||
await db0.soaEntry.upsert({
|
||||
where: { tenantId_framework_control: { tenantId: tenant.id, framework: "ISO_27001", control } },
|
||||
update: { justification: "Testbegruendung", implementationStatus: "umgesetzt" },
|
||||
create: {
|
||||
tenantId: tenant.id, framework: "ISO_27001", control,
|
||||
justification: "Testbegruendung", implementationStatus: "umgesetzt", applicable: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
const gepflegt = await db0.soaEntry.count({ where: { justification: "Testbegruendung" } });
|
||||
check(gepflegt === 3, "SoA-Einträge zum Test gepflegt", `${gepflegt}`);
|
||||
|
||||
console.log("\n2. ISO wieder deaktivieren");
|
||||
await apply(tenant.id, before.fw as Framework[]);
|
||||
const off = await state(tenant.id);
|
||||
console.log(` ${off.fw.join("+")} · TISAX=${off.tisax}/${off.tisaxArch} · ISO=${off.iso}/${off.isoArch} · SoA=${off.soa} · ${off.flags}`);
|
||||
check(!off.fw.includes("ISO_27001"), "Zugehörigkeit entfernt");
|
||||
check(off.iso === 0 && off.isoArch === 120,
|
||||
"ISO-Anforderungen stillgelegt statt gelöscht", `aktiv=${off.iso} archiviert=${off.isoArch}`);
|
||||
check(off.tisax === before.tisax, "TISAX weiterhin unangetastet", `${off.tisax}`);
|
||||
const ueberlebt = await dbForTenant(tenant.id).soaEntry.count({ where: { justification: "Testbegruendung" } });
|
||||
check(ueberlebt === 3, "gepflegte SoA-Begründungen überleben das Deaktivieren unverändert", `${ueberlebt} von 3`);
|
||||
check(off.flags.includes("FLAG_FW_ISO27001=false"), "Flag zurückgesetzt", off.flags);
|
||||
|
||||
console.log("\n3. Erneut aktivieren — stillgelegte Anforderungen reaktivieren");
|
||||
await apply(tenant.id, [...before.fw, "ISO_27001"] as Framework[]);
|
||||
const again = await state(tenant.id);
|
||||
check(again.iso === 120 && again.isoArch === 0,
|
||||
"Re-Import reaktiviert statt zu duplizieren", `aktiv=${again.iso} archiviert=${again.isoArch}`);
|
||||
} finally {
|
||||
console.log("\n4. Ausgangszustand wiederherstellen");
|
||||
await apply(tenant.id, before.fw as Framework[]);
|
||||
const db = dbForTenant(tenant.id);
|
||||
await db.policyRequirement.deleteMany({ where: { framework: "ISO_27001" } });
|
||||
await db.soaEntry.deleteMany({ where: { framework: "ISO_27001" } });
|
||||
const end = await state(tenant.id);
|
||||
console.log(` ${end.fw.join("+")} · TISAX=${end.tisax}/${end.tisaxArch} · ISO=${end.iso}/${end.isoArch} · ${end.flags}`);
|
||||
check(JSON.stringify(end) === JSON.stringify(before), "Mandant steht wie vorher",
|
||||
JSON.stringify(end) === JSON.stringify(before) ? "" : `${JSON.stringify(before)} → ${JSON.stringify(end)}`);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => { console.error(e); failed++; })
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
console.log(`\n${failed === 0 ? "OK — alle Prüfungen bestanden" : `FEHLGESCHLAGEN — ${failed} Prüfung(en)`}`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
// Akzeptanztest der Funktionstrennungs-Regeln FT-01…FT-06 (Story A4-2, C7 §2).
|
||||
// Reine Logik, kein DB-Zugriff. Lauf: npx tsx scripts/test-ft-rules.ts
|
||||
|
||||
import { evaluateFt, type FtRoleInput } from "../src/lib/ft-rules";
|
||||
|
||||
let failures = 0;
|
||||
const ok = (cond: boolean, msg: string) => {
|
||||
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
const base: FtRoleInput = {
|
||||
roles: { ISB: "Frau A", MANAGEMENT: "Herr B", IT_LEAD: "Herr C", HR_LEAD: "Frau D", DPO: "Herr E" },
|
||||
isbNamed: true, isbIsIt: false, isbInternal: false, personalData: false,
|
||||
};
|
||||
const codes = (i: FtRoleInput) => evaluateFt(i).map((f) => f.code);
|
||||
const byCode = (i: FtRoleInput, c: string) => evaluateFt(i).find((f) => f.code === c);
|
||||
|
||||
// — Sauberer Fall: nur FT-06 (manueller Hinweis) —
|
||||
ok(JSON.stringify(codes(base)) === JSON.stringify(["FT-06"]), "sauber → nur FT-06 (manuell)");
|
||||
|
||||
// — FT-04: ISB nicht benannt → Lücke + Aufgabe (reuse isb_not_named) —
|
||||
{
|
||||
const f = byCode({ ...base, isbNamed: false }, "FT-04");
|
||||
ok(!!f && f.severity === "luecke" && f.task?.origin === "isb_not_named" && f.task?.reuse === true, "FT-04 ISB nicht benannt → Lücke + Aufgabe (reuse)");
|
||||
}
|
||||
|
||||
// — FT-01: ISB = IT → Konflikt + Aufgabe (reuse isb_equals_it) —
|
||||
{
|
||||
const f = byCode({ ...base, isbIsIt: true }, "FT-01");
|
||||
ok(!!f && f.severity === "konflikt" && f.task?.origin === "isb_equals_it" && f.task?.reuse === true, "FT-01 ISB=IT → Konflikt + Aufgabe (reuse)");
|
||||
}
|
||||
|
||||
// — FT-03: ISB = Leitung → Konflikt + neue Aufgabe ft-03 —
|
||||
{
|
||||
const f = byCode({ ...base, roles: { ...base.roles, MANAGEMENT: "Frau A" } }, "FT-03");
|
||||
ok(!!f && f.severity === "konflikt" && f.task?.origin === "ft-03" && f.task?.reuse === false, "FT-03 ISB=Leitung → Konflikt + neue Aufgabe");
|
||||
}
|
||||
|
||||
// — FT-05: DPO fehlt trotz personenbezogener Daten → Lücke + neue Aufgabe ft-05 —
|
||||
{
|
||||
const f = byCode({ ...base, personalData: true, roles: { ...base.roles, DPO: "" } }, "FT-05");
|
||||
ok(!!f && f.severity === "luecke" && f.task?.origin === "ft-05" && f.task?.reuse === false, "FT-05 DPO fehlt + FLAG_PERSONAL_DATA → Lücke + neue Aufgabe");
|
||||
// Kein FT-05, wenn DPO gesetzt:
|
||||
ok(!byCode({ ...base, personalData: true }, "FT-05"), "FT-05 nicht, wenn DPO benannt");
|
||||
// Kein FT-05, wenn keine personenbezogenen Daten:
|
||||
ok(!byCode({ ...base, roles: { ...base.roles, DPO: "" } }, "FT-05"), "FT-05 nicht ohne FLAG_PERSONAL_DATA");
|
||||
}
|
||||
|
||||
// — FT-02: interner ISB (nicht IT) → schwacher Hinweis, keine Aufgabe —
|
||||
{
|
||||
const f = byCode({ ...base, isbInternal: true }, "FT-02");
|
||||
ok(!!f && f.severity === "schwach" && !f.task, "FT-02 interner ISB → Hinweis ohne Aufgabe");
|
||||
}
|
||||
|
||||
// — FT-06 immer vorhanden (manuell) —
|
||||
ok(!!byCode(base, "FT-06") && !byCode(base, "FT-06")!.task, "FT-06 immer vorhanden, ohne Aufgabe");
|
||||
|
||||
console.log(failures === 0 ? "\nOK — alle FT-Tests grün" : `\nPRUEFEN — ${failures} Fehler`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -1,91 +0,0 @@
|
||||
// Unit-Tests (Story A8) für die Gap-Konsolidierung gegen die C8-Beispiele.
|
||||
// Lauf: npx tsx scripts/test-gap-consolidation.ts
|
||||
import { consolidate, gapPriority, isQuickWin, summarize, type RawGap } from "../src/lib/gap-consolidation";
|
||||
|
||||
let failed = 0;
|
||||
function check(name: string, cond: boolean, detail = "") {
|
||||
if (cond) console.log(` ok ${name}`);
|
||||
else {
|
||||
failed++;
|
||||
console.error(`FAIL ${name}${detail ? ` — ${detail}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
function raw(p: Partial<RawGap>): RawGap {
|
||||
return {
|
||||
id: p.id ?? "x",
|
||||
source: p.source ?? "control",
|
||||
title: p.title ?? "",
|
||||
action: p.action ?? "",
|
||||
controls: p.controls ?? [],
|
||||
effort: p.effort ?? "mittel",
|
||||
maturityImpact: p.maturityImpact ?? 1,
|
||||
hasDependency: p.hasDependency ?? false,
|
||||
taskOrigin: p.taskOrigin ?? "wizard:gap",
|
||||
...p,
|
||||
};
|
||||
}
|
||||
|
||||
// C8 §1 Prioritätsregeln + §4 Beispieltabelle.
|
||||
console.log("§1/§4 Priorität:");
|
||||
check(
|
||||
"Kein Patchmanagement (MUSS<2 + Risiko) → Hoch",
|
||||
gapPriority(raw({ requirementType: "MUSS", maturity: 1, riskScore: 16, aboveAcceptance: true })) === "hoch",
|
||||
);
|
||||
check(
|
||||
"Restore-Test fehlt (MUSS-Nachweis) → Hoch",
|
||||
gapPriority(raw({ requirementType: "MUSS", maturity: 1, kind: "nachweis" })) === "hoch",
|
||||
);
|
||||
check("ISB=IT (FT-01 Konflikt) → Hoch", gapPriority(raw({ ftConflict: true, source: "role" })) === "hoch");
|
||||
check(
|
||||
"SOLL Berechtigungs-Review nur Vorlage → Mittel",
|
||||
gapPriority(raw({ requirementType: "SOLL", kind: "verfahren", maturity: 1 })) === "mittel",
|
||||
);
|
||||
check("Zonenbezeichnung redaktionell → Niedrig", gapPriority(raw({ kind: "redaktionell", maturity: 3 })) === "niedrig");
|
||||
check(
|
||||
"MUSS-Control Reifegrad 2 unter Ziel 3 → Mittel",
|
||||
gapPriority(raw({ requirementType: "MUSS", maturity: 2, target: 3, kind: "nachweis" })) === "mittel",
|
||||
);
|
||||
check("HOCH-Zusatzanforderung offen → Hoch", gapPriority(raw({ requirementType: "HOCH", maturity: 2, target: 3 })) === "hoch");
|
||||
|
||||
// C8 §3 Quick-Wins.
|
||||
console.log("§3 Quick-Wins:");
|
||||
check("Restore-Test (organisatorisch, +1) → Quick-Win", isQuickWin(raw({ effort: "gering", maturityImpact: 1 })));
|
||||
check("Patchmanagement (Tool) → kein Quick-Win", !isQuickWin(raw({ effort: "hoch", maturityImpact: 1 })));
|
||||
check("Abhängigkeit → kein Quick-Win", !isQuickWin(raw({ effort: "gering", maturityImpact: 1, hasDependency: true })));
|
||||
check("keine Reifegrad-Wirkung → kein Quick-Win", !isQuickWin(raw({ effort: "gering", maturityImpact: 0 })));
|
||||
|
||||
// C8 §2 Deduplizierung.
|
||||
console.log("§2 Deduplizierung:");
|
||||
const dupSame = consolidate([
|
||||
raw({ id: "control:5.2.3:nachweis", source: "control", controls: ["5.2.3"], kind: "nachweis", requirementType: "MUSS", maturity: 2, target: 3 }),
|
||||
raw({ id: "control:5.2.3:nachweis", source: "control", controls: ["5.2.3"], kind: "nachweis", requirementType: "MUSS", maturity: 2, target: 3 }),
|
||||
]);
|
||||
check("gleiche Teilanforderung → ein Punkt", dupSame.length === 1);
|
||||
|
||||
// Cross-Source-Merge: Risiko-Maßnahme + Control-Gaps derselben Controls (C8 §2, R-OPS-03-Beispiel).
|
||||
const crossMerge = consolidate([
|
||||
raw({ id: "risk:R1", source: "risk", controls: ["5.2.3", "5.2.5"], riskScore: 16, aboveAcceptance: true, title: "Patchmanagement", taskId: "t1" }),
|
||||
raw({ id: "control:5.2.3:nachweis", source: "control", controls: ["5.2.3"], kind: "nachweis", requirementType: "MUSS", maturity: 2, target: 3 }),
|
||||
raw({ id: "control:5.2.5:nachweis", source: "control", controls: ["5.2.5"], kind: "nachweis", requirementType: "MUSS", maturity: 2, target: 3 }),
|
||||
raw({ id: "control:1.1.1:nachweis", source: "control", controls: ["1.1.1"], kind: "nachweis", requirementType: "MUSS", maturity: 2, target: 3 }),
|
||||
]);
|
||||
check("Risiko absorbiert zugehörige Control-Gaps → 2 Punkte (Risiko + 1.1.1)", crossMerge.length === 2);
|
||||
const riskItem = crossMerge.find((i) => i.source === "risk")!;
|
||||
check("gemergter Risiko-Punkt hat Priorität Hoch", riskItem.priority === "hoch");
|
||||
check("gemergter Risiko-Punkt vereint 5.2.3/5.2.5", riskItem.controls.includes("5.2.3") && riskItem.controls.includes("5.2.5"));
|
||||
check("gemergter Risiko-Punkt behält bestehende Aufgabe", riskItem.taskId === "t1");
|
||||
check("Risiko-Punkt vor Control-Punkt sortiert (Priorität + betroffene Controls)", crossMerge[0].source === "risk");
|
||||
|
||||
// Sortierung: Hoch vor Mittel.
|
||||
console.log("Sortierung & Summary:");
|
||||
const sorted = consolidate([
|
||||
raw({ id: "a", source: "control", controls: ["3.1.1"], kind: "redaktionell", maturity: 3 }),
|
||||
raw({ id: "b", source: "control", controls: ["1.4.1"], requirementType: "MUSS", maturity: 1, kind: "verfahren" }),
|
||||
]);
|
||||
check("Hoch (b) vor Niedrig (a)", sorted[0].id === "b" && sorted[1].id === "a");
|
||||
const sum = summarize(sorted);
|
||||
check("Summary zählt korrekt", sum.total === 2 && sum.hoch === 1 && sum.niedrig === 1);
|
||||
|
||||
console.log(failed === 0 ? "\nAlle Gap-Konsolidierungs-Tests grün." : `\n${failed} Test(s) fehlgeschlagen.`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -1,205 +0,0 @@
|
||||
// Reiner Test der Fristen-/Timer-Logik für Vorfälle (IM-B, Fachkonzept §6).
|
||||
//
|
||||
// Prüft:
|
||||
// 1. Fristen aus dem Kenntniszeitpunkt (detectedAt/reportedAt) korrekt berechnet.
|
||||
// 2. NIS2-Timer greifen NUR bei nis2Category ∈ {wichtig, wesentlich} UND nis2Relevant.
|
||||
// 3. DSGVO-Frist (72 h) greift bei Personenbezug (dsgvoRelevant).
|
||||
// 4. Interne SLA je Severity (Reaktions-/Behebungsfrist).
|
||||
// 5. Überfällig-Erkennung + offene/erledigte Meldestufen (reportStatus).
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-incident-deadlines.ts (rein, keine DB).
|
||||
|
||||
import {
|
||||
addHours,
|
||||
addMonths,
|
||||
computeDeadlines,
|
||||
computeSla,
|
||||
deadlineItems,
|
||||
isDueSoon,
|
||||
isOverdue,
|
||||
isReportable,
|
||||
knowledgeTime,
|
||||
nextReportStatus,
|
||||
nis2TimersApply,
|
||||
openReportDeadlines,
|
||||
remainingMs,
|
||||
slaForSeverity,
|
||||
stageDone,
|
||||
NIS2_ERSTMELDUNG_HOURS,
|
||||
NIS2_FOLGEMELDUNG_HOURS,
|
||||
} from "../src/lib/incident-deadlines";
|
||||
|
||||
let failures = 0;
|
||||
const ok = (cond: boolean, msg: string) => {
|
||||
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
const known = new Date("2026-03-10T08:00:00.000Z");
|
||||
|
||||
// ── 1. Kenntniszeitpunkt-Priorität ──────────────────────────────────────────
|
||||
{
|
||||
const detected = new Date("2026-03-10T08:00:00Z");
|
||||
const reported = new Date("2026-03-11T08:00:00Z");
|
||||
ok(
|
||||
knowledgeTime({ detectedAt: detected, reportedAt: reported })?.getTime() === detected.getTime(),
|
||||
"knowledgeTime: detectedAt hat Vorrang vor reportedAt",
|
||||
);
|
||||
ok(
|
||||
knowledgeTime({ detectedAt: null, reportedAt: reported })?.getTime() === reported.getTime(),
|
||||
"knowledgeTime: ohne detectedAt greift reportedAt",
|
||||
);
|
||||
ok(
|
||||
knowledgeTime({ detectedAt: null, reportedAt: null, occurredAt: null, createdAt: known })?.getTime() ===
|
||||
known.getTime(),
|
||||
"knowledgeTime: Fallback auf createdAt",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 1b. Fristen aus Kenntniszeitpunkt (NIS2 wesentlich) ─────────────────────
|
||||
{
|
||||
const d = computeDeadlines({
|
||||
nis2Category: "wesentlich",
|
||||
nis2Relevant: true,
|
||||
dsgvoRelevant: false,
|
||||
knownAt: known,
|
||||
});
|
||||
ok(
|
||||
d.erstmeldungDueAt?.getTime() === known.getTime() + NIS2_ERSTMELDUNG_HOURS * HOUR,
|
||||
"NIS2 Erstmeldung = Kenntnis + 24 h",
|
||||
);
|
||||
ok(
|
||||
d.folgemeldungDueAt?.getTime() === known.getTime() + NIS2_FOLGEMELDUNG_HOURS * HOUR,
|
||||
"NIS2 Folgemeldung = Kenntnis + 72 h",
|
||||
);
|
||||
ok(d.abschlussDueAt !== null, "NIS2 Abschlussbericht gesetzt");
|
||||
ok(
|
||||
d.abschlussDueAt!.getMonth() === addMonths(known, 1).getMonth(),
|
||||
"NIS2 Abschlussbericht = Kenntnis + 1 Monat (Monat +1)",
|
||||
);
|
||||
ok(d.dsgvoDueAt === null, "ohne Personenbezug keine DSGVO-Frist");
|
||||
}
|
||||
|
||||
// ── 2. NIS2-Timer nur bei passender Kategorie UND Relevanz ──────────────────
|
||||
{
|
||||
ok(nis2TimersApply("wichtig", true), "nis2TimersApply: wichtig + relevant → true");
|
||||
ok(nis2TimersApply("wesentlich", true), "nis2TimersApply: wesentlich + relevant → true");
|
||||
ok(!nis2TimersApply("keine", true), "nis2TimersApply: keine → false (auch wenn relevant)");
|
||||
ok(!nis2TimersApply("wesentlich", false), "nis2TimersApply: nicht relevant → false");
|
||||
|
||||
const noCat = computeDeadlines({
|
||||
nis2Category: "keine",
|
||||
nis2Relevant: true,
|
||||
dsgvoRelevant: false,
|
||||
knownAt: known,
|
||||
});
|
||||
ok(
|
||||
noCat.erstmeldungDueAt === null && noCat.folgemeldungDueAt === null && noCat.abschlussDueAt === null,
|
||||
"computeDeadlines: nis2Category=keine → keine NIS2-Fristen",
|
||||
);
|
||||
|
||||
const notRelevant = computeDeadlines({
|
||||
nis2Category: "wesentlich",
|
||||
nis2Relevant: false,
|
||||
dsgvoRelevant: false,
|
||||
knownAt: known,
|
||||
});
|
||||
ok(
|
||||
notRelevant.erstmeldungDueAt === null,
|
||||
"computeDeadlines: nis2Relevant=false → keine NIS2-Fristen (trotz Kategorie)",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3. DSGVO bei Personenbezug ──────────────────────────────────────────────
|
||||
{
|
||||
const d = computeDeadlines({
|
||||
nis2Category: "keine",
|
||||
nis2Relevant: false,
|
||||
dsgvoRelevant: true,
|
||||
knownAt: known,
|
||||
});
|
||||
ok(d.dsgvoDueAt?.getTime() === known.getTime() + 72 * HOUR, "DSGVO-Frist = Kenntnis + 72 h");
|
||||
ok(d.erstmeldungDueAt === null, "DSGVO ohne NIS2: keine NIS2-Fristen");
|
||||
ok(
|
||||
isReportable({ nis2Category: "keine", nis2Relevant: false, dsgvoRelevant: true }),
|
||||
"isReportable: nur DSGVO → meldepflichtig",
|
||||
);
|
||||
ok(
|
||||
!isReportable({ nis2Category: "keine", nis2Relevant: true, dsgvoRelevant: false }),
|
||||
"isReportable: NIS2-relevant aber Kategorie keine → nicht meldepflichtig",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 4. Interne SLA je Severity ──────────────────────────────────────────────
|
||||
{
|
||||
ok(slaForSeverity("kritisch").reactionHours === 1, "SLA kritisch: Reaktion 1 h");
|
||||
ok(slaForSeverity("kritisch").resolutionHours === 24, "SLA kritisch: Behebung 24 h");
|
||||
ok(
|
||||
slaForSeverity("niedrig").reactionHours > slaForSeverity("hoch").reactionHours,
|
||||
"SLA: niedrigere Severity → längere Reaktionsfrist",
|
||||
);
|
||||
const sla = computeSla("hoch", known);
|
||||
ok(
|
||||
sla.reactionDueAt?.getTime() === addHours(known, 4).getTime(),
|
||||
"computeSla hoch: Reaktionsfrist = Kenntnis + 4 h",
|
||||
);
|
||||
ok(computeSla("mittel", null).reactionDueAt === null, "computeSla ohne Kenntnis → null");
|
||||
}
|
||||
|
||||
// ── 5. Überfällig-Erkennung + Meldestufen ───────────────────────────────────
|
||||
{
|
||||
const now = new Date("2026-03-11T10:00:00.000Z"); // 26 h nach Kenntnis
|
||||
ok(isOverdue(addHours(known, 24), now), "isOverdue: 24-h-Frist ist nach 26 h überfällig");
|
||||
ok(!isOverdue(addHours(known, 72), now), "isOverdue: 72-h-Frist noch nicht überfällig");
|
||||
ok(remainingMs(addHours(known, 72), now)! > 0, "remainingMs: 72-h-Frist hat positive Restzeit");
|
||||
ok(isDueSoon(addHours(known, 30), now), "isDueSoon: Frist in 4 h ist bald fällig");
|
||||
ok(!isDueSoon(addHours(known, 240), now), "isDueSoon: Frist in >24 h ist nicht bald fällig");
|
||||
|
||||
ok(stageDone("erstmeldung", "erstmeldung"), "stageDone: reportStatus=erstmeldung → Erstmeldung erledigt");
|
||||
ok(!stageDone("pruefung", "erstmeldung"), "stageDone: reportStatus=pruefung → Erstmeldung offen");
|
||||
ok(stageDone("folgemeldung", "erstmeldung"), "stageDone: reportStatus=folgemeldung → Erstmeldung erledigt");
|
||||
|
||||
const inc = {
|
||||
severity: "hoch",
|
||||
reportStatus: "pruefung",
|
||||
detectedAt: known,
|
||||
reportedAt: null,
|
||||
occurredAt: null,
|
||||
createdAt: known,
|
||||
erstmeldungDueAt: addHours(known, 24),
|
||||
folgemeldungDueAt: addHours(known, 72),
|
||||
abschlussDueAt: addMonths(known, 1),
|
||||
dsgvoDueAt: null,
|
||||
};
|
||||
const items = deadlineItems(inc, now);
|
||||
const erst = items.find((i) => i.kind === "erstmeldung")!;
|
||||
ok(erst.overdue, "deadlineItems: offene Erstmeldung nach 26 h ist überfällig");
|
||||
ok(items.some((i) => i.kind === "reaction"), "deadlineItems: interne SLA-Reaktionsfrist enthalten");
|
||||
|
||||
const open = openReportDeadlines(inc, now);
|
||||
ok(
|
||||
open.every((i) => !i.done) && open.some((i) => i.kind === "erstmeldung"),
|
||||
"openReportDeadlines: nur offene Meldestufen, Erstmeldung enthalten",
|
||||
);
|
||||
|
||||
const submitted = openReportDeadlines({ ...inc, reportStatus: "folgemeldung" }, now);
|
||||
ok(
|
||||
!submitted.some((i) => i.kind === "erstmeldung" || i.kind === "folgemeldung"),
|
||||
"openReportDeadlines: eingereichte Stufen (reportStatus=folgemeldung) fallen raus",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 6. Meldung-Track Vorwärts-Übergänge ─────────────────────────────────────
|
||||
{
|
||||
ok(nextReportStatus("pruefung") === "erstmeldung", "nextReportStatus: pruefung → erstmeldung");
|
||||
ok(nextReportStatus("erstmeldung") === "folgemeldung", "nextReportStatus: erstmeldung → folgemeldung");
|
||||
ok(nextReportStatus("folgemeldung") === "abschluss", "nextReportStatus: folgemeldung → abschluss");
|
||||
ok(nextReportStatus("abschluss") === null, "nextReportStatus: abschluss ist Endzustand");
|
||||
}
|
||||
|
||||
if (failures) {
|
||||
console.error(`\n✗ ${failures} Prüfung(en) fehlgeschlagen.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\n✓ Alle Fristen-/Timer-Prüfungen bestanden.");
|
||||
@@ -1,179 +0,0 @@
|
||||
// Reiner Test der Inbound-Mail-Logik für Vorfälle (IM-D, Fachkonzept §2/§14.3).
|
||||
//
|
||||
// Prüft parse.ts + route.ts OHNE Netz/DB/IMAP (Fixtures):
|
||||
// 1. Token wird aus Delivered-To/X-Envelope-To gezogen — NICHT aus To (Weiterleitung).
|
||||
// 2. Weiterleitung: SPF-Fail wird toleriert, DKIM-pass + Allowlist tragen → Vorfall.
|
||||
// 3. Auto-Reply/Bounce (Auto-Submitted/Precedence/leerer Return-Path) → ignorieren.
|
||||
// 4. Dedupe per Message-ID (alreadySeen) → ignorieren.
|
||||
// 5. Unbekannter Token → Betreiber-Review (nicht droppen).
|
||||
// 6. Kein Token → Betreiber-Review.
|
||||
// 7. Absender nicht in Allowlist → Review (fail-closed).
|
||||
// 8. DKIM=fail → Review (auch bei getroffener Allowlist).
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-incident-inbound.ts (rein, keine DB).
|
||||
|
||||
import {
|
||||
parseInbound,
|
||||
deliveryRecipients,
|
||||
tokenFromAddress,
|
||||
isAutoSubmitted,
|
||||
authResults,
|
||||
intakeAddress,
|
||||
type RawHeaders,
|
||||
} from "../src/server/incident-inbound/parse";
|
||||
import { decideRoute, generateIntakeToken, type IntakeConfigView } from "../src/server/incident-inbound/route";
|
||||
|
||||
let failures = 0;
|
||||
const ok = (cond: boolean, msg: string) => {
|
||||
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
const DOMAIN = "in.certvia.de";
|
||||
const TOKEN = "abc123def456"; // passt auf [a-z0-9]{8,64}
|
||||
const INTAKE = intakeAddress(TOKEN, DOMAIN); // vorfall-abc123def456@in.certvia.de
|
||||
|
||||
const config: IntakeConfigView = {
|
||||
tenantId: "tenant-1",
|
||||
token: TOKEN,
|
||||
allowlistDomains: ["kunde.de"],
|
||||
sourceAddress: "vorfall@kunde.de",
|
||||
};
|
||||
|
||||
// Hilfsbau einer Fixture-Mail.
|
||||
function mail(headers: RawHeaders, extra: Partial<{ from: string; subject: string; text: string; messageId: string }> = {}) {
|
||||
return parseInbound({ headers, ...extra }, DOMAIN);
|
||||
}
|
||||
|
||||
// --- 1) Token aus Delivered-To / X-Envelope-To, nicht aus To -------------------
|
||||
{
|
||||
const parsed = mail(
|
||||
{
|
||||
"To": "vorfall@kunde.de", // Kundenadresse — hier steht KEIN Token
|
||||
"Delivered-To": INTAKE, // Zustelladresse trägt den Token
|
||||
"From": "Melder <melder@kunde.de>",
|
||||
"Message-ID": "<m1@kunde.de>",
|
||||
"Authentication-Results": "mx.certvia.de; dkim=pass header.d=kunde.de; spf=fail",
|
||||
},
|
||||
{ subject: "Serverausfall", text: "Bitte prüfen." },
|
||||
);
|
||||
ok(parsed.token === TOKEN, "Token wird aus Delivered-To gezogen");
|
||||
ok(parsed.recipientForToken === INTAKE, "recipientForToken = Intake-Adresse");
|
||||
ok(tokenFromAddress("vorfall@kunde.de", DOMAIN) === null, "To-Adresse liefert keinen Token");
|
||||
ok(deliveryRecipients({ To: "vorfall@kunde.de" }).length === 0, "To zählt nicht als Zustell-Empfänger");
|
||||
}
|
||||
|
||||
// --- 2) Weiterleitung: SPF-Fail toleriert, DKIM+Allowlist → Vorfall ------------
|
||||
{
|
||||
const parsed = mail(
|
||||
{
|
||||
"X-Envelope-To": INTAKE,
|
||||
"From": "Alice <alice@kunde.de>",
|
||||
"Message-ID": "<m2@kunde.de>",
|
||||
"Authentication-Results": "mx; dkim=pass; spf=fail", // Weiterleitung bricht SPF
|
||||
},
|
||||
{ subject: "Phishing gemeldet", text: "Verdächtige Mail erhalten." },
|
||||
);
|
||||
ok(parsed.spf === "fail" && parsed.dkim === "pass", "SPF=fail, DKIM=pass erkannt");
|
||||
const d = decideRoute(parsed, { config, alreadySeen: false });
|
||||
ok(d.action === "incident", "trotz SPF-Fail → Vorfall (DKIM+Allowlist tragen)");
|
||||
if (d.action === "incident") {
|
||||
ok(d.incident.tenantId === "tenant-1", "Vorfall im richtigen Mandanten");
|
||||
ok(d.incident.title === "Phishing gemeldet", "Betreff → Titel");
|
||||
ok(d.incident.reporterContact === "alice@kunde.de", "Absender → Melder-Kontakt");
|
||||
ok(d.spfWarning === true, "SPF-Fail als Warnung markiert (nicht blockierend)");
|
||||
ok(d.incident.inboundMessageId === "m2@kunde.de", "Message-ID normalisiert übernommen");
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3) Auto-Reply / Bounce → ignorieren --------------------------------------
|
||||
{
|
||||
ok(isAutoSubmitted({ "Auto-Submitted": "auto-replied" }), "Auto-Submitted erkannt");
|
||||
ok(isAutoSubmitted({ "Precedence": "bulk" }), "Precedence: bulk erkannt");
|
||||
ok(isAutoSubmitted({ "Return-Path": "<>" }), "Leerer Return-Path (Bounce) erkannt");
|
||||
ok(!isAutoSubmitted({ "Auto-Submitted": "no" }), "Auto-Submitted: no ist normal");
|
||||
|
||||
const parsed = mail({
|
||||
"Delivered-To": INTAKE,
|
||||
"From": "mailer@kunde.de",
|
||||
"Auto-Submitted": "auto-replied",
|
||||
"Message-ID": "<auto1@kunde.de>",
|
||||
"Authentication-Results": "mx; dkim=pass; spf=pass",
|
||||
});
|
||||
const d = decideRoute(parsed, { config, alreadySeen: false });
|
||||
ok(d.action === "ignore" && d.reason === "auto_submitted", "Auto-Reply → ignore");
|
||||
}
|
||||
|
||||
// --- 4) Dedupe per Message-ID → ignorieren ------------------------------------
|
||||
{
|
||||
const parsed = mail({
|
||||
"Delivered-To": INTAKE,
|
||||
"From": "alice@kunde.de",
|
||||
"Message-ID": "<dup@kunde.de>",
|
||||
"Authentication-Results": "mx; dkim=pass; spf=pass",
|
||||
});
|
||||
const d = decideRoute(parsed, { config, alreadySeen: true });
|
||||
ok(d.action === "ignore" && d.reason === "duplicate", "bereits gesehene Message-ID → ignore");
|
||||
}
|
||||
|
||||
// --- 5) Unbekannter Token → Review --------------------------------------------
|
||||
{
|
||||
const parsed = mail({
|
||||
"Delivered-To": intakeAddress("unknowntoken99", DOMAIN),
|
||||
"From": "alice@kunde.de",
|
||||
"Message-ID": "<u1@kunde.de>",
|
||||
"Authentication-Results": "mx; dkim=pass; spf=pass",
|
||||
});
|
||||
ok(parsed.token === "unknowntoken99", "Token geparst, aber unbekannt");
|
||||
const d = decideRoute(parsed, { config: null, alreadySeen: false });
|
||||
ok(d.action === "review" && d.review.reason === "unknown_token", "unbekannter Token → Review");
|
||||
ok(d.action === "review" && d.review.tenantId === null, "kein Mandant bei unbekanntem Token");
|
||||
}
|
||||
|
||||
// --- 6) Kein Token → Review ---------------------------------------------------
|
||||
{
|
||||
const parsed = mail({
|
||||
"Delivered-To": "hallo@in.certvia.de", // kein vorfall-<token>
|
||||
"From": "alice@kunde.de",
|
||||
"Message-ID": "<n1@kunde.de>",
|
||||
});
|
||||
ok(parsed.token === null, "kein Token erkannt");
|
||||
const d = decideRoute(parsed, { config: null, alreadySeen: false });
|
||||
ok(d.action === "review" && d.review.reason === "no_token", "kein Token → Review");
|
||||
}
|
||||
|
||||
// --- 7) Absender nicht in Allowlist → Review (fail-closed) --------------------
|
||||
{
|
||||
const parsed = mail({
|
||||
"Delivered-To": INTAKE,
|
||||
"From": "fremd@boese.example", // nicht in Allowlist (kunde.de)
|
||||
"Message-ID": "<a1@boese.example>",
|
||||
"Authentication-Results": "mx; dkim=pass; spf=pass",
|
||||
});
|
||||
const d = decideRoute(parsed, { config, alreadySeen: false });
|
||||
ok(d.action === "review" && d.review.reason === "allowlist_failed", "Absender außerhalb Allowlist → Review");
|
||||
ok(d.action === "review" && d.review.tenantId === "tenant-1", "Mandant trotzdem aufgelöst (Token bekannt)");
|
||||
}
|
||||
|
||||
// --- 8) DKIM=fail → Review (auch bei getroffener Allowlist) -------------------
|
||||
{
|
||||
const parsed = mail({
|
||||
"Delivered-To": INTAKE,
|
||||
"From": "alice@kunde.de",
|
||||
"Message-ID": "<dk1@kunde.de>",
|
||||
"Authentication-Results": "mx; dkim=fail; spf=pass",
|
||||
});
|
||||
ok(authResults({ "Authentication-Results": "mx; dkim=fail" }).dkim === "fail", "DKIM=fail geparst");
|
||||
const d = decideRoute(parsed, { config, alreadySeen: false });
|
||||
ok(d.action === "review" && d.review.reason === "dkim_failed", "DKIM-Fail → Review");
|
||||
}
|
||||
|
||||
// --- 9) Token-Generator liefert gültiges Format -------------------------------
|
||||
{
|
||||
const tok = generateIntakeToken();
|
||||
ok(/^[a-z0-9]{8,64}$/.test(tok), "generateIntakeToken erfüllt Token-Format");
|
||||
ok(tokenFromAddress(intakeAddress(tok, DOMAIN), DOMAIN) === tok, "Round-Trip Token↔Adresse");
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? "\nAlle Inbound-Tests grün." : `\n${failures} Test(s) fehlgeschlagen.`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -1,212 +0,0 @@
|
||||
// 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());
|
||||
@@ -1,153 +0,0 @@
|
||||
// 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());
|
||||
@@ -40,7 +40,7 @@ const SAMPLE = {
|
||||
email_change_verify: { name: "Erika Muster", actionUrl: "https://example.test/v", expires: "in 60 Minuten", newEmail: "neu@example.test" },
|
||||
email_changed_notice: { name: "Erika Muster", newEmail: "neu@example.test", when: "heute" },
|
||||
mfa_changed: { name: "Erika Muster", change: "aktiviert", when: "heute" },
|
||||
notification: { name: "Erika Muster", subject: "Neue Aufgabe", body: "Text", actionUrl: "https://example.test/t", taskType: "policy_approval" },
|
||||
notification: { name: "Erika Muster", subject: "Neuer Auftrag", body: "Text", actionUrl: "https://example.test/t", eventType: "work_order_assigned" },
|
||||
incident_notification: { name: "Erika Muster", subject: "Neuer Vorfall gemeldet", body: "Text", actionUrl: "https://example.test/i", refNo: "INC-2026-0042" },
|
||||
test: { name: "Erika Muster", when: "heute" },
|
||||
} as const;
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
// Unit-Tests (Story A7-2) für die Reifegrad-Engine gegen die C5-Regelbeispiele.
|
||||
// Lauf: npx tsx scripts/test-maturity.ts
|
||||
import {
|
||||
suggestMaturity,
|
||||
targetMaturity,
|
||||
openPoints,
|
||||
specForControl,
|
||||
type ControlEvidence,
|
||||
} from "../src/lib/maturity";
|
||||
|
||||
let failed = 0;
|
||||
function check(name: string, cond: boolean, detail = "") {
|
||||
if (cond) console.log(` ok ${name}`);
|
||||
else {
|
||||
failed++;
|
||||
console.error(`FAIL ${name}${detail ? ` — ${detail}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
const base: ControlEvidence = {
|
||||
policy: "fehlt",
|
||||
verfahren: "fehlt",
|
||||
assetLinked: false,
|
||||
riskLinked: false,
|
||||
operationalProof: false,
|
||||
};
|
||||
|
||||
// Control mit Richtlinie + Verfahren + Asset (1.3.1: R02 / VA-08 / (A)).
|
||||
const s131 = specForControl("1.3.1");
|
||||
check("1.3.1 hat Verfahren + needsAsset", s131.verfahren.length === 1 && s131.needsAsset);
|
||||
|
||||
console.log("§2 Belegkonstellationen (Control 1.3.1):");
|
||||
check("R0 — keine Belege → 0", suggestMaturity(s131, base).value === 0);
|
||||
check("R1a — Richtlinie verknüpft, unvalidiert → 1", suggestMaturity(s131, { ...base, policy: "verknuepft" }).rule === "R1a");
|
||||
check(
|
||||
"R1b — Richtlinie validiert, Verfahren fehlt → 1",
|
||||
suggestMaturity(s131, { ...base, policy: "validiert" }).rule === "R1b",
|
||||
);
|
||||
check(
|
||||
"R1c — nur operativer Nachweis, keine validierte Richtlinie → 1",
|
||||
suggestMaturity(s131, { ...base, operationalProof: true }).rule === "R1c",
|
||||
);
|
||||
check(
|
||||
"R2 — Richtlinie+Verfahren validiert + Asset, kein Nachweis → 2",
|
||||
suggestMaturity(s131, { ...base, policy: "validiert", verfahren: "validiert", assetLinked: true }).value === 2,
|
||||
);
|
||||
check(
|
||||
"R2 verweigert bei fehlender Asset-Verknüpfung → 1",
|
||||
suggestMaturity(s131, { ...base, policy: "validiert", verfahren: "validiert", assetLinked: false }).value === 1,
|
||||
);
|
||||
check(
|
||||
"R3 — R2 + aktueller Wirksamkeitsnachweis → 3",
|
||||
suggestMaturity(s131, { ...base, policy: "validiert", verfahren: "validiert", assetLinked: true, operationalProof: true }).value === 3,
|
||||
);
|
||||
check(
|
||||
"Deckelung — Widerspruch/Findings → max. 1 (CAP)",
|
||||
suggestMaturity(s131, { ...base, policy: "validiert", verfahren: "validiert", assetLinked: true, operationalProof: true, contradiction: true }).value === 1,
|
||||
);
|
||||
|
||||
// Control ohne Verfahren (1.1.1: L00 / — / …): Grad 2 über validierte Umsetzungsregelung (N-basisch).
|
||||
console.log("§2 Sonderfall ohne Verfahren (Control 1.1.1):");
|
||||
const s111 = specForControl("1.1.1");
|
||||
check("1.1.1 ohne Verfahren", s111.verfahren.length === 0);
|
||||
check(
|
||||
"R2 ohne V — Richtlinie validiert + validierte Umsetzungsregelung → 2",
|
||||
suggestMaturity(s111, { ...base, policy: "validiert", implementationRule: true }).value === 2,
|
||||
);
|
||||
check(
|
||||
"R1b ohne V — Richtlinie validiert, keine Umsetzungsregelung → 1",
|
||||
suggestMaturity(s111, { ...base, policy: "validiert" }).value === 1,
|
||||
);
|
||||
check(
|
||||
"R3 ohne V — Umsetzungsregelung + aktueller Wirksamkeitsnachweis → 3",
|
||||
suggestMaturity(s111, { ...base, policy: "validiert", implementationRule: true, operationalProof: true }).value === 3,
|
||||
);
|
||||
|
||||
// Zielreifegrad (§3).
|
||||
console.log("§3 Zielreifegrad:");
|
||||
check("AL3 → 3", targetMaturity({ level: "AL3", flags: {} }) === 3);
|
||||
check("AL2 reiner MUSS-Scope → 2", targetMaturity({ level: "AL2", flags: {} }) === 2);
|
||||
check("AL2 + SOLL → 3", targetMaturity({ level: "AL2", flags: { FLAG_INCLUDE_SHOULD: true } }) === 3);
|
||||
check("AL2 + HOCH → 3", targetMaturity({ level: "AL2", flags: { FLAG_HIGH_PROTECTION: true } }) === 3);
|
||||
|
||||
// Offene Punkte (§4).
|
||||
console.log("§4 Offene Punkte:");
|
||||
const evGap: ControlEvidence = { ...base, policy: "validiert", verfahren: "fehlt", assetLinked: false };
|
||||
const sugGap = suggestMaturity(s131, evGap);
|
||||
const ops = openPoints(s131, evGap, sugGap, 3);
|
||||
check("Verfahren-Gap erzeugt Punkt", ops.some((o) => o.kind === "verfahren"));
|
||||
check("Asset-Verknüpfungs-Gap erzeugt Punkt", ops.some((o) => o.kind === "verknuepfung"));
|
||||
check("Nachweis-Gap bei Ziel 3 erzeugt Punkt", ops.some((o) => o.kind === "nachweis"));
|
||||
check(
|
||||
"kein Gap bei erfülltem Ziel",
|
||||
openPoints(s131, { ...base, policy: "validiert", verfahren: "validiert", assetLinked: true, operationalProof: true }, suggestMaturity(s131, { ...base, policy: "validiert", verfahren: "validiert", assetLinked: true, operationalProof: true }), 3).length === 0,
|
||||
);
|
||||
|
||||
console.log(failed === 0 ? "\nAlle Reifegrad-Tests grün." : `\n${failed} Test(s) fehlgeschlagen.`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -1,56 +0,0 @@
|
||||
// Akzeptanztest der Readiness-Logik (Story B7-1, C9 §1/§2). Reine Logik, kein DB-Zugriff.
|
||||
// Lauf: npx tsx scripts/test-readiness.ts (Exit 1 bei Fehler).
|
||||
|
||||
import { interpretationBand, averageReifegrad, buildNextSteps, computeReadiness, type ControlAssessment } from "../src/lib/readiness";
|
||||
|
||||
let failures = 0;
|
||||
const ok = (cond: boolean, msg: string) => {
|
||||
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
// — C9 §1: Textbänder je Ø-Reifegrad —
|
||||
ok(interpretationBand(0).band === "Aufbau", "Ø 0 → Aufbau");
|
||||
ok(interpretationBand(1.49).band === "Aufbau", "Ø 1,49 → Aufbau");
|
||||
ok(interpretationBand(1.5).band === "Etabliert im Aufbau", "Ø 1,5 → Etabliert im Aufbau");
|
||||
ok(interpretationBand(2.49).band === "Etabliert im Aufbau", "Ø 2,49 → Etabliert im Aufbau");
|
||||
ok(interpretationBand(2.5).band === "Assessment-nah", "Ø 2,5 → Assessment-nah");
|
||||
ok(interpretationBand(2.99).band === "Assessment-nah", "Ø 2,99 → Assessment-nah");
|
||||
ok(interpretationBand(3.0).band === "Assessment-reif", "Ø 3,0 → Assessment-reif");
|
||||
|
||||
// — Ø-Reifegrad: „unbestätigt zählt nicht als erfüllt" (geht mit 0 ein) —
|
||||
{
|
||||
const a: ControlAssessment[] = [
|
||||
{ control: "1.1.1", chapter: "1", reifegrad: 3, bestaetigt: true },
|
||||
{ control: "1.1.2", chapter: "1", reifegrad: 3, bestaetigt: false }, // zählt als 0
|
||||
];
|
||||
ok(averageReifegrad(a) === 1.5, "unbestätigt zählt als 0 → Ø(3,0) = 1,5");
|
||||
ok(averageReifegrad([]) === null, "keine Assessments → null (A7 ausstehend)");
|
||||
}
|
||||
|
||||
// — C9 §2: dynamische nächste Schritte —
|
||||
{
|
||||
const all = buildNextSteps({ openHighCount: 2, unvalidatedCount: 5, prototypeGap: true, evidenceUploadPending: true });
|
||||
ok(all.some((s) => s.includes("2 Hoch-Punkte")), "Hoch-Punkte → Schritt mit Anzahl");
|
||||
ok(all.some((s) => s.includes("5 Objekte")), "unvalidierte Objekte → Schritt mit Anzahl");
|
||||
ok(all.some((s) => s.includes("Prototypen")), "Prototyp-Gap → Prototyp-Schritt");
|
||||
ok(all.length === 4, "alle vier Regeln greifen");
|
||||
const none = buildNextSteps({ openHighCount: 0, unvalidatedCount: 0, prototypeGap: false, evidenceUploadPending: false });
|
||||
ok(none.length === 0, "keine Bedingung → keine Schritte");
|
||||
}
|
||||
|
||||
// — computeReadiness: assessmentPending ohne A7-Daten —
|
||||
{
|
||||
const r = computeReadiness({
|
||||
assessments: [], bestaetigtAnteil: 0,
|
||||
offenJePrioritaet: { hoch: 1, mittel: 2, niedrig: 0 },
|
||||
pruefzielAbdeckung: [{ pruefziel: "informationssicherheit", anforderungen: 300 }],
|
||||
zielReifegrad: 3,
|
||||
nextSteps: { openHighCount: 1, unvalidatedCount: 0, prototypeGap: false, evidenceUploadPending: false },
|
||||
});
|
||||
ok(r.assessmentPending === true && r.avgReifegrad === null && r.band === null, "ohne Assessments → pending, kein Reifegrad/Band");
|
||||
ok(r.nextSteps.length === 1, "Kennzahlen-basierte Schritte trotzdem berechnet");
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? "\nOK — alle Readiness-Tests grün" : `\nPRUEFEN — ${failures} Fehler`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -1,88 +0,0 @@
|
||||
import "dotenv/config";
|
||||
import { join } from "node:path";
|
||||
import { prisma } from "../src/server/db";
|
||||
import { importPolicies } from "../prisma/import-policies";
|
||||
|
||||
const SEED_DIR = join(process.cwd(), "seed", "isms-vorlagenpaket-v2");
|
||||
const ok = (c: boolean, m: string) => console.log(`${c ? "✓" : "✗ FEHLER"} ${m}`);
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { slug: "demo" } });
|
||||
if (!tenant) throw new Error("Demo-Mandant fehlt");
|
||||
const t = tenant.id;
|
||||
|
||||
// Ausgangszustand eines echten Dokuments + einer Variable sichern
|
||||
const doc0 = await prisma.policyDocument.findFirst({ where: { tenantId: t, code: "R08" } });
|
||||
const varName = "ORG_NAME";
|
||||
const var0 = await prisma.policyVariable.findFirst({ where: { tenantId: t, key: varName } });
|
||||
if (!doc0 || !var0) throw new Error("Erwartetes Dokument R08 / Variable ORG_NAME fehlt");
|
||||
|
||||
// 1) Kuratierten Zustand simulieren: Freigabe-Status, TISAX-Override, Einreicher,
|
||||
// geänderter Titel (Inhalts-Diff) + nutzergepflegter Variablenwert.
|
||||
await prisma.policyDocument.update({
|
||||
where: { id: doc0.id },
|
||||
data: { status: "ENTWURF", protectionOverride: "AL3", submittedBy: "tester", title: "MANUELL GEÄNDERTER TITEL" },
|
||||
});
|
||||
await prisma.policyVariable.update({ where: { id: var0.id }, data: { value: "Mustermann Spezial GmbH" } });
|
||||
|
||||
// 2) Entfernte Anforderung simulieren (nicht im Paket): muss deaktiviert, nicht gelöscht werden.
|
||||
const fakeReqId = "ZZ-REMOVED-TEST-1";
|
||||
await prisma.policyRequirement.deleteMany({ where: { tenantId: t, reqId: fakeReqId } });
|
||||
await prisma.policyRequirement.create({
|
||||
data: { tenantId: t, reqId: fakeReqId, policyCode: "R08", control: "0.0.0", obligation: "MUSS", requirement: "Test", implementation: "" },
|
||||
});
|
||||
|
||||
// 3) Dry-Run: Vorschau ohne Schreibzugriff
|
||||
const preview = await importPolicies(prisma, t, SEED_DIR, { dryRun: true });
|
||||
console.log("\n— Dry-Run-Report —");
|
||||
console.log(JSON.stringify(preview.report, null, 0));
|
||||
ok(preview.report.documents.updated >= 1, "Dry-Run erkennt Titeländerung an R08 (updated ≥ 1)");
|
||||
ok(preview.report.documents.archived === 0, "Dry-Run deaktiviert KEINE verwalteten Register (CRYPTO/HANDBUCH/…)");
|
||||
ok(preview.report.requirements.archived === 1, "Dry-Run erkennt 1 zu deaktivierende Anforderung");
|
||||
const stillDraftAfterDry = await prisma.policyDocument.findUnique({ where: { id: doc0.id } });
|
||||
ok(stillDraftAfterDry?.title === "MANUELL GEÄNDERTER TITEL", "Dry-Run schreibt NICHT (Titel unverändert)");
|
||||
|
||||
// 4) Echter Re-Import
|
||||
const run1 = await importPolicies(prisma, t, SEED_DIR, { dryRun: false, actorId: null });
|
||||
console.log("\n— Re-Import-Report —");
|
||||
console.log(JSON.stringify(run1.report, null, 0));
|
||||
|
||||
const docA = await prisma.policyDocument.findUnique({ where: { id: doc0.id } });
|
||||
ok(docA?.status === "ENTWURF", "Freigabe-Status bleibt erhalten (ENTWURF)");
|
||||
ok(docA?.protectionOverride === "AL3", "TISAX-Override bleibt erhalten (AL3)");
|
||||
ok(docA?.submittedBy === "tester", "Einreicher (Freigabe-Workflow) bleibt erhalten");
|
||||
ok(docA?.title === doc0.title, "Titel wurde aus dem Paket aktualisiert (Inhalt)");
|
||||
ok(docA?.archivedAt === null, "Dokument bleibt aktiv");
|
||||
|
||||
const varA = await prisma.policyVariable.findUnique({ where: { id: var0.id } });
|
||||
ok(varA?.value === "Mustermann Spezial GmbH", "Nutzergepflegter Variablenwert bleibt erhalten");
|
||||
|
||||
const fakeA = await prisma.policyRequirement.findFirst({ where: { tenantId: t, reqId: fakeReqId } });
|
||||
ok(!!fakeA && fakeA.archivedAt !== null, "Entfernte Anforderung ist deaktiviert (archivedAt gesetzt), NICHT gelöscht");
|
||||
|
||||
const auditA = await prisma.auditLog.findFirst({ where: { tenantId: t, entity: "policy_package" }, orderBy: { createdAt: "desc" } });
|
||||
ok(!!auditA, "Änderungsreport im Audit-Log protokolliert");
|
||||
|
||||
// 5) Idempotenz: zweiter Lauf ohne externe Änderung → keine Diffs
|
||||
const run2 = await importPolicies(prisma, t, SEED_DIR, { dryRun: false });
|
||||
const r = run2.report;
|
||||
const noChanges =
|
||||
r.documents.added + r.documents.updated + r.documents.archived + r.documents.reactivated === 0 &&
|
||||
r.requirements.added + r.requirements.updated + r.requirements.archived + r.requirements.reactivated === 0 &&
|
||||
r.variables.added + r.variables.updated === 0 && r.baseline.added + r.baseline.updated === 0 &&
|
||||
r.evidence.added + r.evidence.updated === 0;
|
||||
console.log("\n— Idempotenz-Report —");
|
||||
console.log(JSON.stringify(r, null, 0));
|
||||
ok(noChanges, "Zweiter Lauf ist idempotent (keine added/updated/archived/reactivated)");
|
||||
|
||||
// Aufräumen: Testartefakt entfernen, kuratierten Zustand zurücksetzen
|
||||
await prisma.policyRequirement.deleteMany({ where: { tenantId: t, reqId: fakeReqId } });
|
||||
await prisma.policyDocument.update({
|
||||
where: { id: doc0.id },
|
||||
data: { status: doc0.status, protectionOverride: doc0.protectionOverride, submittedBy: doc0.submittedBy, title: doc0.title, archivedAt: null },
|
||||
});
|
||||
await prisma.policyVariable.update({ where: { id: var0.id }, data: { value: var0.value } });
|
||||
console.log("\n✓ aufgeräumt (Testartefakte entfernt, R08/ORG_NAME zurückgesetzt)");
|
||||
}
|
||||
|
||||
main().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -1,78 +0,0 @@
|
||||
// AP4 — Managementklauseln (9.1 Kennzahlen, 9.3 Managementbewertung, 10.2 CAPA).
|
||||
// Prüft Datenmodell, Relationen und Kern-Invarianten der DoD gegen die lokale DB
|
||||
// (Wegwerf-Mandant, dbForTenant → RLS-Pfad).
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-review.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { prisma, dbForTenant } from "../src/server/db";
|
||||
|
||||
let failures = 0;
|
||||
const ok = (c: boolean, m: string) => { console.log(`${c ? "✓" : "✗ FEHLER"} ${m}`); if (!c) failures++; };
|
||||
const SLUG = "ap4-test-review";
|
||||
|
||||
async function cleanup() {
|
||||
const t = await prisma.tenant.findUnique({ where: { slug: SLUG }, select: { id: true } });
|
||||
if (!t) return;
|
||||
const id = t.id;
|
||||
await prisma.correctiveAction.deleteMany({ where: { tenantId: id } });
|
||||
await prisma.nonconformity.deleteMany({ where: { tenantId: id } });
|
||||
await prisma.managementReviewDecision.deleteMany({ where: { tenantId: id } });
|
||||
await prisma.managementReview.deleteMany({ where: { tenantId: id } });
|
||||
await prisma.kpiValue.deleteMany({ where: { tenantId: id } });
|
||||
await prisma.kpi.deleteMany({ where: { tenantId: id } });
|
||||
await prisma.tenant.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await cleanup();
|
||||
const tenant = await prisma.tenant.create({ data: { name: "AP4", slug: SLUG } });
|
||||
const t = tenant.id;
|
||||
const db = dbForTenant(t);
|
||||
|
||||
// ── 9.1 Kennzahl über zwei Perioden auswertbar ──────────────────────────────
|
||||
const kpi = await db.kpi.create({ data: { tenantId: t, name: "Überfällige Maßnahmen", target: "< 5%", unit: "%", cadence: "quartalsweise" }, select: { id: true } });
|
||||
for (const [period, value] of [["2026-Q1", "8%"], ["2026-Q2", "4%"]]) {
|
||||
await db.kpiValue.upsert({
|
||||
where: { tenantId_kpiId_period: { tenantId: t, kpiId: kpi.id, period } },
|
||||
update: { value }, create: { tenantId: t, kpiId: kpi.id, period, value },
|
||||
});
|
||||
}
|
||||
// Upsert derselben Periode → Update statt Duplikat (Unique).
|
||||
await db.kpiValue.upsert({ where: { tenantId_kpiId_period: { tenantId: t, kpiId: kpi.id, period: "2026-Q2" } }, update: { value: "3%" }, create: { tenantId: t, kpiId: kpi.id, period: "2026-Q2", value: "3%" } });
|
||||
const vals = await db.kpiValue.findMany({ where: { kpiId: kpi.id }, orderBy: { period: "asc" } });
|
||||
ok(vals.length === 2, `Kennzahl über zwei Perioden auswertbar (${vals.length} Werte, keine Dubletten)`);
|
||||
ok(vals[1].value === "3%", "Messwert je Periode ist aktualisierbar (Upsert)");
|
||||
|
||||
// ── 9.3 Managementbewertung entlang 9.3.2 mit Beschlüssen ───────────────────
|
||||
const review = await db.managementReview.create({ data: { tenantId: t, reviewDate: new Date("2026-06-01"), inputs: "Kennzahlen, Auditergebnisse, Vorjahresmaßnahmen …", results: "Ressourcen genehmigt" }, select: { id: true } });
|
||||
await db.managementReviewDecision.create({ data: { tenantId: t, reviewId: review.id, decision: "Zusätzliche Awareness-Schulung", dueDate: new Date("2026-09-30") } });
|
||||
const d2 = await db.managementReviewDecision.create({ data: { tenantId: t, reviewId: review.id, decision: "Backup-Konzept prüfen" }, select: { id: true } });
|
||||
await db.managementReviewDecision.update({ where: { id: d2.id }, data: { status: "erledigt" } });
|
||||
const withDec = await db.managementReview.findUnique({ where: { id: review.id }, include: { decisions: true } });
|
||||
ok(withDec?.decisions.length === 2, "Managementbewertung mit Beschlüssen (Verantwortlicher/Termin) protokollierbar");
|
||||
ok(withDec?.decisions.filter((d) => d.status === "erledigt").length === 1, "Beschluss-Status nachverfolgbar (1 erledigt)");
|
||||
await db.managementReview.update({ where: { id: review.id }, data: { status: "abgeschlossen" } });
|
||||
ok((await db.managementReview.findUnique({ where: { id: review.id } }))?.status === "abgeschlossen", "Managementbewertung abschließbar");
|
||||
|
||||
// ── 10.2 Nichtkonformität + Korrekturmaßnahme inkl. Wirksamkeit ──────────────
|
||||
const nc = await db.nonconformity.create({ data: { tenantId: t, refNo: "NC-2026-0001", source: "Internes Audit", description: "Zugriffsrechte nicht rezertifiziert", immediateCorrection: "Sofort-Review" }, select: { id: true } });
|
||||
const nc2 = await db.nonconformity.create({ data: { tenantId: t, refNo: "NC-2026-0002", source: "Vorfall", description: "Test" }, select: { refNo: true } });
|
||||
ok(nc2.refNo === "NC-2026-0002", "fortlaufende NC-Kennung (NC-2026-0002)");
|
||||
const ca = await db.correctiveAction.create({ data: { tenantId: t, nonconformityId: nc.id, action: "Rezertifizierungs-Prozess etablieren", rootCause: "Kein definierter Turnus" }, select: { id: true } });
|
||||
// Wirksamkeit dokumentieren + Maßnahme umgesetzt.
|
||||
await db.correctiveAction.update({ where: { id: ca.id }, data: { status: "umgesetzt", effectivenessCheck: "Rezertifizierung im Folgequartal vollständig", effectivenessConfirmedAt: new Date() } });
|
||||
const caAfter = await db.correctiveAction.findUnique({ where: { id: ca.id } });
|
||||
ok(!!caAfter?.effectivenessConfirmedAt && !!caAfter?.effectivenessCheck, "Korrekturmaßnahme mit dokumentierter Wirksamkeitsprüfung");
|
||||
// Nichtkonformität schließen.
|
||||
await db.nonconformity.update({ where: { id: nc.id }, data: { status: "abgeschlossen", closedAt: new Date() } });
|
||||
const ncAfter = await db.nonconformity.findUnique({ where: { id: nc.id } });
|
||||
ok(ncAfter?.status === "abgeschlossen" && !!ncAfter?.closedAt, "Maßnahmenfall inkl. Wirksamkeitsprüfung abschließbar");
|
||||
|
||||
await cleanup();
|
||||
console.log("\n✓ aufgeräumt (Wegwerf-Mandant entfernt)");
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => { console.log(failures === 0 ? "\nAP4-Managementklauseln grün." : `\n${failures} Prüfung(en) fehlgeschlagen.`); process.exit(failures === 0 ? 0 : 1); })
|
||||
.catch(async (e) => { console.error(e); await cleanup().catch(() => {}); process.exit(1); });
|
||||
@@ -1,27 +1,32 @@
|
||||
// Korrektheitsnachweis für F-04: Row Level Security scharfschalten.
|
||||
//
|
||||
// Beweist, dass die scharfe RLS (FORCE + WITH CHECK + Kontext, Rolle isms_app)
|
||||
// Beweist, dass die scharfe RLS (FORCE + WITH CHECK + Kontext, Rolle craftvia_app)
|
||||
// die Mandantentrennung erzwingt, OHNE den lokalen Owner-Betrieb zu brechen.
|
||||
// Fünf Nachweise (jeweils Assertion; am Ende "OK" oder Exit-Code != 0):
|
||||
// Nachweise (jeweils Assertion; am Ende "OK" oder Exit-Code != 0):
|
||||
// (0) Baseline: die RLS-Funktion enable_tenant_rls() existiert und alle Tabellen der
|
||||
// TENANT_MODELS tragen FORCE RLS + Policy tenant_isolation.
|
||||
// (1) Owner-Betrieb bleibt heil: Owner (Superuser/BYPASSRLS) sieht ohne
|
||||
// app.tenant_id weiterhin ALLE Zeilen über Mandanten hinweg.
|
||||
// (2) RLS greift für isms_app: mit Kontext=A nur A-Zeilen, keine von B.
|
||||
// (2) RLS greift für craftvia_app: mit Kontext=A nur A-Zeilen, keine von B.
|
||||
// (3) Ohne Kontext = null Zeilen (fail-closed).
|
||||
// (4) WITH CHECK wirkt: INSERT mit eigenem Mandanten gelingt, mit fremdem
|
||||
// Mandanten wird abgelehnt.
|
||||
// (5) dbForTenant end-to-end mit RLS_ENFORCED=true (dynamischer Import).
|
||||
//
|
||||
// Voraussetzung (einmalig lokal):
|
||||
// docker exec isms-tool-postgres-1 psql -U isms -d isms \
|
||||
// -c "ALTER ROLE isms_app WITH LOGIN PASSWORD 'isms_app_local';"
|
||||
// npx prisma migrate deploy
|
||||
// Fixture: Fundament-Modell `Role`.
|
||||
//
|
||||
// Voraussetzung (einmalig lokal, nach `prisma migrate deploy`):
|
||||
// docker compose exec postgres psql -U craftvia -d craftvia \
|
||||
// -c "ALTER ROLE craftvia_app WITH LOGIN PASSWORD 'craftvia_app_local';"
|
||||
// Ohne Login-Recht der Rolle wird der Test mit klarer Meldung übersprungen (Exit 0),
|
||||
// es sei denn RLS_TEST_REQUIRED=true.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-rls-enforcement.ts
|
||||
// Nutzt die lokale Postgres-DB (Container isms-tool-postgres-1); .env im Worktree.
|
||||
|
||||
import "dotenv/config";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { buildTenantTopology, TENANT_MODELS } from "../src/server/backup/topology";
|
||||
|
||||
let failures = 0;
|
||||
const ok = (cond: boolean, msg: string) => {
|
||||
@@ -39,7 +44,7 @@ async function expectThrow(fn: () => Promise<unknown>, msg: string) {
|
||||
|
||||
const RLS_URL =
|
||||
process.env.RLS_DATABASE_URL ??
|
||||
"postgresql://isms_app:isms_app_local@localhost:5432/isms?schema=public";
|
||||
"postgresql://craftvia_app:craftvia_app_local@localhost:5432/craftvia?schema=public";
|
||||
|
||||
const SLUG_A = "zz-rls-test-a";
|
||||
const SLUG_B = "zz-rls-test-b";
|
||||
@@ -50,7 +55,7 @@ const SLUG_B = "zz-rls-test-b";
|
||||
const owner = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
||||
});
|
||||
// Client der eingeschränkten App-Rolle isms_app (unterliegt der scharfen RLS).
|
||||
// Client der eingeschränkten App-Rolle craftvia_app (unterliegt der scharfen RLS).
|
||||
const app = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: RLS_URL }),
|
||||
});
|
||||
@@ -62,114 +67,115 @@ async function cleanup() {
|
||||
});
|
||||
const ids = tenants.map((t) => t.id);
|
||||
if (ids.length) {
|
||||
await owner.risk.deleteMany({ where: { tenantId: { in: ids } } });
|
||||
await owner.role.deleteMany({ where: { tenantId: { in: ids } } });
|
||||
await owner.tenant.deleteMany({ where: { id: { in: ids } } });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// ── (0) Baseline-Struktur ─────────────────────────────────────────────────
|
||||
const fn = await owner.$queryRawUnsafe<{ n: bigint }[]>(
|
||||
"SELECT count(*)::bigint AS n FROM pg_proc WHERE proname = 'enable_tenant_rls'",
|
||||
);
|
||||
ok(Number(fn[0]?.n ?? 0) === 1, "(0a) SQL-Funktion enable_tenant_rls(text) existiert");
|
||||
|
||||
const topo = buildTenantTopology();
|
||||
const tables = TENANT_MODELS.map((m) => topo.nodes.get(m)!.table);
|
||||
const rls = await owner.$queryRawUnsafe<{ relname: string; relrowsecurity: boolean; relforcerowsecurity: boolean; policies: bigint }[]>(
|
||||
`SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity,
|
||||
(SELECT count(*) FROM pg_policies p WHERE p.tablename = c.relname AND p.policyname = 'tenant_isolation')::bigint AS policies
|
||||
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public' AND c.relkind = 'r'`,
|
||||
);
|
||||
const byTable = new Map(rls.map((r) => [r.relname, r]));
|
||||
const missing = tables.filter((t) => {
|
||||
const r = byTable.get(t);
|
||||
return !r || !r.relrowsecurity || !r.relforcerowsecurity || Number(r.policies) !== 1;
|
||||
});
|
||||
ok(missing.length === 0, `(0b) alle ${tables.length} Tenant-Tabellen haben ENABLE+FORCE RLS + tenant_isolation${missing.length ? ` — fehlt: ${missing.join(", ")}` : ""}`);
|
||||
|
||||
// Login-Fähigkeit der App-Rolle prüfen (lokal ggf. noch nicht eingerichtet).
|
||||
try {
|
||||
await app.$queryRawUnsafe("SELECT 1");
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (process.env.RLS_TEST_REQUIRED === "true") throw e;
|
||||
console.log(`\n⚠ ÜBERSPRUNGEN (1)-(5): Verbindung als craftvia_app nicht möglich (${msg.split("\n")[0]}).`);
|
||||
console.log(" Einmalig: ALTER ROLE craftvia_app WITH LOGIN PASSWORD 'craftvia_app_local';");
|
||||
if (failures > 0) process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
await cleanup();
|
||||
|
||||
// Setup: zwei Test-Mandanten mit je einem Risk (über den Owner angelegt).
|
||||
const tenantA = await owner.tenant.create({
|
||||
data: { name: "ZZ RLS Test A", slug: SLUG_A },
|
||||
});
|
||||
const tenantB = await owner.tenant.create({
|
||||
data: { name: "ZZ RLS Test B", slug: SLUG_B },
|
||||
});
|
||||
const riskA = await owner.risk.create({
|
||||
data: { tenantId: tenantA.id, refNo: 9001, title: "Risiko A" },
|
||||
});
|
||||
const riskB = await owner.risk.create({
|
||||
data: { tenantId: tenantB.id, refNo: 9001, title: "Risiko B" },
|
||||
});
|
||||
// Setup: zwei Test-Mandanten mit je einer Rolle (über den Owner angelegt).
|
||||
const tenantA = await owner.tenant.create({ data: { name: "ZZ RLS Test A", slug: SLUG_A } });
|
||||
const tenantB = await owner.tenant.create({ data: { name: "ZZ RLS Test B", slug: SLUG_B } });
|
||||
const roleA = await owner.role.create({ data: { tenantId: tenantA.id, key: "zz-rls", name: "Rolle A" } });
|
||||
const roleB = await owner.role.create({ data: { tenantId: tenantB.id, key: "zz-rls", name: "Rolle B" } });
|
||||
|
||||
// ── (1) Owner-Betrieb bleibt heil (kritisch für lokal + devB) ──────────────
|
||||
// Owner ist Superuser/BYPASSRLS → sieht trotz FORCE alle Mandanten, OHNE dass
|
||||
// app.tenant_id gesetzt ist.
|
||||
const ownerCount = await owner.risk.count({
|
||||
where: { tenantId: { in: [tenantA.id, tenantB.id] } },
|
||||
});
|
||||
ok(
|
||||
ownerCount === 2,
|
||||
`(1) Owner sieht ohne Kontext beide Mandanten (${ownerCount}/2) — FORCE bricht Owner-Betrieb nicht`,
|
||||
);
|
||||
const ownerTotal = await owner.risk.count();
|
||||
// ── (1) Owner-Betrieb bleibt heil ────────────────────────────────────────
|
||||
const ownerCount = await owner.role.count({ where: { tenantId: { in: [tenantA.id, tenantB.id] } } });
|
||||
ok(ownerCount === 2, `(1) Owner sieht ohne Kontext beide Mandanten (${ownerCount}/2) — FORCE bricht Owner-Betrieb nicht`);
|
||||
const ownerTotal = await owner.role.count();
|
||||
ok(ownerTotal > 0, `(1b) Owner sieht global Zeilen (${ownerTotal} > 0)`);
|
||||
|
||||
// ── (2) RLS greift für isms_app: mit Kontext=A nur A-Zeilen ────────────────
|
||||
// ── (2) RLS greift für craftvia_app: mit Kontext=A nur A-Zeilen ─────────────
|
||||
const seenA = await app.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantA.id}, true)`;
|
||||
return tx.risk.findMany({ select: { id: true, tenantId: true } });
|
||||
return tx.role.findMany({ select: { id: true, tenantId: true } });
|
||||
});
|
||||
ok(
|
||||
seenA.length > 0 &&
|
||||
seenA.every((r) => r.tenantId === tenantA.id) &&
|
||||
seenA.some((r) => r.id === riskA.id) &&
|
||||
!seenA.some((r) => r.id === riskB.id),
|
||||
`(2) isms_app mit Kontext=A sieht nur A-Zeilen (${seenA.length}), keine von B`,
|
||||
seenA.some((r) => r.id === roleA.id) &&
|
||||
!seenA.some((r) => r.id === roleB.id),
|
||||
`(2) craftvia_app mit Kontext=A sieht nur A-Zeilen (${seenA.length}), keine von B`,
|
||||
);
|
||||
|
||||
// ── (3) Ohne Kontext = null Zeilen (fail-closed) ───────────────────────────
|
||||
const seenNone = await app.risk.count();
|
||||
ok(seenNone === 0, `(3) isms_app ohne Kontext sieht 0 Zeilen (${seenNone})`);
|
||||
const seenNone = await app.role.count();
|
||||
ok(seenNone === 0, `(3) craftvia_app ohne Kontext sieht 0 Zeilen (${seenNone})`);
|
||||
|
||||
// ── (4) WITH CHECK wirkt: eigener Mandant erlaubt, fremder abgelehnt ────────
|
||||
const inserted = await app.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantA.id}, true)`;
|
||||
return tx.risk.create({
|
||||
data: { tenantId: tenantA.id, refNo: 9002, title: "Risiko A insert" },
|
||||
});
|
||||
return tx.role.create({ data: { tenantId: tenantA.id, key: "zz-rls-insert", name: "Rolle A insert" } });
|
||||
});
|
||||
ok(
|
||||
inserted.tenantId === tenantA.id,
|
||||
"(4a) INSERT mit eigenem Mandanten (A) unter Kontext=A gelingt",
|
||||
);
|
||||
ok(inserted.tenantId === tenantA.id, "(4a) INSERT mit eigenem Mandanten (A) unter Kontext=A gelingt");
|
||||
await expectThrow(
|
||||
() =>
|
||||
app.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantA.id}, true)`;
|
||||
// Fremder Mandant B unter Kontext A → WITH-CHECK-Policy lehnt ab.
|
||||
return tx.risk.create({
|
||||
data: { tenantId: tenantB.id, refNo: 9003, title: "Fremd-Insert" },
|
||||
});
|
||||
return tx.role.create({ data: { tenantId: tenantB.id, key: "zz-rls-foreign", name: "Fremd-Insert" } });
|
||||
}),
|
||||
"(4b) INSERT mit fremdem Mandanten (B) unter Kontext=A wird von WITH CHECK abgelehnt",
|
||||
);
|
||||
|
||||
// ── (5) dbForTenant end-to-end mit RLS_ENFORCED=true ───────────────────────
|
||||
// Flag + URL VOR dem Import von db.ts setzen (dynamischer Import).
|
||||
process.env.RLS_ENFORCED = "true";
|
||||
process.env.RLS_DATABASE_URL = RLS_URL;
|
||||
const db = await import("../src/server/db");
|
||||
|
||||
const e2eA = await db.dbForTenant(tenantA.id).risk.findMany({
|
||||
select: { id: true, tenantId: true },
|
||||
});
|
||||
const e2eA = await db.dbForTenant(tenantA.id).role.findMany({ select: { id: true, tenantId: true } });
|
||||
ok(
|
||||
e2eA.length > 0 &&
|
||||
e2eA.every((r) => r.tenantId === tenantA.id) &&
|
||||
!e2eA.some((r) => r.id === riskB.id),
|
||||
`(5a) dbForTenant(A).risk.findMany() liefert nur A (${e2eA.length})`,
|
||||
);
|
||||
const foreign = await db.dbForTenant(tenantA.id).risk.findFirst({
|
||||
where: { id: riskB.id },
|
||||
});
|
||||
ok(
|
||||
foreign === null,
|
||||
"(5b) dbForTenant(A).risk.findFirst({ id: B-Risk }) → null (kein Fremdzugriff)",
|
||||
e2eA.length > 0 && e2eA.every((r) => r.tenantId === tenantA.id) && !e2eA.some((r) => r.id === roleB.id),
|
||||
`(5a) dbForTenant(A).role.findMany() liefert nur A (${e2eA.length})`,
|
||||
);
|
||||
const foreign = await db.dbForTenant(tenantA.id).role.findFirst({ where: { id: roleB.id } });
|
||||
ok(foreign === null, "(5b) dbForTenant(A).role.findFirst({ id: B-Rolle }) → null (kein Fremdzugriff)");
|
||||
|
||||
await cleanup();
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(`\n${failures} Nachweis(e) fehlgeschlagen.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nOK — alle F-04-Nachweise (1)-(5) erfüllt.");
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => {
|
||||
if (failures > 0) {
|
||||
console.error(`\n${failures} Nachweis(e) fehlgeschlagen.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nOK — alle F-04-Nachweise erfüllt.");
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
// Akzeptanztest der Regel-Engine (Story F3/B2). Deterministische Assertions gegen
|
||||
// C2 §5 (Q-FEAT-01…10) und §8 (Aufgaben-Trigger). Kein DB-Zugriff, reine Logik.
|
||||
// Lauf: npx tsx scripts/test-rules.ts (Exit 1 bei Fehler).
|
||||
|
||||
import { evaluateRules, makeContext } from "../src/lib/rules/engine";
|
||||
import { ALL_RULES, FEATURE_RULES, Q } from "../src/lib/rules/feature-rules";
|
||||
import { triggerById } from "../src/lib/task-triggers";
|
||||
import type { Answer } from "../src/lib/rules/dsl";
|
||||
|
||||
let failures = 0;
|
||||
const ok = (cond: boolean, msg: string) => {
|
||||
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
const evalAnswers = (answers: Record<string, Answer>, pruefziele?: string[]) =>
|
||||
evaluateRules(ALL_RULES, makeContext({ answers, ...(pruefziele ? { pruefziele } : {}) }));
|
||||
|
||||
// — Q-FEAT-02 Cloud —
|
||||
{
|
||||
const on = evalAnswers({ [Q.CLOUD]: true });
|
||||
ok(on.flags.FLAG_CLOUD_USED === true, "Q-FEAT-02=ja → FLAG_CLOUD_USED");
|
||||
ok(["5.3.2", "5.3.4", "6.1.3"].every((c) => on.controls.includes(c)), "Q-FEAT-02=ja → Controls 5.3.2/5.3.4/6.1.3");
|
||||
ok(on.risks.includes("R-CLOUD"), "Q-FEAT-02=ja → Risiko R-CLOUD");
|
||||
ok(on.matched.includes("feat02-cloud"), "Q-FEAT-02=ja → Regel feat02-cloud ausgelöst");
|
||||
const off = evalAnswers({ [Q.CLOUD]: false });
|
||||
ok(off.flags.FLAG_CLOUD_USED !== true, "Q-FEAT-02=nein → kein FLAG_CLOUD_USED");
|
||||
ok(!off.matched.includes("feat02-cloud"), "Q-FEAT-02=nein → feat02-cloud NICHT ausgelöst");
|
||||
ok(!off.controls.includes("5.3.2"), "Q-FEAT-02=nein → Control 5.3.2 nicht im Scope");
|
||||
}
|
||||
|
||||
// — A2-1: Schutzbedarf kommt zentral aus dem Assessment-Level (Seed), NICHT aus dem Fragebogen —
|
||||
{
|
||||
// Ohne AL-Seed setzt keine Fragebogen-Antwort HIGH/VERY_HIGH.
|
||||
const noSeed = evalAnswers({ [Q.CLOUD]: true, [Q.EXTERNAL_IT]: true });
|
||||
ok(noSeed.flags.FLAG_HIGH_PROTECTION !== true && noSeed.flags.FLAG_VERY_HIGH_PROTECTION !== true, "Fragebogen setzt HIGH/VERY_HIGH nicht");
|
||||
ok(noSeed.flags.FLAG_ELEVATED_PROTECTION === false, "ohne AL-Seed → ELEVATED aus");
|
||||
// AL2-Seed → HIGH an, VERY_HIGH aus; AL3-Seed → beide an; ELEVATED jeweils abgeleitet.
|
||||
const al2 = evaluateRules(ALL_RULES, makeContext({ flags: { FLAG_HIGH_PROTECTION: true, FLAG_VERY_HIGH_PROTECTION: false } }));
|
||||
ok(al2.flags.FLAG_HIGH_PROTECTION === true && al2.flags.FLAG_VERY_HIGH_PROTECTION !== true, "AL2-Seed → HIGH an, VERY_HIGH aus");
|
||||
ok(al2.flags.FLAG_ELEVATED_PROTECTION === true, "AL2-Seed → ELEVATED abgeleitet an");
|
||||
const al3 = evaluateRules(ALL_RULES, makeContext({ flags: { FLAG_HIGH_PROTECTION: true, FLAG_VERY_HIGH_PROTECTION: true } }));
|
||||
ok(al3.flags.FLAG_VERY_HIGH_PROTECTION === true && al3.flags.FLAG_ELEVATED_PROTECTION === true, "AL3-Seed → VERY_HIGH + ELEVATED an");
|
||||
}
|
||||
|
||||
// — Q-FEAT-09 externe IT (Control + Risiko + Aufgabe) —
|
||||
{
|
||||
const r = evalAnswers({ [Q.EXTERNAL_IT]: true });
|
||||
ok(r.flags.FLAG_EXTERNAL_IT === true, "Q-FEAT-09=ja → FLAG_EXTERNAL_IT");
|
||||
ok(r.controls.includes("6.1.1") && r.controls.includes("6.1.3"), "Q-FEAT-09=ja → Controls 6.1.1/6.1.3");
|
||||
ok(r.risks.includes("R-SUP"), "Q-FEAT-09=ja → Risiko R-SUP");
|
||||
ok(r.tasks.includes("external_it_yes"), "Q-FEAT-09=ja → Aufgabe external_it_yes");
|
||||
}
|
||||
|
||||
// — C2 §8 Aufgaben-Trigger —
|
||||
{
|
||||
const cloud = evalAnswers({ [Q.CLOUD]: true });
|
||||
ok(cloud.tasks.includes("cloud_ai_without_approval"), "Cloud=ja → Aufgabe cloud_ai_without_approval");
|
||||
const ai = evalAnswers({ [Q.AI]: true });
|
||||
ok(ai.tasks.includes("cloud_ai_without_approval"), "KI=ja → Aufgabe cloud_ai_without_approval");
|
||||
const neither = evalAnswers({ [Q.CLOUD]: false, [Q.AI]: false });
|
||||
ok(!neither.tasks.includes("cloud_ai_without_approval"), "Cloud/KI=nein → keine Freigabeverfahren-Aufgabe");
|
||||
const isb = evalAnswers({ [Q.ISB_BENANNT]: false });
|
||||
ok(isb.tasks.includes("isb_not_named"), "ISB nicht benannt → Aufgabe isb_not_named");
|
||||
const restore = evalAnswers({ [Q.RESTORE_GETESTET]: false });
|
||||
ok(restore.tasks.includes("no_restore_test"), "kein Restore-Test → Aufgabe no_restore_test");
|
||||
}
|
||||
|
||||
// — Determinismus: gleiche Eingabe → gleiches Ergebnis —
|
||||
{
|
||||
const a = evalAnswers({ [Q.CLOUD]: true, [Q.EXTERNAL_IT]: true });
|
||||
const b = evalAnswers({ [Q.CLOUD]: true, [Q.EXTERNAL_IT]: true });
|
||||
ok(JSON.stringify(a) === JSON.stringify(b), "Determinismus: identische Auswertung bei gleicher Eingabe");
|
||||
}
|
||||
|
||||
// — Integrität: alle referenzierten Trigger-IDs existieren im Katalog (B1) —
|
||||
{
|
||||
const taskIds = new Set(ALL_RULES.flatMap((r) => r.effect.tasks ?? []));
|
||||
const allExist = [...taskIds].every((id) => triggerById(id) !== null);
|
||||
ok(allExist, `alle Aufgaben-Trigger-IDs existieren in task-triggers.ts (${[...taskIds].join(", ")})`);
|
||||
}
|
||||
|
||||
// — Abdeckung: jede Q-FEAT-02…10 hat eine Regel (Q-FEAT-01/Schutzbedarf ist zentral, A2-1) —
|
||||
{
|
||||
const covered = new Set(FEATURE_RULES.map((r) => r.source));
|
||||
const missing = Array.from({ length: 9 }, (_, i) => `C2 §5 Q-FEAT-${String(i + 2).padStart(2, "0")}`).filter((s) => !covered.has(s));
|
||||
ok(missing.length === 0, `alle Q-FEAT-02…10 durch Regeln abgedeckt${missing.length ? " — fehlt: " + missing.join(", ") : ""}`);
|
||||
}
|
||||
|
||||
// — Follow-up: Prüfziele kommen aus WizardScope (Scoping), nicht aus dem Fragebogen —
|
||||
{
|
||||
const ds = evalAnswers({}, ["informationssicherheit", "datenschutz"]);
|
||||
ok(ds.flags.FLAG_PERSONAL_DATA === true, "Prüfziel Datenschutz → FLAG_PERSONAL_DATA");
|
||||
ok(ds.controls.includes("7.1.2") && ds.risks.includes("R-DSGVO"), "Prüfziel Datenschutz → Control 7.1.2 + Risiko R-DSGVO");
|
||||
const proto = evalAnswers({}, ["informationssicherheit", "prototypenschutz"]);
|
||||
ok(proto.flags.FLAG_PROTOTYPE_PROTECTION === true, "Prüfziel Prototypenschutz → FLAG_PROTOTYPE_PROTECTION");
|
||||
const isOnly = evalAnswers({}, ["informationssicherheit"]);
|
||||
ok(isOnly.flags.FLAG_PERSONAL_DATA !== true && isOnly.flags.FLAG_PROTOTYPE_PROTECTION !== true, "nur Informationssicherheit → Datenschutz/Prototyp-Flags aus");
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? "\nOK — alle Regel-Tests grün" : `\nPRUEFEN — ${failures} Fehler`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -1,72 +0,0 @@
|
||||
// Akzeptanztest des Scope-Filters (Story A2-2). Repräsentative C1-Zeilen + Scope-
|
||||
// Dynamik. Reine Logik, kein DB-Zugriff. Lauf: npx tsx scripts/test-scope-filter.ts
|
||||
|
||||
import { C1_ROWS, activeRequirements, isRequirementActive, scopeSummary, type Pruefziel } from "../src/lib/scope-filter";
|
||||
|
||||
let failures = 0;
|
||||
const ok = (cond: boolean, msg: string) => {
|
||||
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
const row = (id: string) => {
|
||||
const r = C1_ROWS.find((x) => x.id === id);
|
||||
if (!r) throw new Error(`C1-Zeile ${id} nicht gefunden`);
|
||||
return r;
|
||||
};
|
||||
|
||||
// Flag-Sets: HIGH ist AL2-Baseline, VERY_HIGH nur AL3 (A2-1).
|
||||
const AL2 = { FLAG_HIGH_PROTECTION: true, FLAG_VERY_HIGH_PROTECTION: false, FLAG_ELEVATED_PROTECTION: true };
|
||||
const AL3 = { FLAG_HIGH_PROTECTION: true, FLAG_VERY_HIGH_PROTECTION: true, FLAG_ELEVATED_PROTECTION: true };
|
||||
const SHOULD = { FLAG_INCLUDE_SHOULD: true };
|
||||
const IS: Pruefziel[] = ["informationssicherheit"];
|
||||
|
||||
// — Grunddaten —
|
||||
ok(C1_ROWS.length === 412, `C1-Datengrundlage: 412 Anforderungen (${C1_ROWS.length})`);
|
||||
|
||||
// — MUSS (immer im Scope) —
|
||||
ok(isRequirementActive(row("1.1.1-M1"), { pruefziele: IS, flags: {} }), "1.1.1-M1 (MUSS) → im Scope ohne Flags");
|
||||
|
||||
// — SOLL nur bei FLAG_INCLUDE_SHOULD —
|
||||
ok(!isRequirementActive(row("1.1.1-S1"), { pruefziele: IS, flags: { ...AL2 } }), "1.1.1-S1 (SOLL) → NICHT ohne FLAG_INCLUDE_SHOULD");
|
||||
ok(isRequirementActive(row("1.1.1-S1"), { pruefziele: IS, flags: { ...AL2, ...SHOULD } }), "1.1.1-S1 (SOLL) → im Scope mit FLAG_INCLUDE_SHOULD");
|
||||
|
||||
// — HOCH nur bei FLAG_HIGH_PROTECTION —
|
||||
ok(!isRequirementActive(row("1.2.2-H1"), { pruefziele: IS, flags: { FLAG_HIGH_PROTECTION: false } }), "1.2.2-H1 (HOCH) → NICHT ohne FLAG_HIGH_PROTECTION");
|
||||
ok(isRequirementActive(row("1.2.2-H1"), { pruefziele: IS, flags: { ...AL2 } }), "1.2.2-H1 (HOCH) → im Scope bei AL2 (HIGH-Baseline)");
|
||||
|
||||
// — SEHR HOCH nur bei FLAG_VERY_HIGH_PROTECTION (AL3) —
|
||||
ok(!isRequirementActive(row("1.3.4-V1"), { pruefziele: IS, flags: { ...AL2 } }), "1.3.4-V1 (SEHR HOCH) → NICHT bei AL2");
|
||||
ok(isRequirementActive(row("1.3.4-V1"), { pruefziele: IS, flags: { ...AL3 } }), "1.3.4-V1 (SEHR HOCH) → im Scope bei AL3");
|
||||
|
||||
// — Kapitel 8.x nur bei Prüfziel Prototypenschutz —
|
||||
ok(!isRequirementActive(row("8.1.1-M1"), { pruefziele: IS, flags: { ...AL3, ...SHOULD } }), "8.1.1-M1 → NICHT ohne Prüfziel Prototypenschutz");
|
||||
ok(isRequirementActive(row("8.1.1-M1"), { pruefziele: [...IS, "prototypenschutz"], flags: {} }), "8.1.1-M1 → im Scope bei Prüfziel Prototypenschutz");
|
||||
|
||||
// — Kapitel 9.x nur bei Prüfziel Datenschutz —
|
||||
ok(!isRequirementActive(row("9.1.1-M1"), { pruefziele: IS, flags: { ...AL3, ...SHOULD } }), "9.1.1-M1 → NICHT ohne Prüfziel Datenschutz");
|
||||
ok(isRequirementActive(row("9.1.1-M1"), { pruefziele: [...IS, "datenschutz"], flags: {} }), "9.1.1-M1 → im Scope bei Prüfziel Datenschutz");
|
||||
|
||||
// — Scope-Dynamik (Kennzahlen) —
|
||||
const al2 = scopeSummary({ pruefziele: IS, flags: { ...AL2, ...SHOULD } });
|
||||
const al3 = scopeSummary({ pruefziele: IS, flags: { ...AL3, ...SHOULD } });
|
||||
ok(al3.total > al2.total, `AL3 hat mehr Anforderungen als AL2 (${al3.total} > ${al2.total}) — SEHR HOCH kommt hinzu`);
|
||||
ok(al3.total - al2.total === al3.byType["SEHR HOCH"], "AL3−AL2 == Anzahl SEHR HOCH (nur SEHR HOCH unterscheidet sich)");
|
||||
|
||||
const noShould = scopeSummary({ pruefziele: IS, flags: { ...AL2 } });
|
||||
ok(al2.total - noShould.total === al2.byType.SOLL, "FLAG_INCLUDE_SHOULD schaltet genau die SOLL-Anforderungen");
|
||||
|
||||
const withProto = scopeSummary({ pruefziele: [...IS, "prototypenschutz"], flags: { ...AL3, ...SHOULD } });
|
||||
ok(withProto.total > al3.total, `Prüfziel Prototypenschutz erhöht den Scope (${withProto.total} > ${al3.total})`);
|
||||
ok((withProto.byPruefziel["prototypenschutz"] ?? 0) > 0 && !al3.byPruefziel["prototypenschutz"], "Prototyp-Anforderungen nur bei aktivem Prüfziel");
|
||||
|
||||
// — Volles Prüfziel-/Flag-Set = alle 412 —
|
||||
const all = scopeSummary({ pruefziele: ["informationssicherheit", "prototypenschutz", "datenschutz"], flags: { ...AL3, ...SHOULD } });
|
||||
ok(all.total === 412, `alle Prüfziele + AL3 + SOLL → alle 412 Anforderungen im Scope (${all.total})`);
|
||||
|
||||
// — Nur MUSS (keine Flags, nur IS) —
|
||||
const mussOnly = activeRequirements({ pruefziele: IS, flags: {} });
|
||||
ok(mussOnly.every((r) => r.type === "MUSS"), "ohne Flags/Zusatz-Prüfziele → nur MUSS (IS)");
|
||||
|
||||
console.log(failures === 0 ? "\nOK — alle Scope-Filter-Tests grün" : `\nPRUEFEN — ${failures} Fehler`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -1,67 +0,0 @@
|
||||
// AP3 — Anwendbarkeitserklärung (SoA). Prüft Vorbefüllung, Bedingungs-Logik,
|
||||
// Idempotenz und Vollständigkeits-Markierung gegen die lokale DB (Wegwerf-Mandant).
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-soa.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { prisma, dbForTenant } from "../src/server/db";
|
||||
import { ensureSoaEntries, loadIsoSoaControls } from "../src/server/soa-statement";
|
||||
import { isSoaEntryComplete } from "../src/lib/soa";
|
||||
|
||||
let failures = 0;
|
||||
const ok = (c: boolean, m: string) => { console.log(`${c ? "✓" : "✗ FEHLER"} ${m}`); if (!c) failures++; };
|
||||
const SLUG = "ap3-test-soa";
|
||||
|
||||
async function cleanup() {
|
||||
const t = await prisma.tenant.findUnique({ where: { slug: SLUG }, select: { id: true } });
|
||||
if (t) {
|
||||
await prisma.soaEntry.deleteMany({ where: { tenantId: t.id } });
|
||||
await prisma.policyVariable.deleteMany({ where: { tenantId: t.id } });
|
||||
await prisma.tenant.delete({ where: { id: t.id } });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await cleanup();
|
||||
|
||||
const controls = loadIsoSoaControls();
|
||||
ok(controls.length === 93, `loadIsoSoaControls: ${controls.length} Annex-A-Controls (erwartet 93)`);
|
||||
const devControl = controls.find((c) => c.condition === "FLAG_DEV_INHOUSE");
|
||||
const plainControl = controls.find((c) => c.condition === null);
|
||||
ok(!!devControl && !!plainControl, "je ein Control mit/ohne Bedingung vorhanden");
|
||||
|
||||
const tenant = await prisma.tenant.create({ data: { name: "AP3 SoA", slug: SLUG } });
|
||||
const t = tenant.id;
|
||||
// Flags: DEV-Inhouse AUS → DEV-Controls default nicht anwendbar; Personendaten AN.
|
||||
await prisma.policyVariable.createMany({
|
||||
data: [
|
||||
{ tenantId: t, key: "FLAG_DEV_INHOUSE", title: "Dev", kind: "boolean", value: "false" },
|
||||
{ tenantId: t, key: "FLAG_PERSONAL_DATA", title: "PD", kind: "boolean", value: "true" },
|
||||
],
|
||||
});
|
||||
const db = dbForTenant(t);
|
||||
|
||||
const n1 = await ensureSoaEntries(db, t);
|
||||
ok(n1 === 93, `ensureSoaEntries legt 93 Zeilen an (${n1})`);
|
||||
const n2 = await ensureSoaEntries(db, t);
|
||||
ok(n2 === 0, "zweiter Lauf ist idempotent (0 neue Zeilen)");
|
||||
ok((await prisma.soaEntry.count({ where: { tenantId: t } })) === 93, "93 SoA-Zeilen persistiert");
|
||||
|
||||
const devEntry = await prisma.soaEntry.findFirst({ where: { tenantId: t, control: devControl!.control } });
|
||||
ok(devEntry?.applicable === false, `Bedingung greift: ${devControl!.control} (FLAG_DEV_INHOUSE aus) → nicht anwendbar`);
|
||||
const plainEntry = await prisma.soaEntry.findFirst({ where: { tenantId: t, control: plainControl!.control } });
|
||||
ok(plainEntry?.applicable === true, `Control ohne Bedingung (${plainControl!.control}) → anwendbar`);
|
||||
|
||||
// Vollständigkeit: frisch ohne Begründung → unvollständig; nach Begründung → vollständig.
|
||||
ok(!isSoaEntryComplete({ applicable: true, justification: "" }), "Control ohne Begründung → unvollständig");
|
||||
ok(isSoaEntryComplete({ applicable: false, justification: "Ausschluss: keine Eigenentwicklung" }), "Ausschluss mit Begründung → vollständig");
|
||||
const incomplete = await prisma.soaEntry.count({ where: { tenantId: t, justification: "" } });
|
||||
ok(incomplete === 93, "alle 93 Zeilen initial ohne Begründung (unvollständig)");
|
||||
|
||||
await cleanup();
|
||||
console.log("\n✓ aufgeräumt (Wegwerf-Mandant entfernt)");
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => { console.log(failures === 0 ? "\nAP3-SoA grün." : `\n${failures} Prüfung(en) fehlgeschlagen.`); process.exit(failures === 0 ? 0 : 1); })
|
||||
.catch(async (e) => { console.error(e); await cleanup().catch(() => {}); process.exit(1); });
|
||||
@@ -6,8 +6,9 @@
|
||||
// Fall), noch mit `include`, ohne `select` oder über einen Compound-Unique-Key.
|
||||
// Der legitime Eigenzugriff muss unverändert funktionieren.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-tenant-isolation.ts
|
||||
// Nutzt die lokale Postgres-DB (Container isms-tool-postgres-1); .env liegt im Worktree.
|
||||
// Fixture: Fundament-Modell `Role` (tenantId + Compound-Unique `tenantId_key`).
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-tenant-isolation.ts (lokale Postgres-DB aus .env)
|
||||
|
||||
import "dotenv/config";
|
||||
import { prisma, dbForTenant } from "../src/server/db";
|
||||
@@ -36,6 +37,7 @@ async function expectNull(fn: () => Promise<unknown>, msg: string) {
|
||||
|
||||
const SLUG_A = "zz-sec-test-a";
|
||||
const SLUG_B = "zz-sec-test-b";
|
||||
const KEY = "zz-isolation-role";
|
||||
|
||||
async function cleanup() {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
@@ -44,7 +46,8 @@ async function cleanup() {
|
||||
});
|
||||
const ids = tenants.map((t) => t.id);
|
||||
if (ids.length) {
|
||||
await prisma.risk.deleteMany({ where: { tenantId: { in: ids } } });
|
||||
await prisma.auditLog.deleteMany({ where: { tenantId: { in: ids } } });
|
||||
await prisma.role.deleteMany({ where: { tenantId: { in: ids } } });
|
||||
await prisma.tenant.deleteMany({ where: { id: { in: ids } } });
|
||||
}
|
||||
}
|
||||
@@ -53,57 +56,53 @@ async function main() {
|
||||
// Idempotenz: eventuelle Reste eines früheren Laufs entfernen.
|
||||
await cleanup();
|
||||
|
||||
// Zwei Test-Mandanten mit je einem Risiko anlegen (roher Client = ohne Guard).
|
||||
// Zwei Test-Mandanten mit je einer Rolle anlegen (roher Client = ohne Guard).
|
||||
const tenantA = await prisma.tenant.create({ data: { name: "SEC-Test A", slug: SLUG_A } });
|
||||
const tenantB = await prisma.tenant.create({ data: { name: "SEC-Test B", slug: SLUG_B } });
|
||||
|
||||
const riskA = await prisma.risk.create({
|
||||
data: { tenantId: tenantA.id, refNo: 900001, title: "Risiko A (eigen)", likelihood: 3, impact: 3, score: 9 },
|
||||
});
|
||||
const riskB = await prisma.risk.create({
|
||||
data: { tenantId: tenantB.id, refNo: 900001, title: "GEHEIM-B (fremd)", likelihood: 4, impact: 4, score: 16 },
|
||||
});
|
||||
const roleA = await prisma.role.create({ data: { tenantId: tenantA.id, key: KEY, name: "Rolle A (eigen)" } });
|
||||
const roleB = await prisma.role.create({ data: { tenantId: tenantB.id, key: KEY, name: "GEHEIM-B (fremd)" } });
|
||||
|
||||
const dbA = dbForTenant(tenantA.id);
|
||||
|
||||
console.log("\n— Fremdzugriff (Mandant A liest Risiko von B) muss scheitern —");
|
||||
console.log("\n— Fremdzugriff (Mandant A liest Rolle von B) muss scheitern —");
|
||||
|
||||
// (1) Der ursprüngliche Exploit: select-Projektion OHNE tenantId.
|
||||
await expectNull(
|
||||
() => dbA.risk.findUnique({ where: { id: riskB.id }, select: { refNo: true, title: true } }),
|
||||
() => dbA.role.findUnique({ where: { id: roleB.id }, select: { key: true, name: true } }),
|
||||
"findUnique + select OHNE tenantId → null (F-02-Kernfall)"
|
||||
);
|
||||
|
||||
// (2) select MIT tenantId.
|
||||
await expectNull(
|
||||
() => dbA.risk.findUnique({ where: { id: riskB.id }, select: { title: true, tenantId: true } }),
|
||||
() => dbA.role.findUnique({ where: { id: roleB.id }, select: { name: true, tenantId: true } }),
|
||||
"findUnique + select MIT tenantId → null"
|
||||
);
|
||||
|
||||
// (3) include (tenantId wäre ohnehin enthalten).
|
||||
// (3) include.
|
||||
await expectNull(
|
||||
() => dbA.risk.findUnique({ where: { id: riskB.id }, include: { riskMeasures: true } }),
|
||||
() => dbA.role.findUnique({ where: { id: roleB.id }, include: { rolePermissions: true } }),
|
||||
"findUnique + include → null"
|
||||
);
|
||||
|
||||
// (4) ohne select/include.
|
||||
await expectNull(
|
||||
() => dbA.risk.findUnique({ where: { id: riskB.id } }),
|
||||
() => dbA.role.findUnique({ where: { id: roleB.id } }),
|
||||
"findUnique ohne Projektion → null"
|
||||
);
|
||||
|
||||
// (5) findUniqueOrThrow → muss werfen statt fremden Datensatz zu liefern.
|
||||
await expectThrow(
|
||||
() => dbA.risk.findUniqueOrThrow({ where: { id: riskB.id }, select: { title: true } }),
|
||||
() => dbA.role.findUniqueOrThrow({ where: { id: roleB.id }, select: { name: true } }),
|
||||
"findUniqueOrThrow + select OHNE tenantId → Throw"
|
||||
);
|
||||
|
||||
// (6) Compound-Unique-Key (tenantId_refNo) mit fremdem tenantId, select ohne tenantId.
|
||||
// (6) Compound-Unique-Key (tenantId_key) mit fremdem tenantId, select ohne tenantId.
|
||||
await expectThrow(
|
||||
() =>
|
||||
dbA.risk.findUnique({
|
||||
where: { tenantId_refNo: { tenantId: tenantB.id, refNo: riskB.refNo } },
|
||||
select: { title: true },
|
||||
dbA.role.findUnique({
|
||||
where: { tenantId_key: { tenantId: tenantB.id, key: roleB.key } },
|
||||
select: { name: true },
|
||||
}),
|
||||
"findUnique über Compound-Key (fremd) + select → Throw (fail-closed)"
|
||||
);
|
||||
@@ -111,34 +110,50 @@ async function main() {
|
||||
// (7) Compound-Unique-Key mit fremdem tenantId, ohne select.
|
||||
await expectThrow(
|
||||
() =>
|
||||
dbA.risk.findUnique({
|
||||
where: { tenantId_refNo: { tenantId: tenantB.id, refNo: riskB.refNo } },
|
||||
dbA.role.findUnique({
|
||||
where: { tenantId_key: { tenantId: tenantB.id, key: roleB.key } },
|
||||
}),
|
||||
"findUnique über Compound-Key (fremd) ohne select → Throw"
|
||||
);
|
||||
|
||||
console.log("\n— Legitimer Eigenzugriff (Mandant A liest eigenes Risiko A) muss funktionieren —");
|
||||
// (7b) Mutation auf fremden Datensatz → Ownership-Vorprüfung wirft.
|
||||
await expectThrow(
|
||||
() => dbA.role.update({ where: { id: roleB.id }, data: { name: "gekapert" } }),
|
||||
"update auf fremde Rolle → Throw (Ownership-Vorprüfung)"
|
||||
);
|
||||
const bAfter = await prisma.role.findUnique({ where: { id: roleB.id } });
|
||||
ok(bAfter?.name === roleB.name, "fremde Rolle bleibt unverändert");
|
||||
|
||||
// (7c) findMany liefert nie fremde Zeilen.
|
||||
const listA = await dbA.role.findMany({ where: { key: KEY } });
|
||||
ok(listA.length === 1 && listA[0].id === roleA.id, "findMany(key) liefert nur die eigene Rolle");
|
||||
|
||||
console.log("\n— Legitimer Eigenzugriff (Mandant A liest eigene Rolle A) muss funktionieren —");
|
||||
|
||||
// (8) skalarer Key + select: Treffer, und tenantId darf NICHT auftauchen (kein Injektions-Leck).
|
||||
const own1 = await dbA.risk.findUnique({ where: { id: riskA.id }, select: { title: true } });
|
||||
ok(own1?.title === riskA.title, "Eigenzugriff findUnique + select → Treffer");
|
||||
ok(own1 !== null && !("tenantId" in (own1 as object)), "Eigenzugriff select {title} → Rückgabe OHNE tenantId");
|
||||
const own1 = await dbA.role.findUnique({ where: { id: roleA.id }, select: { name: true } });
|
||||
ok(own1?.name === roleA.name, "Eigenzugriff findUnique + select → Treffer");
|
||||
ok(own1 !== null && !("tenantId" in (own1 as object)), "Eigenzugriff select {name} → Rückgabe OHNE tenantId");
|
||||
|
||||
// (9) Compound-Key (eigen) + select: Treffer, injiziertes tenantId wieder entfernt.
|
||||
const own2 = await dbA.risk.findUnique({
|
||||
where: { tenantId_refNo: { tenantId: tenantA.id, refNo: riskA.refNo } },
|
||||
select: { title: true },
|
||||
const own2 = await dbA.role.findUnique({
|
||||
where: { tenantId_key: { tenantId: tenantA.id, key: roleA.key } },
|
||||
select: { name: true },
|
||||
});
|
||||
ok(own2?.title === riskA.title, "Eigenzugriff über Compound-Key + select → Treffer");
|
||||
ok(own2 !== null && !("tenantId" in (own2 as object)), "Compound-Key select {title} → Rückgabe OHNE tenantId (Injektion bereinigt)");
|
||||
ok(own2?.name === roleA.name, "Eigenzugriff über Compound-Key + select → Treffer");
|
||||
ok(own2 !== null && !("tenantId" in (own2 as object)), "Compound-Key select {name} → Rückgabe OHNE tenantId (Injektion bereinigt)");
|
||||
|
||||
// (10) ohne Projektion: voller Datensatz inkl. tenantId (Normalfall).
|
||||
const own3 = await dbA.risk.findUnique({ where: { id: riskA.id } });
|
||||
const own3 = await dbA.role.findUnique({ where: { id: roleA.id } });
|
||||
ok(own3?.tenantId === tenantA.id, "Eigenzugriff ohne Projektion → voller Datensatz inkl. tenantId");
|
||||
|
||||
// (11) findUniqueOrThrow auf eigenen Datensatz → kein Throw.
|
||||
const own4 = await dbA.risk.findUniqueOrThrow({ where: { id: riskA.id }, select: { title: true } });
|
||||
ok(own4.title === riskA.title, "Eigenzugriff findUniqueOrThrow → Treffer");
|
||||
const own4 = await dbA.role.findUniqueOrThrow({ where: { id: roleA.id }, select: { name: true } });
|
||||
ok(own4.name === roleA.name, "Eigenzugriff findUniqueOrThrow → Treffer");
|
||||
|
||||
// (12) create injiziert den eigenen Mandanten.
|
||||
const created = await dbA.role.create({ data: { key: `${KEY}-2`, name: "Neu A", tenantId: tenantB.id } });
|
||||
ok(created.tenantId === tenantA.id, "create über dbForTenant(A) setzt tenantId=A (auch bei fremdem Input)");
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// F-13: Ein `role:manage`-Inhaber (z. B. tenant-admin) konnte über `createRole`/
|
||||
// `updateRolePermissions` eine Rolle mit BELIEBIGEN Katalog-Rechten bauen und sie sich
|
||||
// über `setUserRoles` selbst zuweisen — obwohl der tenant-admin bewusst NICHT über
|
||||
// `policy:approve`/`risk:accept`/`soa:write` verfügt. Der Fix begrenzt vergebbare Rechte
|
||||
// `report:approve`/`work_order:release_billing`/`customer:merge` verfügt. Der Fix begrenzt vergebbare Rechte
|
||||
// auf die eigene effektive Rechtemenge (autoritativ aus der DB) und unterbindet die
|
||||
// Selbstzuweisung höher privilegierter Rollen.
|
||||
//
|
||||
@@ -62,10 +62,10 @@ async function main() {
|
||||
|
||||
const tenant = await prisma.tenant.create({ data: { name: "F13 AuthZ Test", slug: SLUG } });
|
||||
|
||||
// tenant-admin-typische Rechte (bewusst OHNE policy:approve / risk:accept / soa:write).
|
||||
// tenant-admin-typische Rechte (bewusst OHNE report:approve / work_order:release_billing / customer:merge).
|
||||
const adminPermKeys = ["tenant:manage", "user:read", "user:manage", "role:manage", "report:read"];
|
||||
const adminPerms = await Promise.all(adminPermKeys.map(ensurePerm));
|
||||
const escalationTargets = await Promise.all(["policy:approve", "risk:accept", "soa:write"].map(ensurePerm));
|
||||
const escalationTargets = await Promise.all(["report:approve", "work_order:release_billing", "customer:merge"].map(ensurePerm));
|
||||
|
||||
const adminRole = await prisma.role.create({
|
||||
data: {
|
||||
@@ -84,10 +84,10 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
// Höher privilegierte Rolle (ISB-artig), die der Actor sich NICHT selbst geben darf.
|
||||
// Höher privilegierte Rolle (Backoffice-artig), die der Actor sich NICHT selbst geben darf.
|
||||
const isbRole = await prisma.role.create({
|
||||
data: {
|
||||
tenantId: tenant.id, key: "isb", name: "ISB / CISO",
|
||||
tenantId: tenant.id, key: "backoffice", name: "Backoffice",
|
||||
rolePermissions: { create: escalationTargets.map((p) => ({ permissionId: p.id })) },
|
||||
},
|
||||
});
|
||||
@@ -96,29 +96,29 @@ async function main() {
|
||||
|
||||
// (1) Effektive Menge stimmt und enthält bewusst NICHT die kritischen Rechte.
|
||||
ok(effective.has("role:manage"), "(1) Actor hat role:manage");
|
||||
ok(!effective.has("policy:approve") && !effective.has("risk:accept") && !effective.has("soa:write"),
|
||||
"(1) Actor hat bewusst KEIN policy:approve/risk:accept/soa:write");
|
||||
ok(!effective.has("report:approve") && !effective.has("work_order:release_billing") && !effective.has("customer:merge"),
|
||||
"(1) Actor hat bewusst KEIN report:approve/work_order:release_billing/customer:merge");
|
||||
|
||||
// (2) createRole/updateRolePermissions: Delegation ist bewusst ERLAUBT (pragmatische
|
||||
// F-13-Variante) — ein Admin darf Rollen mit Rechten oberhalb seines Niveaus für ANDERE
|
||||
// anlegen (z. B. ISB klonen+bearbeiten). Die Escalation-Abwehr sitzt in der Selbst-
|
||||
// anlegen (z. B. Backoffice klonen+bearbeiten). Die Escalation-Abwehr sitzt in der Selbst-
|
||||
// zuweisung (4). Hier nur festhalten, dass die kritischen Rechte tatsächlich außerhalb
|
||||
// des eigenen Niveaus liegen (sonst wäre der Test bedeutungslos).
|
||||
ok(excessOf(["policy:approve", "risk:accept"], effective).length === 2,
|
||||
"(2) policy:approve/risk:accept liegen außerhalb des Actor-Niveaus (Delegation dennoch erlaubt)");
|
||||
ok(excessOf(["report:approve", "work_order:release_billing"], effective).length === 2,
|
||||
"(2) report:approve/work_order:release_billing liegen außerhalb des Actor-Niveaus (Delegation dennoch erlaubt)");
|
||||
|
||||
// (3) Die für ANDERE delegierbaren Rechte sind nicht künstlich beschnitten.
|
||||
ok(excessOf(["user:read", "report:read"], effective).length === 0,
|
||||
"(3) Vergabe eigener Rechte (user:read/report:read) ist ohnehin zulässig");
|
||||
|
||||
// (4) Selbstzuweisung der höher privilegierten ISB-Rolle → blockiert (Kern-Abwehr).
|
||||
// (4) Selbstzuweisung der höher privilegierten Backoffice-Rolle → blockiert (Kern-Abwehr).
|
||||
const isbPerms = new Set(
|
||||
(await prisma.rolePermission.findMany({ where: { roleId: isbRole.id }, select: { permission: { select: { key: true } } } }))
|
||||
.map((r) => r.permission.key),
|
||||
);
|
||||
const selfAssignExcess = [...isbPerms].filter((p) => !effective.has(p));
|
||||
ok(selfAssignExcess.length > 0,
|
||||
"(4) Selbstzuweisung der ISB-Rolle bringt Rechte oberhalb des eigenen Niveaus → blockiert");
|
||||
"(4) Selbstzuweisung der Backoffice-Rolle bringt Rechte oberhalb des eigenen Niveaus → blockiert");
|
||||
|
||||
// (5) Selbstzuweisung der eigenen (bereits gehaltenen) Admin-Rolle → erlaubt (⊆ effektiv).
|
||||
const adminRolePerms = new Set(
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
// Akzeptanztest des VDA-ISA-Export-Moduls (Story B7-2, C9 §3). Reine Logik.
|
||||
// Lauf: npx tsx scripts/test-vda-isa.ts (Exit 1 bei Fehler).
|
||||
|
||||
import { pruefzielOfControl, sortForExport, toCatalogCsv, kennzahlen, type ExportControl } from "../src/lib/export/vda-isa";
|
||||
|
||||
let failures = 0;
|
||||
const ok = (c: boolean, m: string) => { console.log(`${c ? "✓" : "✗ FEHLER"} ${m}`); if (!c) failures++; };
|
||||
|
||||
const mk = (control: string, reifegrad: number | null, bestaetigt: boolean): ExportControl => ({
|
||||
control, frage: `Frage ${control}`, reifegrad, bestaetigt, umsetzung: "u", belege: "b", offenePunkte: 0, pruefziel: pruefzielOfControl(control),
|
||||
});
|
||||
|
||||
// — Prüfziel-Zuordnung —
|
||||
ok(pruefzielOfControl("4.1.2") === "informationssicherheit", "4.1.2 → Informationssicherheit");
|
||||
ok(pruefzielOfControl("8.1.1") === "prototypenschutz", "8.1.1 → Prototypenschutz");
|
||||
ok(pruefzielOfControl("9.2.1") === "datenschutz", "9.2.1 → Datenschutz");
|
||||
|
||||
// — Sortierung IS → Proto → DS —
|
||||
{
|
||||
const sorted = sortForExport([mk("9.1.1", 2, true), mk("8.1.1", 1, true), mk("1.1.1", 3, true), mk("2.1.1", 2, true)]);
|
||||
ok(sorted.map((r) => r.control).join(",") === "1.1.1,2.1.1,8.1.1,9.1.1", "Reihenfolge IS→Proto→DS, dann numerisch");
|
||||
}
|
||||
|
||||
// — CSV: BOM, Header, Status-Markierung, Semikolon —
|
||||
{
|
||||
const csv = toCatalogCsv([mk("1.1.1", 3, true), mk("1.1.2", null, false)]);
|
||||
ok(csv.charCodeAt(0) === 0xfeff, "CSV beginnt mit UTF-8-BOM");
|
||||
ok(csv.includes("Control-ID;Kontrollfrage/Ziel;Reifegrad;Status"), "Header vorhanden");
|
||||
ok(/1\.1\.1;[^;]*;3;bestätigt/.test(csv), "bestätigtes Control mit Reifegrad 3");
|
||||
ok(/1\.1\.2;[^;]*;na;unbestätigt/.test(csv), "unbestätigt → na + Markierung");
|
||||
}
|
||||
|
||||
// — Kennzahlen: „unbestätigt zählt als 0" —
|
||||
{
|
||||
const k = kennzahlen([mk("1.1.1", 3, true), mk("1.1.2", 3, false), mk("8.1.1", 2, true)]);
|
||||
ok(k.total === 3 && k.bestaetigt === 2, "total 3, bestätigt 2");
|
||||
ok(Math.abs(k.gesamtAvg! - (3 + 0 + 2) / 3) < 1e-9, "Ø gesamt = (3+0+2)/3 (unbestätigt=0)");
|
||||
const is = k.jePruefziel.find((p) => p.pruefziel === "informationssicherheit")!;
|
||||
ok(is.controls === 2 && is.bestaetigt === 1 && Math.abs(is.avg! - 1.5) < 1e-9, "IS: 2 Controls, 1 bestätigt, Ø 1,5");
|
||||
ok(k.jePruefziel.some((p) => p.pruefziel === "prototypenschutz"), "Prototyp-Prüfziel vorhanden");
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? "\nOK — alle VDA-ISA-Export-Tests grün" : `\nPRUEFEN — ${failures} Fehler`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user