L15 Testphase & Onboarding: Selbstanmeldung mit Double-Opt-in, Plattform-Wizard, Nur-Lesen-Sperre, Export, Lebenszyklus-Job

- 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>
This commit is contained in:
2026-09-15 19:01:47 +02:00
co-authored by Claude Opus 5
parent 6b8cdf543b
commit d9290a187c
79 changed files with 4576 additions and 19 deletions
+152
View File
@@ -0,0 +1,152 @@
/**
* L15 Testphase — HTTP smoke against a running server, WITHOUT typing passwords (session cookies are
* built like scripts/smoke-auth.ts). Creates zz trial tenants (expired + running) through the real
* services, checks the public wizard pages, banners, the "Erste Schritte" card, the export page, the
* central write lock on real /api/v1 routes (customers, sync, uploads, work-order documents,
* backoffice upload) and the platform pages; removes the zz tenants afterwards.
*
* Usage: BASE=http://localhost:3115 npx tsx scripts/smoke-testphase.ts
*/
import "dotenv/config";
import { encode } from "next-auth/jwt";
import { prisma } from "../src/server/db";
import { finalizeIdentityLogin } from "../src/server/auth";
import { addDaysToKey, todayKey } from "../src/lib/trial/dates";
import { endTrialNow } from "../src/server/services/trial/admin";
import { addMember, cleanupL15, platformAdmin, trialTenant } from "./lib/testphase-fixture";
const BASE = process.env.BASE ?? "http://localhost:3115";
const SECURE = BASE.startsWith("https");
const COOKIE = SECURE ? "__Secure-authjs.session-token" : "authjs.session-token";
const PLATFORM_COOKIE = `${SECURE ? "__Secure-" : ""}platform-authjs.session-token`;
type Check = { label?: string; path: string; method?: string; body?: BodyInit; headers?: Record<string, string>; expect?: number[]; mustContain?: string[]; mustNotContain?: string[]; redirectTo?: string };
async function tenantCookie(email: string, slug: string): Promise<string> {
const identity = await prisma.identity.findUniqueOrThrow({ where: { email } });
const user = await finalizeIdentityLogin(identity.id, slug);
if (!user) throw new Error(`no membership for ${email} in ${slug}`);
const token = {
sub: user.id, name: user.name, email: user.email, userId: user.id, identityId: user.identityId, tenantId: user.tenantId, tenantSlug: user.tenantSlug,
activeMembershipId: user.activeMembershipId, memberships: user.memberships, roles: user.roles, permissions: user.permissions, isPlatformAdmin: user.isPlatformAdmin, mfaEnrolled: user.mfaEnrolled,
};
return `${COOKIE}=${await encode({ token, secret: process.env.AUTH_SECRET!, salt: COOKIE, maxAge: 1800 })}`;
}
async function platformCookie(adminId: string): Promise<string> {
const token = { sub: adminId, userId: adminId, isPlatformAdmin: true, mfaEnrolled: false };
return `${PLATFORM_COOKIE}=${await encode({ token, secret: process.env.AUTH_SECRET!, salt: PLATFORM_COOKIE, maxAge: 1800 })}`;
}
function multipart(fields: Record<string, string>, file?: { name: string; type: string; bytes: Buffer }): FormData {
const fd = new FormData();
for (const [k, v] of Object.entries(fields)) fd.set(k, v);
if (file) fd.set("file", new Blob([new Uint8Array(file.bytes)], { type: file.type }), file.name);
return fd;
}
async function main() {
await cleanupL15();
const today = todayKey();
const expired = await trialTenant("Smoke Abgelaufen", addDaysToKey(today, 5), { sampleData: true });
const running = await trialTenant("Smoke Laufend", addDaysToKey(today, 3), { sampleData: true });
const tech = await addMember(expired.tenantId, "tech-smoke", "technician");
const admin = await platformAdmin("full");
await endTrialNow({ platformAdminId: admin.id }, expired.tenantId);
const expiredSlug = (await prisma.tenant.findUniqueOrThrow({ where: { id: expired.tenantId } })).slug;
const runningSlug = (await prisma.tenant.findUniqueOrThrow({ where: { id: running.tenantId } })).slug;
const order = await prisma.workOrder.findFirstOrThrow({ where: { tenantId: expired.tenantId, status: "assigned" } });
const pdf = Buffer.from("%PDF-1.4\n%%EOF\n");
const plans: { who: string; cookie: string; checks: Check[] }[] = [
{
who: "anonym",
cookie: "",
checks: [
{ path: "/testen", mustContain: ["Craftvia kostenlos testen", "Firmenname", "Schritt 1 von 5", "Nutzungsbedingungen"] },
{ path: "/testen/bestaetigen?token=ungueltig", mustContain: ["ungültig"] },
{ path: "/testen/nutzungsbedingungen", mustContain: ["Nutzungsbedingungen", "Platzhalter"] },
{ path: "/testen/datenschutz", mustContain: ["Datenschutz"] },
{ path: "/dashboard", expect: [307], redirectTo: "/login" },
{ path: "/settings/export", expect: [307], redirectTo: "/login" },
],
},
{
who: `Admin abgelaufen (${expiredSlug})`,
cookie: await tenantCookie(`admin-smoke-abgelaufen@zz-l15.test`, expiredSlug),
checks: [
{ path: "/dashboard", mustContain: ["Testphase abgelaufen – nur Lesezugriff.", "Daten werden am", "Daten exportieren", 'data-trial-banner="expired"'] },
{ path: "/customers", mustContain: ["Testphase abgelaufen", "Hausverwaltung Musterhof"] },
{ path: "/settings/export", mustContain: ["Datenexport", "Export erstellen", "auch nach Ablauf"] },
{ path: "/api/v1/customers", mustContain: ['"data"'] },
{ label: "POST /api/v1/customers → gesperrt", path: "/api/v1/customers", method: "POST", body: JSON.stringify({ companyName: "ZZ Smoke" }), headers: { "content-type": "application/json" }, expect: [422], mustContain: ["trial_expired", "nur Lesezugriff"] },
{ label: "POST /api/v1/work-orders/[id]/documents → gesperrt", path: `/api/v1/work-orders/${order.id}/documents`, method: "POST", body: multipart({ category: "other", visibility: "team" }, { name: "a.pdf", type: "application/pdf", bytes: pdf }), headers: { accept: "application/json" }, expect: [422], mustContain: ["trial_expired"] },
{ label: "POST /documents/upload → gesperrt", path: "/documents/upload", method: "POST", body: multipart({ category: "other", visibility: "team" }, { name: "a.pdf", type: "application/pdf", bytes: pdf }), headers: { accept: "application/json" }, expect: [422], mustContain: ["trial_expired"] },
{ path: "/settings/export/unbekannt", expect: [404] },
],
},
{
who: `Monteur abgelaufen (${expiredSlug})`,
cookie: await tenantCookie(tech.email, expiredSlug),
checks: [
{ path: "/m", mustContain: ["Testphase abgelaufen – nur Lesezugriff."], mustNotContain: ["Daten exportieren"] },
{ path: "/api/v1/field/bundle", mustContain: ['"orders"'] },
{ label: "POST /api/v1/sync → gesperrt", path: "/api/v1/sync", method: "POST", body: JSON.stringify({ deviceId: "zz-smoke", operations: [] }), headers: { "content-type": "application/json" }, expect: [422], mustContain: ["trial_expired"] },
{ label: "POST /api/v1/uploads → gesperrt", path: "/api/v1/uploads", method: "POST", body: multipart({ clientId: "7c1d6a0e-3b1f-4c55-9d2a-00000000f016", workOrderId: order.id, kind: "photo" }, { name: "a.jpg", type: "image/jpeg", bytes: Buffer.from([0xff, 0xd8, 0xff, 0xd9]) }), expect: [422], mustContain: ["trial_expired"] },
{ path: "/settings/export", expect: [307], redirectTo: "/dashboard" },
],
},
{
who: `Admin laufend (${runningSlug})`,
cookie: await tenantCookie(`admin-smoke-laufend@zz-l15.test`, runningSlug),
checks: [
{ path: "/dashboard", mustContain: ["Testphase endet in 3 Tagen.", "Erste Schritte", "von 5 erledigt", "Team anlegen", "Monteur einladen"] },
{ path: "/dashboard?welcome=1", mustContain: ["Willkommen! Ihre Testphase ist eingerichtet."] },
{ path: "/settings/export", mustContain: ["Datenexport"] },
],
},
{
who: "Plattform-Admin",
cookie: await platformCookie(admin.id),
checks: [
{ path: "/admin", mustContain: ["Testmandant anlegen", "ZZ L15 Smoke Abgelaufen", "abgelaufen – nur lesen", "Löschung am", "Test bis"] },
{ path: "/admin?plan=trial", mustContain: ["ZZ L15 Smoke Laufend"], mustNotContain: ["Musterbau Haustechnik"] },
{ path: "/admin?plan=full", mustNotContain: ["ZZ L15 Smoke Laufend"] },
{ path: "/admin/trial", mustContain: ["Testmandant anlegen", "Testphase bis", "Mit Beispieldaten"] },
{ path: `/admin/${expired.tenantId}`, mustContain: ["Testphase", "abgelaufen – nur lesen", "In Vollversion umwandeln", "Enddatum ändern / verlängern", "Löschung abbrechen"] },
{ path: `/admin/${expired.tenantId}?trial=extend`, mustContain: ["Enddatum ändern", "Ich bestätige diese Änderung."] },
{ path: `/admin/${running.tenantId}?trial=end`, mustContain: ["Testphase sofort beenden"] },
],
},
];
let failures = 0;
let total = 0;
for (const plan of plans) {
console.log(`\n== ${plan.who}`);
for (const c of plan.checks) {
total++;
const res = await fetch(BASE + c.path, { method: c.method ?? "GET", body: c.body, headers: { ...(plan.cookie ? { cookie: plan.cookie } : {}), ...c.headers }, redirect: "manual", signal: AbortSignal.timeout(120_000) });
const body = res.status >= 300 && res.status < 400 ? "" : await res.text();
const expect = c.expect ?? [200];
const loc = res.headers.get("location");
const okRedirect = !c.redirectTo || (loc ? new URL(loc, BASE).pathname === c.redirectTo : false);
const errorPage = res.status === 200 && /Application error|Internal Server Error|Unhandled Runtime Error/i.test(body);
const missing = (c.mustContain ?? []).filter((s) => !body.includes(s));
const leaked = (c.mustNotContain ?? []).filter((s) => body.includes(s));
const pass = expect.includes(res.status) && okRedirect && !errorPage && missing.length === 0 && leaked.length === 0;
if (!pass) failures++;
console.log(`${pass ? "✓" : "✗"} ${String(res.status).padEnd(3)} ${c.label ?? `${c.method ?? "GET"} ${c.path}`}${loc ? ` → ${loc}` : ""}${missing.length ? ` [fehlt: ${missing.join(" | ")}]` : ""}${leaked.length ? ` [unerwartet: ${leaked.join(" | ")}]` : ""}`);
}
}
await cleanupL15();
await prisma.$disconnect();
console.log(failures ? `\n${failures} von ${total} Prüfungen fehlgeschlagen` : `\nOK — ${total} Prüfungen`);
process.exit(failures ? 1 : 0);
}
main().catch(async (err) => {
console.error(err);
await cleanupL15().catch(() => undefined);
process.exit(1);
});