Files
craftvia/scripts/test-testphase-readonly.ts
msolarczekandClaude Opus 5 273a4535e3 L15 Testphase & Onboarding: Lese-Modus am moduleGuard für Seitenkontexte, Sperre für Auftragsdokument-Upload
Der HTTP-Smoke zeigte 500 auf /m für abgelaufene Testmandanten: mobile Seitenkontexte (field,
emergency) und der Import-Datei-Download nutzen moduleGuard zum Lesen. moduleGuard(key, { read: true })
überspringt dort die Schreibsperre; der Guard-Check verbietet den Lese-Modus in Server-Actions.
POST /api/v1/work-orders/[id]/documents läuft nicht über withApi und prüft die Sperre jetzt explizit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 19:07:20 +02:00

190 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});