- 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>
153 lines
9.9 KiB
TypeScript
153 lines
9.9 KiB
TypeScript
/**
|
||
* 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."], mustNotContain: ["Daten exportieren"] },
|
||
{ 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);
|
||
});
|