- 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>
110 lines
5.1 KiB
TypeScript
110 lines
5.1 KiB
TypeScript
// 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;
|
|
}
|