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
+203
View File
@@ -0,0 +1,203 @@
import "dotenv/config";
import { prisma } from "../src/server/db";
import { hashPassword, verifyPassword } from "../src/server/password";
import { redeemPasswordReset, requestPasswordReset } from "../src/server/actions/auth-recovery";
import { issueToken, peekToken } from "../src/server/auth-token";
import { resetRateLimits } from "../src/server/rate-limit";
import { closeQueues } from "../src/server/mail/queue";
import { closeMailProvider } from "../src/server/mail/provider-smtp";
/**
* SEC2 — End-to-End des Reset-Ablaufs gegen ein Wegwerf-Konto
* (`npx tsx scripts/test-reset-flow.ts`).
*
* Ergänzt `test-auth-selfservice.ts` (dort: die Token-/Limit-Eigenschaften
* isoliert) um das Zusammenspiel: Anfrage → Token → Einlösung → Passwort
* geschrieben → Sessions entwertet → Bestätigungsmail → Audit.
*
* Zusätzlich die beiden Fälle, die man leicht falsch baut:
* - ein **Policy-Verstoß darf den Link nicht entwerten** (sonst ist der Nutzer
* nach einem Tippfehler ausgesperrt),
* - ein **deaktiviertes Konto** bekommt keinen Reset, aber dieselbe Antwort.
*
* Das Testkonto wird angelegt und am Ende restlos entfernt.
*/
const EMAIL = "sec2-e2e@example.test";
const OLD_PW = "Sec2-E2E-Alt-2026!";
const NEW_PW = "Sec2-E2E-Neu-2026!";
let failures = 0;
function check(name: string, ok: boolean, detail?: string) {
if (ok) console.log(` ✓ ${name}`);
else {
failures++;
console.error(` ✗ ${name}${detail ? ` — ${detail}` : ""}`);
}
}
async function main() {
resetRateLimits();
const tenant = await prisma.tenant.findFirst({ where: { status: "ACTIVE" } });
if (!tenant) throw new Error("Kein aktiver Mandant vorhanden — bitte zuerst seeden.");
await prisma.user.deleteMany({ where: { email: EMAIL } });
await prisma.identity.deleteMany({ where: { email: EMAIL, memberships: { none: {} } } });
// Option C: Mitgliedschaft braucht eine globale Identity (Anmeldung).
const identity = await prisma.identity.create({ data: { email: EMAIL, passwordHash: await hashPassword(OLD_PW) } });
const user = await prisma.user.create({
data: {
tenantId: tenant.id,
identityId: identity.id,
email: EMAIL,
name: "SEC2 E2E",
status: "ACTIVE",
},
});
try {
console.log("1) Anfrage");
const fd = new FormData();
fd.set("email", EMAIL);
fd.set("domain", "tenant");
const req = await requestPasswordReset({ status: "idle" }, fd);
check("neutrale Antwort", req.status === "done");
const unknownEmail = `unbekannt-${Date.now()}@example.test`;
const fdUnknown = new FormData();
fdUnknown.set("email", unknownEmail);
fdUnknown.set("domain", "tenant");
const reqUnknown = await requestPasswordReset({ status: "idle" }, fdUnknown);
check("unbekannte Adresse: identische Antwort", JSON.stringify(req) === JSON.stringify(reqUnknown));
check(
"keine Mail an die unbekannte Adresse",
(await prisma.mailLog.count({ where: { to: unknownEmail } })) === 0,
);
check(
"Token nur für das existierende Konto (Prinzipal = Identity)",
(await prisma.authToken.count({ where: { principalId: identity.id, usedAt: null } })) === 1,
);
const resetMail = await prisma.mailLog.findFirst({
where: { to: EMAIL, template: "password_reset" },
});
check("Reset-Mail eingestellt", resetMail != null, resetMail?.status);
// Das Rohtoken existiert nur im Link. Für den weiteren Ablauf einen frischen
// Link ausstellen — die Neuanforderung entwertet den vorherigen (geprüft in
// test-auth-selfservice.ts).
const issued = await issueToken({
principalType: "identity",
principalId: identity.id,
tenantId: tenant.id,
type: "password_reset",
});
console.log("2) Policy-Verstoß entwertet den Link nicht");
const weak = new FormData();
weak.set("token", issued.raw);
weak.set("domain", "tenant");
weak.set("password", "kurz");
weak.set("confirm", "kurz");
check("schwaches Passwort abgelehnt", (await redeemPasswordReset({ status: "idle" }, weak)).status === "error");
check("Link danach noch gültig", (await peekToken(issued.raw, "password_reset")) != null);
const mismatch = new FormData();
mismatch.set("token", issued.raw);
mismatch.set("domain", "tenant");
mismatch.set("password", NEW_PW);
mismatch.set("confirm", `${NEW_PW}x`);
check(
"abweichende Wiederholung abgelehnt",
(await redeemPasswordReset({ status: "idle" }, mismatch)).status === "error",
);
check("Link weiterhin gültig", (await peekToken(issued.raw, "password_reset")) != null);
console.log("3) Einlösung (Passwort/Session an der Identity)");
const before = await prisma.identity.findUniqueOrThrow({ where: { id: identity.id } });
const fd2 = new FormData();
fd2.set("token", issued.raw);
fd2.set("domain", "tenant");
fd2.set("password", NEW_PW);
fd2.set("confirm", NEW_PW);
const done = await redeemPasswordReset({ status: "idle" }, fd2);
check("Einlösung erfolgreich", done.status === "done", JSON.stringify(done));
const after = await prisma.identity.findUniqueOrThrow({ where: { id: identity.id } });
check("Passwort-Hash (Identity) geändert", before.passwordHash !== after.passwordHash);
check("neues Passwort gültig", await verifyPassword(after.passwordHash, NEW_PW));
check("altes Passwort ungültig", !(await verifyPassword(after.passwordHash, OLD_PW)));
check("Sessions invalidiert (Identity)", after.sessionsValidAfter != null);
check("Force-Change zurückgesetzt (Identity)", after.mustChangePassword === false);
check("Link verbraucht", (await peekToken(issued.raw, "password_reset")) === null);
const secondUse = new FormData();
secondUse.set("token", issued.raw);
secondUse.set("domain", "tenant");
secondUse.set("password", NEW_PW);
secondUse.set("confirm", NEW_PW);
check(
"zweite Einlösung abgewiesen",
(await redeemPasswordReset({ status: "idle" }, secondUse)).status === "error",
);
const confirmMail = await prisma.mailLog.findFirst({
where: { to: EMAIL, template: "password_changed" },
});
check("Bestätigungsmail versendet", confirmMail != null, confirmMail?.status);
const audit = await prisma.auditLog.findFirst({
where: { entity: "password_reset", entityId: identity.id },
});
check("Audit-Eintrag vorhanden", audit != null);
check(
"kein Token im Audit",
audit == null || !JSON.stringify(audit).includes(issued.raw.slice(0, 12)),
);
check(
"kein Token im MailLog",
(await prisma.mailLog.count({ where: { dedupeKey: { contains: issued.raw.slice(0, 12) } } })) === 0,
);
console.log("4) Deaktivierte Identity");
// Option C: das Konto ist die Identity — Deaktivierung wirkt an ihr, nicht an
// der einzelnen Mitgliedschaft.
await prisma.identity.update({ where: { id: identity.id }, data: { status: "DISABLED" } });
resetRateLimits();
const openBefore = await prisma.authToken.count({
where: { principalId: identity.id, type: "password_reset", usedAt: null },
});
const fd3 = new FormData();
fd3.set("email", EMAIL);
fd3.set("domain", "tenant");
const req3 = await requestPasswordReset({ status: "idle" }, fd3);
const openAfter = await prisma.authToken.count({
where: { principalId: identity.id, type: "password_reset", usedAt: null },
});
check("Antwort trotzdem neutral", req3.status === "done");
check("kein neuer Token", openAfter === openBefore);
} finally {
await prisma.authToken.deleteMany({ where: { principalId: { in: [identity.id, user.id] } } });
await prisma.mailLog.deleteMany({ where: { to: EMAIL } });
await prisma.auditLog.deleteMany({ where: { actorId: { in: [identity.id, user.id] } } });
await prisma.user.delete({ where: { id: user.id } }).catch(() => {});
await prisma.identity.deleteMany({ where: { email: EMAIL } });
}
await prisma.$disconnect();
await closeMailProvider();
await closeQueues();
if (failures > 0) {
console.error(`\n✗ ${failures} Prüfung(en) fehlgeschlagen.`);
process.exit(1);
}
console.log("\n✓ SEC2 Reset-Ablauf: alle Prüfungen bestanden.");
process.exit(0);
}
main().catch(async (err) => {
console.error(err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});