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
+184
View File
@@ -0,0 +1,184 @@
// Lane L15 „Testphase & Onboarding" — Nur-Lesen nach Ablauf + Export:
// zentrale Schreibsperre (moduleGuard, /api/v1-Mutationen inkl. Sync und Uploads, Backoffice-Upload,
// Einstellungen/Nutzerverwaltung, Worker-Jobs im Namen von Nutzern) bei gleichzeitig erlaubtem Lesen,
// Downloads und Export; Verlängern hebt die Sperre auf; Onboarding-Checkliste; Mandanten-Export
// (ZIP mit CSV/JSON + Dateien) inkl. Mandantentrennung und Rollen.
//
// Lauf: npx tsx scripts/test-testphase-readonly.ts (lokale Postgres-DB + Garage aus .env)
import "dotenv/config";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { prisma } from "../src/server/db";
import { json, withApi } from "../src/server/api/respond";
import { assertApiWriteAllowed } from "../src/server/api/context";
import { applyOperations } from "../src/server/services/sync/apply";
import { storeFile } from "../src/server/services/documents/store";
import { addDaysToKey, todayKey } from "../src/lib/trial/dates";
import { changeTrialEndDate, endTrialNow } from "../src/server/services/trial/admin";
import { buildTenantExport, listTenantExports, openTenantExport, requestTenantExport, toCsv } from "../src/server/services/trial/export";
import { isJobBlockedByTrial } from "../src/server/services/trial/jobs";
import { getOnboardingChecklist, setOnboardingHidden, setOnboardingItem } from "../src/server/services/trial/onboarding";
import { assertTenantWritable, getTrialState } from "../src/server/services/trial/state";
import { addMember, cleanupL15, ctxFor, expectCode, failures, ok, platformAdmin, readZip, trialTenant } from "./lib/testphase-fixture";
const post = (body: unknown = {}) => new Request("http://localhost/api/v1/zz", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
const get = () => new Request("http://localhost/api/v1/zz", { method: "GET" });
function walk(dir: string): string[] {
return readdirSync(dir).flatMap((n) => (statSync(join(dir, n)).isDirectory() ? walk(join(dir, n)) : [join(dir, n)]));
}
async function main() {
await cleanupL15();
const actor = { platformAdminId: (await platformAdmin("full")).id };
const today = todayKey();
const A = await trialTenant("Readonly A", addDaysToKey(today, 10), { sampleData: true });
const B = await trialTenant("Readonly B", addDaysToKey(today, 10));
const adminA = ctxFor(A.tenantId, A.adminUserId, "tenant-admin");
const adminB = ctxFor(B.tenantId, B.adminUserId, "tenant-admin");
const techA = await addMember(A.tenantId, "tech-readonly-a", "technician");
const ctxTechA = ctxFor(A.tenantId, techA.id, "technician");
await adminB.db.customer.create({ data: { tenantId: B.tenantId, companyName: "ZZ Kunde nur B", city: "Kiel" } });
console.log("\n— laufende Testphase: schreibbar, Onboarding —");
await assertTenantWritable(A.tenantId);
ok(true, "laufende Testphase ist schreibbar");
const checklist = await getOnboardingChecklist(adminA);
ok(checklist?.items.length === 5 && checklist.items.find((i) => i.key === "team")?.done === false, "Erste Schritte: 5 Punkte, Beispielteam zählt nicht als eigenes Team");
ok(checklist?.items.find((i) => i.key === "technician")?.done === true, "Erste Schritte: eingeladener Monteur automatisch erkannt");
ok(checklist?.items.find((i) => i.key === "first_order")?.done === false, "Erste Schritte: Beispielaufträge zählen nicht als erster Auftrag");
await setOnboardingItem(adminA, "mobile", true);
ok((await getOnboardingChecklist(adminA))?.items.find((i) => i.key === "mobile")?.done === true, "Punkt manuell abgehakt");
await expectCode(() => setOnboardingItem(ctxTechA, "mobile", false), "forbidden", "Monteur kann die Checkliste nicht ändern");
await expectCode(() => setOnboardingItem(adminA, "hacken", true), "invalid", "unbekannter Punkt → invalid");
ok((await getOnboardingChecklist(ctxTechA)) === null, "Monteur sieht die Checkliste nicht");
const fullTenant = await prisma.tenant.findUniqueOrThrow({ where: { slug: "demo" }, select: { id: true } }).catch(() => null);
if (fullTenant) {
const demoAdmin = await prisma.user.findFirst({ where: { tenantId: fullTenant.id, email: "admin@demo.example" } });
if (demoAdmin) ok((await getOnboardingChecklist(ctxFor(fullTenant.id, demoAdmin.id, "tenant-admin"))) === null, "Vollversions-Mandant ohne Testphase → keine Checkliste");
}
console.log("\n— Ablauf: zentrale Schreibsperre —");
await endTrialNow(actor, A.tenantId);
const stateA = await getTrialState(A.tenantId);
ok(stateA.readOnly && stateA.expired && !!stateA.deletionDueAt, "Testphase beendet → nur lesen, Löschtermin gesetzt");
try {
await assertTenantWritable(A.tenantId);
ok(false, "Schreibsperre greift nicht");
} catch (err) {
const e = err as { code?: string; message?: string; details?: { readOnly?: boolean; message?: string } };
ok(e.code === "blocked" && e.message === "trial_expired" && e.details?.readOnly === true && !!e.details.message, "ServiceError blocked/trial_expired mit Klartext");
}
await assertTenantWritable(B.tenantId);
ok(true, "Mandant B bleibt schreibbar");
// /api/v1: the same wrapper + check the real routes use (withApi → requireApiContext → assertApiWriteAllowed)
const handler = (tenantId: string) => withApi(async () => {
await assertApiWriteAllowed(tenantId);
return json({ ok: true });
});
const blocked = await handler(A.tenantId)(post());
const blockedBody = (await blocked.json()) as { error?: { code: string; message: string; details?: { readOnly?: boolean } } };
ok(blocked.status === 422 && blockedBody.error?.code === "blocked" && blockedBody.error.message === "trial_expired" && blockedBody.error.details?.readOnly === true, "API-Mutation (POST) → 422 blocked trial_expired");
ok((await handler(A.tenantId)(new Request("http://localhost/x", { method: "DELETE" }))).status === 422, "API-Mutation (DELETE) → 422");
ok((await handler(A.tenantId)(get())).status === 200, "API-Lesezugriff (GET) bleibt erlaubt");
ok((await handler(B.tenantId)(post())).status === 200, "API-Mutation von Mandant B unberührt");
await assertApiWriteAllowed(A.tenantId);
ok(true, "außerhalb von withApi (Downloads /files, Export) keine Sperre");
const syncBefore = await prisma.syncOperation.count({ where: { tenantId: A.tenantId } });
const order = await adminA.db.workOrder.findFirstOrThrow({ where: { status: "assigned" } });
const sync = withApi(async (req: Request) => {
await assertApiWriteAllowed(A.tenantId);
return json(await applyOperations(ctxTechA, (await req.json()) as never));
});
const syncRes = await sync(post({ deviceId: "zz", operations: [{ clientOpId: "7c1d6a0e-3b1f-4c55-9d2a-00000000f015", opType: "note.create", payload: { workOrderId: order.id, kind: "work_done", text: "zz" }, clientCreatedAt: new Date().toISOString() }] }));
ok(syncRes.status === 422 && (await prisma.syncOperation.count({ where: { tenantId: A.tenantId } })) === syncBefore, "Sync-Batch → 422, keine Operation angewendet (Outbox wiederholt später)");
// static coverage of every write entry point
const guard = readFileSync("src/server/action-guard.ts", "utf8");
ok(/assertModuleEnabled\(session, moduleKey\);[\s\S]*await assertTenantWritable\(session\.user\.tenantId\);[\s\S]*return \{ session, db/.test(guard), "moduleGuard: Schreibsperre für alle Modul-Actions (inkl. Lotse, Uploads per Action)");
const context = readFileSync("src/server/api/context.ts", "utf8");
ok(/enforceApiRateLimit\(session\.user\.id, moduleKey\);\s*await assertApiWriteAllowed\(tenantId\);/.test(context), "requireApiContext: Schreibsperre für jede /api/v1-Mutation");
const routes = walk("src/app/api/v1").filter((f) => f.endsWith("route.ts"));
const mutating = routes.filter((f) => /export const (POST|PUT|PATCH|DELETE)\b|export async function (POST|PUT|PATCH|DELETE)\b/.test(readFileSync(f, "utf8")));
// every mutating route: withApi (central lock in requireApiContext) OR an explicit assertTenantWritable
const unwrapped = mutating.filter((f) => {
const src = readFileSync(f, "utf8");
return !/export const (POST|PUT|PATCH|DELETE) = withApi\(/.test(src) && !/await assertTenantWritable\(ctx\.tenantId\)/.test(src);
});
ok(mutating.length > 10 && unwrapped.length === 0, `alle ${mutating.length} mutierenden /api/v1-Routen gesperrt (withApi bzw. explizit; Sync und Uploads eingeschlossen)${unwrapped.length ? ` — ohne: ${unwrapped.join(", ")}` : ""}`);
ok(readFileSync("src/app/(app)/documents/upload/route.ts", "utf8").includes("await assertTenantWritable(ctx.tenantId)"), "Backoffice-Upload-Route gesperrt");
ok(readFileSync("src/server/actions/tenant-settings.ts", "utf8").includes("await assertTenantWritable(tenantId)"), "Einstellungen gesperrt");
ok(readFileSync("src/server/actions/tenant-users.ts", "utf8").includes("await assertTenantWritable(session.user.tenantId)"), "Nutzer-/Rollenverwaltung gesperrt");
ok(readFileSync("src/server/actions/lotse-settings.ts", "utf8").includes("await assertTenantWritable(ctx.tenantId)"), "Lotse-Einstellungen gesperrt");
ok(readFileSync("scripts/craftvia-worker.ts", "utf8").includes("isJobBlockedByTrial(name, job.data)"), "Worker prüft die Sperre vor jedem Job");
ok(await isJobBlockedByTrial("import-extraction", { tenantId: A.tenantId }), "Job Import-Extraktion für abgelaufenen Mandanten übersprungen");
ok(await isJobBlockedByTrial("transcription", { tenantId: A.tenantId }), "Job Transkription (Lotse) übersprungen");
ok(!(await isJobBlockedByTrial("report-pdf", { tenantId: A.tenantId })), "PDF-Erzeugung bereits gespeicherter Berichte läuft weiter");
ok(!(await isJobBlockedByTrial("tenant-export", { tenantId: A.tenantId })), "Export-Job läuft auch im Nur-Lesen-Zustand");
ok(!(await isJobBlockedByTrial("import-extraction", { tenantId: B.tenantId })), "Jobs von Mandant B unberührt");
ok(!(await isJobBlockedByTrial("trial-lifecycle", { tenantId: "*" })), "plattformweite Jobs nicht betroffen");
await expectCode(() => setOnboardingItem(adminA, "team", true), "blocked", "Checkliste im Nur-Lesen-Zustand gesperrt");
await expectCode(() => setOnboardingHidden(adminA, true), "blocked", "Ausblenden im Nur-Lesen-Zustand gesperrt");
ok((await adminA.db.customer.count()) === 3 && (await adminA.db.workOrder.count()) === 4, "Lesen bleibt möglich");
console.log("\n— Export (auch im Nur-Lesen-Zustand) —");
// one stored file for the export
const pdf = Buffer.from("%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n");
const doc = await storeFile(ctxFor(A.tenantId, A.adminUserId, "tenant-admin"), { bytes: pdf, fileName: "zz-auftrag.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { workOrderId: order.id } }).catch((err) => {
console.log(" (Hinweis: Dokument konnte nicht gespeichert werden:", (err as Error).message, ")");
return null;
});
await prisma.customer.updateMany({ where: { tenantId: A.tenantId, companyName: { startsWith: "Hausverwaltung" } }, data: { notes: "=HYPERLINK(\"http://evil\")" } });
await expectCode(() => requestTenantExport(ctxTechA, { dispatch: async () => undefined }), "forbidden", "Monteur darf keinen Export anfordern");
const exp = await requestTenantExport(adminA, { dispatch: (tid, id) => buildTenantExport(tid, id) });
const done = await prisma.tenantExport.findUniqueOrThrow({ where: { id: exp.id } });
ok(done.status === "done" && !!done.storageKey?.startsWith(`${A.tenantId}/`) && (done.bytes ?? 0) > 0, `Export im Nur-Lesen-Zustand erstellt (${done.status}${done.error ? `: ${done.error}` : ""})`);
await expectCode(() => requestTenantExport(adminA, { dispatch: async () => undefined }).then(() => requestTenantExport(adminA, { dispatch: async () => undefined })), "conflict", "paralleler Export → conflict");
const { bytes, fileName } = await openTenantExport(adminA, exp.id);
ok(bytes.subarray(0, 2).toString() === "PK" && fileName.endsWith(".zip"), "Download liefert ZIP");
const zip = readZip(bytes);
const kunden = zip.get("csv/kunden.csv")?.toString("utf8") ?? "";
ok(zip.has("LIESMICH.txt") && zip.has("json/auftraege.json") && zip.has("csv/zeiten.csv") && zip.has("csv/material.csv") && zip.has("csv/berichte.csv"), "ZIP enthält Stammdaten, Aufträge, Zeiten, Material, Berichte (CSV/JSON)");
ok(kunden.startsWith("") && kunden.includes("Hausverwaltung Musterhof GmbH"), "CSV mit BOM und Daten von Mandant A");
ok(!kunden.includes("ZZ Kunde nur B"), "Export enthält keine Daten von Mandant B");
ok(kunden.includes("'=HYPERLINK"), "CSV-Formel-Injektion entschärft");
ok((JSON.parse(zip.get("json/auftraege.json")?.toString("utf8") ?? "[]") as unknown[]).length === 4, "JSON enthält alle Aufträge");
if (doc) ok([...zip.keys()].some((k) => k.startsWith(`dateien/${doc.id}-`)) && zip.get([...zip.keys()].find((k) => k.startsWith(`dateien/${doc.id}-`))!)?.equals(pdf) === true, "gespeicherte Datei im ZIP (byte-identisch)");
const nutzer = zip.get("csv/nutzer.csv")?.toString("utf8") ?? "";
ok(!nutzer.includes("password") && !nutzer.includes("$argon2"), "keine Passwort-Hashes im Export");
ok((await listTenantExports(adminA)).some((e) => e.id === exp.id && !e.expired), "Exportliste zeigt den fertigen Export");
await expectCode(() => openTenantExport(adminB, exp.id), "not_found", "Mandant B kann den Export von A nicht laden");
await expectCode(() => openTenantExport(ctxTechA, exp.id), "forbidden", "Monteur kann den Export nicht laden");
await expectCode(() => openTenantExport(adminA, exp.id, { now: new Date(Date.now() + 8 * 86_400_000) }), "invalid", "Download nach 7 Tagen abgelaufen");
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "tenant_export_download" } })) >= 1, "Audit: Export-Download");
ok(toCsv([{ a: 'x;"y"', b: new Date("2026-01-01T00:00:00Z"), c: null }]) === 'a;b;c\r\n"x;""y""";2026-01-01T00:00:00.000Z;\r\n', "CSV-Quoting, Datum, leere Werte");
console.log("\n— Verlängern hebt die Sperre auf —");
const before = await prisma.tenant.findUniqueOrThrow({ where: { id: B.tenantId } });
await changeTrialEndDate(actor, A.tenantId, addDaysToKey(today, 5));
await assertTenantWritable(A.tenantId);
const stateAfter = await getTrialState(A.tenantId);
ok(!stateAfter.readOnly && stateAfter.daysLeft === 5, "nach Verlängerung wieder schreibbar (noch 5 Tage)");
ok((await handler(A.tenantId)(post())).status === 200, "API-Mutation nach Verlängerung erlaubt");
await setOnboardingItem(adminA, "team", true);
ok(true, "Checkliste nach Verlängerung wieder änderbar");
const after = await prisma.tenant.findUniqueOrThrow({ where: { id: B.tenantId } });
ok(before.trialEndsAt?.getTime() === after.trialEndsAt?.getTime() && after.readOnlySince === null, "Mandant B unverändert");
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);
});