// Lane L15 „Testphase & Onboarding" — öffentliche Selbstanmeldung: // Wizard-Validierung (Pflichtfelder, Passwort-Policy, Enddatum-Grenzen, TRIAL_MAX_DAYS, Europe/Berlin), // Double-Opt-in (Token nur als Hash, 24 h, Einmalverwendung, ältere Links entwertet), // Honeypot, Enumeration-Schutz (gleiche Antwort, Hinweis-Mail), Rate-Limit je IP und je E-Mail, // Provisionierung mit/ohne Beispieldaten + Modulauswahl, Slug-Kollisionen, Mandantentrennung. // // Lauf: npx tsx scripts/test-testphase-signup.ts (lokale Postgres-DB aus .env) import "dotenv/config"; import { readFileSync } from "node:fs"; import { prisma, dbForTenant } from "../src/server/db"; import { verifyPassword } from "../src/server/password"; import { resetRateLimits } from "../src/server/rate-limit"; import { addDaysToKey, trialBounds, trialEndDateKey, trialEndInstant } from "../src/lib/trial/dates"; import { normalizeTrialValues, validateTrialSignup, validateTrialStep, type TrialSignupValues } from "../src/lib/trial/signup"; import { MODULE_KEYS } from "../src/lib/modules"; import { trialMaxDays } from "../src/server/services/trial/config"; import { enforceTrialRateLimit } from "../src/server/services/trial/abuse"; import { checkTrialStep, confirmTrialSignup, currentTrialBounds, hashSignupToken, peekTrialSignup, submitTrialSignup } from "../src/server/services/trial/signup"; import { SAMPLE_TEAM_NAME } from "../src/server/services/trial/sample-data"; import { captureMail, cleanupL15, DOMAIN, failures, ok } from "./lib/testphase-fixture"; const PASSWORD = "Testphase2026Sicher"; function values(over: Partial = {}): TrialSignupValues { return { companyName: "ZZ L15 Signup Sanitär", sector: "Sanitär, Heizung, Klima", companySize: "6-20", adminName: "Paula Probe", email: `paula${DOMAIN}`, password: PASSWORD, trialEndDate: currentTrialBounds().defaultEnd, sampleData: true, modules: [...MODULE_KEYS], acceptTerms: true, acceptPrivacy: true, website: "", ...over, }; } const tokenOf = (url: string) => new URL(url).searchParams.get("token") ?? ""; async function main() { await cleanupL15(); resetRateLimits(); console.log("\n— Wizard-Validierung —"); const now = new Date("2026-03-10T10:00:00Z"); const b = trialBounds(now, 30); ok(b.today === "2026-03-10" && b.min === "2026-03-11" && b.max === "2026-04-09" && b.defaultEnd === "2026-03-24", "Grenzen: morgen … heute + 30, Vorbelegung heute + 14"); ok(trialBounds(now, 10).defaultEnd === "2026-03-20", "Vorbelegung höchstens TRIAL_MAX_DAYS"); const v = values(); ok(validateTrialStep("period", { ...v, trialEndDate: "2026-03-10" }, b).trialEndDate === "date_too_early", "Enddatum heute → zu früh"); ok(!validateTrialStep("period", { ...v, trialEndDate: "2026-03-11" }, b).trialEndDate, "Enddatum morgen → erlaubt"); ok(!validateTrialStep("period", { ...v, trialEndDate: "2026-04-09" }, b).trialEndDate, "Enddatum heute + 30 → erlaubt"); ok(validateTrialStep("period", { ...v, trialEndDate: "2026-04-10" }, b).trialEndDate === "date_too_late", "Enddatum heute + 31 → zu spät"); ok(validateTrialStep("period", { ...v, trialEndDate: "2026-02-30" }, b).trialEndDate === "date_invalid", "ungültiges Kalenderdatum"); ok(validateTrialStep("company", { ...v, companyName: " " }, b).companyName === "company_required", "Firmenname Pflicht"); ok(validateTrialStep("company", { ...v, companySize: "999" }, b).companySize === "invalid_choice", "Betriebsgröße nur aus der Liste"); const acc = validateTrialStep("account", { ...v, adminName: "", email: "kein-mail", password: "kurz" }, b); ok(acc.adminName === "name_required" && acc.email === "email_invalid" && acc.password === "password_policy", "Admin-Konto: Name, E-Mail und Passwort-Policy"); ok(validateTrialStep("setup", { ...v, modules: [] }, b).modules === "modules_required", "mindestens ein Modul"); const sum = validateTrialStep("summary", { ...v, acceptTerms: false, acceptPrivacy: false }, b); ok(sum.acceptTerms === "terms_required" && sum.acceptPrivacy === "privacy_required", "Pflicht-Checkboxen Nutzungsbedingungen/Datenschutz"); ok(Object.keys(validateTrialSignup({ ...v, trialEndDate: "2026-03-24" }, b)).length === 0, "vollständige gültige Anmeldung ohne Fehler"); ok(normalizeTrialValues({ ...v, email: " Paula@ZZ-L15.test ", modules: ["customers", "hacker"] })?.email === "paula@zz-l15.test", "E-Mail normalisiert"); ok(JSON.stringify(normalizeTrialValues({ ...v, modules: ["customers", "hacker"] })?.modules) === '["customers"]', "unbekannte Module verworfen"); ok(normalizeTrialValues({ modules: "kein-array" }) === null, "fehlerhafte Eingabe → null"); ok(checkTrialStep("hacken", v)._form === "invalid_request", "serverseitige Schrittprüfung: unbekannter Schritt"); ok(checkTrialStep("account", { ...v, password: "x" }).password === "password_policy", "serverseitige Schrittprüfung: Passwort-Policy"); const prevMax = process.env.TRIAL_MAX_DAYS; process.env.TRIAL_MAX_DAYS = "10"; ok(trialMaxDays() === 10, "TRIAL_MAX_DAYS aus der Umgebung"); process.env.TRIAL_MAX_DAYS = "abc"; ok(trialMaxDays() === 30, "ungültiges TRIAL_MAX_DAYS → Default 30"); if (prevMax === undefined) delete process.env.TRIAL_MAX_DAYS; else process.env.TRIAL_MAX_DAYS = prevMax; ok(trialEndInstant("2026-03-24").toISOString() === "2026-03-24T23:00:00.000Z", "Ende des gewählten Tages in Europe/Berlin (Winterzeit)"); ok(trialEndInstant("2026-07-01").toISOString() === "2026-07-01T22:00:00.000Z", "Ende des gewählten Tages in Europe/Berlin (Sommerzeit)"); ok(trialEndInstant("2026-03-29").toISOString() === "2026-03-29T22:00:00.000Z", "Ende am Tag der Zeitumstellung"); ok(trialEndDateKey(trialEndInstant("2026-10-25")) === "2026-10-25", "letzter Testtag aus dem gespeicherten Ende zurückgerechnet"); console.log("\n— Absenden: ungültig / Honeypot —"); const mail = captureMail(); const invalid = await submitTrialSignup({ ...values(), acceptTerms: false }, { ip: "198.51.100.1", sendMail: mail.fn }); ok(invalid.status === "invalid" && (invalid as { errors: Record }).errors.acceptTerms === "terms_required", "ungültig → Fehler je Feld"); ok((await prisma.trialSignup.count({ where: { email: { endsWith: DOMAIN } } })) === 0 && mail.sent.length === 0, "ungültig → keine Anmeldung, keine Mail"); const honey = await submitTrialSignup({ ...values(), website: "http://spam.example" }, { ip: "198.51.100.1", sendMail: mail.fn }); ok(honey.status === "sent", "Honeypot gefüllt → neutrale Erfolgsantwort"); ok((await prisma.trialSignup.count({ where: { email: { endsWith: DOMAIN } } })) === 0 && mail.sent.length === 0, "Honeypot → nichts gespeichert, keine Mail"); console.log("\n— Double-Opt-in —"); const first = await submitTrialSignup(values({ sampleData: true, modules: MODULE_KEYS.filter((m) => m !== "lotse") }), { ip: "198.51.100.7", sendMail: mail.fn }); ok(first.status === "sent" && mail.sent.length === 1 && mail.sent[0].template === "trial_confirm", "gültig → Bestätigungsmail"); const token1 = tokenOf((mail.sent[0].vars as { actionUrl: string }).actionUrl); const row1 = await prisma.trialSignup.findFirstOrThrow({ where: { email: `paula${DOMAIN}` }, orderBy: { createdAt: "desc" } }); ok(row1.tokenHash === hashSignupToken(token1) && row1.tokenHash !== token1 && token1.length >= 40, "Token nur als SHA-256-Hash gespeichert"); ok(row1.passwordHash.startsWith("$argon2id$") && !row1.passwordHash.includes(PASSWORD), "Passwort als Argon2id-Hash (mit Pepper)"); ok(!!row1.ipHash && row1.ipHash !== "198.51.100.7", "IP nur als HMAC"); const ttl = row1.expiresAt.getTime() - row1.createdAt.getTime(); ok(Math.abs(ttl - 24 * 3600_000) < 60_000, "Link 24 Stunden gültig"); ok(row1.status === "pending" && (await prisma.tenant.count({ where: { slug: { startsWith: "zz-l15-signup" } } })) === 0, "vor der Bestätigung wird kein Mandant angelegt"); const rawDump = JSON.stringify(await prisma.trialSignup.findMany({ where: { email: { endsWith: DOMAIN } } })); ok(!rawDump.includes(token1) && !rawDump.includes(PASSWORD), "weder Klartext-Token noch -Passwort in der Tabelle"); const second = await submitTrialSignup(values({ sampleData: true, modules: MODULE_KEYS.filter((m) => m !== "lotse") }), { ip: "198.51.100.7", sendMail: mail.fn }); const token2 = tokenOf((mail.sent[1].vars as { actionUrl: string }).actionUrl); ok(second.status === "sent" && token2 !== token1, "erneute Anmeldung → neuer Link"); ok((await prisma.trialSignup.findUniqueOrThrow({ where: { id: row1.id } })).status === "superseded" && (await peekTrialSignup(token1)) === null, "älterer Link entwertet"); ok((await confirmTrialSignup(token1)).status === "invalid", "entwerteter Link lässt sich nicht einlösen"); const peek = await peekTrialSignup(token2); ok(peek?.companyName === "ZZ L15 Signup Sanitär", "Bestätigungsseite zeigt die Anmeldung (ohne Einlösen)"); ok((await confirmTrialSignup("falsches-token")).status === "invalid", "falsches Token → ungültig"); // expiry on a separate signup const mailExp = captureMail(); await submitTrialSignup(values({ email: `ablauf${DOMAIN}`, companyName: "ZZ L15 Ablauf" }), { ip: "198.51.100.8", sendMail: mailExp.fn }); const tokenExp = tokenOf((mailExp.sent[0].vars as { actionUrl: string }).actionUrl); const expRow = await prisma.trialSignup.findFirstOrThrow({ where: { email: `ablauf${DOMAIN}` } }); ok((await confirmTrialSignup(tokenExp, { now: new Date(expRow.expiresAt.getTime() + 1000) })).status === "expired", "nach 24 h → abgelaufen"); const expAfter = await prisma.trialSignup.findUniqueOrThrow({ where: { id: expRow.id } }); ok(expAfter.status === "expired" && expAfter.passwordHash === "", "abgelaufene Anmeldung: Status expired, Passwort-Hash entfernt"); ok((await confirmTrialSignup(tokenExp)).status === "invalid", "abgelaufener Link bleibt ungültig"); console.log("\n— Bestätigung → Provisionierung (mit Beispieldaten, Modulauswahl) —"); const confirmed = await confirmTrialSignup(token2); ok(confirmed.status === "ok", "Bestätigung → Mandant provisioniert"); if (confirmed.status !== "ok") throw new Error("confirm failed"); const tenantA = await prisma.tenant.findUniqueOrThrow({ where: { id: confirmed.tenantId } }); const endKey = values().trialEndDate; ok(tenantA.plan === "TRIAL" && tenantA.trialSource === "self_signup" && !!tenantA.trialStartedAt, "Plan TRIAL, Herkunft Selbstanmeldung"); ok(tenantA.trialEndsAt?.getTime() === trialEndInstant(endKey).getTime(), "trialEndsAt = Ende des gewählten Tages (Europe/Berlin)"); ok(tenantA.deletionDueAt?.getTime() === trialEndInstant(endKey).getTime() + 30 * 86_400_000, "Löschung 30 Tage nach Ende vorgemerkt"); ok(tenantA.slug === "zz-l15-signup-sanitar", "Slug aus dem Firmennamen"); const admin = await prisma.user.findFirstOrThrow({ where: { tenantId: tenantA.id }, include: { userRoles: { include: { role: true } }, identity: true } }); ok(admin.userRoles.some((r) => r.role.key === "tenant-admin") && admin.email === `paula${DOMAIN}`, "Admin mit Rolle tenant-admin"); ok(await verifyPassword(admin.identity.passwordHash, PASSWORD), "Login-Passwort = Passwort aus dem Wizard"); ok(!admin.identity.mustChangePassword, "kein Passwortzwang für die Selbstanmeldung"); const signupAfter = await prisma.trialSignup.findFirstOrThrow({ where: { tokenHash: hashSignupToken(token2) } }); ok(signupAfter.status === "confirmed" && signupAfter.passwordHash === "" && signupAfter.provisionedTenantId === tenantA.id, "Anmeldung bestätigt, Passwort-Hash aus der Anmeldung entfernt"); const modules = await prisma.tenantModule.findMany({ where: { tenantId: tenantA.id } }); ok(modules.length === MODULE_KEYS.length && modules.find((m) => m.moduleKey === "lotse")?.enabled === false && modules.filter((m) => m.enabled).length === MODULE_KEYS.length - 1, "abgewähltes Modul deaktiviert, übrige aktiv"); const dbA = dbForTenant(tenantA.id); ok((await dbA.customer.count()) === 3 && (await dbA.site.count()) === 3 && (await dbA.workOrder.count()) === 4, "Beispieldaten: 3 Kunden, 3 Objekte, 4 Aufträge"); ok((await dbA.team.count({ where: { name: SAMPLE_TEAM_NAME } })) === 1 && (await dbA.workOrder.count({ where: { status: "assigned" } })) === 1, "Beispieldaten: Team + zugewiesener Auftrag (über die Fachservices)"); ok((await prisma.auditLog.count({ where: { tenantId: tenantA.id, entity: "trial", action: "create" } })) === 1, "Audit: Testphase angelegt"); ok((await confirmTrialSignup(token2)).status === "invalid", "Einmalverwendung: zweiter Klick → ungültig"); console.log("\n— Enumeration-Schutz —"); const mailEnum = captureMail(); const known = await submitTrialSignup(values({ companyName: "ZZ L15 Doppelt" }), { ip: "198.51.100.9", sendMail: mailEnum.fn }); const unknown = await submitTrialSignup(values({ companyName: "ZZ L15 Neu", email: `neu${DOMAIN}` }), { ip: "198.51.100.9", sendMail: mailEnum.fn }); ok(JSON.stringify(known) === JSON.stringify(unknown), "bestehende und neue Adresse → identische Antwort"); ok(mailEnum.sent[0].template === "trial_existing_account" && mailEnum.sent[0].to === `paula${DOMAIN}` && mailEnum.sent[1].template === "trial_confirm", "bestehende Adresse → Hinweis-Mail statt Bestätigungslink"); ok(!JSON.stringify(mailEnum.sent[0].vars).includes("token="), "Hinweis-Mail enthält keinen Aktivierungslink"); ok((await prisma.trialSignup.count({ where: { email: `paula${DOMAIN}`, status: "pending" } })) === 0, "bestehende Adresse → keine offene Anmeldung"); console.log("\n— Rate-Limit je IP und je E-Mail —"); resetRateLimits(); const ipA = "203.0.113.10"; const results = Array.from({ length: 6 }, () => enforceTrialRateLimit("trialSignup", { ip: ipA, email: `rl${DOMAIN}` }).allowed); ok(results.slice(0, 5).every(Boolean) && results[5] === false, "je IP/E-Mail: 5 Anmeldungen pro Stunde, die 6. wird abgewiesen"); ok(enforceTrialRateLimit("trialSignup", { ip: "203.0.113.11", email: `rl${DOMAIN}` }).allowed === false, "gleiche E-Mail von anderer IP → weiter gesperrt"); ok(enforceTrialRateLimit("trialSignup", { ip: ipA, email: `anders${DOMAIN}` }).allowed === false, "gleiche IP mit anderer E-Mail → weiter gesperrt"); ok(enforceTrialRateLimit("trialSignup", { ip: "203.0.113.12", email: `anders${DOMAIN}` }).allowed, "andere IP + andere E-Mail → erlaubt"); const retry = enforceTrialRateLimit("trialSignup", { ip: ipA, email: null }); ok(!retry.allowed && retry.retryAfterSeconds > 0, "Retry-After gesetzt"); resetRateLimits(); const actionsSrc = readFileSync("src/server/actions/trial-signup.ts", "utf8"); const exported = [...actionsSrc.matchAll(/export async function (\w+)/g)].map((m) => m[1]); ok(exported.length === 3 && exported.every((name) => new RegExp(`export async function ${name}[\\s\\S]*?enforceTrialRateLimit\\(`).test(actionsSrc)), "jede öffentliche Action prüft das Rate-Limit"); ok(readFileSync("scripts/check-module-guards.ts", "utf8").includes('"trial-signup.ts": "PUBLIC"'), "Guard-Check erzwingt Rate-Limit für öffentliche Actions"); ok(readFileSync("src/proxy.ts", "utf8").includes('"/testen"'), "/testen ist ohne Login erreichbar (Proxy)"); console.log("\n— ohne Beispieldaten, Slug-Kollision, Mandantentrennung —"); const mailB = captureMail(); await submitTrialSignup(values({ email: `bernd${DOMAIN}`, adminName: "Bernd Probe", sampleData: false }), { ip: "198.51.100.20", sendMail: mailB.fn }); const confirmedB = await confirmTrialSignup(tokenOf((mailB.sent[0].vars as { actionUrl: string }).actionUrl)); ok(confirmedB.status === "ok", "zweite Anmeldung mit gleichem Firmennamen bestätigt"); if (confirmedB.status !== "ok") throw new Error("confirm B failed"); ok(confirmedB.tenantSlug === "zz-l15-signup-sanitar-2" && confirmedB.tenantId !== tenantA.id, "Slug-Kollision automatisch gelöst (-2), eigener Mandant"); const dbB = dbForTenant(confirmedB.tenantId); ok((await dbB.customer.count()) === 0 && (await dbB.workOrder.count()) === 0, "ohne Beispieldaten → leerer Mandant"); ok((await dbB.tenantModule.count({ where: { enabled: true } })) === MODULE_KEYS.length, "Standard: alle Module aktiv"); const customerA = await dbA.customer.findFirstOrThrow(); ok((await dbB.customer.findFirst({ where: { id: customerA.id } })) === null, "Mandant B sieht die Kunden von A nicht"); let crossWrite = false; try { await dbB.customer.update({ where: { id: customerA.id }, data: { city: "Hack" } }); crossWrite = true; } catch { crossWrite = false; } ok(!crossWrite && (await dbA.customer.findFirstOrThrow({ where: { id: customerA.id } })).city !== "Hack", "Mandant B kann Kunden von A nicht ändern"); ok(addDaysToKey("2026-12-31", 1) === "2027-01-01", "Datumsrechnung über den Jahreswechsel"); await cleanupL15(); await prisma.$disconnect(); console.log(failures ? `\n${failures} Prüfung(en) fehlgeschlagen` : "\nAlle Prüfungen bestanden"); process.exit(failures ? 1 : 0); } main().catch(async (err) => { console.error(err); await cleanupL15().catch(() => undefined); process.exit(1); });