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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user