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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:05:39 +02:00
co-authored by Claude Opus 5
commit c8e6f30a27
720 changed files with 140143 additions and 0 deletions
+157
View File
@@ -0,0 +1,157 @@
// Backup-Portal-Lane: Enqueue-/Kontroll-Logik + DSGVO-Zustellpaket.
//
// Deckt OHNE laufenden BullMQ-Worker ab (KONZEPT §4-Kontrollen + §5-Zustellung):
// (1) Getippte Bestätigung „RESTORE <slug>" — exakter Match (Kontrolle 3).
// (2) MFA-Step-up mit Replay-Schutz: frischer Code ok, verbrauchter/älterer
// Zeitschritt wird abgelehnt (Kontrolle 2).
// (3) ZIP-Writer: rundläuft durch das System-`unzip` (echtes ZIP-Format).
// (4) DSGVO-Zustellpaket (Per-Person) enthält die erwarteten Einträge OHNE
// Secrets; der dsgvo_export-Job erzeugt Store-Objekt + signierten Token
// mit kurzer TTL (processBackupJob direkt, ohne Redis).
//
// Lauf: npx tsx scripts/test-backup-portal.ts (nutzt lokale isms-DB, räumt auf)
import "dotenv/config";
import { execFileSync } from "node:child_process";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { generateSync } from "otplib";
import { prisma } from "../src/server/db";
import { verifyTotp, newTotpSecret } from "../src/server/mfa";
import { matchesRestoreConfirmation, restoreConfirmationFor } from "../src/server/backup/restore-confirm";
import { buildZip } from "../src/server/backup/zip";
import { buildDsgvoPackage } from "../src/server/backup/dsgvo-zip";
import { processBackupJob, dsgvoPackageKey } from "../src/server/backup/ops";
import { getBackupStore } from "../src/server/storage/backup-store";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
const SLUG = "zz-portal-test";
const EMAIL = "zz-portal-person@example.test";
async function cleanup() {
const t = await prisma.tenant.findUnique({ where: { slug: SLUG }, select: { id: true } });
const identityIds = new Set<string>();
if (t) {
for (const u of await prisma.user.findMany({ where: { tenantId: t.id }, select: { identityId: true } })) identityIds.add(u.identityId);
await prisma.backupJob.deleteMany({ where: { tenantId: t.id } });
await prisma.tombstoneEntry.deleteMany({ where: { tenantId: t.id } });
await prisma.deletionCertificate.deleteMany({ where: { tenantId: t.id } });
await prisma.auditLog.deleteMany({ where: { tenantId: t.id } });
await prisma.asset.deleteMany({ where: { tenantId: t.id } });
await prisma.userRole.deleteMany({ where: { user: { tenantId: t.id } } });
await prisma.user.deleteMany({ where: { tenantId: t.id } });
await prisma.role.deleteMany({ where: { tenantId: t.id } });
await prisma.tenant.delete({ where: { id: t.id } });
await (await getBackupStore()).remove(`${t.id}/`);
}
const byEmail = await prisma.identity.findUnique({ where: { email: EMAIL }, select: { id: true } });
if (byEmail) identityIds.add(byEmail.id);
for (const id of identityIds) {
if ((await prisma.user.count({ where: { identityId: id } })) === 0) {
await prisma.identity.delete({ where: { id } }).catch(() => {});
}
}
}
async function main() {
await cleanup();
// ── (1) Getippte Bestätigung ────────────────────────────────────────────────
console.log("\n(1) Getippte Bestätigung „RESTORE <slug>\":");
ok(matchesRestoreConfirmation("kunde-a", "RESTORE kunde-a"), "exakter Match akzeptiert");
ok(!matchesRestoreConfirmation("kunde-a", "RESTORE kunde-b"), "falscher Slug abgelehnt");
ok(!matchesRestoreConfirmation("kunde-a", "restore kunde-a"), "abweichende Groß-/Kleinschreibung abgelehnt");
ok(!matchesRestoreConfirmation("kunde-a", " RESTORE kunde-a "), "Leerzeichen-Abweichung abgelehnt");
ok(restoreConfirmationFor("kunde-a") === "RESTORE kunde-a", "Erwartungstext korrekt gebildet");
// ── (2) MFA-Step-up + Replay-Schutz ─────────────────────────────────────────
console.log("\n(2) MFA-Step-up (Replay-Schutz über lastTotpStep):");
const secret = newTotpSecret();
const code = generateSync({ secret });
const first = verifyTotp(code, secret);
ok(first.ok, "frischer TOTP-Code wird akzeptiert");
ok(!verifyTotp("000000", secret).ok, "falscher Code abgelehnt");
if (first.ok) {
const replay = verifyTotp(code, secret, first.step);
ok(!replay.ok, "verbrauchter Zeitschritt (Replay) wird abgelehnt");
}
// ── (3) ZIP-Writer rundläuft durch System-unzip ────────────────────────────
console.log("\n(3) ZIP-Writer (Standardformat):");
const zip = buildZip([
{ name: "hello.txt", data: "Hallo Welt" },
{ name: "data/inner.json", data: JSON.stringify({ a: 1 }) },
]);
const dir = mkdtempSync(join(tmpdir(), "cvzip-"));
const zipPath = join(dir, "t.zip");
writeFileSync(zipPath, zip);
let unzipOk = true;
let content = "";
try {
const list = execFileSync("unzip", ["-l", zipPath], { encoding: "utf8" });
unzipOk = list.includes("hello.txt") && list.includes("data/inner.json");
content = execFileSync("unzip", ["-p", zipPath, "hello.txt"], { encoding: "utf8" });
} catch (e) {
unzipOk = false;
console.warn(" (unzip nicht verfügbar/ Fehler:", e instanceof Error ? e.message : e, ")");
}
ok(unzipOk, "ZIP von System-`unzip` gelesen (beide Einträge vorhanden)");
ok(content.trim() === "Hallo Welt", "entpackter Inhalt stimmt (CRC/Deflate korrekt)");
rmSync(dir, { recursive: true, force: true });
// ── Fixture-Mandant für DSGVO ──────────────────────────────────────────────
const tenant = await prisma.tenant.create({ data: { name: "Portal Test", slug: SLUG } });
const identity = await prisma.identity.create({ data: { email: EMAIL, passwordHash: "x", status: "ACTIVE" } });
const user = await prisma.user.create({ data: { tenantId: tenant.id, identityId: identity.id, email: EMAIL, name: "Portal Person" } });
await prisma.asset.create({ data: { tenantId: tenant.id, name: "Asset der Person", type: "SYSTEM", ownerId: user.id, createdBy: user.id } });
// ── (4a) DSGVO-Paket (Per-Person) direkt ────────────────────────────────────
console.log("\n(4) DSGVO-Zustellpaket:");
const pkg = await buildDsgvoPackage(tenant.id, identity.id);
ok(pkg.summary.scope === "person", "Scope=person erkannt");
const pdir = mkdtempSync(join(tmpdir(), "cvdsgvo-"));
const ppath = join(pdir, "p.zip");
writeFileSync(ppath, pkg.zip);
let names = "";
let identityJson = "";
try {
names = execFileSync("unzip", ["-l", ppath], { encoding: "utf8" });
identityJson = execFileSync("unzip", ["-p", ppath, "identity.json"], { encoding: "utf8" });
} catch { /* ohne unzip überspringen */ }
ok(names.includes("identity.json") && names.includes("memberships.json") && names.includes("references.json"), "Paket enthält identity/memberships/references");
ok(identityJson.includes(EMAIL), "identity.json enthält die E-Mail der Person");
ok(!identityJson.includes("passwordHash") && !identityJson.includes("mfaSecret"), "identity.json enthält KEINE Secrets");
rmSync(pdir, { recursive: true, force: true });
// ── (4b) dsgvo_export-Job (processBackupJob, ohne Redis) → Store + Token/TTL ─
const job = await prisma.backupJob.create({
data: { kind: "dsgvo_export", status: "queued", tenantId: tenant.id, tenantSlug: tenant.slug, subjectIdentityId: identity.id, actorId: null },
});
await processBackupJob({ kind: "dsgvo_export", jobId: job.id, tenantId: tenant.id, subjectIdentityId: identity.id, actorId: "test-admin" });
const done = await prisma.backupJob.findUnique({ where: { id: job.id } });
ok(done?.status === "done", "dsgvo_export-Job auf 'done' gesetzt");
ok(!!done?.downloadToken && done.downloadToken.length >= 20, "signierter Download-Token erzeugt");
ok(!!done?.downloadExpiresAt && done.downloadExpiresAt.getTime() > Date.now(), "TTL/Ablaufzeitpunkt in der Zukunft");
const stored = await (await getBackupStore()).get(dsgvoPackageKey(tenant.id, job.id));
ok(!!stored && stored.length > 0, "ZIP im Backup-Store abgelegt (für den Download-Endpunkt)");
}
main()
.then(async () => {
await cleanup();
await prisma.$disconnect();
if (failures > 0) { console.error(`\n✗ ${failures} Testfall/-fälle fehlgeschlagen.`); process.exit(1); }
console.log("\nOK");
})
.catch(async (e) => {
console.error(e);
await cleanup().catch(() => {});
await prisma.$disconnect();
process.exit(1);
});