Nicht-destruktiver Richtlinien-Re-Import (Phase-1-Härtung Paket 3)

Der Re-Import löschte bisher alle Policy-Zeilen und legte sie neu an — das setzte
per-Dokument-Status, TISAX-Override, Freigabe/Version und nutzergepflegte
Variablenwerte zurück. Jetzt Diff/Upsert über stabile Schlüssel, verlustfrei.

- Schema: PolicyDocument.archivedAt + PolicyRequirement.archivedAt (Lifecycle,
  orthogonal zum Freigabe-status). Migration additiv/nullable.
- import-policies.ts komplett auf Reconcile umgebaut:
  - Dokumente (code): neu→anlegen; vorhanden→nur Inhaltsfelder aktualisieren,
    Status/Version/Owner/Freigabe/Override bleiben; fehlend→archivedAt setzen
    (deaktivieren, nicht löschen); Wiederauftauchen→reaktivieren.
  - Anforderungen (reqId): analog inkl. Deaktivierung entfernter Anforderungen.
  - Variablen (key): Metadaten aktualisieren, aber value NIE überschreiben
    (Kundenpflege); fehlende bleiben bestehen (als obsolet ausgewiesen).
  - Baseline (blId) und Nachweise (nr): Upsert; fehlende bleiben erhalten.
  - Deaktivierung greift nur im Paket-Namensraum (L00/R*/VA-*/BASELINE/NACHWEIS)
    — verwaltete Register aus import-managed (CRYPTO/RISKMATRIX/CLASSIFICATION/
    HANDBUCH) werden nicht angetastet.
- Änderungsreport (added/updated/archived/reactivated/unchanged/obsolete) wird
  zurückgegeben und ins Audit-Log geschrieben (Entity policy_package). Option
  { dryRun: true } liefert die Vorschau ohne Schreibzugriff. Idempotent.
- Aktive Ansichten (Bibliothek/Coverage, Dokument-Detail) filtern archivedAt: null;
  Historie bleibt per Direktlink erreichbar.

