- Datenmodell: Testphasen-Lebenszyklus am Mandanten (plan, trialEndsAt, readOnlySince, deletionDueAt, Versandmarker), TrialSignup (Plattform, Hashes statt Klartext), TenantExport (RLS), Onboarding-Status - /testen: 5-Schritte-Wizard (Betrieb, Admin-Konto, Enddatum, Einrichtung, Zusammenfassung), Bestätigung per POST, direkte Anmeldung über login-ticket; Rate-Limit je IP/E-Mail, Honeypot, Enumeration-Schutz, Slug-Kollisionen - Plattform: Wizard „Testmandant anlegen“ mit Einladung, Badges/Filter, Enddatum ändern, umwandeln, beenden, Löschung vormerken/abbrechen (Bestätigung + Audit) - Schreibsperre nach Ablauf zentral in moduleGuard und requireApiContext (non-GET über withApi), Upload-Routen, Einstellungen/Nutzerverwaltung, Worker-Jobs; Banner Backoffice + mobil - Datenexport (ZIP mit CSV/JSON + Dateien) als Worker-Job, auch im Nur-Lesen-Zustand - Täglicher Job trial-lifecycle: Erinnerungen 7/3/1, Ablauf, Löschhinweis, Löschung über das Offboarding - Erste-Schritte-Checkliste im Dashboard, Mail-Vorlagen de/en, Tests + Smoke, Betriebsdoku Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
212 lines
17 KiB
TypeScript
212 lines
17 KiB
TypeScript
// 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> = {}): 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<string, string> }).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);
|
|
});
|