// 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); });