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

Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:05:39 +02:00
co-authored by Claude Opus 5
commit c8e6f30a27
720 changed files with 140143 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
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); });
+41
View File
@@ -0,0 +1,41 @@
import "dotenv/config";
import { isBackupQueueEnabled } from "../src/server/backup/queue";
import { startBackupWorker, shutdownBackupWorker } from "../src/server/backup/worker";
/**
* Einstiegspunkt des Backup-/DSGVO-Ops-Workers (`npm run worker:backup`).
*
* Eigener Prozess/Container neben der App (analog Mail-Worker), damit der
* destruktive Portal-Restore, „Export jetzt" und die DSGVO-Zustellung unabhängig
* vom Web-Request-Lebenszyklus laufen (Timeouts/Progress/Audit). Ohne `REDIS_URL`
* gibt es keinen Worker-Betrieb — die Enqueue-Action meldet das und führt NICHTS
* inline aus (Restore ist destruktiv, KONZEPT §4).
*/
async function main() {
if (!isBackupQueueEnabled()) {
console.error(
"[backup-worker] REDIS_URL ist nicht gesetzt. Ohne Redis gibt es keinen Backup-Worker; " +
"Portal-Restore/Export/DSGVO können nicht ausgeführt werden.",
);
process.exit(1);
}
const worker = startBackupWorker();
console.info("[backup-worker] bereit — Queue 'backup-ops' (Restore/Export/DSGVO, seriell).");
let shuttingDown = false;
const stop = async (signal: string) => {
if (shuttingDown) return;
shuttingDown = true;
console.info(`[backup-worker] ${signal} — fahre herunter…`);
await shutdownBackupWorker(worker);
process.exit(0);
};
process.on("SIGTERM", () => void stop("SIGTERM"));
process.on("SIGINT", () => void stop("SIGINT"));
}
main().catch((err) => {
console.error("[backup-worker] Start fehlgeschlagen:", err);
process.exit(1);
});
+83
View File
@@ -0,0 +1,83 @@
import "dotenv/config";
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { hashPassword } from "@/server/password";
import { provisionTenant } from "@/server/provision";
/**
* Erst-Bootstrap für Produktiv (Ersatz für den Demo-Seed, der nur für Test/Dev ist).
* Ohne diesen Schritt gibt es in Prod keinen Zugang.
*
* Legt an:
* 1. Ersten Mandanten + Mandanten-Admin → Login unter /login
* 2. Plattform-Admin (Superadmin, eigener Store) → Login unter /platform/login (MFA
* wird beim ersten Login eingerichtet)
* Beide mit denselben Zugangsdaten (BOOTSTRAP_ADMIN_*), analog zum Seed.
*
* Ausführung: im migrate-Job nach `prisma migrate deploy`, gesteuert per
* BOOTSTRAP_ADMIN=true (siehe docker-compose.coolify.yml, docs/DEPLOY-PROD-CONTABO.md).
*
* Idempotent: provisionTenant und der platformAdmin.upsert nutzen upserts; ein bereits
* gesetztes Passwort wird beim erneuten Lauf NICHT überschrieben.
*
* Erforderliche Umgebungsvariablen:
* BOOTSTRAP_ADMIN_EMAIL, BOOTSTRAP_ADMIN_PASSWORD, BOOTSTRAP_ADMIN_NAME
* BOOTSTRAP_TENANT_NAME, BOOTSTRAP_TENANT_SLUG
* Optional:
* BOOTSTRAP_TENANT_SHORT, BOOTSTRAP_TENANT_SECTOR
*/
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
function req(name: string): string {
const v = process.env[name];
if (!v || v.trim() === "") {
console.error(`✖ Bootstrap abgebrochen: Umgebungsvariable ${name} fehlt.`);
process.exit(1);
}
return v.trim();
}
async function main() {
const email = req("BOOTSTRAP_ADMIN_EMAIL").toLowerCase();
const password = req("BOOTSTRAP_ADMIN_PASSWORD");
const name = req("BOOTSTRAP_ADMIN_NAME");
const tenantName = req("BOOTSTRAP_TENANT_NAME");
const tenantSlug = req("BOOTSTRAP_TENANT_SLUG").toLowerCase();
const short = process.env.BOOTSTRAP_TENANT_SHORT?.trim() || undefined;
const sector = process.env.BOOTSTRAP_TENANT_SECTOR?.trim() || undefined;
// 1. Mandant + Mandanten-Admin (Zugang /login). Idempotent (upserts).
const tenant = await provisionTenant(prisma, {
name: tenantName,
slug: tenantSlug,
short,
sector,
admin: { email, name, password },
});
// 2. Plattform-Admin / Superadmin (Zugang /platform/login, MFA beim ersten Login).
// upsert: Passwort nur bei Neuanlage setzen — bei erneutem Lauf nicht zurücksetzen.
await prisma.platformAdmin.upsert({
where: { email },
update: { name, status: "ACTIVE" },
create: { email, name, passwordHash: await hashPassword(password), status: "ACTIVE" },
});
console.log(
`✔ Bootstrap fertig: Mandant "${tenant.name}" (${tenantSlug}), Mandanten-Admin + Plattform-Admin ${email} angelegt.`
);
console.log(
" → /platform/login = Superadmin (MFA beim ersten Login), /login = Mandanten-Zugang. Passwort nach erstem Login ändern."
);
}
main()
.then(() => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Plan B — Prod-Images auf einem leistungsfähigen amd64-Host bauen und in die
# Registry pushen, damit der Prod-Host (Coolify) nur noch PULLT statt zu bauen.
#
# Voraussetzungen auf dem Build-Host:
# - Docker mit BuildKit, Architektur amd64 (empfohlen: interner Server, der die
# Test-Instanz baut). Auf arm64 (z. B. Mac) läuft es via Emulation, aber langsam.
# - Vorher an der Registry angemeldet: docker login "$REGISTRY_HOST"
#
# Aufruf (im Repo-Root, auf dem gewünschten Commit ausgecheckt):
# REGISTRY=git.certvia.de/msolarczek TAG=$(git rev-parse --short HEAD) ./scripts/build-and-push-images.sh
#
# Env-Variablen:
# REGISTRY Image-Präfix (Default: git.certvia.de/msolarczek)
# TAG Image-Tag (Default: kurzer Git-SHA des aktuellen HEAD)
# PLATFORM Zielplattform (Default: linux/amd64 — passend zum Prod-Host)
# ALSO_MAIN wenn "true": zusätzlich das bewegliche Tag :main setzen/pushen
set -euo pipefail
REGISTRY="${REGISTRY:-git.certvia.de/msolarczek}"
TAG="${TAG:-$(git rev-parse --short HEAD)}"
PLATFORM="${PLATFORM:-linux/amd64}"
ALSO_MAIN="${ALSO_MAIN:-false}"
REGISTRY_HOST="${REGISTRY%%/*}"
echo ">> Registry: $REGISTRY"
echo ">> Tag: $TAG"
echo ">> Plattform: $PLATFORM"
echo ">> Login-Host: $REGISTRY_HOST (vorher: docker login $REGISTRY_HOST)"
echo
export DOCKER_BUILDKIT=1
# target -> Image-Name (siehe docker-compose.coolify.prebuilt.yml)
build_one() {
local target="$1" name="$2"
echo ">> Baue $name (target=$target) ..."
docker build --platform "$PLATFORM" --target "$target" \
-t "$REGISTRY/$name:$TAG" \
$( [ "$ALSO_MAIN" = "true" ] && echo -t "$REGISTRY/$name:main" ) \
.
}
# migrate + runner teilen sich die teuren Stages deps/builder (npm ci + next build).
# Sequentiell auf demselben Host -> zweiter Build nutzt den Layer-Cache des ersten.
build_one runner certvia-app
build_one migrate certvia-migrate
build_one garage certvia-garage
echo
echo ">> Push ..."
for name in certvia-app certvia-migrate certvia-garage; do
docker push "$REGISTRY/$name:$TAG"
[ "$ALSO_MAIN" = "true" ] && docker push "$REGISTRY/$name:main" || true
done
echo
echo ">> Fertig. In Coolify (Prod) setzen: IMAGE_TAG=$TAG (und ggf. REGISTRY=$REGISTRY)"
+63
View File
@@ -0,0 +1,63 @@
// 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)")));
+67
View File
@@ -0,0 +1,67 @@
// 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`);
+145
View File
@@ -0,0 +1,145 @@
/**
* Vollständigkeitscheck der serverseitigen Modul-Durchsetzung (§3.4, Phase-1-Härtung).
*
* Jede mutierende Server-Action eines gegateten Moduls MUSS über einen
* `moduleGuard("<key>")`-Guard laufen (siehe 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"),
* - 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 { 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). */
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.
"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",
"tenant-users.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"));
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;
}
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 });
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.`
);
}
}
}
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.`
);
+35
View File
@@ -0,0 +1,35 @@
// SEC3-d Backfill: bestehende Klartext-TOTP-Secrets (Identity + PlatformAdmin) verschlüsseln.
// Idempotent — bereits verschlüsselte (enc:v1:) werden übersprungen. Optional: der Code
// akzeptiert Alt-Klartext ohnehin weiter; dieses Skript verschlüsselt ihn aktiv.
// Lauf (mit gesetztem DATABASE_URL + AUTH_SECRET): npx tsx scripts/encrypt-mfa-secrets.ts
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { encryptSecret, isEncrypted } from "../src/server/secret-crypto";
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) });
async function main() {
let users = 0;
// Option C: MFA-Secrets leben an der globalen Identity.
for (const u of await prisma.identity.findMany({ where: { mfaSecret: { not: null } }, select: { id: true, mfaSecret: true } })) {
if (u.mfaSecret && !isEncrypted(u.mfaSecret)) {
await prisma.identity.update({ where: { id: u.id }, data: { mfaSecret: encryptSecret(u.mfaSecret) } });
users++;
}
}
let admins = 0;
for (const a of await prisma.platformAdmin.findMany({ where: { mfaSecret: { not: null } }, select: { id: true, mfaSecret: true } })) {
if (a.mfaSecret && !isEncrypted(a.mfaSecret)) {
await prisma.platformAdmin.update({ where: { id: a.id }, data: { mfaSecret: encryptSecret(a.mfaSecret) } });
admins++;
}
}
console.log(`Verschlüsselt: ${users} Nutzer-Secret(s), ${admins} Plattform-Admin-Secret(s).`);
}
main()
.catch((e) => {
console.error("Backfill fehlgeschlagen:", e.message);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+265
View File
@@ -0,0 +1,265 @@
import "dotenv/config";
/**
* IM/Garage-Migration — idempotentes Provisioning eines Single-Node-Garage
* (docs/KONZEPT-garage-migration.md §6 Lane B, §7 Runbook).
*
* Garage verwaltet Buckets/Keys/Rechte NICHT über die S3-API (`CreateBucket` gibt es
* dort nicht), sondern out-of-band. Dieser Init-Job (Compose-Service „garage-provision",
* restart:no, analog „migrate") stellt aus einer leeren Garage reproduzierbar den
* betriebsbereiten Zustand her:
* 1. Layout: dem Node einmalig Zone + Kapazität zuweisen und anwenden (ohne Layout
* lehnt Garage jeden Schreibzugriff mit „no capacity" ab).
* 2. Bucket `S3_BUCKET` (Default isms-documents) anlegen.
* 3. Access-Key deterministisch IMPORTIEREN — aus S3_ACCESS_KEY/S3_SECRET_KEY der
* Coolify-Env, damit App-Env und Garage denselben Schlüssel teilen (kein
* Nachpflegen erzeugter Keys).
* 4. Key→Bucket-Rechte read/write setzen.
* 5. Optional denselben Ablauf für einen separaten Backup-Bucket (BACKUP_S3_BUCKET),
* falls der Backup-Store auf S3 statt BACKUP_LOCAL_DIR läuft.
*
* WARUM Admin-API (HTTP) statt `garage`-CLI: Der Job läuft im schlanken „migrate"-Image
* (Node/tsx) — kein Garage-Binary, kein geteiltes meta-Volume, kein Node-Key nötig. Die
* Admin-API v1 ist gegen die gepinnte Garage-Version (v1.2.0) stabil und liefert klare
* Statuscodes (409 = „existiert bereits" → als Erfolg gewertet). Alle Schritte sind
* idempotent: bei JEDEM Deploy lauffähig, „already exists" ist kein Fehler.
*
* Fail-/No-op-Verhalten (Deployment-Falle: ein Init-Container darf den Stack nicht
* reißen): Ist `GARAGE_ADMIN_TOKEN` NICHT gesetzt, gilt Garage als nicht in Betrieb
* (z. B. lokaler/Stub-Betrieb) → sauberer No-op (Exit 0). Ist der Token gesetzt, aber
* Pflicht-Config (S3-Key) fehlt oder die Garage ist nicht erreichbar → klare Meldung +
* Exit ≠ 0. Es werden KEINE Secrets geloggt (Access-Key-ID ist der öffentliche Teil).
*/
interface Cfg {
adminUrl: string;
adminToken: string;
accessKeyId: string;
secretKey: string;
bucket: string;
backupBucket: string | null;
zone: string;
capacityBytes: number;
}
function log(msg: string): void {
// stdout mit Zeitstempel; Coolify erfasst die Logs. Bei „Container weg ohne Logs"
// zusätzlich in eine Datei spiegeln (GARAGE_PROVISION_LOG), siehe DEPLOY-COOLIFY.
const line = `[garage-provision] ${msg}`;
console.log(line);
}
function readCfg(): Cfg | null {
const adminToken = process.env.GARAGE_ADMIN_TOKEN?.trim();
if (!adminToken) {
// Garage nicht in Betrieb → No-op (siehe Datei-Doc, Deployment-Falle).
log("GARAGE_ADMIN_TOKEN nicht gesetzt — Garage-Provisioning übersprungen (No-op).");
return null;
}
const accessKeyId = process.env.S3_ACCESS_KEY?.trim();
const secretKey = process.env.S3_SECRET_KEY?.trim();
const bucket = process.env.S3_BUCKET?.trim() || "isms-documents";
if (!accessKeyId || !secretKey) {
throw new Error(
"GARAGE_ADMIN_TOKEN ist gesetzt, aber S3_ACCESS_KEY/S3_SECRET_KEY fehlen. " +
"Beide müssen den zu importierenden Garage-Key definieren (App-Env == Garage).",
);
}
// Garage erzwingt beim Key-Import ein festes Format (sonst HTTP 400). Früh & klar
// prüfen statt kryptisch beim Import scheitern:
// Access-Key-ID = "GK" + 24 Hex · Secret = 64 Hex.
// S3_ACCESS_KEY: echo "GK$(openssl rand -hex 12)"
// S3_SECRET_KEY: openssl rand -hex 32
if (!/^GK[0-9a-f]{24}$/.test(accessKeyId)) {
throw new Error(
`S3_ACCESS_KEY hat nicht das von Garage geforderte Format „GK" + 24 Hex-Zeichen ` +
`(erhalten: „${accessKeyId}"). Erzeugen: echo "GK$(openssl rand -hex 12)".`,
);
}
if (!/^[0-9a-f]{64}$/i.test(secretKey)) {
throw new Error("S3_SECRET_KEY muss 64 Hex-Zeichen sein (openssl rand -hex 32).");
}
const capacityBytes = Number(process.env.GARAGE_CAPACITY_BYTES ?? "100000000000"); // 100 GB nominal
if (!Number.isFinite(capacityBytes) || capacityBytes <= 0) {
throw new Error(`GARAGE_CAPACITY_BYTES ungültig: ${process.env.GARAGE_CAPACITY_BYTES}`);
}
return {
adminUrl: (process.env.GARAGE_ADMIN_URL?.trim() || "http://garage:3903").replace(/\/+$/, ""),
adminToken,
accessKeyId,
secretKey,
bucket,
backupBucket: process.env.BACKUP_S3_BUCKET?.trim() || null,
zone: process.env.GARAGE_ZONE?.trim() || "dc1",
capacityBytes,
};
}
/** Admin-API-Aufruf. `expectMissing` erlaubt 404 (Existenzprüfung) ohne Wurf. */
async function admin(
cfg: Cfg,
method: string,
path: string,
body?: unknown,
): Promise<{ status: number; json: unknown }> {
const res = await fetch(`${cfg.adminUrl}${path}`, {
method,
headers: {
Authorization: `Bearer ${cfg.adminToken}`,
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
let json: unknown = null;
const text = await res.text();
if (text) {
try {
json = JSON.parse(text);
} catch {
json = text;
}
}
return { status: res.status, json };
}
/** Wartet, bis die Admin-API /health mit 200 antwortet (Garage-Start abwarten). */
async function waitForHealth(cfg: Cfg, timeoutMs = 90_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastErr = "";
while (Date.now() < deadline) {
try {
const res = await fetch(`${cfg.adminUrl}/health`);
if (res.status === 200) {
log("Garage Admin-API erreichbar (/health 200).");
return;
}
lastErr = `HTTP ${res.status}`;
} catch (err) {
lastErr = err instanceof Error ? err.message : String(err);
}
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error(`Garage Admin-API nicht erreichbar (${cfg.adminUrl}/health): ${lastErr}`);
}
/** Layout einmalig sicherstellen: Node bekommt Zone + Kapazität (nur falls noch ohne Rolle). */
async function ensureLayout(cfg: Cfg): Promise<void> {
const status = await admin(cfg, "GET", "/v1/status");
if (status.status !== 200) throw new Error(`GET /v1/status fehlgeschlagen (HTTP ${status.status}).`);
const nodeId = (status.json as { node?: string })?.node;
if (!nodeId) throw new Error("Konnte die Node-ID nicht aus /v1/status lesen.");
const layout = await admin(cfg, "GET", "/v1/layout");
if (layout.status !== 200) throw new Error(`GET /v1/layout fehlgeschlagen (HTTP ${layout.status}).`);
const l = layout.json as { version: number; roles: { id: string }[] };
if (l.roles.some((r) => r.id === nodeId)) {
log(`Layout bereits gesetzt (Node hat eine Rolle, Version ${l.version}) — übersprungen.`);
return;
}
log(`Layout wird zugewiesen: Zone „${cfg.zone}", Kapazität ${cfg.capacityBytes} Bytes.`);
const stage = await admin(cfg, "POST", "/v1/layout", [
{ id: nodeId, zone: cfg.zone, capacity: cfg.capacityBytes, tags: [] },
]);
if (stage.status !== 200) throw new Error(`Layout-Staging fehlgeschlagen (HTTP ${stage.status}).`);
const apply = await admin(cfg, "POST", "/v1/layout/apply", { version: l.version + 1 });
if (apply.status !== 200) throw new Error(`Layout-Apply fehlgeschlagen (HTTP ${apply.status}).`);
log(`Layout angewendet (Version ${l.version + 1}).`);
}
/** Bucket sicherstellen (idempotent). Gibt die Bucket-ID zurück. */
async function ensureBucket(cfg: Cfg, alias: string): Promise<string> {
const existing = await admin(cfg, "GET", `/v1/bucket?globalAlias=${encodeURIComponent(alias)}`);
if (existing.status === 200) {
log(`Bucket „${alias}" existiert bereits.`);
return (existing.json as { id: string }).id;
}
if (existing.status !== 404) {
throw new Error(`GET /v1/bucket (${alias}) unerwartet (HTTP ${existing.status}).`);
}
const created = await admin(cfg, "POST", "/v1/bucket", { globalAlias: alias });
if (created.status === 200) {
log(`Bucket „${alias}" angelegt.`);
return (created.json as { id: string }).id;
}
if (created.status === 409) {
// Rennen: parallel angelegt → erneut lesen.
const again = await admin(cfg, "GET", `/v1/bucket?globalAlias=${encodeURIComponent(alias)}`);
if (again.status === 200) {
log(`Bucket „${alias}" existierte bereits (409, Race).`);
return (again.json as { id: string }).id;
}
}
throw new Error(`Bucket „${alias}" konnte nicht angelegt werden (HTTP ${created.status}).`);
}
/** Access-Key deterministisch importieren (idempotent). */
async function ensureKey(cfg: Cfg): Promise<void> {
const existing = await admin(
cfg,
"GET",
`/v1/key?id=${encodeURIComponent(cfg.accessKeyId)}&showSecretKey=false`,
);
if (existing.status === 200) {
log(`Access-Key ${cfg.accessKeyId} existiert bereits — Import übersprungen.`);
return;
}
if (existing.status !== 404) {
throw new Error(`GET /v1/key unerwartet (HTTP ${existing.status}).`);
}
const imported = await admin(cfg, "POST", "/v1/key/import", {
accessKeyId: cfg.accessKeyId,
secretAccessKey: cfg.secretKey,
name: "isms-app",
});
if (imported.status === 200) {
log(`Access-Key ${cfg.accessKeyId} importiert.`);
return;
}
if (imported.status === 409) {
log(`Access-Key ${cfg.accessKeyId} existierte bereits (409).`);
return;
}
throw new Error(
`Access-Key-Import fehlgeschlagen (HTTP ${imported.status}). ` +
`Hinweis: Ist der Key mit anderem Secret bereits vorhanden, zuerst löschen und neu importieren.`,
);
}
/** Key→Bucket-Rechte read/write setzen (idempotent — Garage bestätigt erneut). */
async function allow(cfg: Cfg, bucketId: string): Promise<void> {
const res = await admin(cfg, "POST", "/v1/bucket/allow", {
bucketId,
accessKeyId: cfg.accessKeyId,
permissions: { read: true, write: true, owner: false },
});
if (res.status !== 200) {
throw new Error(`bucket/allow fehlgeschlagen (HTTP ${res.status}).`);
}
log(`Rechte read/write für ${cfg.accessKeyId} auf Bucket ${bucketId} gesetzt.`);
}
async function main() {
const cfg = readCfg();
if (!cfg) return; // No-op (kein Admin-Token)
log(`Ziel: ${cfg.adminUrl} · Bucket „${cfg.bucket}"${cfg.backupBucket ? ` + Backup „${cfg.backupBucket}"` : ""}.`);
await waitForHealth(cfg);
await ensureLayout(cfg);
const bucketId = await ensureBucket(cfg, cfg.bucket);
await ensureKey(cfg);
await allow(cfg, bucketId);
if (cfg.backupBucket && cfg.backupBucket !== cfg.bucket) {
const backupId = await ensureBucket(cfg, cfg.backupBucket);
await allow(cfg, backupId);
}
log("Provisioning abgeschlossen — Garage ist betriebsbereit.");
}
main().catch((err) => {
console.error(`[garage-provision] FEHLER: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
+15
View File
@@ -0,0 +1,15 @@
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());
+15
View File
@@ -0,0 +1,15 @@
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());
+205
View File
@@ -0,0 +1,205 @@
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);
});
+56
View File
@@ -0,0 +1,56 @@
import "dotenv/config";
import { getMailConfig } from "../src/server/mail/config";
import { isQueueEnabled } from "../src/server/mail/queue";
import {
scheduleDueReminders,
shutdownWorkers,
startMailWorker,
startReminderWorker,
} from "../src/server/mail/worker";
/**
* SEC1 — Einstiegspunkt des Mail-Workers (`npm run worker:mail`).
*
* Betriebsmodus: eigener Prozess/Container neben der App (Coolify), damit
* Zustellung, Retry und der tägliche Fristen-Job unabhängig vom Web-Request-
* Lebenszyklus laufen. Ohne `REDIS_URL` gibt es keinen Worker-Betrieb — die App
* versendet dann inline (siehe src/server/mail/queue.ts).
*/
async function main() {
if (!isQueueEnabled()) {
console.error(
"[mail-worker] REDIS_URL ist nicht gesetzt. Ohne Redis läuft der Versand inline in der App; ein Worker wird nicht benötigt.",
);
process.exit(1);
}
const { config, reason } = getMailConfig();
if (!config) {
// Kein harter Abbruch: der Worker läuft weiter und meldet den Zustand — so
// ist nach dem Nachreichen der Secrets kein Neustart-Rennen nötig.
console.warn(`[mail-worker] ${reason}`);
} else {
console.info(`[mail-worker] SMTP ${config.host}:${config.port} (secure=${config.secure})`);
}
const mailWorker = startMailWorker();
const reminderWorker = startReminderWorker();
await scheduleDueReminders();
console.info("[mail-worker] bereit — Queue 'mail' + Fristen-Job (täglich 07:00 Europe/Berlin).");
let shuttingDown = false;
const stop = async (signal: string) => {
if (shuttingDown) return;
shuttingDown = true;
console.info(`[mail-worker] ${signal} — fahre herunter…`);
await shutdownWorkers([mailWorker, reminderWorker]);
process.exit(0);
};
process.on("SIGTERM", () => void stop("SIGTERM"));
process.on("SIGINT", () => void stop("SIGINT"));
}
main().catch((err) => {
console.error("[mail-worker] Start fehlgeschlagen:", err);
process.exit(1);
});
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
// 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());
+79
View File
@@ -0,0 +1,79 @@
// Rollen-Rechte-Sync (Wartung): gleicht die Rollen→Rechte-Zuordnung ALLER bestehenden
// Mandanten mit dem aktuellen Rechtekatalog (ROLE_DEFS) ab. Nötig, weil Rechte bei der
// Provisionierung in die DB geschrieben werden — neu hinzugekommene Rechte (z. B.
// `onboarding:use`, `validate_objects`) fehlen bestehenden Mandanten sonst, bis dieses
// Skript läuft. Rein additiv (entfernt keine bestehenden Zuordnungen).
//
// Lauf (lokal): npx tsx scripts/sync-role-permissions.ts
// Lauf (Deploy): im Container/Coolify mit gesetzter DATABASE_URL ausführen.
// WICHTIG: Betroffene Nutzer müssen sich danach neu einloggen (Rechte werden im JWT
// beim Login aufgelöst).
import "dotenv/config"; // lädt DATABASE_URL aus .env beim lokalen `npx tsx`-Lauf
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { PERMISSIONS, ROLE_DEFS } from "../src/server/rbac";
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) });
async function main() {
// 1. Globalen Rechtekatalog sicherstellen.
for (const key of PERMISSIONS) {
await prisma.permission.upsert({ where: { key }, update: {}, create: { key } });
}
const permByKey = new Map((await prisma.permission.findMany()).map((p) => [p.key, p.id]));
// 2. Fehlende ROLE_DEFS-Rollen je Mandant anlegen (z. B. neue Rolle „pm").
const tenants = await prisma.tenant.findMany({ select: { id: true } });
let createdRoles = 0;
for (const tenant of tenants) {
for (const [key, def] of Object.entries(ROLE_DEFS)) {
const existing = await prisma.role.findUnique({ where: { tenantId_key: { tenantId: tenant.id, key } } });
if (!existing) {
await prisma.role.create({ data: { tenantId: tenant.id, key, name: def.name } });
createdRoles++;
console.log(` ${tenant.id}: Rolle „${key}" angelegt`);
}
}
}
// 3. Alle Rollen (mandantenübergreifend) gegen ROLE_DEFS abgleichen (Rechte + Name).
const roles = await prisma.role.findMany({ include: { rolePermissions: true } });
let addedTotal = 0;
let touchedRoles = 0;
for (const role of roles) {
const def = ROLE_DEFS[role.key as keyof typeof ROLE_DEFS];
if (!def) continue; // unbekannte/manuelle Rolle unangetastet lassen
const have = new Set(role.rolePermissions.map((rp) => rp.permissionId));
const missing = def.permissions.filter((k) => {
const id = permByKey.get(k);
return id && !have.has(id);
});
if (missing.length === 0) continue;
for (const key of missing) {
const permissionId = permByKey.get(key)!;
await prisma.rolePermission.upsert({
where: { roleId_permissionId: { roleId: role.id, permissionId } },
update: {},
create: { roleId: role.id, permissionId },
});
}
// Rollenname ebenfalls aktualisieren (falls in ROLE_DEFS geändert).
if (role.name !== def.name) await prisma.role.update({ where: { id: role.id }, data: { name: def.name } });
addedTotal += missing.length;
touchedRoles++;
console.log(` ${role.tenantId}/${role.key}: +${missing.length} Recht(e) [${missing.join(", ")}]`);
}
console.log(`\nFertig: ${createdRoles} Rolle(n) neu angelegt, ${addedTotal} Zuordnung(en) über ${touchedRoles} Rolle(n) ergänzt. Betroffene Nutzer bitte neu einloggen.`);
}
main()
.catch((e) => {
console.error("Sync fehlgeschlagen:", e.message);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+99
View File
@@ -0,0 +1,99 @@
// Test der Error-Boundary `withActionErrors` (Sicherheitsbefund F-16).
//
// Prüft:
// 1. Bei `ForbiddenError` wirft die Boundary nach außen die GENERISCHE Meldung
// ("Vorgang nicht möglich.") — kein Leak interner Details ("Fehlende
// Berechtigung: …").
// 2. Intern wird der denied-Pfad genommen: ein Audit-Eintrag `action:"denied"`
// landet für Mandant + Entität in der DB.
// 3. Ein generischer Fehler wird ebenfalls generisch gekappt, aber NICHT
// auditiert (keine Zugriffsverweigerung).
// 4. Der Erfolgsfall reicht den Rückgabewert unverändert durch.
//
// Lauf: npx tsx scripts/test-action-error.ts
// Nutzt die lokale Postgres-DB (wie test-tenant-isolation.ts); .env liegt im Worktree.
import "dotenv/config";
import { prisma } from "../src/server/db";
import { withActionErrors, GENERIC_ACTION_ERROR } from "../src/server/action-error";
import { ForbiddenError, type Permission } from "../src/server/rbac";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
const SLUG = "zz-actionerr-test";
const ENTITY = "risk_ae_test";
async function cleanup() {
const tenant = await prisma.tenant.findFirst({
where: { slug: SLUG },
select: { id: true },
});
if (tenant) {
await prisma.auditLog.deleteMany({ where: { tenantId: tenant.id } });
await prisma.tenant.deleteMany({ where: { id: tenant.id } });
}
}
async function main() {
await cleanup();
const tenant = await prisma.tenant.create({
data: { name: "AE-Test", slug: SLUG },
});
const ctx = { tenantId: tenant.id, actorId: undefined, entity: ENTITY };
// (1) + (2) ForbiddenError → generische Meldung + denied-Audit.
let outward = "";
try {
await withActionErrors(async () => {
throw new ForbiddenError("risk:write" as Permission);
}, ctx);
} catch (e) {
outward = e instanceof Error ? e.message : String(e);
}
ok(outward === GENERIC_ACTION_ERROR, `ForbiddenError → nach außen "${GENERIC_ACTION_ERROR}"`);
ok(
!/Fehlende Berechtigung/.test(outward),
"ForbiddenError → interne Meldung leakt NICHT nach außen",
);
const deniedRows = await prisma.auditLog.findMany({
where: { tenantId: tenant.id, entity: ENTITY, action: "denied" },
});
ok(deniedRows.length === 1, `denied-Audit geschrieben (gefunden: ${deniedRows.length})`);
// (3) Generischer Fehler → generisch gekappt, KEIN zusätzliches Audit.
let outward2 = "";
try {
await withActionErrors(async () => {
throw new Error("interne DB-Details XYZ");
}, ctx);
} catch (e) {
outward2 = e instanceof Error ? e.message : String(e);
}
ok(outward2 === GENERIC_ACTION_ERROR, "generischer Fehler → generisch gekappt");
ok(!/XYZ/.test(outward2), "generischer Fehler → interne Details leaken NICHT");
const afterRows = await prisma.auditLog.count({
where: { tenantId: tenant.id, entity: ENTITY, action: "denied" },
});
ok(afterRows === 1, `kein zusätzliches denied-Audit für generischen Fehler (weiterhin ${afterRows})`);
// (4) Erfolgsfall reicht Rückgabewert durch.
const result = await withActionErrors(async () => 42, ctx);
ok(result === 42, "Erfolgsfall → Rückgabewert unverändert durchgereicht");
await cleanup();
console.log(failures === 0 ? "\nOK" : `\nFEHLGESCHLAGEN (${failures})`);
await prisma.$disconnect();
process.exit(failures === 0 ? 0 : 1);
}
main().catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+132
View File
@@ -0,0 +1,132 @@
// Nachweis der autoritativen Rechte-/Statusprüfung im moduleGuard (Sicherheitsbefund F-06).
//
// Der moduleGuard prüft Kontostatus, Passwortzwang und effektive Rechte NICHT mehr
// aus dem JWT, sondern autoritativ aus der Datenbank (src/server/action-guard.ts).
// Dieser Test repliziert die exakte autoritative Query und weist nach, dass die
// Guard-Entscheidung korrekt kippt, sobald sich der DB-Zustand ändert — ohne dass
// ein neues Login (Token) nötig wäre. (Ein direkter moduleGuard-Aufruf würde eine
// NextAuth-Session voraussetzen; die sicherheitsrelevante Logik ist die Query.)
//
// Lauf: npx tsx scripts/test-action-guard-authz.ts
// Nutzt die lokale Postgres-DB (Container isms-tool-postgres-1); .env liegt im Worktree.
import "dotenv/config";
import { prisma } from "../src/server/db";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
const SLUG = "zz-f06-authz-test";
const EMAIL = "f06-user@zz-authz.test";
const PERM = "asset:write"; // existiert im globalen Permissionskatalog
/**
* Repliziert die autoritative Prüfung aus moduleGuard (Option C): Membership-Status +
* effektive Rechte kommen aus `User`, Passwortzwang + globaler Status aus der `Identity`.
* Liefert `null`, wenn Mitgliedschaft ODER Identity nicht (mehr) aktiv ist.
*/
async function authorize(userId: string, identityId: string): Promise<{ mustChangePassword: boolean; perms: Set<string> } | null> {
const account = await prisma.user.findFirst({
where: { id: userId, status: "ACTIVE" },
select: {
userRoles: {
select: {
role: {
select: {
rolePermissions: { select: { permission: { select: { key: true } } } },
},
},
},
},
},
});
const identity = await prisma.identity.findUnique({
where: { id: identityId },
select: { status: true, mustChangePassword: true },
});
if (!account || !identity || identity.status !== "ACTIVE") return null;
return {
mustChangePassword: identity.mustChangePassword,
perms: new Set(account.userRoles.flatMap((ur) => ur.role.rolePermissions.map((rp) => rp.permission.key))),
};
}
async function cleanup() {
const t = await prisma.tenant.findUnique({ where: { slug: SLUG } });
if (!t) return;
await prisma.user.deleteMany({ where: { tenantId: t.id } });
await prisma.role.deleteMany({ where: { tenantId: t.id } });
await prisma.tenant.delete({ where: { id: t.id } });
// Verwaiste Test-Identity(s) entfernen (Membership wurde eben gelöscht).
await prisma.identity.deleteMany({ where: { email: EMAIL, memberships: { none: {} } } });
}
async function main() {
await cleanup();
const tenant = await prisma.tenant.create({ data: { name: "F06 AuthZ Test", slug: SLUG } });
const perm = await prisma.permission.findFirstOrThrow({ where: { key: PERM } });
const role = await prisma.role.create({
data: {
tenantId: tenant.id,
key: "f06-role",
name: "F06 Rolle",
rolePermissions: { create: [{ permissionId: perm.id }] },
},
});
// Option C: Mitgliedschaft braucht eine globale Identity (Anmeldung).
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: "F06 User",
status: "ACTIVE",
userRoles: { create: [{ roleId: role.id }] },
},
});
// (1) Aktives Konto mit Recht → Guard ließe die Mutation zu.
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");
// (2) Membership deaktiviert → Query liefert null → Guard wirft „Konto ist nicht aktiv".
await prisma.user.update({ where: { id: user.id }, data: { status: "DEACTIVATED" } });
const a2 = await authorize(user.id, identity.id);
ok(a2 === null, "(2) deaktivierte Mitgliedschaft → null (Mutation sofort geblockt)");
// (2b) Membership aktiv, aber IDENTITY deaktiviert → ebenfalls null (globale Sperre).
await prisma.user.update({ where: { id: user.id }, data: { status: "ACTIVE" } });
await prisma.identity.update({ where: { id: identity.id }, data: { status: "DISABLED" } });
ok((await authorize(user.id, identity.id)) === null, "(2b) deaktivierte Identity → null (globale Sperre wirkt)");
await prisma.identity.update({ where: { id: identity.id }, data: { status: "ACTIVE" } });
// (3) Recht entzogen → Recht fehlt → Guard wirft ForbiddenError.
await prisma.rolePermission.delete({ where: { roleId_permissionId: { roleId: role.id, permissionId: perm.id } } });
const a3 = await authorize(user.id, identity.id);
ok(a3 !== null, "(3) Konto aktiv");
ok(a3?.perms.has(PERM) === false, `(3) entzogenes Recht ${PERM} sofort weg (kein Warten auf neues Login)`);
// (4) Passwortzwang an der Identity → Guard wirft „Passwortwechsel erforderlich".
await prisma.identity.update({ where: { id: identity.id }, data: { mustChangePassword: true } });
const a4 = await authorize(user.id, identity.id);
ok(a4?.mustChangePassword === true, "(4) mustChangePassword (Identity) wird autoritativ erkannt");
await cleanup();
if (failures === 0) console.log("\nOK — alle F-06-Nachweise (1)-(4) erfüllt.");
else console.log(`\n${failures} FEHLER.`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch(async (e) => {
console.error(e);
await cleanup().catch(() => {});
process.exit(1);
});
+60
View File
@@ -0,0 +1,60 @@
// 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());
+170
View File
@@ -0,0 +1,170 @@
import "dotenv/config";
import { prisma } from "../src/server/db";
import { consumeToken, issueToken, peekToken, purgeExpiredTokens } from "../src/server/auth-token";
import { isTokenStillValid } from "../src/server/sessions";
import { checkRateLimit, RATE_LIMITS, resetRateLimits } from "../src/server/rate-limit";
import { findAccountByEmail } from "../src/server/auth-selfservice";
import { closeQueues } from "../src/server/mail/queue";
import { closeMailProvider } from "../src/server/mail/provider-smtp";
/**
* SEC2 — Abnahmetest der Sicherheitseigenschaften
* (`npx tsx scripts/test-auth-selfservice.ts`).
*
* Geprüft wird, was sich ohne Browser prüfen lässt — die eigentlichen
* Sicherheitsgarantien:
* 1. Token: nur der Hash liegt in der DB, nie das Rohtoken.
* 2. Single-use: zweite Einlösung schlägt fehl.
* 3. Ablauf: abgelaufene Tokens werden abgewiesen.
* 4. Neuanforderung entwertet den vorherigen Link.
* 5. Manipuliertes/unbekanntes Token wird abgewiesen.
* 6. Typ-Verwechslung (email_change als password_reset) wird abgewiesen.
* 7. Rate-Limit greift je IP und je Konto.
* 8. Session-Invalidierung: älteres JWT ungültig, neueres gültig.
* 9. Enumeration: unbekannte Adresse liefert kein Konto.
*
* Die Browser-Abläufe (Reset-Mail → Link → neues Passwort) stehen im Testplan
* von docs/SEC2-AUTH-SELFSERVICE.md.
*/
let failures = 0;
function check(name: string, ok: boolean, detail?: string) {
if (ok) console.log(` ✓ ${name}`);
else {
failures++;
console.error(` ✗ ${name}${detail ? ` — ${detail}` : ""}`);
}
}
const PRINCIPAL = "sec2-test-principal";
async function cleanup() {
await prisma.authToken.deleteMany({ where: { principalId: { startsWith: PRINCIPAL } } });
}
async function main() {
await cleanup();
resetRateLimits();
console.log("1) Token wird nur gehasht gespeichert");
const t1 = await issueToken({
principalType: "tenant_user",
principalId: `${PRINCIPAL}-1`,
tenantId: null,
type: "password_reset",
});
const rows = await prisma.authToken.findMany({ where: { principalId: `${PRINCIPAL}-1` } });
check("genau ein Token angelegt", rows.length === 1);
check("Rohtoken steht NICHT in der DB", !rows.some((r) => r.tokenHash === t1.raw));
check("Hash ist 64 Hex-Zeichen (SHA-256)", /^[0-9a-f]{64}$/.test(rows[0]?.tokenHash ?? ""));
check("Rohtoken ist ausreichend lang", t1.raw.length >= 40, `${t1.raw.length} Zeichen`);
console.log("2) Single-use");
const first = await consumeToken(t1.raw, "password_reset");
check("erste Einlösung erfolgreich", first != null);
const second = await consumeToken(t1.raw, "password_reset");
check("zweite Einlösung abgewiesen", second === null);
check("peek nach Verbrauch liefert null", (await peekToken(t1.raw, "password_reset")) === null);
console.log("3) Ablauf");
const t2 = await issueToken({
principalType: "tenant_user",
principalId: `${PRINCIPAL}-2`,
type: "password_reset",
});
await prisma.authToken.updateMany({
where: { principalId: `${PRINCIPAL}-2` },
data: { expiresAt: new Date(Date.now() - 1000) },
});
check("abgelaufenes Token abgewiesen", (await peekToken(t2.raw, "password_reset")) === null);
console.log("4) Neuanforderung entwertet den vorherigen Link");
const t3a = await issueToken({
principalType: "tenant_user",
principalId: `${PRINCIPAL}-3`,
type: "password_reset",
});
const t3b = await issueToken({
principalType: "tenant_user",
principalId: `${PRINCIPAL}-3`,
type: "password_reset",
});
check("alter Link ungültig", (await peekToken(t3a.raw, "password_reset")) === null);
check("neuer Link gültig", (await peekToken(t3b.raw, "password_reset")) != null);
console.log("5) Unbekanntes/manipuliertes Token");
check("Zufallswert abgewiesen", (await peekToken("nicht-existent-xyz", "password_reset")) === null);
check("leeres Token abgewiesen", (await peekToken("", "password_reset")) === null);
check(
"manipuliertes Token abgewiesen",
(await peekToken(t3b.raw.slice(0, -2) + "AA", "password_reset")) === null,
);
console.log("6) Typ-Verwechslung");
const t4 = await issueToken({
principalType: "tenant_user",
principalId: `${PRINCIPAL}-4`,
type: "email_change",
newEmail: "neu@example.test",
});
check("email_change nicht als password_reset einlösbar", (await peekToken(t4.raw, "password_reset")) === null);
check("email_change als email_change gültig", (await peekToken(t4.raw, "email_change")) != null);
console.log("7) Rate-Limit");
resetRateLimits();
const rule = RATE_LIMITS.passwordResetRequest;
let blockedAt = -1;
for (let i = 1; i <= rule.limit + 2; i++) {
const r = checkRateLimit("passwordResetRequest", { ip: "203.0.113.9", account: "a@example.test" });
if (!r.allowed && blockedAt < 0) blockedAt = i;
}
check(`greift nach ${rule.limit} Versuchen`, blockedAt === rule.limit + 1, `blockiert ab ${blockedAt}`);
const otherIp = checkRateLimit("passwordResetRequest", { ip: "198.51.100.4", account: "b@example.test" });
check("anderes Konto/IP unberührt", otherIp.allowed);
const sameAccountOtherIp = checkRateLimit("passwordResetRequest", {
ip: "198.51.100.5",
account: "a@example.test",
});
check("Kontozähler greift auch von anderer IP", !sameAccountOtherIp.allowed);
console.log("8) Session-Invalidierung");
const mark = new Date("2026-07-30T12:00:00Z");
const before = Math.floor(mark.getTime() / 1000) - 10;
const after = Math.floor(mark.getTime() / 1000) + 10;
check("älteres JWT ungültig", !isTokenStillValid(before, mark));
check("neueres JWT gültig", isTokenStillValid(after, mark));
check("ohne Marke immer gültig", isTokenStillValid(before, null));
check("ohne iat fail-closed", !isTokenStillValid(undefined, mark));
console.log("9) Enumeration");
check(
"unbekannte Adresse liefert kein Konto",
(await findAccountByEmail("tenant", `nichtda-${Date.now()}@example.test`)) === null,
);
check(
"unbekannter Plattform-Admin liefert kein Konto",
(await findAccountByEmail("platform", `nichtda-${Date.now()}@example.test`)) === null,
);
console.log("10) Aufräumen abgelaufener Tokens");
const purged = await purgeExpiredTokens();
check("purge läuft durch", purged >= 0, `${purged} entfernt`);
await cleanup();
await prisma.$disconnect();
await closeMailProvider();
await closeQueues();
if (failures > 0) {
console.error(`\n✗ ${failures} Prüfung(en) fehlgeschlagen.`);
process.exit(1);
}
console.log("\n✓ SEC2-Self-Service: alle Prüfungen bestanden.");
process.exit(0);
}
main().catch(async (err) => {
console.error(err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});
+29
View File
@@ -0,0 +1,29 @@
// Direkt-Download-Pfad: exportTenant(persist:false) liefert das verschlüsselte
// Artefakt als Buffer OHNE Store-Roundtrip (Grundlage der /api/platform/backup/download-Route).
// Lauf: npx tsx scripts/test-backup-download.ts (nutzt lokale isms-DB)
import "dotenv/config";
import { prisma } from "../src/server/db";
import { exportTenant } from "../src/server/backup/export";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
async function main() {
const demo = await prisma.tenant.findFirst({ where: { slug: "demo" }, select: { id: true } });
if (!demo) throw new Error("Mandant 'demo' fehlt — bitte seeden.");
const res = await exportTenant(demo.id, { persist: false });
ok(res.artifact.length > 1000, `Artefakt hat Bytes (${res.artifact.length})`);
ok(res.artifact.subarray(0, 4).toString("utf8") === "CVB1", `verschlüsselter Kopf „CVB1" (hex ${res.artifact.subarray(0, 4).toString("hex")})`);
ok(res.artifactKey === null, "persist:false → NICHT im Store abgelegt (artifactKey === null)");
ok((res.manifest.totalRows ?? 0) > 0, `Manifest: Zeilen > 0 (${res.manifest.totalRows})`);
ok(res.snapshotId.length > 0, `snapshotId für den Dateinamen vorhanden (${res.snapshotId})`);
await prisma.$disconnect();
console.log(failures === 0 ? "\nAlle grün." : `\n${failures} FEHLER`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((e) => { console.error(e); process.exit(1); });
+118
View File
@@ -0,0 +1,118 @@
// DSGVO-Grundgerüst: Auskunft (Art. 15/20), Löschung/Anonymisierung (Art. 17)
// und der restore-feste Tombstone (KONZEPT §6). Arbeitet auf einem WEGWERF-
// Mandanten (Slug zz-dsgvo-*) — demo/demo2 bleiben unberührt.
//
// Lauf: npx tsx scripts/test-backup-dsgvo.ts
import "dotenv/config";
import { prisma } from "../src/server/db";
import { exportTenant } from "../src/server/backup/export";
import { restoreTenant } from "../src/server/backup/restore";
import { exportSubject } from "../src/server/dsgvo/export";
import { deleteSubject } from "../src/server/dsgvo/deletion";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
const SLUG = "zz-dsgvo-test";
const EMAIL = "zz-dsgvo-person@example.test";
async function cleanup() {
const t = await prisma.tenant.findUnique({ where: { slug: SLUG }, select: { id: true } });
const identityIds = new Set<string>();
if (t) {
for (const u of await prisma.user.findMany({ where: { tenantId: t.id }, select: { identityId: true } })) {
identityIds.add(u.identityId);
}
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.userRole.deleteMany({ where: { user: { tenantId: t.id } } });
await prisma.user.deleteMany({ where: { tenantId: t.id } });
await prisma.role.deleteMany({ where: { tenantId: t.id } });
await prisma.tenant.delete({ where: { id: t.id } });
}
// Identity per E-Mail (frischer Lauf) UND per gesammelter Id (ggf. schon anonymisiert).
const byEmail = await prisma.identity.findUnique({ where: { email: EMAIL }, select: { id: true } });
if (byEmail) identityIds.add(byEmail.id);
for (const id of identityIds) {
if ((await prisma.user.count({ where: { identityId: id } })) === 0) {
await prisma.identity.delete({ where: { id } }).catch(() => {});
}
}
}
async function main() {
await cleanup();
// Fixture: Mandant + Identity + Mitgliedschaft + Rolle + Asset + 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" },
});
const user = await prisma.user.create({
data: { tenantId: tenant.id, identityId: identity.id, email: EMAIL, name: "Test Person" },
});
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.auditLog.create({
data: { tenantId: tenant.id, actorId: user.id, action: "create", entity: "asset" },
});
// 1. Auskunft (Art. 15/20).
const subj = await exportSubject(tenant.id, identity.id);
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["AuditLog.actorId"], "Auskunft findet AuditLog über actorId");
// Snapshot MIT Original-PII (für den Tombstone-on-Restore-Beweis).
const snap = await exportTenant(tenant.id, { persist: false, reason: "dsgvo-test" });
// 2. Löschung/Anonymisierung (Art. 17).
const del = await deleteSubject(tenant.id, identity.id, { reason: "test" });
ok(del.anonymizedMemberships === 1, "Löschung anonymisiert die Mitgliedschaft");
ok(del.identityAnonymized, "Globale Identity anonymisiert (keine Rest-Mitgliedschaft)");
const userAfter = await prisma.user.findUnique({ where: { id: user.id } });
ok(userAfter?.name === "Gelöschte Person" && userAfter?.email !== EMAIL, "User-Zeile ist anonymisiert (Name/E-Mail Tombstone)");
const idnAfter = await prisma.identity.findUnique({ where: { id: identity.id } });
ok(idnAfter?.email !== EMAIL && idnAfter?.status === "DISABLED", "Identity anonymisiert + DISABLED");
const cert = await prisma.deletionCertificate.findUnique({ where: { id: del.certificateId } });
ok(cert?.scope === "person", "Löschnachweis (DeletionCertificate) erstellt");
const tomb = await prisma.tombstoneEntry.findFirst({ where: { tenantId: tenant.id, model: "User", targetValue: user.id } });
ok(tomb?.action === "anonymize", "Tombstone-Eintrag gesetzt (restore-fest)");
// 3. BEWEIS Tombstone-on-Restore: alter Snapshot bringt PII NICHT zurück.
const res = await restoreTenant(tenant.id, { artifact: snap.artifact, manifest: snap.manifest }, { skipPreRestore: true });
ok(res.tombstonesReapplied >= 1, `Restore wendet Tombstones erneut an (${res.tombstonesReapplied})`);
const userReborn = await prisma.user.findUnique({ where: { id: user.id } });
ok(userReborn != null, "User-Zeile nach Restore wieder vorhanden (referenzielle Struktur)");
ok(userReborn?.name === "Gelöschte Person" && userReborn?.email !== EMAIL, "PII bleibt trotz altem Snapshot getilgt (Tombstone erneut angewandt)");
}
main()
.then(async () => {
await cleanup();
await prisma.$disconnect();
if (failures > 0) {
console.error(`\n✗ ${failures} Testfall/-fälle fehlgeschlagen.`);
process.exit(1);
}
console.log("\nOK");
})
.catch(async (e) => {
console.error(e);
await cleanup().catch(() => {});
await prisma.$disconnect();
process.exit(1);
});
+165
View File
@@ -0,0 +1,165 @@
// Isolationsnachweis der Backup-/Restore-Engine (destruktive Lane).
//
// Beweisziel: Export → Wipe → Restore von Mandant `demo` lässt JEDE Tabelle des
// zweiten Mandanten `demo2` (inkl. der Join-Tabellen UserRole/RolePermission)
// BIT-genau unberührt. Gemessen wird per Owner-Rowcount (BYPASSRLS) über die aus
// dem DMMF abgeleitete Traversierungs-Topologie — also über GENAU die Tabellen,
// die die Engine anfasst.
//
// Lauf: npx tsx scripts/test-backup-isolation.ts
// Nutzt die lokale isms-DB (geseedet: demo + demo2). Nach Erfolg ist `demo`
// wiederhergestellt; bei Fehlschlag ggf. `npx tsx prisma/seed.ts` nachziehen.
import "dotenv/config";
import { prisma } from "../src/server/db";
import { buildTenantTopology, assertTopologyMatchesDatabase } from "../src/server/backup/topology";
import { exportTenant } from "../src/server/backup/export";
import { restoreTenant } from "../src/server/backup/restore";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
type Fingerprint = Map<string, number>;
/** Owner-Rowcount je Modell für genau einen Mandanten (Join-Tabellen via id-Menge). */
async function fingerprint(tenantId: string): Promise<Fingerprint> {
const topo = buildTenantTopology();
const fp: Fingerprint = new Map();
// id-Mengen der scopenden Elternteile (User/Role) einmal bestimmen.
const idSets = new Map<string, string[]>();
for (const node of topo.insertOrder) {
const delegate = (prisma as unknown as Record<string, {
count: (a: unknown) => Promise<number>;
findMany: (a: unknown) => Promise<Record<string, unknown>[]>;
}>)[node.delegate];
let count: number;
if (node.scope.by === "tenantColumn") {
count = await delegate.count({ where: { tenantId } });
if (node.pk.length === 1) {
const rows = await delegate.findMany({ where: { tenantId }, select: { [node.pk[0]]: true } });
idSets.set(node.model, rows.map((r) => String(r[node.pk[0]])));
}
} else {
const via = node.scope.via;
const ids = idSets.get(via.parent) ?? [];
count = ids.length
? await delegate.count({ where: { [via.fromFields[0]]: { in: ids } } })
: 0;
}
fp.set(node.model, count);
}
return fp;
}
function compare(before: Fingerprint, after: Fingerprint, adjust: Record<string, number> = {}): string[] {
const diffs: string[] = [];
for (const [model, b] of before) {
const expected = b + (adjust[model] ?? 0);
const a = after.get(model) ?? -1;
if (a !== expected) diffs.push(`${model}: erwartet ${expected}, ist ${a}`);
}
return diffs;
}
/** Destruktiver Wipe eines Mandanten (nur DB-Zeilen; Identities unangetastet). */
async function wipeTenant(tenantId: string): Promise<void> {
const topo = buildTenantTopology();
await prisma.$transaction(async (tx) => {
await tx.$executeRawUnsafe(`SET LOCAL session_replication_role = replica`);
for (const node of topo.deleteOrder) {
const del = (tx as unknown as Record<string, {
deleteMany: (a: unknown) => Promise<{ count: number }>;
findMany: (a: unknown) => Promise<Record<string, unknown>[]>;
}>)[node.delegate];
if (node.scope.by === "tenantColumn") {
await del.deleteMany({ where: { tenantId } });
} else {
const via = node.scope.via;
const parentNode = topo.nodes.get(via.parent)!;
const parentPk = parentNode.pk[0];
const parentDelegate = (tx as unknown as Record<string, {
findMany: (a: unknown) => Promise<Record<string, unknown>[]>;
}>)[parentNode.delegate];
const parents = await parentDelegate.findMany({ where: { tenantId }, select: { [parentPk]: true } });
const ids = parents.map((p) => String(p[parentPk]));
if (ids.length) await del.deleteMany({ where: { [via.fromFields[0]]: { in: ids } } });
}
}
});
}
async function main() {
// 0. Topologie-Selbstnachweis: abgeleitete FK-Ordnung == reale DB-Constraints.
await assertTopologyMatchesDatabase(prisma);
ok(true, "Topologie deckt sich mit den DB-FK-Constraints (fail-closed-Prüfung bestanden)");
const demo = await prisma.tenant.findUnique({ where: { slug: "demo" }, select: { id: true } });
const demo2 = await prisma.tenant.findUnique({ where: { slug: "demo2" }, select: { id: true } });
if (!demo || !demo2) {
console.error("demo/demo2 nicht gefunden — bitte seeden (npx tsx prisma/seed.ts).");
process.exit(1);
}
// 1. Ausgangs-Fingerprints.
const demoBefore = await fingerprint(demo.id);
const demo2Before = await fingerprint(demo2.id);
const demoTotal = [...demoBefore.values()].reduce((a, b) => a + b, 0);
const demo2Total = [...demo2Before.values()].reduce((a, b) => a + b, 0);
ok(demoTotal > 0, `demo hat Daten (${demoTotal} Zeilen über ${demoBefore.size} Tabellen)`);
ok(demo2Total > 0, `demo2 hat Daten (${demo2Total} Zeilen über ${demo2Before.size} Tabellen)`);
// Join-Tabellen explizit sichtbar machen (Prompt-Fokus).
ok((demo2Before.get("UserRole") ?? 0) > 0, `demo2 UserRole-Zuweisungen vorhanden (${demo2Before.get("UserRole")})`);
ok((demo2Before.get("RolePermission") ?? 0) > 0, `demo2 RolePermission-Zuordnungen vorhanden (${demo2Before.get("RolePermission")})`);
// 2. Export demo (in-memory, kein Upload nötig).
const snap = await exportTenant(demo.id, { persist: false, reason: "isolation-test" });
ok(snap.manifest.totalRows === demoTotal, `Export demo erfasst alle Zeilen (${snap.manifest.totalRows}/${demoTotal})`);
// 3. Wipe demo (destruktiv).
await wipeTenant(demo.id);
const demoWiped = await fingerprint(demo.id);
ok([...demoWiped.values()].every((c) => c === 0), "demo nach Wipe leer (alle Tabellen 0)");
// 3a. BEWEIS: demo2 nach dem demo-Wipe unverändert.
const demo2AfterWipe = await fingerprint(demo2.id);
const wipeDiffs = compare(demo2Before, demo2AfterWipe);
ok(wipeDiffs.length === 0, `demo2 nach demo-Wipe UNVERÄNDERT${wipeDiffs.length ? " — " + wipeDiffs.join("; ") : ""}`);
// 4. Restore demo aus dem Artefakt.
const res = await restoreTenant(
demo.id,
{ artifact: snap.artifact, manifest: snap.manifest },
{ skipPreRestore: true, actorId: undefined },
);
ok(res.restoredRows === demoTotal, `Restore demo reinsertet alle Zeilen (${res.restoredRows}/${demoTotal})`);
ok(res.stubIdentities.length === 0, "Restore demo ohne Identity-Stubs (Identities waren intakt)");
// 4a. demo exakt wiederhergestellt (AuditLog +1 durch den Restore-Audit-Eintrag).
const demoAfter = await fingerprint(demo.id);
const demoDiffs = compare(demoBefore, demoAfter, { AuditLog: 1 });
ok(demoDiffs.length === 0, `demo exakt wiederhergestellt${demoDiffs.length ? " — " + demoDiffs.join("; ") : " (AuditLog +1 = Restore-Audit)"}`);
// 4b. BEWEIS (Hauptziel): demo2 über den gesamten Restore unverändert.
const demo2After = await fingerprint(demo2.id);
const restoreDiffs = compare(demo2Before, demo2After);
ok(restoreDiffs.length === 0, `demo2 nach demo-Restore UNVERÄNDERT — JEDE Tabelle${restoreDiffs.length ? " — " + restoreDiffs.join("; ") : " (inkl. UserRole/RolePermission)"}`);
}
main()
.then(async () => {
await prisma.$disconnect();
if (failures > 0) {
console.error(`\n✗ ${failures} Testfall/-fälle fehlgeschlagen.`);
process.exit(1);
}
console.log("\nOK");
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+157
View File
@@ -0,0 +1,157 @@
// Backup-Portal-Lane: Enqueue-/Kontroll-Logik + DSGVO-Zustellpaket.
//
// Deckt OHNE laufenden BullMQ-Worker ab (KONZEPT §4-Kontrollen + §5-Zustellung):
// (1) Getippte Bestätigung „RESTORE <slug>" — exakter Match (Kontrolle 3).
// (2) MFA-Step-up mit Replay-Schutz: frischer Code ok, verbrauchter/älterer
// Zeitschritt wird abgelehnt (Kontrolle 2).
// (3) ZIP-Writer: rundläuft durch das System-`unzip` (echtes ZIP-Format).
// (4) DSGVO-Zustellpaket (Per-Person) enthält die erwarteten Einträge OHNE
// Secrets; der dsgvo_export-Job erzeugt Store-Objekt + signierten Token
// mit kurzer TTL (processBackupJob direkt, ohne Redis).
//
// Lauf: npx tsx scripts/test-backup-portal.ts (nutzt lokale isms-DB, räumt auf)
import "dotenv/config";
import { execFileSync } from "node:child_process";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { generateSync } from "otplib";
import { prisma } from "../src/server/db";
import { verifyTotp, newTotpSecret } from "../src/server/mfa";
import { matchesRestoreConfirmation, restoreConfirmationFor } from "../src/server/backup/restore-confirm";
import { buildZip } from "../src/server/backup/zip";
import { buildDsgvoPackage } from "../src/server/backup/dsgvo-zip";
import { processBackupJob, dsgvoPackageKey } from "../src/server/backup/ops";
import { getBackupStore } from "../src/server/storage/backup-store";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
const SLUG = "zz-portal-test";
const EMAIL = "zz-portal-person@example.test";
async function cleanup() {
const t = await prisma.tenant.findUnique({ where: { slug: SLUG }, select: { id: true } });
const identityIds = new Set<string>();
if (t) {
for (const u of await prisma.user.findMany({ where: { tenantId: t.id }, select: { identityId: true } })) identityIds.add(u.identityId);
await prisma.backupJob.deleteMany({ where: { tenantId: t.id } });
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.userRole.deleteMany({ where: { user: { tenantId: t.id } } });
await prisma.user.deleteMany({ where: { tenantId: t.id } });
await prisma.role.deleteMany({ where: { tenantId: t.id } });
await prisma.tenant.delete({ where: { id: t.id } });
await (await getBackupStore()).remove(`${t.id}/`);
}
const byEmail = await prisma.identity.findUnique({ where: { email: EMAIL }, select: { id: true } });
if (byEmail) identityIds.add(byEmail.id);
for (const id of identityIds) {
if ((await prisma.user.count({ where: { identityId: id } })) === 0) {
await prisma.identity.delete({ where: { id } }).catch(() => {});
}
}
}
async function main() {
await cleanup();
// ── (1) Getippte Bestätigung ────────────────────────────────────────────────
console.log("\n(1) Getippte Bestätigung „RESTORE <slug>\":");
ok(matchesRestoreConfirmation("kunde-a", "RESTORE kunde-a"), "exakter Match akzeptiert");
ok(!matchesRestoreConfirmation("kunde-a", "RESTORE kunde-b"), "falscher Slug abgelehnt");
ok(!matchesRestoreConfirmation("kunde-a", "restore kunde-a"), "abweichende Groß-/Kleinschreibung abgelehnt");
ok(!matchesRestoreConfirmation("kunde-a", " RESTORE kunde-a "), "Leerzeichen-Abweichung abgelehnt");
ok(restoreConfirmationFor("kunde-a") === "RESTORE kunde-a", "Erwartungstext korrekt gebildet");
// ── (2) MFA-Step-up + Replay-Schutz ─────────────────────────────────────────
console.log("\n(2) MFA-Step-up (Replay-Schutz über lastTotpStep):");
const secret = newTotpSecret();
const code = generateSync({ secret });
const first = verifyTotp(code, secret);
ok(first.ok, "frischer TOTP-Code wird akzeptiert");
ok(!verifyTotp("000000", secret).ok, "falscher Code abgelehnt");
if (first.ok) {
const replay = verifyTotp(code, secret, first.step);
ok(!replay.ok, "verbrauchter Zeitschritt (Replay) wird abgelehnt");
}
// ── (3) ZIP-Writer rundläuft durch System-unzip ────────────────────────────
console.log("\n(3) ZIP-Writer (Standardformat):");
const zip = buildZip([
{ name: "hello.txt", data: "Hallo Welt" },
{ name: "data/inner.json", data: JSON.stringify({ a: 1 }) },
]);
const dir = mkdtempSync(join(tmpdir(), "cvzip-"));
const zipPath = join(dir, "t.zip");
writeFileSync(zipPath, zip);
let unzipOk = true;
let content = "";
try {
const list = execFileSync("unzip", ["-l", zipPath], { encoding: "utf8" });
unzipOk = list.includes("hello.txt") && list.includes("data/inner.json");
content = execFileSync("unzip", ["-p", zipPath, "hello.txt"], { encoding: "utf8" });
} catch (e) {
unzipOk = false;
console.warn(" (unzip nicht verfügbar/ Fehler:", e instanceof Error ? e.message : e, ")");
}
ok(unzipOk, "ZIP von System-`unzip` gelesen (beide Einträge vorhanden)");
ok(content.trim() === "Hallo Welt", "entpackter Inhalt stimmt (CRC/Deflate korrekt)");
rmSync(dir, { recursive: true, force: true });
// ── Fixture-Mandant für DSGVO ──────────────────────────────────────────────
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 } });
// ── (4a) DSGVO-Paket (Per-Person) direkt ────────────────────────────────────
console.log("\n(4) DSGVO-Zustellpaket:");
const pkg = await buildDsgvoPackage(tenant.id, identity.id);
ok(pkg.summary.scope === "person", "Scope=person erkannt");
const pdir = mkdtempSync(join(tmpdir(), "cvdsgvo-"));
const ppath = join(pdir, "p.zip");
writeFileSync(ppath, pkg.zip);
let names = "";
let identityJson = "";
try {
names = execFileSync("unzip", ["-l", ppath], { encoding: "utf8" });
identityJson = execFileSync("unzip", ["-p", ppath, "identity.json"], { encoding: "utf8" });
} catch { /* ohne unzip überspringen */ }
ok(names.includes("identity.json") && names.includes("memberships.json") && names.includes("references.json"), "Paket enthält identity/memberships/references");
ok(identityJson.includes(EMAIL), "identity.json enthält die E-Mail der Person");
ok(!identityJson.includes("passwordHash") && !identityJson.includes("mfaSecret"), "identity.json enthält KEINE Secrets");
rmSync(pdir, { recursive: true, force: true });
// ── (4b) dsgvo_export-Job (processBackupJob, ohne Redis) → Store + Token/TTL ─
const job = await prisma.backupJob.create({
data: { kind: "dsgvo_export", status: "queued", tenantId: tenant.id, tenantSlug: tenant.slug, subjectIdentityId: identity.id, actorId: null },
});
await processBackupJob({ kind: "dsgvo_export", jobId: job.id, tenantId: tenant.id, subjectIdentityId: identity.id, actorId: "test-admin" });
const done = await prisma.backupJob.findUnique({ where: { id: job.id } });
ok(done?.status === "done", "dsgvo_export-Job auf 'done' gesetzt");
ok(!!done?.downloadToken && done.downloadToken.length >= 20, "signierter Download-Token erzeugt");
ok(!!done?.downloadExpiresAt && done.downloadExpiresAt.getTime() > Date.now(), "TTL/Ablaufzeitpunkt in der Zukunft");
const stored = await (await getBackupStore()).get(dsgvoPackageKey(tenant.id, job.id));
ok(!!stored && stored.length > 0, "ZIP im Backup-Store abgelegt (für den Download-Endpunkt)");
}
main()
.then(async () => {
await cleanup();
await prisma.$disconnect();
if (failures > 0) { console.error(`\n✗ ${failures} Testfall/-fälle fehlgeschlagen.`); process.exit(1); }
console.log("\nOK");
})
.catch(async (e) => {
console.error(e);
await cleanup().catch(() => {});
await prisma.$disconnect();
process.exit(1);
});
+168
View File
@@ -0,0 +1,168 @@
// Lane „Konfigurierbarer Backup-Zielspeicher": Store-Auflösung + Präzedenz + fail-secure.
//
// Deckt OHNE DB/Redis ab (reine Auflösungslogik `resolveBackupStore` + echte
// Byte-Persistenz des LocalBackupStore):
// (1) DB-Config lokal (expliziter Pfad) → LocalBackupStore auf genau diesem Pfad.
// (2) DB-Config S3 (vollständig) → S3BackupStore (kein still-lokaler Fallback).
// (3) Fail-secure: DB-Config S3 unvollständig → klarer Fehler (kein Local-Fallback).
// (4) Präzedenz DB → Env → Default (inkl. „unberührter Default-Datensatz fällt auf Env").
// (5) „Verbindung testen"-Pfad: put→get→remove eines winzigen Test-Keys (Local, echt).
//
// Lauf: npx tsx scripts/test-backup-target.ts (braucht KEINE DB und KEIN Redis)
import "dotenv/config";
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolveBackupStore, type BackupTargetConfig } from "../src/server/storage/backup-store";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
/** Minimal-Config-Fabrik (nur die relevanten Felder überschreiben). */
function cfg(partial: Partial<BackupTargetConfig>): BackupTargetConfig {
return {
backupTarget: "local",
backupLocalDir: null,
backupS3Endpoint: null,
backupS3Bucket: null,
backupS3Region: null,
backupS3AccessKey: null,
backupS3SecretKey: null,
...partial,
};
}
const storeKind = (s: unknown) => (s as { constructor: { name: string } }).constructor.name;
/** Env-Variablen für einen Testblock setzen/entfernen und danach wiederherstellen. */
function withEnv(vars: Record<string, string | undefined>, fn: () => void) {
const S3_KEYS = ["S3_ENDPOINT", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_BUCKET", "S3_REGION", "BACKUP_LOCAL_DIR"];
const saved: Record<string, string | undefined> = {};
for (const k of S3_KEYS) saved[k] = process.env[k];
try {
// Erst alle relevanten Keys leeren, dann die gewünschten setzen (deterministisch).
for (const k of S3_KEYS) delete process.env[k];
for (const [k, v] of Object.entries(vars)) if (v !== undefined) process.env[k] = v;
fn();
} finally {
for (const k of S3_KEYS) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
}
}
async function main() {
const tmp = mkdtempSync(join(tmpdir(), "cvbktarget-"));
// ── (1) DB-Config lokal (expliziter Pfad) ──────────────────────────────────
console.log("\n(1) DB-Config lokal (expliziter Pfad):");
const localDir = join(tmp, "db-local");
withEnv({}, () => {
const store = resolveBackupStore(cfg({ backupTarget: "local", backupLocalDir: localDir }));
ok(storeKind(store) === "LocalBackupStore", "explizite lokale DB-Config → LocalBackupStore");
});
{
// Byte-Persistenz auf genau diesem Pfad nachweisen.
const store = resolveBackupStore(cfg({ backupTarget: "local", backupLocalDir: localDir }));
await store.put("t/probe.txt", Buffer.from("hi", "utf8"));
ok(existsSync(join(localDir, "t/probe.txt")), "Bytes landen unter dem konfigurierten Pfad");
ok(readFileSync(join(localDir, "t/probe.txt"), "utf8") === "hi", "Inhalt korrekt persistiert");
}
// ── (2) DB-Config S3 vollständig ────────────────────────────────────────────
console.log("\n(2) DB-Config S3 (vollständig):");
withEnv({}, () => {
const store = resolveBackupStore(
cfg({
backupTarget: "s3",
backupS3Endpoint: "http://minio:9000",
backupS3Bucket: "certvia-backups",
backupS3AccessKey: "AK",
backupS3SecretKey: "SK",
}),
);
ok(storeKind(store) === "S3BackupStore", "vollständige S3-DB-Config → S3BackupStore (kein Local-Fallback)");
});
// ── (3) Fail-secure: S3 unvollständig ───────────────────────────────────────
console.log("\n(3) Fail-secure (S3 unvollständig):");
withEnv({ S3_ENDPOINT: "http://env-minio:9000", S3_ACCESS_KEY: "E", S3_SECRET_KEY: "E", S3_BUCKET: "env" }, () => {
let threw = false;
try {
resolveBackupStore(cfg({ backupTarget: "s3", backupS3Endpoint: "http://minio:9000", backupS3Bucket: "b" }));
} catch {
threw = true;
}
ok(threw, "S3-DB-Config ohne Secret/Access-Key wirft (fällt NICHT still auf lokal/Env)");
});
// ── (4) Präzedenz DB → Env → Default ────────────────────────────────────────
console.log("\n(4) Präzedenz DB → Env → Default:");
// (4a) Keine DB-Config, aber Env-S3 vollständig → S3 (Rückwärtskompatibilität).
withEnv({ S3_ENDPOINT: "http://env-minio:9000", S3_ACCESS_KEY: "E", S3_SECRET_KEY: "E", S3_BUCKET: "env" }, () => {
ok(storeKind(resolveBackupStore(null)) === "S3BackupStore", "cfg=null + Env-S3 → S3 (Env-Fallback)");
});
// (4b) „Unberührter Default-Datensatz" (local/NULL) fällt ebenfalls auf Env zurück.
withEnv({ S3_ENDPOINT: "http://env-minio:9000", S3_ACCESS_KEY: "E", S3_SECRET_KEY: "E", S3_BUCKET: "env" }, () => {
const store = resolveBackupStore(cfg({ backupTarget: "local", backupLocalDir: null }));
ok(storeKind(store) === "S3BackupStore", "DB local/NULL (unkonfiguriert) → Env-S3 gewinnt (Rückwärtskompatibilität)");
});
// (4c) Keine DB-Config, kein Env-S3, aber BACKUP_LOCAL_DIR gesetzt → Local dort.
const envLocalDir = join(tmp, "env-local");
withEnv({ BACKUP_LOCAL_DIR: envLocalDir }, async () => {
const store = resolveBackupStore(null);
ok(storeKind(store) === "LocalBackupStore", "cfg=null + kein Env-S3, aber BACKUP_LOCAL_DIR → Local");
});
{
const store = withEnvReturn({ BACKUP_LOCAL_DIR: envLocalDir }, () => resolveBackupStore(null));
await store.put("p.txt", Buffer.from("x", "utf8"));
ok(existsSync(join(envLocalDir, "p.txt")), "Env-BACKUP_LOCAL_DIR steuert den Ablagepfad");
}
// (4d) Nichts gesetzt → lokaler Default <cwd>/.backups.
withEnv({}, () => {
const store = resolveBackupStore(null);
ok(storeKind(store) === "LocalBackupStore", "cfg=null + keinerlei Env → lokaler Default (.backups)");
});
// ── (5) „Verbindung testen"-Pfad (put→get→remove) ───────────────────────────
console.log("\n(5) „Verbindung testen\"-Pfad (Local, put→get→remove):");
{
const store = resolveBackupStore(cfg({ backupTarget: "local", backupLocalDir: join(tmp, "conn") }));
const key = "__connectivity-test__/probe.txt";
const payload = Buffer.from("certvia connectivity", "utf8");
await store.put(key, payload);
const back = await store.get(key);
ok(!!back && back.equals(payload), "put→get liefert identische Bytes");
await store.remove(key);
ok((await store.get(key)) === null, "remove entfernt den Test-Key wieder (kein Rückstand)");
}
rmSync(tmp, { recursive: true, force: true });
}
/** Wie withEnv, aber gibt den Rückgabewert der Funktion durch (für synchrone Fälle). */
function withEnvReturn<T>(vars: Record<string, string | undefined>, fn: () => T): T {
let out!: T;
withEnv(vars, () => {
out = fn();
});
return out;
}
main()
.then(() => {
if (failures > 0) {
console.error(`\n✗ ${failures} Testfall/-fälle fehlgeschlagen.`);
process.exit(1);
}
console.log("\nOK");
})
.catch((e) => {
console.error(e);
process.exit(1);
});
+73
View File
@@ -0,0 +1,73 @@
// 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); });
+103
View File
@@ -0,0 +1,103 @@
// 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());
+93
View File
@@ -0,0 +1,93 @@
// 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); });
+118
View File
@@ -0,0 +1,118 @@
// 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);
});
+106
View File
@@ -0,0 +1,106 @@
// 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); });
+62
View File
@@ -0,0 +1,62 @@
// 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); });
+150
View File
@@ -0,0 +1,150 @@
// 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);
});
+60
View File
@@ -0,0 +1,60 @@
// 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);
+91
View File
@@ -0,0 +1,91 @@
// 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);
+99
View File
@@ -0,0 +1,99 @@
// Smoke-Test des S3-Objektspeichers gegen einen echten Garage-Node (Phase C/D der
// MinIO→Garage-Migration, docs/KONZEPT-garage-migration.md §9).
//
// Prüft den realen S3-Pfad (AWS SDK v3, path-style) über die exportierte
// `resolveBackupStore()`:
// 1. Put → Get (Byte-Identität),
// 2. List (Prefix enthält den Key),
// 3. Delete (remove räumt den Prefix),
// 4. Negativfall: fehlender Bucket → sprechender Konfigurationsfehler
// (KEIN stiller CreateBucket-Versuch mehr).
//
// Ausführung:
// - OHNE S3_ENDPOINT → der Test ÜBERSPRINGT sich (Exit 0), damit das Standard-Gate
// (npm run test) ohne laufenden Objektspeicher grün bleibt.
// - MIT gesetzten S3_*-Variablen (z. B. gegen den Compose-Garage oder einen lokalen
// Container) → echter End-to-End-Durchstich.
//
// S3_ENDPOINT=http://127.0.0.1:3900 S3_ACCESS_KEY=… S3_SECRET_KEY=… \
// S3_BUCKET=isms-documents S3_REGION=us-east-1 npx tsx scripts/test-garage-storage.ts
import { resolveBackupStore, type BackupTargetConfig } from "../src/server/storage/backup-store";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
function envCfg(bucketOverride?: string): BackupTargetConfig {
return {
backupTarget: "s3",
backupLocalDir: null,
backupS3Endpoint: process.env.S3_ENDPOINT?.trim() ?? null,
backupS3Bucket: bucketOverride ?? process.env.S3_BUCKET?.trim() ?? "isms-documents",
backupS3Region: process.env.S3_REGION?.trim() || "us-east-1",
backupS3AccessKey: process.env.S3_ACCESS_KEY?.trim() ?? null,
backupS3SecretKey: process.env.S3_SECRET_KEY?.trim() ?? null,
};
}
async function main() {
const endpoint = process.env.S3_ENDPOINT?.trim();
if (!endpoint) {
console.log("↷ übersprungen: kein S3_ENDPOINT gesetzt (kein laufender Objektspeicher).");
console.log(" Für den echten Durchstich S3_ENDPOINT/S3_ACCESS_KEY/S3_SECRET_KEY/S3_BUCKET setzen.");
return;
}
if (!process.env.S3_ACCESS_KEY || !process.env.S3_SECRET_KEY) {
console.log("✗ FEHLER S3_ENDPOINT gesetzt, aber S3_ACCESS_KEY/S3_SECRET_KEY fehlen.");
failures++;
return;
}
// ── Positiv: Put → Get → List → Delete gegen den provisionierten Bucket ──────
const store = resolveBackupStore(envCfg());
const prefix = `__smoketest__/${process.pid}-${Date.now()}/`;
const key = `${prefix}garage-probe.bin`;
const payload = Buffer.from("certvia · garage smoke test · äöü · 🗄️", "utf8");
await store.put(key, payload);
ok(true, "Put gegen Garage erfolgreich");
const got = await store.get(key);
ok(got != null && got.equals(payload), "Get liefert identische Bytes zurück");
const missing = await store.get(`${prefix}gibtsnicht.bin`);
ok(missing === null, "Get eines fehlenden Keys → null (kein Wurf)");
const listed = await store.list(prefix);
ok(listed.includes(key), "List enthält den geschriebenen Key (Prefix-Scan)");
const removed = await store.remove(prefix);
ok(removed >= 1, `Delete räumt den Prefix (entfernt: ${removed})`);
const afterRemove = await store.list(prefix);
ok(afterRemove.length === 0, "Prefix ist nach remove leer");
// ── Negativ: fehlender Bucket → sprechender Konfigurationsfehler ─────────────
const badBucket = `does-not-exist-${process.pid}-${Date.now()}`;
const badStore = resolveBackupStore(envCfg(badBucket));
try {
await badStore.put(`${badBucket}/x.bin`, Buffer.from("x"));
ok(false, "fehlender Bucket hätte einen Fehler werfen müssen");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const speaks = /nicht provisioniert|nicht erreichbar/.test(msg) && msg.includes(badBucket);
ok(speaks, "fehlender Bucket → sprechender Konfigurationsfehler (kein stiller CreateBucket)");
if (!speaks) console.log(" erhaltene Meldung:", msg);
}
}
main()
.then(() => {
console.log(failures === 0 ? "\nGarage-Storage-Smoke-Test grün." : `\n${failures} Prüfung(en) fehlgeschlagen.`);
process.exit(failures === 0 ? 0 : 1);
})
.catch((err) => {
console.error("\n✗ Unerwarteter Fehler im Smoke-Test:", err);
process.exit(1);
});
+93
View File
@@ -0,0 +1,93 @@
// WS4-Abnahmetest (Option C) — Passwort/Reset/Session-Kill-Switch an der Identity.
//
// Prüft, dass die Self-Service-/Reset-Bausteine auf der GLOBALEN Identity arbeiten
// (nicht mehr auf der per-Mandant-Mitgliedschaft):
// 1. findAccountByEmail("tenant") liefert die Identity (id === Identity.id).
// 2. writePasswordHash/readPasswordHash operieren auf der Identity; ein neuer
// Hash wirkt sofort im Login (authorizeTenantCredentials).
// 3. invalidateSessions({type:"identity"}) setzt die Kill-Switch-Marke an der Identity.
// 4. Der Mandanten-Admin-Reset (resetUserPassword/resetTenantUserPassword) ist
// entfernt — durch tsc/lint bereits abgesichert (keine Referenzen mehr).
//
// Lauf: npx tsx scripts/test-identity-account.ts (setzt den Demo-Seed voraus)
import "dotenv/config";
import { prisma } from "../src/server/db";
import { hashPassword, verifyPassword } from "../src/server/password";
import { authorizeTenantCredentials } from "../src/server/auth";
import { findAccountByEmail, readPasswordHash, writePasswordHash } from "../src/server/auth-selfservice";
import { invalidateSessions, getSessionsValidAfter } from "../src/server/sessions";
const EMAIL = "ws4-account@demo.example";
const PW1 = "Start-Passwort-1!";
const PW2 = "Neues-Passwort-2!";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
async function cleanup() {
await prisma.user.deleteMany({ where: { email: EMAIL } });
await prisma.identity.deleteMany({ where: { email: EMAIL } });
}
async function main() {
await cleanup();
const demo = await prisma.tenant.findUniqueOrThrow({ where: { slug: "demo" } });
const role = await prisma.role.findFirst({ where: { tenantId: demo.id, key: "user" } });
const identity = await prisma.identity.create({ data: { email: EMAIL, passwordHash: await hashPassword(PW1) } });
await prisma.user.create({
data: {
tenantId: demo.id, identityId: identity.id, email: EMAIL, name: "WS4 Konto",
status: "ACTIVE", ...(role ? { userRoles: { create: [{ roleId: role.id }] } } : {}),
},
});
console.log("\n— 1) findAccountByEmail liefert die Identity —");
const acc = await findAccountByEmail("tenant", EMAIL);
ok(acc?.id === identity.id, `Account.id === Identity.id (${acc?.id === identity.id})`);
ok(acc?.email === EMAIL, "Account.email = Identity.email");
console.log("\n— 2) Passwort lebt an der Identity; Login nutzt es —");
const loginOld = await authorizeTenantCredentials({ email: EMAIL, password: PW1, tenant: "demo" });
ok(!!loginOld && loginOld.identityId === identity.id, "Login mit Start-Passwort erfolgreich");
// Passwort an der Identity ändern (Reset-Baustein).
await writePasswordHash("tenant", identity.id, await hashPassword(PW2));
const storedHash = await readPasswordHash("tenant", identity.id);
ok(!!storedHash && (await verifyPassword(storedHash, PW2)), "readPasswordHash liefert den neuen Identity-Hash");
const loginOldAfter = await authorizeTenantCredentials({ email: EMAIL, password: PW1, tenant: "demo" });
ok(loginOldAfter === null, "altes Passwort funktioniert nach Änderung NICHT mehr");
const loginNew = await authorizeTenantCredentials({ email: EMAIL, password: PW2, tenant: "demo" });
ok(!!loginNew, "neues Passwort funktioniert sofort (Passwort an Identity, nicht Membership)");
console.log("\n— 3) Session-Kill-Switch an der Identity —");
const before = await getSessionsValidAfter({ type: "identity", id: identity.id });
ok(before === null, "vor Invalidierung keine Marke");
await invalidateSessions({ type: "identity", id: identity.id });
const after = await getSessionsValidAfter({ type: "identity", id: identity.id });
ok(after instanceof Date, "invalidateSessions setzt sessionsValidAfter an der Identity");
// Der Kill-Switch existiert NUR an der Identity — die Membership hat kein solches Feld
// mehr (Contract-Migration hat die User-Auth-Spalten entfernt).
await cleanup();
}
main()
.then(async () => {
await prisma.$disconnect();
if (failures > 0) {
console.error(`\n✗ ${failures} Testfall/-fälle fehlgeschlagen.`);
process.exit(1);
}
console.log("\nOK");
})
.catch(async (e) => {
console.error(e);
await cleanup().catch(() => {});
await prisma.$disconnect();
process.exit(1);
});
+83
View File
@@ -0,0 +1,83 @@
// WS1-Abnahmetest (Option C) — Mandanten-Login gegen die globale Identity.
//
// Prüft `authorizeTenantCredentials` (src/server/auth.ts) gegen die Seed-DB:
// 1. Single-Membership: admin@demo (ohne Slug) landet in seinem Mandanten.
// 2. Falsches Passwort → null (kein Login).
// 3. Multi-Membership OHNE Slug → null (Auswahlseite /select-tenant = WS2).
// 4. Multi-Membership MIT Slug → richtiger Mandant + mandantenspezifische Rechte.
// 5. Falscher/fremder Slug → null.
// 6. Unbekannte E-Mail → null.
// 7. Session-Shape trägt identityId, activeMembershipId und memberships[].
//
// Lauf: npx tsx scripts/test-identity-login.ts (setzt den Demo-Seed voraus)
import "dotenv/config";
import { prisma } from "../src/server/db";
import { authorizeTenantCredentials } from "../src/server/auth";
const PW = "Demo1234!";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
async function main() {
console.log("\n— 1) Single-Membership: admin@demo ohne Slug —");
const admin = await authorizeTenantCredentials({ email: "admin@demo.example", password: PW });
ok(!!admin, "admin@demo meldet sich an");
ok(admin?.tenantSlug === "demo", `aktiver Mandant = demo (${admin?.tenantSlug})`);
ok(!!admin?.identityId, "Session trägt identityId");
ok(admin?.activeMembershipId === admin?.id, "activeMembershipId = User.id der Mitgliedschaft");
ok((admin?.memberships?.length ?? 0) === 1, `memberships-Liste = 1 (${admin?.memberships?.length})`);
ok(admin?.roles.includes("tenant-admin") ?? false, "Rolle tenant-admin im aktiven Mandanten");
console.log("\n— 2) Falsches Passwort → null —");
const wrong = await authorizeTenantCredentials({ email: "admin@demo.example", password: "falsch!!" });
ok(wrong === null, "falsches Passwort ⇒ null");
// Zähler zurücksetzen, damit wiederholte Läufe die Identity nicht sperren.
await prisma.identity.update({ where: { email: "admin@demo.example" }, data: { failedLogins: 0, lockedUntil: null } });
console.log("\n— 3) Multi-Membership ohne Slug → Session ohne aktiven Mandanten (WS2) —");
const noTenant = await authorizeTenantCredentials({ email: "multi@demo.example", password: PW });
ok(!!noTenant && noTenant.tenantId === "", "multi@ ohne Slug ⇒ Session ohne aktiven Mandanten (tenantId leer → /select-tenant)");
ok((noTenant?.memberships?.length ?? 0) === 2, `memberships-Liste = 2 im No-Tenant-State (${noTenant?.memberships?.length})`);
console.log("\n— 4) Multi-Membership mit Slug → korrekter Mandant + Rechte —");
const inDemo2 = await authorizeTenantCredentials({ email: "multi@demo.example", password: PW, tenant: "demo2" });
ok(inDemo2?.tenantSlug === "demo2", `Slug demo2 ⇒ aktiver Mandant demo2 (${inDemo2?.tenantSlug})`);
ok(inDemo2?.roles.includes("tenant-admin") ?? false, "in demo2: Rolle tenant-admin");
ok((inDemo2?.memberships?.length ?? 0) === 2, `memberships-Liste = 2 (${inDemo2?.memberships?.length})`);
const inDemo = await authorizeTenantCredentials({ email: "multi@demo.example", password: PW, tenant: "demo" });
ok(inDemo?.tenantSlug === "demo", `Slug demo ⇒ aktiver Mandant demo (${inDemo?.tenantSlug})`);
ok(inDemo?.roles.includes("user") ?? false, "in demo: Rolle user");
// Rechte je aktivem Mandant verschieden (tenant-admin demo2 ≠ user demo).
ok(
JSON.stringify([...(inDemo?.permissions ?? [])].sort()) !== JSON.stringify([...(inDemo2?.permissions ?? [])].sort()),
"Rechte je aktivem Mandant verschieden (demo user ≠ demo2 admin)"
);
console.log("\n— 5) Fremder/falscher Slug → null —");
const wrongSlug = await authorizeTenantCredentials({ email: "admin@demo.example", password: PW, tenant: "gibtsnicht" });
ok(wrongSlug === null, "unbekannter Slug ⇒ null");
console.log("\n— 6) Unbekannte E-Mail → null —");
const unknown = await authorizeTenantCredentials({ email: "gibtsnicht@demo.example", password: PW });
ok(unknown === null, "unbekannte E-Mail ⇒ null");
}
main()
.then(async () => {
await prisma.$disconnect();
if (failures > 0) {
console.error(`\n✗ ${failures} Testfall/-fälle fehlgeschlagen.`);
process.exit(1);
}
console.log("\nOK");
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+123
View File
@@ -0,0 +1,123 @@
// WS0-Abnahmetest (Option C — "Zentrale Identität mit Mandanten-Mitgliedschaften").
//
// Weist die "Goldenen Regeln" des Fundament-Umbaus auf DB-Ebene nach:
// 1. `identities` ist GLOBAL: KEIN tenant_id, KEINE RLS-Policy.
// 2. Jede Mitgliedschaft (`users`) verweist auf genau eine Identity (identity_id NOT NULL).
// 3. Eine Person hat je Mandant höchstens EINE Mitgliedschaft (@@unique(tenant,identity)).
// 4. Multi-Membership-Fixture: eine Identity ist in ZWEI Mandanten mit je eigener Rolle.
// 5. Login-Quelle ist Identity: identity.findUnique({email}) liefert passwordHash
// über den rohen (Owner-)`prisma`-Client — ohne Mandantenkontext.
// 6. Expand/Contract: WebAuthn hängt in WS0 noch an `users` (tenant-gebunden) — der
// Umzug auf die Identity ist bewusst WS4 (dieser Test darf nach WS4 angepasst werden).
//
// Lauf: npx tsx scripts/test-identity-schema.ts
// Nutzt die lokale Postgres-DB (Container isms-tool-postgres-1); .env liegt im Worktree.
// Setzt den Demo-Seed voraus (npx tsx prisma/seed.ts).
import "dotenv/config";
import { prisma } from "../src/server/db";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
async function expectThrow(fn: () => Promise<unknown>, msg: string) {
try {
await fn();
ok(false, `${msg} — kein Throw`);
} catch {
ok(true, msg);
}
}
async function count(sql: string): Promise<number> {
const r = await prisma.$queryRawUnsafe<{ c: number }[]>(sql);
return Number(r[0].c);
}
async function main() {
console.log("\n— Regel 1: identities ist global (kein tenant_id, keine RLS-Policy) —");
const hasTenantCol = await count(
`SELECT count(*)::int AS c FROM information_schema.columns WHERE table_name = 'identities' AND column_name = 'tenant_id'`
);
ok(hasTenantCol === 0, "identities hat KEINE tenant_id-Spalte");
const policies = await count(`SELECT count(*)::int AS c FROM pg_policies WHERE tablename = 'identities'`);
ok(policies === 0, "identities hat KEINE RLS-Policy");
const rlsForced = await count(
`SELECT count(*)::int AS c FROM pg_class WHERE relname = 'identities' AND (relrowsecurity OR relforcerowsecurity)`
);
ok(rlsForced === 0, "identities hat RLS weder ENABLED noch FORCED");
console.log("\n— Regel 2: jede Mitgliedschaft hat eine Identity —");
const orphan = await count(`SELECT count(*)::int AS c FROM users WHERE identity_id IS NULL`);
ok(orphan === 0, "keine users-Zeile ohne identity_id");
const memberships = await prisma.user.count();
ok(memberships > 0, `Mitgliedschaften vorhanden (${memberships})`);
console.log("\n— Regel 3: @@unique(tenant_id, identity_id) verhindert Doppel-Mitgliedschaft —");
const demo = await prisma.tenant.findUniqueOrThrow({ where: { slug: "demo" } });
const multi = await prisma.identity.findUniqueOrThrow({ where: { email: "multi@demo.example" } });
// multi@ ist bereits Mitglied in "demo" → ein zweiter Datensatz muss am Unique scheitern.
await expectThrow(
() =>
prisma.user.create({
data: {
tenantId: demo.id,
identityId: multi.id,
email: "dup-membership@demo.example",
name: "Duplikat",
},
}),
"zweite Mitgliedschaft (demo, multi) → Unique-Verstoß"
);
console.log("\n— Regel 4: Multi-Membership-Fixture über zwei Mandanten —");
const multiFull = await prisma.identity.findUniqueOrThrow({
where: { email: "multi@demo.example" },
include: { memberships: { include: { tenant: true, userRoles: { include: { role: true } } } } },
});
const slugs = multiFull.memberships.map((m) => m.tenant.slug).sort();
ok(slugs.length >= 2 && slugs.includes("demo") && slugs.includes("demo2"), `multi@ ist in ≥2 Mandanten (${slugs.join(", ")})`);
const rolesByTenant = Object.fromEntries(
multiFull.memberships.map((m) => [m.tenant.slug, m.userRoles.map((r) => r.role.key).sort()])
);
ok(
JSON.stringify(rolesByTenant.demo) !== JSON.stringify(rolesByTenant.demo2),
`Rollen je Mandant verschieden (demo=${JSON.stringify(rolesByTenant.demo)}, demo2=${JSON.stringify(rolesByTenant.demo2)})`
);
console.log("\n— Regel 5: Login-Quelle ist Identity (Owner-Client, ohne Mandantenkontext) —");
const admin = await prisma.identity.findUnique({ where: { email: "admin@demo.example" } });
ok(!!admin && admin.passwordHash.length > 0, "identity.findUnique({email}) liefert passwordHash");
const singleAdminMemberships = await prisma.user.count({ where: { identity: { email: "admin@demo.example" } } });
ok(singleAdminMemberships === 1, `admin@ bleibt single-membership (Ein-Schritt-Login heil): ${singleAdminMemberships}`);
console.log("\n— Regel 6: WebAuthn ist identitätsgebunden (WS4b) —");
const waTenantCol = await count(
`SELECT count(*)::int AS c FROM information_schema.columns WHERE table_name = 'webauthn_credentials' AND column_name = 'tenant_id'`
);
ok(waTenantCol === 0, "webauthn_credentials hat KEINE tenant_id mehr");
const waIdentityCol = await count(
`SELECT count(*)::int AS c FROM information_schema.columns WHERE table_name = 'webauthn_credentials' AND column_name = 'identity_id'`
);
ok(waIdentityCol === 1, "webauthn_credentials trägt identity_id");
const waPolicies = await count(`SELECT count(*)::int AS c FROM pg_policies WHERE tablename = 'webauthn_credentials'`);
ok(waPolicies === 0, "webauthn_credentials hat KEINE RLS-Policy mehr");
}
main()
.then(async () => {
await prisma.$disconnect();
if (failures > 0) {
console.error(`\n✗ ${failures} Testfall/-fälle fehlgeschlagen.`);
process.exit(1);
}
console.log("\nOK");
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+205
View File
@@ -0,0 +1,205 @@
// 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.");
+179
View File
@@ -0,0 +1,179 @@
// 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);
+212
View File
@@ -0,0 +1,212 @@
// Akzeptanztest Modul „Vorfälle" (IM-C): Verknüpfungen, Abschluss-Pflicht & Export.
//
// Prüft:
// 1. Maßnahme direkt aus dem Vorfall anlegen → landet im ZENTRALEN Maßnahmen-Modul
// (measures-Tabelle) UND ist mit dem Vorfall verknüpft (IncidentMeasure).
// 2. Risiko neu aus dem Vorfall erzeugen → im Risiko-Register + verknüpft (IncidentRisk).
// 3. Nachweis anlegen + verknüpfen (Evidence + IncidentEvidence).
// 4. Abschluss-Pflichtfelder (§8): abgeschlossen erzwingt Ursache/Lösung/Lessons
// Learned (+ Abschlussnotiz); Post-Incident-Review/Wirksamkeit sind NICHT Pflicht.
// 5. Register-Export (CSV) enthält die erwarteten Spalten/Werte.
// 6. NIS2- und DSGVO-Meldevorlagen sind vorbefüllt (Zeiten, Kategorie, Fristen).
//
// Lauf: npx tsx scripts/test-incident-links.ts (lokale isms-DB, .env im Repo).
import "dotenv/config";
import { prisma, dbForTenant } from "../src/server/db";
import { nextIncidentRefNo } from "../src/server/incident-refno";
import { missingRequiredFields } from "../src/lib/incident";
import {
INCIDENT_REGISTER_COLUMNS,
toRegisterCsv,
buildNis2Template,
buildDsgvoTemplate,
type IncidentRegisterInput,
type IncidentTemplateInput,
} from "../src/lib/incident-export";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
const SLUG = "zz-inc-links-a";
const EMAIL = "zz-inc-links-owner@test.example";
async function cleanup() {
const t = await prisma.tenant.findFirst({ where: { slug: SLUG }, select: { id: true } });
if (t) {
await prisma.incident.deleteMany({ where: { tenantId: t.id } });
await prisma.measure.deleteMany({ where: { tenantId: t.id } });
await prisma.risk.deleteMany({ where: { tenantId: t.id } });
await prisma.evidence.deleteMany({ where: { tenantId: t.id } });
await prisma.user.deleteMany({ where: { tenantId: t.id } });
await prisma.tenant.delete({ where: { id: t.id } });
}
await prisma.identity.deleteMany({ where: { email: EMAIL } });
}
async function nextMeasureRef(tenantId: string) {
const last = await prisma.measure.aggregate({ where: { tenantId }, _max: { refNo: true } });
return (last._max.refNo ?? 0) + 1;
}
async function nextRiskRef(tenantId: string) {
const last = await prisma.risk.aggregate({ where: { tenantId }, _max: { refNo: true } });
return (last._max.refNo ?? 0) + 1;
}
async function main() {
await cleanup();
const tenant = await prisma.tenant.create({ data: { name: "IM-C Test", slug: SLUG } });
const identity = await prisma.identity.create({ data: { email: EMAIL, passwordHash: "x" } });
const user = await prisma.user.create({ data: { tenantId: tenant.id, identityId: identity.id, email: EMAIL, name: "Owner" } });
const db = dbForTenant(tenant.id);
const occurredAt = new Date("2026-08-15T08:00:00Z");
const detectedAt = new Date("2026-08-16T09:30:00Z");
const incident = await db.incident.create({
data: {
tenantId: tenant.id,
refNo: await nextIncidentRefNo(prisma, tenant.id),
title: "Ransomware auf Fileserver",
description: "Verschlüsselte Freigaben; Verdacht auf Datenabfluss.",
category: "malware",
severity: "hoch",
status: "in_bearbeitung",
occurredAt,
detectedAt,
impactC: 3,
impactI: 4,
impactA: 4,
urgency: 4,
affectedDataCategories: ["Kundendaten", "Zugangsdaten"],
personalData: true,
dsgvoRelevant: true,
nis2Relevant: true,
ownerId: user.id,
// Meldefristen wie von IM-B aus dem Kenntniszeitpunkt gesetzt (24h/72h/1M).
erstmeldungDueAt: new Date(detectedAt.getTime() + 24 * 3600_000),
folgemeldungDueAt: new Date(detectedAt.getTime() + 72 * 3600_000),
dsgvoDueAt: new Date(detectedAt.getTime() + 72 * 3600_000),
reportStatus: "pruefung",
},
});
// ── 1. Maßnahme direkt aus dem Vorfall anlegen + verknüpfen ────────────────
const measure = await db.measure.create({
data: { tenantId: tenant.id, refNo: await nextMeasureRef(tenant.id), title: "Systeme isolieren & Backups prüfen", priority: "HIGH", ownerId: user.id, createdBy: user.id },
});
await db.incidentMeasure.create({ data: { tenantId: tenant.id, incidentId: incident.id, measureId: measure.id } });
const centralMeasure = await db.measure.findFirst({ where: { id: measure.id } });
ok(!!centralMeasure && centralMeasure.tenantId === tenant.id, "Maßnahme liegt im zentralen Maßnahmen-Modul (measures-Tabelle)");
const linkedMeasures = await db.incidentMeasure.findMany({ where: { incidentId: incident.id }, include: { measure: true } });
ok(linkedMeasures.length === 1 && linkedMeasures[0].measure.title === measure.title, "Maßnahme ist mit dem Vorfall verknüpft (IncidentMeasure, Titel/Status aus dem Modul)");
ok(linkedMeasures[0].measure.status === "OPEN" && linkedMeasures[0].measure.priority === "HIGH", "Verknüpfte Maßnahme trägt Status/Priorität aus dem Maßnahmen-Modul");
// ── 2. Risiko neu erzeugen + verknüpfen ────────────────────────────────────
const risk = await db.risk.create({
data: { tenantId: tenant.id, refNo: await nextRiskRef(tenant.id), title: "Unzureichende Backup-Isolierung", likelihood: 3, impact: 5, score: 15, createdBy: user.id },
});
await db.incidentRisk.create({ data: { tenantId: tenant.id, incidentId: incident.id, riskId: risk.id } });
const centralRisk = await db.risk.findFirst({ where: { id: risk.id } });
ok(!!centralRisk && centralRisk.score === 15, "Risiko im Register angelegt (score = E×S)");
const linkedRisks = await db.incidentRisk.count({ where: { incidentId: incident.id } });
ok(linkedRisks === 1, "Risiko ist mit dem Vorfall verknüpft (IncidentRisk)");
// ── 3. Nachweis anlegen + verknüpfen ───────────────────────────────────────
const ev = await db.evidence.create({ data: { tenantId: tenant.id, title: "Forensik-Report Erstsichtung", kind: "record", createdById: user.id } });
await db.incidentEvidence.create({ data: { tenantId: tenant.id, incidentId: incident.id, evidenceId: ev.id, note: "PDF im DMS" } });
const linkedEv = await db.incidentEvidence.findMany({ where: { incidentId: incident.id }, include: { evidence: true } });
ok(linkedEv.length === 1 && linkedEv[0].evidence.title === ev.title, "Nachweis verknüpft (IncidentEvidence)");
// ── 4. Abschluss-Pflichtfelder (§8) ────────────────────────────────────────
const missWithout = missingRequiredFields("abgeschlossen", { rootCause: "x", resolution: "y", closingNote: "z", lessonsLearned: "" });
ok(missWithout.includes("lessonsLearned"), "Abschluss erzwingt Lessons Learned (fehlt → blockiert)");
const missAll = missingRequiredFields("abgeschlossen", { rootCause: "Ursache", resolution: "Lösung", closingNote: "Notiz", lessonsLearned: "LL", postIncidentReview: null, measuresEffectiveness: null });
ok(missAll.length === 0, "Abschluss mit Ursache/Lösung/Abschlussnotiz/Lessons Learned zulässig — Review/Wirksamkeit NICHT erzwungen");
// ── 5. Register-Export (CSV) ───────────────────────────────────────────────
const regInput: IncidentRegisterInput = {
refNo: incident.refNo,
title: incident.title,
category: incident.category,
severity: incident.severity,
priority: incident.priority,
status: incident.status,
source: incident.source,
occurredAt: incident.occurredAt,
detectedAt: incident.detectedAt,
reportedAt: incident.reportedAt,
ownerName: user.name,
assigneeName: null,
nis2Relevant: incident.nis2Relevant,
dsgvoRelevant: incident.dsgvoRelevant,
personalData: incident.personalData,
prototypeData: incident.prototypeData,
reportStatus: incident.reportStatus,
erstmeldungDueAt: incident.erstmeldungDueAt,
dsgvoDueAt: incident.dsgvoDueAt,
abschlussDueAt: incident.abschlussDueAt,
measureCount: 1,
riskCount: 1,
assetCount: 0,
controlCount: 0,
createdAt: incident.createdAt,
};
const csv = toRegisterCsv([regInput]);
const header = csv.split("\r\n")[0];
ok(INCIDENT_REGISTER_COLUMNS.every((c) => header.includes(c)), "Register-CSV enthält alle erwarteten Spalten (Kennung … Maßnahmen … Erstellt)");
ok(csv.includes(incident.refNo) && csv.includes("Ransomware auf Fileserver"), "Register-CSV-Zeile enthält refNo + Titel");
ok(csv.includes("Schadsoftware"), "Register-CSV übersetzt die Kategorie (Schadsoftware)");
// ── 6. Meldevorlagen (NIS2 / DSGVO) ────────────────────────────────────────
const tmpl: IncidentTemplateInput = {
refNo: incident.refNo,
title: incident.title,
description: incident.description,
category: incident.category,
severity: incident.severity,
organisation: tenant.name,
occurredAt: incident.occurredAt,
detectedAt: incident.detectedAt,
reportedAt: incident.reportedAt,
impactC: incident.impactC,
impactI: incident.impactI,
impactA: incident.impactA,
affectedDataCategories: incident.affectedDataCategories,
personalData: incident.personalData,
nis2Relevant: incident.nis2Relevant,
reporterName: incident.reporterName,
reporterContact: incident.reporterContact,
erstmeldungDueAt: incident.erstmeldungDueAt,
folgemeldungDueAt: incident.folgemeldungDueAt,
abschlussDueAt: incident.abschlussDueAt,
dsgvoDueAt: incident.dsgvoDueAt,
};
const nis2 = buildNis2Template(tmpl);
ok(nis2.includes("NIS2") && nis2.includes(incident.refNo) && nis2.includes("Schadsoftware"), "NIS2-Vorlage vorbefüllt (refNo + Kategorie)");
ok(nis2.includes("24 h") && nis2.includes("72 h") && nis2.includes("1 Monat"), "NIS2-Vorlage nennt die Meldefristen 24 h / 72 h / 1 Monat");
ok(nis2.includes("IM-C Test"), "NIS2-Vorlage trägt die meldende Organisation");
const dsgvo = buildDsgvoTemplate(tmpl);
ok(dsgvo.includes("Art. 33") && dsgvo.includes("72 h"), "DSGVO-Vorlage referenziert Art. 33 (72 h)");
ok(dsgvo.includes("Kundendaten") && dsgvo.includes("Art. 34"), "DSGVO-Vorlage enthält betroffene Datenkategorien + Art.-34-Hinweis");
await cleanup();
if (failures) {
console.error(`\n✗ ${failures} Prüfung(en) fehlgeschlagen.`);
process.exit(1);
}
console.log("\n✓ Alle IM-C-Verknüpfungs-/Export-Prüfungen bestanden.");
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+153
View File
@@ -0,0 +1,153 @@
// 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());
+103
View File
@@ -0,0 +1,103 @@
// WS3-Abnahmetest (Option C) — Einladungs-Lifecycle.
//
// Anlage erfolgt ausschließlich per Einladung (goldene Regel 4): ein Einladungs-
// Token (TokenType "invitation", Prinzipal = Identity) führt auf /invite, wo der
// Eingeladene sein Erst-Passwort an der globalen Identity setzt.
//
// Geprüft:
// 1. checkInvitationToken erkennt einen gültigen Einladungs-Token.
// 2. redeemInvitation setzt das Passwort an der Identity; Login funktioniert sofort.
// 3. Token ist Single-use (zweite Einlösung scheitert).
// 4. Ein password_reset-Token wird vom Einladungs-Flow NICHT akzeptiert (Typ-Trennung).
//
// Lauf: npx tsx scripts/test-invitation.ts (setzt den Demo-Seed voraus)
import "dotenv/config";
import { prisma } from "../src/server/db";
import { hashPassword, verifyPassword } from "../src/server/password";
import { issueToken, peekToken } from "../src/server/auth-token";
import { checkInvitationToken, redeemInvitation } from "../src/server/actions/auth-recovery";
import { authorizeTenantCredentials } from "../src/server/auth";
const EMAIL = "ws3-invite@demo.example";
const NEW_PW = "Einladungs-Passwort-1!";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
async function cleanup() {
const identity = await prisma.identity.findUnique({ where: { email: EMAIL } });
if (identity) await prisma.authToken.deleteMany({ where: { principalId: identity.id } });
await prisma.user.deleteMany({ where: { email: EMAIL } });
await prisma.identity.deleteMany({ where: { email: EMAIL } });
}
function form(token: string, pw: string): FormData {
const fd = new FormData();
fd.set("token", token);
fd.set("password", pw);
fd.set("confirm", pw);
return fd;
}
async function main() {
await cleanup();
const demo = await prisma.tenant.findUniqueOrThrow({ where: { slug: "demo" } });
const role = await prisma.role.findFirst({ where: { tenantId: demo.id, key: "user" } });
// Eingeladene, noch nicht aktivierte Person: Identity (Throwaway-Passwort) + Mitgliedschaft.
const identity = await prisma.identity.create({
data: { email: EMAIL, passwordHash: await hashPassword("throwaway-xyz"), mustChangePassword: true, status: "ACTIVE" },
});
await prisma.user.create({
data: {
tenantId: demo.id, identityId: identity.id, email: EMAIL, name: "WS3 Invite",
status: "ACTIVE", ...(role ? { userRoles: { create: [{ roleId: role.id }] } } : {}),
},
});
console.log("\n— 1) Einladungs-Token wird erkannt —");
const { raw } = await issueToken({ principalType: "identity", principalId: identity.id, tenantId: demo.id, type: "invitation" });
ok(await checkInvitationToken(raw), "checkInvitationToken(raw) === true");
console.log("\n— 2) Einlösung setzt das Passwort an der Identity; Login funktioniert —");
const done = await redeemInvitation({ status: "idle" }, form(raw, NEW_PW));
ok(done.status === "done", `redeemInvitation → done (${done.status})`);
const after = await prisma.identity.findUniqueOrThrow({ where: { id: identity.id } });
ok(await verifyPassword(after.passwordHash, NEW_PW), "Identity-Passwort ist das neu gesetzte");
ok(after.mustChangePassword === false, "mustChangePassword zurückgesetzt");
const login = await authorizeTenantCredentials({ email: EMAIL, password: NEW_PW, tenant: "demo" });
ok(!!login && login.identityId === identity.id, "Login mit dem neuen Passwort erfolgreich");
console.log("\n— 3) Single-use —");
ok((await peekToken(raw, "invitation")) === null, "Token nach Einlösung verbraucht");
const second = await redeemInvitation({ status: "idle" }, form(raw, NEW_PW));
ok(second.status === "error", "zweite Einlösung abgewiesen");
console.log("\n— 4) Typ-Trennung: password_reset ≠ invitation —");
const reset = await issueToken({ principalType: "identity", principalId: identity.id, tenantId: demo.id, type: "password_reset" });
ok((await checkInvitationToken(reset.raw)) === false, "Reset-Token wird vom Einladungs-Flow nicht akzeptiert");
const wrongType = await redeemInvitation({ status: "idle" }, form(reset.raw, NEW_PW));
ok(wrongType.status === "error", "redeemInvitation lehnt password_reset-Token ab");
await cleanup();
}
main()
.then(async () => {
await prisma.$disconnect();
if (failures > 0) {
console.error(`\n✗ ${failures} Testfall/-fälle fehlgeschlagen.`);
process.exit(1);
}
console.log("\nOK");
})
.catch(async (e) => {
console.error(e);
await cleanup().catch(() => {});
await prisma.$disconnect();
process.exit(1);
});
+146
View File
@@ -0,0 +1,146 @@
import "dotenv/config";
import { prisma } from "../src/server/db";
import { enqueueMail } from "../src/server/mail/service";
import { renderTemplate, TEMPLATE_KEYS, formatWhen } from "../src/server/mail/templates";
import { getMailConfig, resetMailConfigCache } from "../src/server/mail/config";
import { closeQueues, isQueueEnabled, isQueueReady } from "../src/server/mail/queue";
import { closeMailProvider } from "../src/server/mail/provider-smtp";
/**
* SEC1 — Abnahmetest der Mail-Strecke (`npx tsx scripts/test-mail.ts`).
*
* Prüft ohne laufende App:
* 1. Alle Templates rendern in de und en, HTML **und** Text, mit Certvia-
* Branding und Dachmarken-Fußzeile.
* 2. Transaktionsmails tragen KEINEN Abmelde-Hinweis, Benachrichtigungen schon.
* 3. Ein echter Versand landet im lokalen SMTP (Mailhog/Mailpit) und das
* MailLog steht auf `sent` mit providerMessageId.
* 4. Idempotenz: derselbe dedupeKey erzeugt nur eine Mail.
* 5. Fehlende SMTP-Konfiguration führt zu `pending` + Begründung, nicht zu
* einem scheinbar erfolgreichen Versand.
*
* Voraussetzung für 3./4.: lokaler SMTP auf SMTP_HOST/SMTP_PORT
* (`docker compose up -d mailhog` → localhost:1025).
*/
let failures = 0;
function check(name: string, ok: boolean, detail?: string) {
if (ok) {
console.log(` ✓ ${name}`);
} else {
failures++;
console.error(` ✗ ${name}${detail ? ` — ${detail}` : ""}`);
}
}
const SAMPLE = {
invitation: { name: "Erika Muster", tenantName: "Muster GmbH", actionUrl: "https://example.test/a", expires: "morgen" },
password_reset: { name: "Erika Muster", actionUrl: "https://example.test/r", expires: "in 60 Minuten" },
password_changed: { name: "Erika Muster", when: "heute", ip: "203.0.113.7" },
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" },
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;
async function main() {
console.log("1) Template-Rendering (de/en, HTML + Text)");
for (const key of TEMPLATE_KEYS) {
for (const locale of ["de", "en"] as const) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const r = renderTemplate(key, locale, SAMPLE[key] as any);
const ok =
r.subject.length > 0 &&
r.html.includes("<!doctype html>") &&
r.html.includes("Certvia") &&
r.html.includes("Ein Produkt von GEFIM") &&
r.text.length > 0 &&
!r.text.includes("<");
check(`${key}/${locale}`, ok, `subject="${r.subject}"`);
}
}
console.log("2) Abmelde-Hinweis nur bei Benachrichtigungen");
const notif = renderTemplate("notification", "de", SAMPLE.notification);
const reset = renderTemplate("password_reset", "de", SAMPLE.password_reset);
check("notification trägt Präferenz-Hinweis", notif.text.includes("Einstellungen dazu"));
check("password_reset trägt KEINEN Abmelde-Hinweis", !reset.text.includes("Einstellungen dazu"));
const { config } = getMailConfig();
if (!config) {
console.log("3-4) übersprungen — keine SMTP-Konfiguration gesetzt.");
} else {
const mode = isQueueEnabled() && isQueueReady() ? "Queue (BullMQ)" : "inline";
console.log(`3) Versand über ${config.host}:${config.port} — Modus: ${mode}`);
const key = `sec1-test:${process.pid}`;
const first = await enqueueMail({
template: "test",
to: "sec1-abnahme@example.test",
tenantId: null,
locale: "de",
dedupeKey: key,
vars: { name: "Abnahme", when: formatWhen(new Date(), "de") },
});
check("Versand erfolgreich", first.status === "sent" || first.status === "queued", first.status);
if ("mailLogId" in first) {
const row = await prisma.mailLog.findUnique({ where: { id: first.mailLogId } });
check("MailLog-Status", row?.status === "sent" || row?.status === "pending", row?.status);
check("scope=platform bei tenantId=null", row?.scope === "platform");
if (row?.status === "sent") {
check("providerMessageId gesetzt", Boolean(row.providerMessageId));
check("keine Klartext-Secrets im Log", !JSON.stringify(row).includes("password"));
}
}
console.log("4) Idempotenz");
const second = await enqueueMail({
template: "test",
to: "sec1-abnahme@example.test",
tenantId: null,
locale: "de",
dedupeKey: key,
vars: { name: "Abnahme", when: formatWhen(new Date(), "de") },
});
check("zweiter Aufruf mit gleichem dedupeKey → duplicate", second.status === "duplicate", second.status);
// Aufräumen
if ("mailLogId" in first) {
await prisma.mailLog.delete({ where: { id: first.mailLogId } }).catch(() => {});
}
}
console.log("5) Fehlende SMTP-Konfiguration");
const saved = process.env.SMTP_HOST;
delete process.env.SMTP_HOST;
resetMailConfigCache();
const missing = getMailConfig();
check("Konfiguration wird als unvollständig erkannt", missing.config === null);
check("Begründung vorhanden", Boolean(missing.reason?.includes("SMTP_HOST")), missing.reason);
if (saved) process.env.SMTP_HOST = saved;
resetMailConfigCache();
await cleanup();
if (failures > 0) {
console.error(`\n✗ ${failures} Prüfung(en) fehlgeschlagen.`);
process.exit(1);
}
console.log("\n✓ SEC1-Mailstrecke: alle Prüfungen bestanden.");
// Explizit beenden: offene Sockets (SMTP-Pool, Redis) halten sonst den
// Event-Loop offen, obwohl alle Prüfungen durch sind.
process.exit(0);
}
async function cleanup() {
await prisma.$disconnect();
await closeMailProvider();
await closeQueues();
}
main().catch(async (err) => {
console.error(err);
await cleanup();
process.exit(1);
});
+98
View File
@@ -0,0 +1,98 @@
// 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);
+106
View File
@@ -0,0 +1,106 @@
// Korrektheitsnachweis für F-17: MFA-Härtung (Krypto + TOTP-Replay-Schutz).
//
// Drei Nachweise (jeweils Assertion; am Ende "OK" oder Exit-Code != 0):
// (1) Recovery-Codes: ein generierter Code verifiziert korrekt gegen seinen
// Argon2id-Hash (matchRecovery findet ihn), ein falscher Code nicht.
// Zusatz: der Kompatibilitätspfad akzeptiert einen Bestands-Hash im
// SHA-256-Format weiterhin.
// (2) Entropie/Format: 4er-Gruppierung, 20 Hex-Zeichen normalisiert = 80 Bit
// Entropie (>= 64 Bit gefordert), Hashes sind Argon2id ($argon2id$).
// (3) TOTP-Replay: derselbe Code/Zeitschritt wird beim zweiten Mal (mit
// afterStep = lastTotpStep) abgelehnt; ein älterer Zeitschritt lässt den
// aktuellen Code weiterhin zu.
//
// Lauf: npx tsx scripts/test-mfa-hardening.ts (reine Krypto-/TOTP-Logik, keine DB).
import "dotenv/config";
import { createHash } from "node:crypto";
import { generateSync } from "otplib";
import {
generateRecoveryCodes,
matchRecovery,
hashRecovery,
verifyTotp,
newTotpSecret,
} from "@/server/mfa";
function assert(cond: unknown, msg: string): asserts cond {
if (!cond) {
console.error(`FEHLGESCHLAGEN: ${msg}`);
process.exit(1);
}
console.log(` ok: ${msg}`);
}
async function main() {
// ── (1) Recovery-Codes: Argon2id-Verifikation korrekt/ falsch ────────────
console.log("\n(1) Recovery-Codes gegen Argon2id-Hashes:");
const { plain, hashed } = await generateRecoveryCodes(10);
assert(plain.length === 10 && hashed.length === 10, "10 Codes + 10 Hashes erzeugt");
assert(
hashed.every((h) => h.startsWith("$argon2id$")),
"alle Hashes sind Argon2id ($argon2id$)",
);
// Ein echter Code (mit und ohne Gruppierungs-Bindestriche) wird gefunden.
const idx = await matchRecovery(plain[3], hashed);
assert(idx === 3, "korrekter Recovery-Code wird am richtigen Index gefunden");
const idxNormalized = await matchRecovery(plain[3].replace(/-/g, "").toUpperCase(), hashed);
assert(idxNormalized === 3, "Code wird normalisiert erkannt (ohne Bindestriche, Großschreibung)");
// Ein falscher Code wird abgelehnt.
const idxWrong = await matchRecovery("0000-0000-0000-0000", hashed);
assert(idxWrong === -1, "falscher Recovery-Code wird abgelehnt (-1)");
// Kompatibilitätspfad: ein Bestands-Hash im SHA-256-Format wird weiter akzeptiert.
const legacyPlain = "abcd-1234-ef56";
const legacySha = createHash("sha256").update(legacyPlain.replace(/[\s-]/g, "").toLowerCase()).digest("hex");
const legacyIdx = await matchRecovery(legacyPlain, [legacySha]);
assert(legacyIdx === 0, "Bestands-Hash (SHA-256, 64 Hex) wird übergangsweise akzeptiert");
const legacyWrong = await matchRecovery("ffff-ffff-ffff", [legacySha]);
assert(legacyWrong === -1, "falscher Code gegen SHA-256-Bestandshash wird abgelehnt");
// ── (2) Entropie / Format ────────────────────────────────────────────────
console.log("\n(2) Entropie und Format:");
const groupFormat = /^[0-9a-f]{5}-[0-9a-f]{5}-[0-9a-f]{5}-[0-9a-f]{5}$/;
assert(plain.every((c) => groupFormat.test(c)), "Format 5-5-5-5 Hex mit Gruppierung");
const normalizedLen = plain[0].replace(/-/g, "").length;
assert(normalizedLen === 20, "20 Hex-Zeichen normalisiert (= 80 Bit Entropie, >= 64 gefordert)");
const bits = normalizedLen * 4;
assert(bits >= 64, `Entropie ${bits} Bit >= 64 Bit`);
// hashRecovery liefert einen frischen Argon2id-Hash (Salt → zwei Hashes verschieden).
const h1 = await hashRecovery(plain[0]);
const h2 = await hashRecovery(plain[0]);
assert(h1.startsWith("$argon2id$") && h2.startsWith("$argon2id$"), "hashRecovery liefert Argon2id");
assert(h1 !== h2, "gesalzen: zwei Hashes desselben Codes unterscheiden sich");
// ── (3) TOTP-Replay-Schutz ────────────────────────────────────────────────
console.log("\n(3) TOTP-Replay-Schutz:");
const secret = newTotpSecret();
const token = generateSync({ secret }); // Code für den aktuellen Zeitschritt
const first = verifyTotp(token, secret);
assert(first.ok, "TOTP-Code verifiziert im aktuellen Zeitfenster");
const step = first.ok ? first.step : -1;
console.log(` (akzeptierter Zeitschritt: ${step})`);
// Zweite Verwendung mit lastTotpStep = step → Replay wird abgelehnt.
const replay = verifyTotp(token, secret, step);
assert(!replay.ok, "derselbe Code/Zeitschritt wird beim zweiten Mal abgelehnt (Replay)");
// Ein älterer lastTotpStep (step - 1) lehnt den aktuellen Code NICHT ab.
const older = verifyTotp(token, secret, step - 1);
assert(older.ok && (older.ok ? older.step : -1) === step, "älterer Zeitschritt lässt den aktuellen Code zu");
// Ein offensichtlich falscher Code ist immer ungültig.
const bad = verifyTotp("000000", secret);
assert(!bad.ok, "falscher 6-stelliger Code wird abgelehnt");
console.log("\nOK — alle F-17-Nachweise bestanden.");
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+59
View File
@@ -0,0 +1,59 @@
// Härtung §1 — Pepper-Abnahmetest.
//
// Weist nach, dass der PASSWORD_PEPPER tatsächlich in Argon2-Hash UND -Verify eingeht:
// 1. hashPassword+verifyPassword (beide peppered) → true.
// 2. Verify OHNE Pepper (roher argon2) gegen einen peppered Hash → false.
// 3. Verify mit FALSCHEM Pepper → false.
// 4. Falsches Passwort mit korrektem Pepper → false.
// 5. Recovery-Codes (mfa.ts nutzt hashPassword/verifyPassword) matchen mit Pepper.
//
// Lauf: npx tsx scripts/test-password-pepper.ts (PASSWORD_PEPPER muss gesetzt sein)
import "dotenv/config";
import { verify as rawVerify } from "@node-rs/argon2";
import { hashPassword, verifyPassword } from "../src/server/password";
import { generateRecoveryCodes, matchRecovery } from "../src/server/mfa";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
const PW = "Pepper-Test-Passwort-1!";
async function main() {
ok(/^[0-9a-fA-F]{64}$/.test(process.env.PASSWORD_PEPPER ?? ""), "PASSWORD_PEPPER ist gesetzt (32-Byte hex)");
const h = await hashPassword(PW);
console.log("\n— 1) korrekter Pepper —");
ok(await verifyPassword(h, PW), "verifyPassword mit korrektem Pepper → true");
console.log("\n— 2/3) fehlender / falscher Pepper —");
ok((await rawVerify(h, PW).catch(() => false)) === false, "verify OHNE Pepper → false (Pepper geht wirklich in den Hash)");
const wrongPepper = Buffer.alloc(32, 0x11);
ok((await rawVerify(h, PW, { secret: wrongPepper }).catch(() => false)) === false, "verify mit FALSCHEM Pepper → false");
console.log("\n— 4) falsches Passwort —");
ok((await verifyPassword(h, "falsch!!")) === false, "falsches Passwort (korrekter Pepper) → false");
console.log("\n— 5) Recovery-Codes mit Pepper —");
const { plain, hashed } = await generateRecoveryCodes(3);
ok((await matchRecovery(plain[0]!, hashed)) === 0, "gültiger Recovery-Code matcht (peppered hash/verify)");
ok((await matchRecovery("000-000", hashed)) === -1, "ungültiger Recovery-Code matcht nicht");
}
main()
.then(() => {
if (failures > 0) {
console.error(`\n✗ ${failures} Testfall/-fälle fehlgeschlagen.`);
process.exit(1);
}
console.log("\nOK");
process.exit(0);
})
.catch((e) => {
console.error(e);
process.exit(1);
});
+56
View File
@@ -0,0 +1,56 @@
// 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);
+88
View File
@@ -0,0 +1,88 @@
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); });
+203
View File
@@ -0,0 +1,203 @@
import "dotenv/config";
import { prisma } from "../src/server/db";
import { hashPassword, verifyPassword } from "../src/server/password";
import { redeemPasswordReset, requestPasswordReset } from "../src/server/actions/auth-recovery";
import { issueToken, peekToken } from "../src/server/auth-token";
import { resetRateLimits } from "../src/server/rate-limit";
import { closeQueues } from "../src/server/mail/queue";
import { closeMailProvider } from "../src/server/mail/provider-smtp";
/**
* SEC2 — End-to-End des Reset-Ablaufs gegen ein Wegwerf-Konto
* (`npx tsx scripts/test-reset-flow.ts`).
*
* Ergänzt `test-auth-selfservice.ts` (dort: die Token-/Limit-Eigenschaften
* isoliert) um das Zusammenspiel: Anfrage → Token → Einlösung → Passwort
* geschrieben → Sessions entwertet → Bestätigungsmail → Audit.
*
* Zusätzlich die beiden Fälle, die man leicht falsch baut:
* - ein **Policy-Verstoß darf den Link nicht entwerten** (sonst ist der Nutzer
* nach einem Tippfehler ausgesperrt),
* - ein **deaktiviertes Konto** bekommt keinen Reset, aber dieselbe Antwort.
*
* Das Testkonto wird angelegt und am Ende restlos entfernt.
*/
const EMAIL = "sec2-e2e@example.test";
const OLD_PW = "Sec2-E2E-Alt-2026!";
const NEW_PW = "Sec2-E2E-Neu-2026!";
let failures = 0;
function check(name: string, ok: boolean, detail?: string) {
if (ok) console.log(` ✓ ${name}`);
else {
failures++;
console.error(` ✗ ${name}${detail ? ` — ${detail}` : ""}`);
}
}
async function main() {
resetRateLimits();
const tenant = await prisma.tenant.findFirst({ where: { status: "ACTIVE" } });
if (!tenant) throw new Error("Kein aktiver Mandant vorhanden — bitte zuerst seeden.");
await prisma.user.deleteMany({ where: { email: EMAIL } });
await prisma.identity.deleteMany({ where: { email: EMAIL, memberships: { none: {} } } });
// Option C: Mitgliedschaft braucht eine globale Identity (Anmeldung).
const identity = await prisma.identity.create({ data: { email: EMAIL, passwordHash: await hashPassword(OLD_PW) } });
const user = await prisma.user.create({
data: {
tenantId: tenant.id,
identityId: identity.id,
email: EMAIL,
name: "SEC2 E2E",
status: "ACTIVE",
},
});
try {
console.log("1) Anfrage");
const fd = new FormData();
fd.set("email", EMAIL);
fd.set("domain", "tenant");
const req = await requestPasswordReset({ status: "idle" }, fd);
check("neutrale Antwort", req.status === "done");
const unknownEmail = `unbekannt-${Date.now()}@example.test`;
const fdUnknown = new FormData();
fdUnknown.set("email", unknownEmail);
fdUnknown.set("domain", "tenant");
const reqUnknown = await requestPasswordReset({ status: "idle" }, fdUnknown);
check("unbekannte Adresse: identische Antwort", JSON.stringify(req) === JSON.stringify(reqUnknown));
check(
"keine Mail an die unbekannte Adresse",
(await prisma.mailLog.count({ where: { to: unknownEmail } })) === 0,
);
check(
"Token nur für das existierende Konto (Prinzipal = Identity)",
(await prisma.authToken.count({ where: { principalId: identity.id, usedAt: null } })) === 1,
);
const resetMail = await prisma.mailLog.findFirst({
where: { to: EMAIL, template: "password_reset" },
});
check("Reset-Mail eingestellt", resetMail != null, resetMail?.status);
// Das Rohtoken existiert nur im Link. Für den weiteren Ablauf einen frischen
// Link ausstellen — die Neuanforderung entwertet den vorherigen (geprüft in
// test-auth-selfservice.ts).
const issued = await issueToken({
principalType: "identity",
principalId: identity.id,
tenantId: tenant.id,
type: "password_reset",
});
console.log("2) Policy-Verstoß entwertet den Link nicht");
const weak = new FormData();
weak.set("token", issued.raw);
weak.set("domain", "tenant");
weak.set("password", "kurz");
weak.set("confirm", "kurz");
check("schwaches Passwort abgelehnt", (await redeemPasswordReset({ status: "idle" }, weak)).status === "error");
check("Link danach noch gültig", (await peekToken(issued.raw, "password_reset")) != null);
const mismatch = new FormData();
mismatch.set("token", issued.raw);
mismatch.set("domain", "tenant");
mismatch.set("password", NEW_PW);
mismatch.set("confirm", `${NEW_PW}x`);
check(
"abweichende Wiederholung abgelehnt",
(await redeemPasswordReset({ status: "idle" }, mismatch)).status === "error",
);
check("Link weiterhin gültig", (await peekToken(issued.raw, "password_reset")) != null);
console.log("3) Einlösung (Passwort/Session an der Identity)");
const before = await prisma.identity.findUniqueOrThrow({ where: { id: identity.id } });
const fd2 = new FormData();
fd2.set("token", issued.raw);
fd2.set("domain", "tenant");
fd2.set("password", NEW_PW);
fd2.set("confirm", NEW_PW);
const done = await redeemPasswordReset({ status: "idle" }, fd2);
check("Einlösung erfolgreich", done.status === "done", JSON.stringify(done));
const after = await prisma.identity.findUniqueOrThrow({ where: { id: identity.id } });
check("Passwort-Hash (Identity) geändert", before.passwordHash !== after.passwordHash);
check("neues Passwort gültig", await verifyPassword(after.passwordHash, NEW_PW));
check("altes Passwort ungültig", !(await verifyPassword(after.passwordHash, OLD_PW)));
check("Sessions invalidiert (Identity)", after.sessionsValidAfter != null);
check("Force-Change zurückgesetzt (Identity)", after.mustChangePassword === false);
check("Link verbraucht", (await peekToken(issued.raw, "password_reset")) === null);
const secondUse = new FormData();
secondUse.set("token", issued.raw);
secondUse.set("domain", "tenant");
secondUse.set("password", NEW_PW);
secondUse.set("confirm", NEW_PW);
check(
"zweite Einlösung abgewiesen",
(await redeemPasswordReset({ status: "idle" }, secondUse)).status === "error",
);
const confirmMail = await prisma.mailLog.findFirst({
where: { to: EMAIL, template: "password_changed" },
});
check("Bestätigungsmail versendet", confirmMail != null, confirmMail?.status);
const audit = await prisma.auditLog.findFirst({
where: { entity: "password_reset", entityId: identity.id },
});
check("Audit-Eintrag vorhanden", audit != null);
check(
"kein Token im Audit",
audit == null || !JSON.stringify(audit).includes(issued.raw.slice(0, 12)),
);
check(
"kein Token im MailLog",
(await prisma.mailLog.count({ where: { dedupeKey: { contains: issued.raw.slice(0, 12) } } })) === 0,
);
console.log("4) Deaktivierte Identity");
// Option C: das Konto ist die Identity — Deaktivierung wirkt an ihr, nicht an
// der einzelnen Mitgliedschaft.
await prisma.identity.update({ where: { id: identity.id }, data: { status: "DISABLED" } });
resetRateLimits();
const openBefore = await prisma.authToken.count({
where: { principalId: identity.id, type: "password_reset", usedAt: null },
});
const fd3 = new FormData();
fd3.set("email", EMAIL);
fd3.set("domain", "tenant");
const req3 = await requestPasswordReset({ status: "idle" }, fd3);
const openAfter = await prisma.authToken.count({
where: { principalId: identity.id, type: "password_reset", usedAt: null },
});
check("Antwort trotzdem neutral", req3.status === "done");
check("kein neuer Token", openAfter === openBefore);
} finally {
await prisma.authToken.deleteMany({ where: { principalId: { in: [identity.id, user.id] } } });
await prisma.mailLog.deleteMany({ where: { to: EMAIL } });
await prisma.auditLog.deleteMany({ where: { actorId: { in: [identity.id, user.id] } } });
await prisma.user.delete({ where: { id: user.id } }).catch(() => {});
await prisma.identity.deleteMany({ where: { email: EMAIL } });
}
await prisma.$disconnect();
await closeMailProvider();
await closeQueues();
if (failures > 0) {
console.error(`\n✗ ${failures} Prüfung(en) fehlgeschlagen.`);
process.exit(1);
}
console.log("\n✓ SEC2 Reset-Ablauf: alle Prüfungen bestanden.");
process.exit(0);
}
main().catch(async (err) => {
console.error(err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});
+78
View File
@@ -0,0 +1,78 @@
// 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); });
+180
View File
@@ -0,0 +1,180 @@
// Korrektheitsnachweis für F-04: Row Level Security scharfschalten.
//
// Beweist, dass die scharfe RLS (FORCE + WITH CHECK + Kontext, Rolle isms_app)
// die Mandantentrennung erzwingt, OHNE den lokalen Owner-Betrieb zu brechen.
// Fünf Nachweise (jeweils Assertion; am Ende "OK" oder Exit-Code != 0):
// (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.
// (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
//
// 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";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
async function expectThrow(fn: () => Promise<unknown>, msg: string) {
try {
await fn();
ok(false, `${msg} — kein Throw (Isolationsbruch möglich!)`);
} catch {
ok(true, msg);
}
}
const RLS_URL =
process.env.RLS_DATABASE_URL ??
"postgresql://isms_app:isms_app_local@localhost:5432/isms?schema=public";
const SLUG_A = "zz-rls-test-a";
const SLUG_B = "zz-rls-test-b";
// Eigener Owner-Client (DATABASE_URL) für Setup/Cleanup — unabhängig von db.ts,
// damit der spätere dynamische Import von db.ts (mit RLS_ENFORCED=true) sauber
// mit der bereits gesetzten Flag-Umgebung evaluiert.
const owner = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
// Client der eingeschränkten App-Rolle isms_app (unterliegt der scharfen RLS).
const app = new PrismaClient({
adapter: new PrismaPg({ connectionString: RLS_URL }),
});
async function cleanup() {
const tenants = await owner.tenant.findMany({
where: { slug: { in: [SLUG_A, SLUG_B] } },
select: { id: true },
});
const ids = tenants.map((t) => t.id);
if (ids.length) {
await owner.risk.deleteMany({ where: { tenantId: { in: ids } } });
await owner.tenant.deleteMany({ where: { id: { in: ids } } });
}
}
async function main() {
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" },
});
// ── (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();
ok(ownerTotal > 0, `(1b) Owner sieht global Zeilen (${ownerTotal} > 0)`);
// ── (2) RLS greift für isms_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 } });
});
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`,
);
// ── (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})`);
// ── (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" },
});
});
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" },
});
}),
"(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 },
});
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)",
);
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()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await owner.$disconnect();
await app.$disconnect();
});
+102
View File
@@ -0,0 +1,102 @@
// 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);
+72
View File
@@ -0,0 +1,72 @@
// 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);
+67
View File
@@ -0,0 +1,67 @@
// 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); });
+159
View File
@@ -0,0 +1,159 @@
// Regressionstest der Mandantentrennung (Sicherheitsbefund F-02).
//
// Prüft den Hybrid-Guard aus `src/server/db.ts` (`dbForTenant`): ein Fremdzugriff
// über `findUnique` darf keinen Datensatz eines anderen Mandanten preisgeben —
// weder mit `select`-Projektion ohne `tenantId` (der ursprünglich ausnutzbare
// 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.
import "dotenv/config";
import { prisma, dbForTenant } from "../src/server/db";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
/** Erwartet, dass `fn` wirft (z. B. Isolationsverletzung oder NotFound). */
async function expectThrow(fn: () => Promise<unknown>, msg: string) {
try {
await fn();
ok(false, `${msg} — kein Throw (Datenabfluss möglich!)`);
} catch {
ok(true, msg);
}
}
/** Erwartet, dass `fn` `null` liefert (kein Datensatz, kein Abfluss). */
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-sec-test-a";
const SLUG_B = "zz-sec-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.risk.deleteMany({ where: { tenantId: { in: ids } } });
await prisma.tenant.deleteMany({ where: { id: { in: ids } } });
}
}
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).
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 dbA = dbForTenant(tenantA.id);
console.log("\n— Fremdzugriff (Mandant A liest Risiko 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 } }),
"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 } }),
"findUnique + select MIT tenantId → null"
);
// (3) include (tenantId wäre ohnehin enthalten).
await expectNull(
() => dbA.risk.findUnique({ where: { id: riskB.id }, include: { riskMeasures: true } }),
"findUnique + include → null"
);
// (4) ohne select/include.
await expectNull(
() => dbA.risk.findUnique({ where: { id: riskB.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 } }),
"findUniqueOrThrow + select OHNE tenantId → Throw"
);
// (6) Compound-Unique-Key (tenantId_refNo) mit fremdem tenantId, select ohne tenantId.
await expectThrow(
() =>
dbA.risk.findUnique({
where: { tenantId_refNo: { tenantId: tenantB.id, refNo: riskB.refNo } },
select: { title: true },
}),
"findUnique über Compound-Key (fremd) + select → Throw (fail-closed)"
);
// (7) Compound-Unique-Key mit fremdem tenantId, ohne select.
await expectThrow(
() =>
dbA.risk.findUnique({
where: { tenantId_refNo: { tenantId: tenantB.id, refNo: riskB.refNo } },
}),
"findUnique über Compound-Key (fremd) ohne select → Throw"
);
console.log("\n— Legitimer Eigenzugriff (Mandant A liest eigenes Risiko 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");
// (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 },
});
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)");
// (10) ohne Projektion: voller Datensatz inkl. tenantId (Normalfall).
const own3 = await dbA.risk.findUnique({ where: { id: riskA.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");
}
main()
.then(async () => {
await cleanup();
await prisma.$disconnect();
if (failures > 0) {
console.error(`\n✗ ${failures} Testfall/-fälle fehlgeschlagen.`);
process.exit(1);
}
console.log("\nOK");
})
.catch(async (e) => {
console.error(e);
await cleanup().catch(() => {});
await prisma.$disconnect();
process.exit(1);
});
+71
View File
@@ -0,0 +1,71 @@
// WS2-Abnahmetest (Option C) — Mandantenwechsel: server-autoritative Auflösung.
//
// `resolveActiveMembership(identityId, membershipId)` ist der Kern von setActiveTenant
// (und des jwt-„update"-Triggers). Geprüft:
// 1. Eine Mitgliedschaft der eigenen Identity wird aufgelöst (Mandant + Rechte).
// 2. Rechte werden je Mandant NEU aufgelöst (demo user ≠ demo2 tenant-admin) —
// Nachweis der Wechsel-Isolation (keine token-eingefrorenen Fremdrechte).
// 3. Eine FREMDE Mitgliedschaft (andere Identity) wird abgelehnt (null).
// 4. Eine unbekannte Membership-ID wird abgelehnt (null).
//
// Lauf: npx tsx scripts/test-tenant-switch.ts (setzt den Demo-Seed voraus)
import "dotenv/config";
import { prisma } from "../src/server/db";
import { resolveActiveMembership } from "../src/server/auth";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
async function main() {
const multi = await prisma.identity.findUniqueOrThrow({
where: { email: "multi@demo.example" },
include: { memberships: { include: { tenant: true } } },
});
const inDemo = multi.memberships.find((m) => m.tenant.slug === "demo")!;
const inDemo2 = multi.memberships.find((m) => m.tenant.slug === "demo2")!;
console.log("\n— 1/2) Auflösung + Rechte je Mandant (Wechsel-Isolation) —");
const demo = await resolveActiveMembership(multi.id, inDemo.id);
const demo2 = await resolveActiveMembership(multi.id, inDemo2.id);
ok(demo?.tenantSlug === "demo", `demo-Mitgliedschaft → Mandant demo (${demo?.tenantSlug})`);
ok(demo2?.tenantSlug === "demo2", `demo2-Mitgliedschaft → Mandant demo2 (${demo2?.tenantSlug})`);
ok(demo?.roles.includes("user") ?? false, "in demo: Rolle user");
ok(demo2?.roles.includes("tenant-admin") ?? false, "in demo2: Rolle tenant-admin");
ok(
JSON.stringify([...(demo?.permissions ?? [])].sort()) !== JSON.stringify([...(demo2?.permissions ?? [])].sort()),
"Rechte je Mandant verschieden (neu aufgelöst, nicht token-eingefroren)"
);
ok(demo?.activeMembershipId === inDemo.id, "activeMembershipId = gewählte Mitgliedschaft");
console.log("\n— 3) Fremde Mitgliedschaft wird abgelehnt —");
// admin@demo ist eine ANDERE Identity; ihre Mitgliedschaft darf multi@ nicht aktivieren.
const admin = await prisma.identity.findUniqueOrThrow({
where: { email: "admin@demo.example" },
include: { memberships: { select: { id: true } } },
});
const foreign = await resolveActiveMembership(multi.id, admin.memberships[0]!.id);
ok(foreign === null, "Mitgliedschaft einer fremden Identity ⇒ null");
console.log("\n— 4) Unbekannte Membership-ID —");
const missing = await resolveActiveMembership(multi.id, "does-not-exist");
ok(missing === null, "unbekannte Membership-ID ⇒ null");
}
main()
.then(async () => {
await prisma.$disconnect();
if (failures > 0) {
console.error(`\n✗ ${failures} Testfall/-fälle fehlgeschlagen.`);
process.exit(1);
}
console.log("\nOK");
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+142
View File
@@ -0,0 +1,142 @@
// Nachweis der Rechte-Begrenzung bei der Rollenverwaltung (Sicherheitsbefund F-13).
//
// 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
// auf die eigene effektive Rechtemenge (autoritativ aus der DB) und unterbindet die
// Selbstzuweisung höher privilegierter Rollen.
//
// Dieser Test repliziert exakt die autoritative Query und die Begrenzungslogik aus
// src/server/actions/tenant-users.ts (actorEffectivePermissions / assertGrantableWithinActor /
// Selbstzuweisungs-Prüfung) und weist nach, dass die Entscheidungen korrekt kippen.
// (Ein direkter Action-Aufruf würde eine NextAuth-Session voraussetzen; die sicherheits-
// relevante Logik ist die Query + die Mengenprüfung.)
//
// Lauf: npx tsx scripts/test-tenant-users-authz.ts
import "dotenv/config";
import { prisma } from "../src/server/db";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
const SLUG = "zz-f13-authz-test";
// Repliziert actorEffectivePermissions() aus tenant-users.ts.
async function effectivePermissions(userId: string): Promise<Set<string>> {
const account = await prisma.user.findFirst({
where: { id: userId, status: "ACTIVE" },
select: {
userRoles: {
select: { role: { select: { rolePermissions: { select: { permission: { select: { key: true } } } } } } },
},
},
});
if (!account) throw new Error("Konto ist nicht aktiv.");
return new Set(account.userRoles.flatMap((ur) => ur.role.rolePermissions.map((rp) => rp.permission.key)));
}
// Repliziert assertGrantableWithinActor() aus tenant-users.ts (Verstoß = excess nicht leer).
const excessOf = (perms: string[], effective: Set<string>) => perms.filter((p) => !effective.has(p));
async function cleanup() {
const t = await prisma.tenant.findUnique({ where: { slug: SLUG } });
if (!t) return;
await prisma.user.deleteMany({ where: { tenantId: t.id } });
await prisma.role.deleteMany({ where: { tenantId: t.id } });
await prisma.tenant.delete({ where: { id: t.id } });
// Verwaiste Test-Identity(s) entfernen (Membership wurde eben gelöscht).
await prisma.identity.deleteMany({ where: { email: { endsWith: "@zz-authz.test" }, memberships: { none: {} } } });
}
async function ensurePerm(key: string) {
return prisma.permission.upsert({ where: { key }, update: {}, create: { key } });
}
async function main() {
await cleanup();
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).
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 adminRole = await prisma.role.create({
data: {
tenantId: tenant.id,
key: "tenant-admin",
name: "Mandanten-Admin",
rolePermissions: { create: adminPerms.map((p) => ({ permissionId: p.id })) },
},
});
// Option C: Mitgliedschaft braucht eine globale Identity (Anmeldung).
const actorIdentity = await prisma.identity.create({ data: { email: "f13-admin@zz-authz.test", passwordHash: "x" } });
const actor = await prisma.user.create({
data: {
tenantId: tenant.id, identityId: actorIdentity.id, email: "f13-admin@zz-authz.test", name: "F13 Admin",
status: "ACTIVE", userRoles: { create: [{ roleId: adminRole.id }] },
},
});
// Höher privilegierte Rolle (ISB-artig), die der Actor sich NICHT selbst geben darf.
const isbRole = await prisma.role.create({
data: {
tenantId: tenant.id, key: "isb", name: "ISB / CISO",
rolePermissions: { create: escalationTargets.map((p) => ({ permissionId: p.id })) },
},
});
const effective = await effectivePermissions(actor.id);
// (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");
// (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-
// 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)");
// (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).
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");
// (5) Selbstzuweisung der eigenen (bereits gehaltenen) Admin-Rolle → erlaubt (⊆ effektiv).
const adminRolePerms = new Set(
(await prisma.rolePermission.findMany({ where: { roleId: adminRole.id }, select: { permission: { select: { key: true } } } }))
.map((r) => r.permission.key),
);
ok([...adminRolePerms].filter((p) => !effective.has(p)).length === 0,
"(5) Selbstzuweisung der eigenen Admin-Rolle bleibt zulässig (keine Eskalation)");
await cleanup();
if (failures === 0) console.log("\nOK — alle F-13-Nachweise (1)-(5) erfüllt.");
else console.log(`\n${failures} FEHLER.`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch(async (e) => {
console.error(e);
await cleanup().catch(() => {});
process.exit(1);
});
+99
View File
@@ -0,0 +1,99 @@
// WS5-Abnahmetest (Option C) — Two-Step-Login (MFA-pending) Bausteine.
//
// Geprüft (die Zustandslogik; die Cookie-/signIn-Verdrahtung ist UI/Runtime):
// 1. verifyIdentityPassword: richtig → {mfaRequired}, falsch → null (kein Orakel),
// MFA-Konto meldet mfaRequired=true.
// 2. verifyIdentityMfa: falscher Code → false, gültiger (Recovery-)Code → true.
// 3. finalizeIdentityLogin: baut die Session NACH Passwort(+MFA)-Prüfung.
// 4. login_ticket: signieren→verifizieren ok; manipuliert → null; abgelaufen → null;
// Zweckbindung (mfa_pending ≠ login_ticket).
//
// Lauf: npx tsx scripts/test-two-step-login.ts (setzt den Demo-Seed voraus)
import "dotenv/config";
import { prisma } from "../src/server/db";
import { hashPassword } from "../src/server/password";
import { generateRecoveryCodes } from "../src/server/mfa";
import { verifyIdentityPassword, verifyIdentityMfa, finalizeIdentityLogin } from "../src/server/auth";
import { signLoginTicket, verifyLoginTicket, signMfaPending, verifyMfaPending } from "../src/server/login-ticket";
const PW = "Two-Step-Passwort-1!";
const A = "ws5-nomfa@demo.example";
const B = "ws5-mfa@demo.example";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
async function cleanup() {
const ids = (await prisma.identity.findMany({ where: { email: { in: [A, B] } }, select: { id: true } })).map((i) => i.id);
if (ids.length) await prisma.auditLog.deleteMany({ where: { actorId: { in: ids } } });
await prisma.user.deleteMany({ where: { email: { in: [A, B] } } });
await prisma.identity.deleteMany({ where: { email: { in: [A, B] } } });
}
async function mkMember(identityId: string, tenantId: string, roleKey: string, email: string) {
const role = await prisma.role.findFirst({ where: { tenantId, key: roleKey } });
await prisma.user.create({
data: { tenantId, identityId, email, name: "WS5", status: "ACTIVE", ...(role ? { userRoles: { create: [{ roleId: role.id }] } } : {}) },
});
}
async function main() {
await cleanup();
const demo = await prisma.tenant.findUniqueOrThrow({ where: { slug: "demo" } });
const hash = await hashPassword(PW);
const idA = await prisma.identity.create({ data: { email: A, passwordHash: hash } });
await mkMember(idA.id, demo.id, "user", A);
const { plain, hashed } = await generateRecoveryCodes(5);
const idB = await prisma.identity.create({
data: { email: B, passwordHash: hash, mfaSecret: "JBSWY3DPEHPK3PXP", mfaEnrolledAt: new Date(), recoveryCodes: hashed },
});
await mkMember(idB.id, demo.id, "user", B);
console.log("\n— 1) Passwort-Schritt (kein Orakel) —");
const pwA = await verifyIdentityPassword(A, PW);
ok(pwA?.mfaRequired === false, "Konto ohne MFA: mfaRequired=false");
ok((await verifyIdentityPassword(A, "falsch!!")) === null, "falsches Passwort ⇒ null");
const pwB = await verifyIdentityPassword(B, PW);
ok(pwB?.mfaRequired === true, "Konto mit MFA: mfaRequired=true (kein Login ohne 2. Schritt)");
console.log("\n— 2) MFA-Schritt —");
ok((await verifyIdentityMfa(idB.id, "000000")) === false, "falscher Code ⇒ false");
ok((await verifyIdentityMfa(idB.id, plain[0]!)) === true, "gültiger Recovery-Code ⇒ true");
console.log("\n— 3) Session erst nach Verifikation —");
const sessA = await finalizeIdentityLogin(idA.id);
ok(sessA?.tenantSlug === "demo" && sessA?.identityId === idA.id, "finalizeIdentityLogin baut Session (Single-Membership → demo)");
console.log("\n— 4) login_ticket: Signatur/Manipulation/Ablauf/Zweck —");
const ticket = signLoginTicket(idA.id, "demo");
ok(verifyLoginTicket(ticket)?.identityId === idA.id, "gültiges Ticket verifiziert");
ok(verifyLoginTicket(ticket + "x") === null, "manipuliertes Ticket ⇒ null");
ok(verifyLoginTicket(signLoginTicket(idA.id, "demo", -1000)) === null, "abgelaufenes Ticket ⇒ null");
ok(verifyMfaPending(ticket) === null, "Zweckbindung: login_ticket wird NICHT als mfa_pending akzeptiert");
const pending = signMfaPending(idB.id, "demo");
ok(verifyMfaPending(pending)?.identityId === idB.id, "mfa_pending verifiziert (identityId + tenant)");
ok(verifyLoginTicket(pending) === null, "Zweckbindung: mfa_pending wird NICHT als login_ticket akzeptiert");
await cleanup();
}
main()
.then(async () => {
await prisma.$disconnect();
if (failures > 0) {
console.error(`\n✗ ${failures} Testfall/-fälle fehlgeschlagen.`);
process.exit(1);
}
console.log("\nOK");
})
.catch(async (e) => {
console.error(e);
await cleanup().catch(() => {});
await prisma.$disconnect();
process.exit(1);
});
+44
View File
@@ -0,0 +1,44 @@
// 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);