Akzeptanztest scripts/test-reimport.ts: Status/Override/Einreicher + Variablenwert
bleiben nach Re-Import erhalten, entfernte Anforderung wird deaktiviert (nicht
gelöscht), verwaltete Register unberührt, zweiter Lauf idempotent. Browser: /policies
rendert unverändert (30 Dokumente). tsc + lint + build + Guard-Check grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 13:46:42 +02:00
co-authored by Claude Opus 4.8
parent 8e4040d1da
commit e07dcd9f2a
7 changed files with 326 additions and 55 deletions
+88
View File
@@ -0,0 +1,88 @@
import "dotenv/config";
import { join } from "node:path";
import { prisma } from "../src/server/db";
import { importPolicies } from "../prisma/import-policies";
const SEED_DIR = join(process.cwd(), "seed", "isms-vorlagenpaket-v2");
const ok = (c: boolean, m: string) => console.log(`${c ? "✓" : "✗ FEHLER"} ${m}`);
async function main() {
const tenant = await prisma.tenant.findFirst({ where: { slug: "demo" } });
if (!tenant) throw new Error("Demo-Mandant fehlt");
const t = tenant.id;
// Ausgangszustand eines echten Dokuments + einer Variable sichern
const doc0 = await prisma.policyDocument.findFirst({ where: { tenantId: t, code: "R08" } });
const varName = "ORG_NAME";
const var0 = await prisma.policyVariable.findFirst({ where: { tenantId: t, key: varName } });
if (!doc0 || !var0) throw new Error("Erwartetes Dokument R08 / Variable ORG_NAME fehlt");
// 1) Kuratierten Zustand simulieren: Freigabe-Status, TISAX-Override, Einreicher,
// geänderter Titel (Inhalts-Diff) + nutzergepflegter Variablenwert.
await prisma.policyDocument.update({
where: { id: doc0.id },
data: { status: "ENTWURF", protectionOverride: "AL3", submittedBy: "tester", title: "MANUELL GEÄNDERTER TITEL" },
});
await prisma.policyVariable.update({ where: { id: var0.id }, data: { value: "Mustermann Spezial GmbH" } });
// 2) Entfernte Anforderung simulieren (nicht im Paket): muss deaktiviert, nicht gelöscht werden.
const fakeReqId = "ZZ-REMOVED-TEST-1";
await prisma.policyRequirement.deleteMany({ where: { tenantId: t, reqId: fakeReqId } });
await prisma.policyRequirement.create({
data: { tenantId: t, reqId: fakeReqId, policyCode: "R08", control: "0.0.0", obligation: "MUSS", requirement: "Test", implementation: "" },
});
// 3) Dry-Run: Vorschau ohne Schreibzugriff
const preview = await importPolicies(prisma, t, SEED_DIR, { dryRun: true });
console.log("\n— Dry-Run-Report —");
console.log(JSON.stringify(preview.report, null, 0));
ok(preview.report.documents.updated >= 1, "Dry-Run erkennt Titeländerung an R08 (updated ≥ 1)");
ok(preview.report.documents.archived === 0, "Dry-Run deaktiviert KEINE verwalteten Register (CRYPTO/HANDBUCH/…)");
ok(preview.report.requirements.archived === 1, "Dry-Run erkennt 1 zu deaktivierende Anforderung");
const stillDraftAfterDry = await prisma.policyDocument.findUnique({ where: { id: doc0.id } });
ok(stillDraftAfterDry?.title === "MANUELL GEÄNDERTER TITEL", "Dry-Run schreibt NICHT (Titel unverändert)");
// 4) Echter Re-Import
const run1 = await importPolicies(prisma, t, SEED_DIR, { dryRun: false, actorId: null });
console.log("\n— Re-Import-Report —");
console.log(JSON.stringify(run1.report, null, 0));
const docA = await prisma.policyDocument.findUnique({ where: { id: doc0.id } });
ok(docA?.status === "ENTWURF", "Freigabe-Status bleibt erhalten (ENTWURF)");
ok(docA?.protectionOverride === "AL3", "TISAX-Override bleibt erhalten (AL3)");
ok(docA?.submittedBy === "tester", "Einreicher (Freigabe-Workflow) bleibt erhalten");
ok(docA?.title === doc0.title, "Titel wurde aus dem Paket aktualisiert (Inhalt)");
ok(docA?.archivedAt === null, "Dokument bleibt aktiv");
const varA = await prisma.policyVariable.findUnique({ where: { id: var0.id } });
ok(varA?.value === "Mustermann Spezial GmbH", "Nutzergepflegter Variablenwert bleibt erhalten");
const fakeA = await prisma.policyRequirement.findFirst({ where: { tenantId: t, reqId: fakeReqId } });
ok(!!fakeA && fakeA.archivedAt !== null, "Entfernte Anforderung ist deaktiviert (archivedAt gesetzt), NICHT gelöscht");
const auditA = await prisma.auditLog.findFirst({ where: { tenantId: t, entity: "policy_package" }, orderBy: { createdAt: "desc" } });
ok(!!auditA, "Änderungsreport im Audit-Log protokolliert");
// 5) Idempotenz: zweiter Lauf ohne externe Änderung → keine Diffs
const run2 = await importPolicies(prisma, t, SEED_DIR, { dryRun: false });
const r = run2.report;
const noChanges =
r.documents.added + r.documents.updated + r.documents.archived + r.documents.reactivated === 0 &&
r.requirements.added + r.requirements.updated + r.requirements.archived + r.requirements.reactivated === 0 &&
r.variables.added + r.variables.updated === 0 && r.baseline.added + r.baseline.updated === 0 &&
r.evidence.added + r.evidence.updated === 0;
console.log("\n— Idempotenz-Report —");
console.log(JSON.stringify(r, null, 0));
ok(noChanges, "Zweiter Lauf ist idempotent (keine added/updated/archived/reactivated)");
// Aufräumen: Testartefakt entfernen, kuratierten Zustand zurücksetzen
await prisma.policyRequirement.deleteMany({ where: { tenantId: t, reqId: fakeReqId } });
await prisma.policyDocument.update({
where: { id: doc0.id },
data: { status: doc0.status, protectionOverride: doc0.protectionOverride, submittedBy: doc0.submittedBy, title: doc0.title, archivedAt: null },
});
await prisma.policyVariable.update({ where: { id: var0.id }, data: { value: var0.value } });
console.log("\n✓ aufgeräumt (Testartefakte entfernt, R08/ORG_NAME zurückgesetzt)");
}
main().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });