L15 Testphase & Onboarding: Selbstanmeldung mit Double-Opt-in, Plattform-Wizard, Nur-Lesen-Sperre, Export, Lebenszyklus-Job

- Datenmodell: Testphasen-Lebenszyklus am Mandanten (plan, trialEndsAt, readOnlySince, deletionDueAt,
  Versandmarker), TrialSignup (Plattform, Hashes statt Klartext), TenantExport (RLS), Onboarding-Status
- /testen: 5-Schritte-Wizard (Betrieb, Admin-Konto, Enddatum, Einrichtung, Zusammenfassung),
  Bestätigung per POST, direkte Anmeldung über login-ticket; Rate-Limit je IP/E-Mail, Honeypot,
  Enumeration-Schutz, Slug-Kollisionen
- Plattform: Wizard „Testmandant anlegen“ mit Einladung, Badges/Filter, Enddatum ändern,
  umwandeln, beenden, Löschung vormerken/abbrechen (Bestätigung + Audit)
- Schreibsperre nach Ablauf zentral in moduleGuard und requireApiContext (non-GET über withApi),
  Upload-Routen, Einstellungen/Nutzerverwaltung, Worker-Jobs; Banner Backoffice + mobil
- Datenexport (ZIP mit CSV/JSON + Dateien) als Worker-Job, auch im Nur-Lesen-Zustand
- Täglicher Job trial-lifecycle: Erinnerungen 7/3/1, Ablauf, Löschhinweis, Löschung über das Offboarding
- Erste-Schritte-Checkliste im Dashboard, Mail-Vorlagen de/en, Tests + Smoke, Betriebsdoku

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 19:01:47 +02:00
co-authored by Claude Opus 5
parent 6b8cdf543b
commit d9290a187c
79 changed files with 4576 additions and 19 deletions
+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;
}