// 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]*if \(!opts\.read\) 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 actionFiles = walk("src/server/actions").filter((f) => f.endsWith(".ts")); ok(actionFiles.every((f) => !/moduleGuard\([^)]*read\s*:/.test(readFileSync(f, "utf8"))), "keine Server-Action nutzt den Lese-Modus des Guards"); const readUsers = walk("src").filter((f) => /\.tsx?$/.test(f) && /moduleGuard\([^)]*read\s*:\s*true/.test(readFileSync(f, "utf8"))).sort(); ok(JSON.stringify(readUsers) === JSON.stringify(["src/app/(app)/imports/[id]/file/route.ts", "src/app/(field)/m/emergency/page.tsx", "src/server/services/field/page-context.ts"]), `Lese-Modus nur in Seitenkontexten/GET-Download (${readUsers.join(", ")})`); ok(readFileSync("scripts/check-module-guards.ts", "utf8").includes("nur für Lesepfade erlaubt"), "Guard-Check verbietet den Lese-Modus in Actions"); 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); });