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,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);
|
||||
});
|
||||
Reference in New Issue
Block a user