Merge lane/testphase in feature/craftvia-mvp

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	prisma/schema.prisma
#	scripts/test-e2e-tenant-isolation.ts
#	src/server/backup/topology.ts
#	src/server/db.ts
#	src/server/dsgvo/pii-fields.ts
This commit is contained in:
2026-09-15 19:16:19 +02:00
83 changed files with 4703 additions and 23 deletions
+24
View File
@@ -57,6 +57,12 @@ const ACTION_MODULE: Record<string, string> = {
"account.ts": "EXEMPT",
"tenant-switch.ts": "EXEMPT",
"webauthn.ts": "EXEMPT",
// L15 Testphase: Plattform-Wizard/-Aktionen (requirePlatformFullAdmin) und Mandanten-Export/Onboarding
// (requireSession + requirePermission + requireApiContext; Export bewusst auch im Nur-Lesen-Zustand)
"trial-platform.ts": "EXEMPT",
"trial-tenant.ts": "EXEMPT",
// L15 Testphase: öffentliche Selbstanmeldung ohne Session — jede Action MUSS das Rate-Limit prüfen
"trial-signup.ts": "PUBLIC",
};
const errors: string[] = [];
@@ -75,6 +81,10 @@ function checkGatedFile(label: string, src: string, moduleKey: string) {
if (!src.includes(`moduleGuard("${moduleKey}")`)) {
errors.push(`${label}: erwartet moduleGuard("${moduleKey}") — Modul-Gating fehlt oder falscher Key.`);
}
// L15 Testphase: der Lese-Modus überspringt die Schreibsperre abgelaufener Testmandanten → in Actions verboten.
if (/moduleGuard\([^)]*read\s*:/.test(src)) {
errors.push(`${label}: moduleGuard(…, { read: true }) ist nur für Lesepfade erlaubt, nicht in Server-Actions.`);
}
for (let i = 0; i < positions.length; i++) {
const start = positions[i].index;
const end = i + 1 < positions.length ? positions[i + 1].index : src.length;
@@ -118,6 +128,20 @@ for (const entry of readdirSync(ACTIONS_DIR)) {
continue;
}
const src = readFileSync(full, "utf8");
if (mapped === "PUBLIC") {
// Öffentliche Actions (ohne Session): jede exportierte Action muss ein Rate-Limit prüfen.
const exportRe = /export async function (\w+)\s*\(/g;
const positions: { name: string; index: number }[] = [];
let pm: RegExpExecArray | null;
while ((pm = exportRe.exec(src))) positions.push({ name: pm[1], index: pm.index });
positions.forEach((p, i) => {
const body = src.slice(p.index, i + 1 < positions.length ? positions[i + 1].index : src.length);
if (!/(enforceTrialRateLimit|checkRateLimit|consumeRateLimit)\(/.test(body)) {
errors.push(`${entry}: öffentliche Action "${p.name}" ohne Rate-Limit-Prüfung.`);
}
});
continue;
}
if (mapped === "EXEMPT") {
// Auth-Nachweis: ein require*-Guard ODER ein direkter auth()-Aufruf.
if (!/require(Session|Platform\w*|Permission)|\bauth\(\)/.test(src)) {
+9 -1
View File
@@ -2,6 +2,7 @@ import "dotenv/config";
import { Worker } from "bullmq";
import { JOB_QUEUES, workerConnection, closeJobQueues, scheduleRecurringJobs, type JobPayload } from "../src/server/jobs/queues";
import { PROCESSORS } from "../src/server/jobs/processors";
import { isJobBlockedByTrial } from "../src/server/services/trial/jobs";
/** Craftvia background worker: `npm run worker:craftvia`. One BullMQ worker per registered queue. */
async function main() {
@@ -20,7 +21,14 @@ async function main() {
const processor = await load();
const geocode = name === JOB_QUEUES.geocodeSite; // L13: OSM Nominatim policy — max. 1 request/s
const concurrency = name === JOB_QUEUES.reportPdf ? 2 : geocode ? 1 : 4;
const w = new Worker<JobPayload>(name, async (job) => processor(job.data), { connection, concurrency, ...(geocode ? { limiter: { max: 1, duration: 1_000 } } : {}) });
const w = new Worker<JobPayload>(name, async (job) => {
// L15 Testphase: user-triggered jobs of an expired trial tenant (read-only) are skipped
if (await isJobBlockedByTrial(name, job.data)) {
console.warn(`[worker] ${name} job ${job.id} skipped: tenant is read-only (trial expired)`);
return;
}
return processor(job.data);
}, { connection, concurrency, ...(geocode ? { limiter: { max: 1, duration: 1_000 } } : {}) });
w.on("failed", (job, err) => console.error(`[worker] ${name} job ${job?.id} failed:`, err.message));
workers.push(w);
console.info(`[worker] listening on ${name}`);
+109
View File
@@ -0,0 +1,109 @@
// Shared fixture of the L15 tests (scripts/test-testphase-*.ts): zz trial tenants, platform admins,
// mail capture, zip reader and a complete tenant purge. Not a test itself (runner only picks up
// scripts/test-*.ts at top level).
import { inflateRawSync } from "node:zlib";
import { prisma, dbForTenant } from "../../src/server/db";
import { ROLE_DEFS, type RoleKey } from "../../src/server/rbac";
import type { ServiceCtx } from "../../src/server/services/context";
import { offboardTenant } from "../../src/server/dsgvo/deletion";
import type { enqueueMail } from "../../src/server/mail/service";
import { provisionTrialTenant } from "../../src/server/services/trial/provision";
export const DOMAIN = "@zz-l15.test";
export const SLUG_PREFIX = "zz-l15";
export let failures = 0;
export const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
/** Expects `fn` to throw an error with the given `code` (ServiceError) — or any error when code is "*". */
export async function expectCode(fn: () => Promise<unknown>, code: string, msg: string) {
try {
await fn();
ok(false, `${msg} — kein Fehler (erwartet ${code})`);
} catch (err) {
const actual = (err as { code?: string }).code;
const pass = code === "*" || actual === code;
ok(pass, `${msg}${pass ? "" : ` — Code ${actual ?? (err as Error).message}`}`);
}
}
export function ctxFor(tenantId: string, userId: string, role: RoleKey): ServiceCtx {
return { db: dbForTenant(tenantId), tenantId, userId, permissions: new Set<string>(ROLE_DEFS[role].permissions) };
}
type MailInput = Parameters<typeof enqueueMail>[0];
export function captureMail() {
const sent: MailInput[] = [];
const fn = (async (input: MailInput) => {
sent.push(input);
return { status: "queued", mailLogId: `zz-${sent.length}` };
}) as typeof enqueueMail;
return { sent, fn };
}
export async function platformAdmin(role: "full" | "readonly" = "full") {
return prisma.platformAdmin.create({
data: { email: `platform-${role}-${Math.random().toString(36).slice(2, 8)}${DOMAIN}`, passwordHash: "x", name: `ZZ Platform ${role}`, role },
});
}
/** Trial tenant through the real provisioning (slug zz-l15-<name>). */
export async function trialTenant(name: string, endDateKey: string, opts: { sampleData?: boolean } = {}) {
const slugPart = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
return provisionTrialTenant({
companyName: `ZZ L15 ${name}`,
admin: { name: `Admin ${name}`, email: `admin-${slugPart}${DOMAIN}`, passwordHash: "x" },
endDateKey,
sampleData: opts.sampleData ?? false,
source: "platform",
});
}
export async function addMember(tenantId: string, local: string, role: RoleKey) {
const email = `${local}${DOMAIN}`;
const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } });
const roleRow = await prisma.role.findUniqueOrThrow({ where: { tenantId_key: { tenantId, key: role } } });
return prisma.user.create({ data: { tenantId, identityId: identity.id, email, name: local, userRoles: { create: [{ roleId: roleRow.id }] } } });
}
/** Removes a test tenant completely (offboarding of all tenant tables + rows around the tenant). */
export async function purgeTenant(tenantId: string) {
const exists = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { id: true } });
if (!exists) return;
await offboardTenant(tenantId, { reason: "zz-test-cleanup", purgeFiles: false });
await prisma.auditLog.deleteMany({ where: { tenantId } });
await prisma.mailLog.deleteMany({ where: { tenantId } });
await prisma.deletionCertificate.deleteMany({ where: { tenantId } });
await prisma.trialSignup.deleteMany({ where: { provisionedTenantId: tenantId } });
await prisma.tenant.delete({ where: { id: tenantId } });
}
export async function cleanupL15() {
const tenants = await prisma.tenant.findMany({ where: { slug: { startsWith: SLUG_PREFIX } }, select: { id: true } });
for (const t of tenants) await purgeTenant(t.id);
await prisma.trialSignup.deleteMany({ where: { email: { endsWith: DOMAIN } } });
await prisma.identity.deleteMany({ where: { email: { endsWith: DOMAIN }, memberships: { none: {} } } });
await prisma.platformAdmin.deleteMany({ where: { email: { endsWith: DOMAIN } } });
}
/** Minimal ZIP reader for the export tests (deflate entries written by src/server/backup/zip.ts). */
export function readZip(buf: Buffer): Map<string, Buffer> {
const out = new Map<string, Buffer>();
let offset = 0;
while (offset + 30 <= buf.length && buf.readUInt32LE(offset) === 0x04034b50) {
const method = buf.readUInt16LE(offset + 8);
const compressed = buf.readUInt32LE(offset + 18);
const nameLen = buf.readUInt16LE(offset + 26);
const extraLen = buf.readUInt16LE(offset + 28);
const name = buf.subarray(offset + 30, offset + 30 + nameLen).toString("utf8");
const start = offset + 30 + nameLen + extraLen;
const data = buf.subarray(start, start + compressed);
out.set(name, method === 8 ? inflateRawSync(data) : Buffer.from(data));
offset = start + compressed;
}
return out;
}
+155
View File
@@ -0,0 +1,155 @@
/**
* 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.", 'data-trial-banner="expired"'] },
{ path: "/m/orders", mustContain: ["Testphase abgelaufen – nur Lesezugriff."] },
{ label: "GET /m/orders/[id] ohne Zuweisung → 404 (Scope)", path: `/m/orders/${order.id}`, expect: [404] },
{ path: "/m/emergency", mustContain: ["Testphase abgelaufen – nur Lesezugriff."] },
{ 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);
});
+1
View File
@@ -141,6 +141,7 @@ async function createModelRows(A: TenantFixture): Promise<Rows> {
put("LotseConversation", lotseConversation.id, { workOrderId: MARK });
put("LotseMessage", (await prisma.lotseMessage.create({ data: { tenantId: t, conversationId: lotseConversation.id, role: "user", text: "ZZ" } })).id, { text: MARK });
put("LotseActionProposal", (await prisma.lotseActionProposal.create({ data: { tenantId: t, conversationId: lotseConversation.id, userId: uid, kind: "add_note", payload: {}, payloadHash: "0".repeat(64), expiresAt: new Date(Date.now() + 1_800_000) } })).id, { kind: MARK });
put("TenantExport", (await prisma.tenantExport.create({ data: { tenantId: t, status: "done", fileName: "zz.zip" } })).id, { fileName: MARK }); // L15 Testphase
return rows;
}
+216
View File
@@ -0,0 +1,216 @@
// Lane L15 „Testphase & Onboarding" — Lebenszyklus-Job und Plattform-Aktionen:
// Erinnerungen 7/3/1 Tage genau einmal (auch nach verpasstem Lauf), Ablaufmail, Löschhinweis,
// Löschung nur fällig + TRIAL (über das bestehende Offboarding inkl. Speicher), umgewandelte,
// verlängerte oder abgebrochene Mandanten bleiben erhalten, Mandant B unberührt;
// Plattform-Rechte (Mandanten-Admin/Read-only-Admin können die Testphase nicht ändern),
// Plattform-Wizard mit Einladung, Mail-Vorlagen, Worker-Registrierung.
//
// Lauf: npx tsx scripts/test-testphase-lifecycle.ts (lokale Postgres-DB + Garage aus .env)
import "dotenv/config";
import { readFileSync } from "node:fs";
import { prisma, dbForTenant } from "../src/server/db";
import { PROCESSORS } from "../src/server/jobs/processors";
import { renderTemplate, TRIAL_TEMPLATE_KEYS, type TemplateVars } from "../src/server/mail/templates";
import { addDaysToKey, todayKey, trialEndInstant } from "../src/lib/trial/dates";
import {
cancelTrialDeletion,
changeTrialEndDate,
convertTenantToFull,
createPlatformTrialTenant,
endTrialNow,
scheduleTrialDeletion,
} from "../src/server/services/trial/admin";
import { deleteTrialTenant, runTrialLifecycle } from "../src/server/services/trial/lifecycle";
import { getTrialState } from "../src/server/services/trial/state";
import { storage } from "../src/server/storage/adapter";
import { readStoredBytes } from "../src/server/services/documents/read";
import { captureMail, cleanupL15, ctxFor, DOMAIN, expectCode, failures, ok, platformAdmin, trialTenant } from "./lib/testphase-fixture";
const HOUR = 3600_000;
const DAY = 24 * HOUR;
async function main() {
await cleanupL15();
const full = await platformAdmin("full");
const readonly = await platformAdmin("readonly");
const actor = { platformAdminId: full.id };
const today = todayKey();
console.log("\n— Erinnerungen 7/3/1 genau einmal —");
const R = await trialTenant("Lifecycle R", addDaysToKey(today, 7));
const endR = trialEndInstant(addDaysToKey(today, 7));
const at = (dayOffset: number) => new Date(endR.getTime() - DAY * 7 + dayOffset * DAY - 12 * HOUR); // noon-ish of "today + offset"
const mail = captureMail();
const run = (now: Date, ids: string[]) => runTrialLifecycle({ now, tenantIds: ids, sendMail: mail.fn });
const count = (tpl: string, tenantId: string) => mail.sent.filter((m) => m.template === tpl && m.tenantId === tenantId).length;
await run(at(0), [R.tenantId]);
ok(count("trial_reminder", R.tenantId) === 1 && (mail.sent[0].vars as { daysLeft: number }).daysLeft === 7, "7 Tage vorher: Erinnerung an den Admin");
ok(mail.sent[0].to === `admin-lifecycle-r${DOMAIN}`, "Empfänger = aktiver Mandanten-Admin");
await run(at(0), [R.tenantId]);
await run(at(1), [R.tenantId]);
ok(count("trial_reminder", R.tenantId) === 1, "zweiter Lauf / Folgetag: keine weitere Erinnerung");
await run(at(4), [R.tenantId]);
ok(count("trial_reminder", R.tenantId) === 2, "3 Tage vorher: zweite Erinnerung");
await run(at(4), [R.tenantId]);
await run(at(6), [R.tenantId]);
await run(at(6), [R.tenantId]);
ok(count("trial_reminder", R.tenantId) === 3, "1 Tag vorher: dritte Erinnerung, jeweils genau einmal");
const markers = await prisma.tenant.findUniqueOrThrow({ where: { id: R.tenantId } });
ok(!!markers.trialReminder7At && !!markers.trialReminder3At && !!markers.trialReminder1At, "Versandmarker gesetzt");
const R2 = await trialTenant("Lifecycle R2", addDaysToKey(today, 2));
await run(new Date(), [R2.tenantId]);
const r2 = await prisma.tenant.findUniqueOrThrow({ where: { id: R2.tenantId } });
ok(count("trial_reminder", R2.tenantId) === 1 && !!r2.trialReminder7At && !!r2.trialReminder3At && !r2.trialReminder1At, "verpasster Lauf: nur die nächstliegende Erinnerung, ältere als erledigt markiert");
console.log("\n— Ablauf, Löschhinweis, Löschung —");
const endExpired = new Date(endR.getTime() + HOUR);
await run(endExpired, [R.tenantId]);
await run(endExpired, [R.tenantId]);
ok(count("trial_expired", R.tenantId) === 1, "Ablaufmail am Endtag genau einmal");
const expiredRow = await prisma.tenant.findUniqueOrThrow({ where: { id: R.tenantId } });
ok(expiredRow.readOnlySince?.getTime() === endR.getTime(), "readOnlySince = Ende der Testphase");
const expiredMail = mail.sent.find((m) => m.template === "trial_expired" && m.tenantId === R.tenantId)!;
ok(!!(expiredMail.vars as { deletionDate?: string }).deletionDate && (expiredMail.vars as { exportUrl: string }).exportUrl.endsWith("/settings/export"), "Ablaufmail nennt Löschdatum und Export");
const due = expiredRow.deletionDueAt!;
ok(due.getTime() === endR.getTime() + 30 * DAY, "Löschung 30 Tage nach Ende fällig");
await run(new Date(due.getTime() - 8 * DAY), [R.tenantId]);
ok(count("trial_deletion_notice", R.tenantId) === 0, "8 Tage vor Löschung noch kein Hinweis");
await run(new Date(due.getTime() - 6 * DAY), [R.tenantId]);
await run(new Date(due.getTime() - 5 * DAY), [R.tenantId]);
ok(count("trial_deletion_notice", R.tenantId) === 1, "Löschhinweis 7 Tage vorher genau einmal");
// tenant data + stored object that must disappear
const dbR = dbForTenant(R.tenantId);
const customerR = await dbR.customer.create({ data: { tenantId: R.tenantId, companyName: "ZZ Löschkunde", city: "Bremen" } });
const stored = await storage.put({ tenantId: R.tenantId, filename: "zz-loeschen.txt", contentType: "text/plain", bytes: Buffer.from("zz") });
const identityR = await prisma.user.findFirstOrThrow({ where: { tenantId: R.tenantId }, select: { identityId: true } });
// untouched neighbours: FULL tenant B (via provisioning then converted), not due trial B2
const B = await trialTenant("Lifecycle B", addDaysToKey(today, 3));
await convertTenantToFull(actor, B.tenantId);
const customerB = await dbForTenant(B.tenantId).customer.create({ data: { tenantId: B.tenantId, companyName: "ZZ Kunde B", city: "Kiel" } });
const B2 = await trialTenant("Lifecycle B2", addDaysToKey(today, 20));
ok(!(await deleteTrialTenant(R.tenantId, { now: new Date(due.getTime() - HOUR) })).deleted, "vor Fälligkeit: keine Löschung");
const summary = await run(new Date(due.getTime() + HOUR), [R.tenantId, B.tenantId, B2.tenantId]);
ok(summary.deleted.length === 1 && summary.deleted[0] === R.tenantId, "fälliger Testmandant gelöscht, nur dieser");
const gone = await prisma.tenant.findUniqueOrThrow({ where: { id: R.tenantId } });
ok(gone.status === "ARCHIVED" && !!gone.trialDeletedAt && gone.deletionDueAt === null, "Mandant archiviert, Löschung protokolliert am Mandanten");
ok((await prisma.customer.count({ where: { tenantId: R.tenantId } })) === 0 && (await prisma.user.count({ where: { tenantId: R.tenantId } })) === 0 && (await prisma.role.count({ where: { tenantId: R.tenantId } })) === 0, "alle Mandanten-Tabellen geleert (Topologie)");
ok((await prisma.identity.findUnique({ where: { id: identityR.identityId } })) === null, "Identity ohne weitere Mitgliedschaft gelöscht");
ok((await readStoredBytes(stored.storageKey)) === null, "Speicherobjekte unter <tenantId>/ entfernt");
ok((await prisma.deletionCertificate.count({ where: { tenantId: R.tenantId, scope: "tenant" } })) === 1, "Löschnachweis erstellt");
ok((await prisma.auditLog.count({ where: { scope: "platform", entity: "trial_tenant", entityId: R.tenantId, action: "delete" } })) === 1, "Plattform-Audit der Löschung");
ok((await prisma.customer.findUnique({ where: { id: customerR.id } })) === null, "Kunde des gelöschten Mandanten entfernt");
ok((await dbForTenant(B.tenantId).customer.count({ where: { id: customerB.id } })) === 1 && (await prisma.tenant.findUniqueOrThrow({ where: { id: B.tenantId } })).status === "ACTIVE", "Mandant B (Vollversion) unberührt");
ok((await prisma.tenant.findUniqueOrThrow({ where: { id: B2.tenantId } })).trialDeletedAt === null, "nicht fälliger Testmandant unberührt");
await run(new Date(due.getTime() + 2 * HOUR), [R.tenantId]);
ok((await prisma.auditLog.count({ where: { scope: "platform", entity: "trial_tenant", entityId: R.tenantId } })) === 1, "gelöschter Mandant wird nicht erneut verarbeitet");
console.log("\n— Umwandeln, Verlängern, Abbrechen verhindern die Löschung —");
const X = await trialTenant("Lifecycle X", addDaysToKey(today, 1));
await endTrialNow(actor, X.tenantId);
const xDue = (await prisma.tenant.findUniqueOrThrow({ where: { id: X.tenantId } })).deletionDueAt!;
await convertTenantToFull(actor, X.tenantId);
const xRow = await prisma.tenant.findUniqueOrThrow({ where: { id: X.tenantId } });
ok(xRow.plan === "FULL" && !!xRow.convertedAt && xRow.deletionDueAt === null && !(await getTrialState(X.tenantId)).readOnly, "Umwandeln: Vollversion, schreibbar, keine Löschung vorgemerkt");
await run(new Date(xDue.getTime() + DAY), [X.tenantId]);
ok(!(await deleteTrialTenant(X.tenantId, { now: new Date(xDue.getTime() + DAY) })).deleted && (await prisma.tenant.findUniqueOrThrow({ where: { id: X.tenantId } })).status === "ACTIVE", "umgewandelter Mandant wird nie gelöscht (Doppelprüfung)");
const Y = await trialTenant("Lifecycle Y", addDaysToKey(today, 1));
await endTrialNow(actor, Y.tenantId);
const yOldDue = (await prisma.tenant.findUniqueOrThrow({ where: { id: Y.tenantId } })).deletionDueAt!;
await changeTrialEndDate(actor, Y.tenantId, addDaysToKey(today, 60));
const yRow = await prisma.tenant.findUniqueOrThrow({ where: { id: Y.tenantId } });
ok(yRow.trialEndsAt!.getTime() === trialEndInstant(addDaysToKey(today, 60)).getTime() && yRow.deletionDueAt!.getTime() > yOldDue.getTime() && yRow.readOnlySince === null && yRow.trialExpiredNoticeAt === null, "Verlängern: neues Ende, Löschtermin verschoben, Marker zurückgesetzt");
await run(new Date(yOldDue.getTime() + DAY), [Y.tenantId]);
ok((await prisma.tenant.findUniqueOrThrow({ where: { id: Y.tenantId } })).trialDeletedAt === null, "verlängerter Mandant nach altem Löschtermin nicht gelöscht");
const Z = await trialTenant("Lifecycle Z", addDaysToKey(today, 1));
await endTrialNow(actor, Z.tenantId);
const zDue = (await prisma.tenant.findUniqueOrThrow({ where: { id: Z.tenantId } })).deletionDueAt!;
await cancelTrialDeletion(actor, Z.tenantId);
await run(new Date(zDue.getTime() + 10 * DAY), [Z.tenantId]);
const zRow = await prisma.tenant.findUniqueOrThrow({ where: { id: Z.tenantId } });
ok(zRow.trialDeletedAt === null && (await getTrialState(Z.tenantId, new Date(zDue.getTime() + 10 * DAY))).readOnly, "Löschung abgebrochen: bleibt erhalten, weiter nur lesbar");
await scheduleTrialDeletion(actor, Z.tenantId);
const zScheduled = (await prisma.tenant.findUniqueOrThrow({ where: { id: Z.tenantId } })).deletionDueAt!;
ok(zScheduled.getTime() >= Date.now() + 7 * DAY - HOUR, "Löschung vormerken: frühestens in 7 Tagen (Hinweis möglich)");
await changeTrialEndDate(actor, Z.tenantId, addDaysToKey(today, 3));
ok((await prisma.tenant.findUniqueOrThrow({ where: { id: Z.tenantId } })).deletionDueAt!.getTime() === trialEndInstant(addDaysToKey(today, 3)).getTime() + 30 * DAY, "vorgemerkte Löschung folgt dem neuen Enddatum");
const auditZ = await prisma.auditLog.findMany({ where: { tenantId: Z.tenantId, scope: "platform", entity: { startsWith: "trial_" } }, orderBy: { createdAt: "asc" } });
ok(auditZ.map((a) => a.entity).join(",") === "trial_ended,trial_deletion_cancelled,trial_deletion_scheduled,trial_end_date" && auditZ.every((a) => a.actorId === full.id && a.before && a.after), "Plattform-Audit je Aktion mit before/after");
console.log("\n— Plattform-Rechte —");
const P = await trialTenant("Lifecycle P", addDaysToKey(today, 5));
const tenantAdminActor = { platformAdminId: P.adminUserId };
const endBefore = (await prisma.tenant.findUniqueOrThrow({ where: { id: P.tenantId } })).trialEndsAt!.getTime();
await expectCode(() => changeTrialEndDate(tenantAdminActor, P.tenantId, addDaysToKey(today, 30)), "forbidden", "Mandanten-Admin kann die Testphase nicht verlängern");
await expectCode(() => convertTenantToFull(tenantAdminActor, P.tenantId), "forbidden", "Mandanten-Admin kann nicht umwandeln");
await expectCode(() => cancelTrialDeletion(tenantAdminActor, P.tenantId), "forbidden", "Mandanten-Admin kann die Löschung nicht abbrechen");
await expectCode(() => changeTrialEndDate({ platformAdminId: readonly.id }, P.tenantId, addDaysToKey(today, 30)), "forbidden", "Read-only-Plattform-Admin kann nichts ändern");
await expectCode(() => createPlatformTrialTenant(tenantAdminActor, { companyName: "ZZ L15 Hack", adminName: "Hack", adminEmail: `hack${DOMAIN}`, endDate: addDaysToKey(today, 5), sampleData: false }), "forbidden", "Mandanten-Admin kann keinen Testmandanten anlegen");
await expectCode(() => changeTrialEndDate(actor, P.tenantId, addDaysToKey(today, -1)), "invalid", "Enddatum in der Vergangenheit → invalid");
await expectCode(() => changeTrialEndDate(actor, P.tenantId, addDaysToKey(today, 400)), "invalid", "Enddatum > 1 Jahr → invalid");
await expectCode(() => changeTrialEndDate(actor, X.tenantId, addDaysToKey(today, 10)), "invalid", "Vollversion: keine Testphasen-Aktion");
ok((await prisma.tenant.findUniqueOrThrow({ where: { id: P.tenantId } })).trialEndsAt!.getTime() === endBefore, "Testphase von P unverändert");
const { trialLifecycleAction } = await import("../src/server/actions/trial-platform");
const fd = new FormData();
fd.set("confirm", "on");
fd.set("endDate", addDaysToKey(today, 40));
await expectCode(() => trialLifecycleAction(P.tenantId, "extend", { status: "idle" }, fd), "*", "Action ohne Plattform-Session wird abgewiesen");
ok((await prisma.tenant.findUniqueOrThrow({ where: { id: P.tenantId } })).trialEndsAt!.getTime() === endBefore, "… und ändert nichts");
const platformSrc = readFileSync("src/server/actions/trial-platform.ts", "utf8");
const exportsP = [...platformSrc.matchAll(/export async function (\w+)/g)].map((m) => m[1]);
ok(exportsP.length === 2 && exportsP.every((n) => new RegExp(`export async function ${n}[\\s\\S]*?await requirePlatformFullAdmin\\(\\)`).test(platformSrc)), "jede Plattform-Action verlangt einen Plattform-Voll-Admin");
const tenantSrc = readFileSync("src/server/actions/trial-tenant.ts", "utf8");
ok(!/services\/trial\/admin/.test(tenantSrc) && !/trialEndsAt|deletionDueAt|convertedAt/.test(tenantSrc), "Mandanten-Actions enthalten keine Testphasen-Änderung");
console.log("\n— Plattform-Wizard mit Einladung —");
const invites: { to: string; tenantId: string }[] = [];
const created = await createPlatformTrialTenant(actor, { companyName: "ZZ L15 Wizard GmbH", sector: "Elektro", adminName: "Wanda Wizard", adminEmail: `wanda${DOMAIN}`, endDate: addDaysToKey(today, 45), sampleData: true }, { invite: async (i) => void invites.push({ to: i.to, tenantId: i.tenantId }) });
const w = await prisma.tenant.findUniqueOrThrow({ where: { id: created.tenantId } });
ok(w.plan === "TRIAL" && w.trialSource === "platform" && w.trialEndsAt!.getTime() === trialEndInstant(addDaysToKey(today, 45)).getTime(), "Wizard: Testmandant mit frei gesetztem Enddatum (> TRIAL_MAX_DAYS)");
ok(created.invited && invites.length === 1 && invites[0].to === `wanda${DOMAIN}` && invites[0].tenantId === w.id, "Einladung über den bestehenden Einladungsweg");
const wId = await prisma.identity.findUniqueOrThrow({ where: { email: `wanda${DOMAIN}` } });
ok(wId.mustChangePassword, "neue Identity: Passwort wird über die Einladung gesetzt");
ok((await prisma.authToken.count({ where: { principalId: wId.id, type: "invitation", usedAt: null } })) === 1, "Einladungs-Token ausgestellt (Hash)");
ok((await dbForTenant(w.id).workOrder.count()) === 4, "Wizard mit Beispieldaten");
const again = await createPlatformTrialTenant(actor, { companyName: "ZZ L15 Wizard Zwei", adminName: "Wanda Wizard", adminEmail: `wanda${DOMAIN}`, endDate: addDaysToKey(today, 10), sampleData: false }, { invite: async (i) => void invites.push({ to: i.to, tenantId: i.tenantId }) });
ok(!again.invited && invites.length === 1 && again.identityId === wId.id, "bestehende Person: nur verknüpft, keine neue Einladung");
ok((await prisma.auditLog.count({ where: { tenantId: w.id, entity: "trial", actorId: full.id } })) === 1, "Audit mit handelndem Plattform-Admin");
console.log("\n— Vorlagen und Worker —");
const sample: { [K in (typeof TRIAL_TEMPLATE_KEYS)[number]]: TemplateVars[K] } = {
trial_confirm: { name: "Paula", companyName: "Muster", trialEnd: "30.09.2026", actionUrl: "https://x.example/testen/bestaetigen?token=abc", expires: "16.09.2026, 10:00" },
trial_existing_account: { name: "Paula", loginUrl: "https://x.example/login", resetUrl: "https://x.example/forgot-password" },
trial_reminder: { name: "Paula", tenantName: "Muster", daysLeft: 3, endDate: "30.09.2026", actionUrl: "https://x.example/dashboard", contact: "vertrieb@craftvia.example" },
trial_expired: { name: "Paula", tenantName: "Muster", endDate: "30.09.2026", deletionDate: "30.10.2026", exportUrl: "https://x.example/settings/export" },
trial_deletion_notice: { name: "Paula", tenantName: "Muster", deletionDate: "30.10.2026", exportUrl: "https://x.example/settings/export" },
};
for (const key of TRIAL_TEMPLATE_KEYS) {
for (const locale of ["de", "en"] as const) {
const r = renderTemplate(key, locale, sample[key] as never);
ok(r.subject.length > 5 && !/undefined|\{/.test(r.subject + r.text) && r.html.includes("<"), `Vorlage ${key} (${locale}) rendert`);
}
}
ok(renderTemplate("trial_reminder", "de", { ...sample.trial_reminder, daysLeft: 1 }).subject.includes("morgen") && renderTemplate("trial_reminder", "de", { ...sample.trial_reminder, daysLeft: 0 }).subject.includes("heute"), "Erinnerung: „morgen“/„heute“");
ok(typeof PROCESSORS["trial-lifecycle"] === "function" && typeof PROCESSORS["tenant-export"] === "function", "Processor trial-lifecycle und tenant-export registriert");
ok(readFileSync("src/server/jobs/queues.ts", "utf8").includes('"trial-lifecycle-daily"'), "täglicher Job-Scheduler angelegt");
const ctxP = ctxFor(P.tenantId, P.adminUserId, "tenant-admin");
ok((await ctxP.db.tenantSettings.findFirst())?.orgName === "ZZ L15 Lifecycle P", "Mandanten-Einstellungen provisioniert");
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);
});
+189
View File
@@ -0,0 +1,189 @@
// 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);
});
+211
View File
@@ -0,0 +1,211 @@
// Lane L15 „Testphase & Onboarding" — öffentliche Selbstanmeldung:
// Wizard-Validierung (Pflichtfelder, Passwort-Policy, Enddatum-Grenzen, TRIAL_MAX_DAYS, Europe/Berlin),
// Double-Opt-in (Token nur als Hash, 24 h, Einmalverwendung, ältere Links entwertet),
// Honeypot, Enumeration-Schutz (gleiche Antwort, Hinweis-Mail), Rate-Limit je IP und je E-Mail,
// Provisionierung mit/ohne Beispieldaten + Modulauswahl, Slug-Kollisionen, Mandantentrennung.
//
// Lauf: npx tsx scripts/test-testphase-signup.ts (lokale Postgres-DB aus .env)
import "dotenv/config";
import { readFileSync } from "node:fs";
import { prisma, dbForTenant } from "../src/server/db";
import { verifyPassword } from "../src/server/password";
import { resetRateLimits } from "../src/server/rate-limit";
import { addDaysToKey, trialBounds, trialEndDateKey, trialEndInstant } from "../src/lib/trial/dates";
import { normalizeTrialValues, validateTrialSignup, validateTrialStep, type TrialSignupValues } from "../src/lib/trial/signup";
import { MODULE_KEYS } from "../src/lib/modules";
import { trialMaxDays } from "../src/server/services/trial/config";
import { enforceTrialRateLimit } from "../src/server/services/trial/abuse";
import { checkTrialStep, confirmTrialSignup, currentTrialBounds, hashSignupToken, peekTrialSignup, submitTrialSignup } from "../src/server/services/trial/signup";
import { SAMPLE_TEAM_NAME } from "../src/server/services/trial/sample-data";
import { captureMail, cleanupL15, DOMAIN, failures, ok } from "./lib/testphase-fixture";
const PASSWORD = "Testphase2026Sicher";
function values(over: Partial<TrialSignupValues> = {}): TrialSignupValues {
return {
companyName: "ZZ L15 Signup Sanitär",
sector: "Sanitär, Heizung, Klima",
companySize: "6-20",
adminName: "Paula Probe",
email: `paula${DOMAIN}`,
password: PASSWORD,
trialEndDate: currentTrialBounds().defaultEnd,
sampleData: true,
modules: [...MODULE_KEYS],
acceptTerms: true,
acceptPrivacy: true,
website: "",
...over,
};
}
const tokenOf = (url: string) => new URL(url).searchParams.get("token") ?? "";
async function main() {
await cleanupL15();
resetRateLimits();
console.log("\n— Wizard-Validierung —");
const now = new Date("2026-03-10T10:00:00Z");
const b = trialBounds(now, 30);
ok(b.today === "2026-03-10" && b.min === "2026-03-11" && b.max === "2026-04-09" && b.defaultEnd === "2026-03-24", "Grenzen: morgen … heute + 30, Vorbelegung heute + 14");
ok(trialBounds(now, 10).defaultEnd === "2026-03-20", "Vorbelegung höchstens TRIAL_MAX_DAYS");
const v = values();
ok(validateTrialStep("period", { ...v, trialEndDate: "2026-03-10" }, b).trialEndDate === "date_too_early", "Enddatum heute → zu früh");
ok(!validateTrialStep("period", { ...v, trialEndDate: "2026-03-11" }, b).trialEndDate, "Enddatum morgen → erlaubt");
ok(!validateTrialStep("period", { ...v, trialEndDate: "2026-04-09" }, b).trialEndDate, "Enddatum heute + 30 → erlaubt");
ok(validateTrialStep("period", { ...v, trialEndDate: "2026-04-10" }, b).trialEndDate === "date_too_late", "Enddatum heute + 31 → zu spät");
ok(validateTrialStep("period", { ...v, trialEndDate: "2026-02-30" }, b).trialEndDate === "date_invalid", "ungültiges Kalenderdatum");
ok(validateTrialStep("company", { ...v, companyName: " " }, b).companyName === "company_required", "Firmenname Pflicht");
ok(validateTrialStep("company", { ...v, companySize: "999" }, b).companySize === "invalid_choice", "Betriebsgröße nur aus der Liste");
const acc = validateTrialStep("account", { ...v, adminName: "", email: "kein-mail", password: "kurz" }, b);
ok(acc.adminName === "name_required" && acc.email === "email_invalid" && acc.password === "password_policy", "Admin-Konto: Name, E-Mail und Passwort-Policy");
ok(validateTrialStep("setup", { ...v, modules: [] }, b).modules === "modules_required", "mindestens ein Modul");
const sum = validateTrialStep("summary", { ...v, acceptTerms: false, acceptPrivacy: false }, b);
ok(sum.acceptTerms === "terms_required" && sum.acceptPrivacy === "privacy_required", "Pflicht-Checkboxen Nutzungsbedingungen/Datenschutz");
ok(Object.keys(validateTrialSignup({ ...v, trialEndDate: "2026-03-24" }, b)).length === 0, "vollständige gültige Anmeldung ohne Fehler");
ok(normalizeTrialValues({ ...v, email: " Paula@ZZ-L15.test ", modules: ["customers", "hacker"] })?.email === "paula@zz-l15.test", "E-Mail normalisiert");
ok(JSON.stringify(normalizeTrialValues({ ...v, modules: ["customers", "hacker"] })?.modules) === '["customers"]', "unbekannte Module verworfen");
ok(normalizeTrialValues({ modules: "kein-array" }) === null, "fehlerhafte Eingabe → null");
ok(checkTrialStep("hacken", v)._form === "invalid_request", "serverseitige Schrittprüfung: unbekannter Schritt");
ok(checkTrialStep("account", { ...v, password: "x" }).password === "password_policy", "serverseitige Schrittprüfung: Passwort-Policy");
const prevMax = process.env.TRIAL_MAX_DAYS;
process.env.TRIAL_MAX_DAYS = "10";
ok(trialMaxDays() === 10, "TRIAL_MAX_DAYS aus der Umgebung");
process.env.TRIAL_MAX_DAYS = "abc";
ok(trialMaxDays() === 30, "ungültiges TRIAL_MAX_DAYS → Default 30");
if (prevMax === undefined) delete process.env.TRIAL_MAX_DAYS;
else process.env.TRIAL_MAX_DAYS = prevMax;
ok(trialEndInstant("2026-03-24").toISOString() === "2026-03-24T23:00:00.000Z", "Ende des gewählten Tages in Europe/Berlin (Winterzeit)");
ok(trialEndInstant("2026-07-01").toISOString() === "2026-07-01T22:00:00.000Z", "Ende des gewählten Tages in Europe/Berlin (Sommerzeit)");
ok(trialEndInstant("2026-03-29").toISOString() === "2026-03-29T22:00:00.000Z", "Ende am Tag der Zeitumstellung");
ok(trialEndDateKey(trialEndInstant("2026-10-25")) === "2026-10-25", "letzter Testtag aus dem gespeicherten Ende zurückgerechnet");
console.log("\n— Absenden: ungültig / Honeypot —");
const mail = captureMail();
const invalid = await submitTrialSignup({ ...values(), acceptTerms: false }, { ip: "198.51.100.1", sendMail: mail.fn });
ok(invalid.status === "invalid" && (invalid as { errors: Record<string, string> }).errors.acceptTerms === "terms_required", "ungültig → Fehler je Feld");
ok((await prisma.trialSignup.count({ where: { email: { endsWith: DOMAIN } } })) === 0 && mail.sent.length === 0, "ungültig → keine Anmeldung, keine Mail");
const honey = await submitTrialSignup({ ...values(), website: "http://spam.example" }, { ip: "198.51.100.1", sendMail: mail.fn });
ok(honey.status === "sent", "Honeypot gefüllt → neutrale Erfolgsantwort");
ok((await prisma.trialSignup.count({ where: { email: { endsWith: DOMAIN } } })) === 0 && mail.sent.length === 0, "Honeypot → nichts gespeichert, keine Mail");
console.log("\n— Double-Opt-in —");
const first = await submitTrialSignup(values({ sampleData: true, modules: MODULE_KEYS.filter((m) => m !== "lotse") }), { ip: "198.51.100.7", sendMail: mail.fn });
ok(first.status === "sent" && mail.sent.length === 1 && mail.sent[0].template === "trial_confirm", "gültig → Bestätigungsmail");
const token1 = tokenOf((mail.sent[0].vars as { actionUrl: string }).actionUrl);
const row1 = await prisma.trialSignup.findFirstOrThrow({ where: { email: `paula${DOMAIN}` }, orderBy: { createdAt: "desc" } });
ok(row1.tokenHash === hashSignupToken(token1) && row1.tokenHash !== token1 && token1.length >= 40, "Token nur als SHA-256-Hash gespeichert");
ok(row1.passwordHash.startsWith("$argon2id$") && !row1.passwordHash.includes(PASSWORD), "Passwort als Argon2id-Hash (mit Pepper)");
ok(!!row1.ipHash && row1.ipHash !== "198.51.100.7", "IP nur als HMAC");
const ttl = row1.expiresAt.getTime() - row1.createdAt.getTime();
ok(Math.abs(ttl - 24 * 3600_000) < 60_000, "Link 24 Stunden gültig");
ok(row1.status === "pending" && (await prisma.tenant.count({ where: { slug: { startsWith: "zz-l15-signup" } } })) === 0, "vor der Bestätigung wird kein Mandant angelegt");
const rawDump = JSON.stringify(await prisma.trialSignup.findMany({ where: { email: { endsWith: DOMAIN } } }));
ok(!rawDump.includes(token1) && !rawDump.includes(PASSWORD), "weder Klartext-Token noch -Passwort in der Tabelle");
const second = await submitTrialSignup(values({ sampleData: true, modules: MODULE_KEYS.filter((m) => m !== "lotse") }), { ip: "198.51.100.7", sendMail: mail.fn });
const token2 = tokenOf((mail.sent[1].vars as { actionUrl: string }).actionUrl);
ok(second.status === "sent" && token2 !== token1, "erneute Anmeldung → neuer Link");
ok((await prisma.trialSignup.findUniqueOrThrow({ where: { id: row1.id } })).status === "superseded" && (await peekTrialSignup(token1)) === null, "älterer Link entwertet");
ok((await confirmTrialSignup(token1)).status === "invalid", "entwerteter Link lässt sich nicht einlösen");
const peek = await peekTrialSignup(token2);
ok(peek?.companyName === "ZZ L15 Signup Sanitär", "Bestätigungsseite zeigt die Anmeldung (ohne Einlösen)");
ok((await confirmTrialSignup("falsches-token")).status === "invalid", "falsches Token → ungültig");
// expiry on a separate signup
const mailExp = captureMail();
await submitTrialSignup(values({ email: `ablauf${DOMAIN}`, companyName: "ZZ L15 Ablauf" }), { ip: "198.51.100.8", sendMail: mailExp.fn });
const tokenExp = tokenOf((mailExp.sent[0].vars as { actionUrl: string }).actionUrl);
const expRow = await prisma.trialSignup.findFirstOrThrow({ where: { email: `ablauf${DOMAIN}` } });
ok((await confirmTrialSignup(tokenExp, { now: new Date(expRow.expiresAt.getTime() + 1000) })).status === "expired", "nach 24 h → abgelaufen");
const expAfter = await prisma.trialSignup.findUniqueOrThrow({ where: { id: expRow.id } });
ok(expAfter.status === "expired" && expAfter.passwordHash === "", "abgelaufene Anmeldung: Status expired, Passwort-Hash entfernt");
ok((await confirmTrialSignup(tokenExp)).status === "invalid", "abgelaufener Link bleibt ungültig");
console.log("\n— Bestätigung → Provisionierung (mit Beispieldaten, Modulauswahl) —");
const confirmed = await confirmTrialSignup(token2);
ok(confirmed.status === "ok", "Bestätigung → Mandant provisioniert");
if (confirmed.status !== "ok") throw new Error("confirm failed");
const tenantA = await prisma.tenant.findUniqueOrThrow({ where: { id: confirmed.tenantId } });
const endKey = values().trialEndDate;
ok(tenantA.plan === "TRIAL" && tenantA.trialSource === "self_signup" && !!tenantA.trialStartedAt, "Plan TRIAL, Herkunft Selbstanmeldung");
ok(tenantA.trialEndsAt?.getTime() === trialEndInstant(endKey).getTime(), "trialEndsAt = Ende des gewählten Tages (Europe/Berlin)");
ok(tenantA.deletionDueAt?.getTime() === trialEndInstant(endKey).getTime() + 30 * 86_400_000, "Löschung 30 Tage nach Ende vorgemerkt");
ok(tenantA.slug === "zz-l15-signup-sanitar", "Slug aus dem Firmennamen");
const admin = await prisma.user.findFirstOrThrow({ where: { tenantId: tenantA.id }, include: { userRoles: { include: { role: true } }, identity: true } });
ok(admin.userRoles.some((r) => r.role.key === "tenant-admin") && admin.email === `paula${DOMAIN}`, "Admin mit Rolle tenant-admin");
ok(await verifyPassword(admin.identity.passwordHash, PASSWORD), "Login-Passwort = Passwort aus dem Wizard");
ok(!admin.identity.mustChangePassword, "kein Passwortzwang für die Selbstanmeldung");
const signupAfter = await prisma.trialSignup.findFirstOrThrow({ where: { tokenHash: hashSignupToken(token2) } });
ok(signupAfter.status === "confirmed" && signupAfter.passwordHash === "" && signupAfter.provisionedTenantId === tenantA.id, "Anmeldung bestätigt, Passwort-Hash aus der Anmeldung entfernt");
const modules = await prisma.tenantModule.findMany({ where: { tenantId: tenantA.id } });
ok(modules.length === MODULE_KEYS.length && modules.find((m) => m.moduleKey === "lotse")?.enabled === false && modules.filter((m) => m.enabled).length === MODULE_KEYS.length - 1, "abgewähltes Modul deaktiviert, übrige aktiv");
const dbA = dbForTenant(tenantA.id);
ok((await dbA.customer.count()) === 3 && (await dbA.site.count()) === 3 && (await dbA.workOrder.count()) === 4, "Beispieldaten: 3 Kunden, 3 Objekte, 4 Aufträge");
ok((await dbA.team.count({ where: { name: SAMPLE_TEAM_NAME } })) === 1 && (await dbA.workOrder.count({ where: { status: "assigned" } })) === 1, "Beispieldaten: Team + zugewiesener Auftrag (über die Fachservices)");
ok((await prisma.auditLog.count({ where: { tenantId: tenantA.id, entity: "trial", action: "create" } })) === 1, "Audit: Testphase angelegt");
ok((await confirmTrialSignup(token2)).status === "invalid", "Einmalverwendung: zweiter Klick → ungültig");
console.log("\n— Enumeration-Schutz —");
const mailEnum = captureMail();
const known = await submitTrialSignup(values({ companyName: "ZZ L15 Doppelt" }), { ip: "198.51.100.9", sendMail: mailEnum.fn });
const unknown = await submitTrialSignup(values({ companyName: "ZZ L15 Neu", email: `neu${DOMAIN}` }), { ip: "198.51.100.9", sendMail: mailEnum.fn });
ok(JSON.stringify(known) === JSON.stringify(unknown), "bestehende und neue Adresse → identische Antwort");
ok(mailEnum.sent[0].template === "trial_existing_account" && mailEnum.sent[0].to === `paula${DOMAIN}` && mailEnum.sent[1].template === "trial_confirm", "bestehende Adresse → Hinweis-Mail statt Bestätigungslink");
ok(!JSON.stringify(mailEnum.sent[0].vars).includes("token="), "Hinweis-Mail enthält keinen Aktivierungslink");
ok((await prisma.trialSignup.count({ where: { email: `paula${DOMAIN}`, status: "pending" } })) === 0, "bestehende Adresse → keine offene Anmeldung");
console.log("\n— Rate-Limit je IP und je E-Mail —");
resetRateLimits();
const ipA = "203.0.113.10";
const results = Array.from({ length: 6 }, () => enforceTrialRateLimit("trialSignup", { ip: ipA, email: `rl${DOMAIN}` }).allowed);
ok(results.slice(0, 5).every(Boolean) && results[5] === false, "je IP/E-Mail: 5 Anmeldungen pro Stunde, die 6. wird abgewiesen");
ok(enforceTrialRateLimit("trialSignup", { ip: "203.0.113.11", email: `rl${DOMAIN}` }).allowed === false, "gleiche E-Mail von anderer IP → weiter gesperrt");
ok(enforceTrialRateLimit("trialSignup", { ip: ipA, email: `anders${DOMAIN}` }).allowed === false, "gleiche IP mit anderer E-Mail → weiter gesperrt");
ok(enforceTrialRateLimit("trialSignup", { ip: "203.0.113.12", email: `anders${DOMAIN}` }).allowed, "andere IP + andere E-Mail → erlaubt");
const retry = enforceTrialRateLimit("trialSignup", { ip: ipA, email: null });
ok(!retry.allowed && retry.retryAfterSeconds > 0, "Retry-After gesetzt");
resetRateLimits();
const actionsSrc = readFileSync("src/server/actions/trial-signup.ts", "utf8");
const exported = [...actionsSrc.matchAll(/export async function (\w+)/g)].map((m) => m[1]);
ok(exported.length === 3 && exported.every((name) => new RegExp(`export async function ${name}[\\s\\S]*?enforceTrialRateLimit\\(`).test(actionsSrc)), "jede öffentliche Action prüft das Rate-Limit");
ok(readFileSync("scripts/check-module-guards.ts", "utf8").includes('"trial-signup.ts": "PUBLIC"'), "Guard-Check erzwingt Rate-Limit für öffentliche Actions");
ok(readFileSync("src/proxy.ts", "utf8").includes('"/testen"'), "/testen ist ohne Login erreichbar (Proxy)");
console.log("\n— ohne Beispieldaten, Slug-Kollision, Mandantentrennung —");
const mailB = captureMail();
await submitTrialSignup(values({ email: `bernd${DOMAIN}`, adminName: "Bernd Probe", sampleData: false }), { ip: "198.51.100.20", sendMail: mailB.fn });
const confirmedB = await confirmTrialSignup(tokenOf((mailB.sent[0].vars as { actionUrl: string }).actionUrl));
ok(confirmedB.status === "ok", "zweite Anmeldung mit gleichem Firmennamen bestätigt");
if (confirmedB.status !== "ok") throw new Error("confirm B failed");
ok(confirmedB.tenantSlug === "zz-l15-signup-sanitar-2" && confirmedB.tenantId !== tenantA.id, "Slug-Kollision automatisch gelöst (-2), eigener Mandant");
const dbB = dbForTenant(confirmedB.tenantId);
ok((await dbB.customer.count()) === 0 && (await dbB.workOrder.count()) === 0, "ohne Beispieldaten → leerer Mandant");
ok((await dbB.tenantModule.count({ where: { enabled: true } })) === MODULE_KEYS.length, "Standard: alle Module aktiv");
const customerA = await dbA.customer.findFirstOrThrow();
ok((await dbB.customer.findFirst({ where: { id: customerA.id } })) === null, "Mandant B sieht die Kunden von A nicht");
let crossWrite = false;
try {
await dbB.customer.update({ where: { id: customerA.id }, data: { city: "Hack" } });
crossWrite = true;
} catch {
crossWrite = false;
}
ok(!crossWrite && (await dbA.customer.findFirstOrThrow({ where: { id: customerA.id } })).city !== "Hack", "Mandant B kann Kunden von A nicht ändern");
ok(addDaysToKey("2026-12-31", 1) === "2027-01-01", "Datumsrechnung über den Jahreswechsel");
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);
});