Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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); });
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Executable
+58
@@ -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)"
|
||||
@@ -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)")));
|
||||
@@ -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`);
|
||||
@@ -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.`
|
||||
);
|
||||
@@ -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());
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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());
|
||||
@@ -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());
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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());
|
||||
@@ -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());
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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());
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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); });
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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); });
|
||||
@@ -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());
|
||||
@@ -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); });
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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); });
|
||||
@@ -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); });
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.");
|
||||
@@ -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);
|
||||
@@ -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());
|
||||
@@ -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());
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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); });
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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); });
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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); });
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user