/** * 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; expect?: number[]; mustContain?: string[]; mustNotContain?: string[]; redirectTo?: string }; async function tenantCookie(email: string, slug: string): Promise { 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 { 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, 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); });