/** * L10b HTTP smoke against a running dev server (no password input — session cookies via * finalizeIdentityLogin + Auth.js encode, like scripts/smoke-auth.ts): * unified /api/v1 error format, 401/403/404/422, OpenAPI, sync ops, bundle mySession, * Lotse budget settings, collapsible backoffice sidebar, tenant separation. * * Usage (dev server running, seeded DB): BASE=http://localhost:3111 npx tsx scripts/smoke-betrieb.ts */ import "dotenv/config"; import { randomUUID } from "node:crypto"; import { encode } from "next-auth/jwt"; import { prisma } from "../src/server/db"; import { finalizeIdentityLogin } from "../src/server/auth"; const BASE = process.env.BASE ?? "http://localhost:3111"; const HOST = new URL(BASE).host; const COOKIE = BASE.startsWith("https") ? "__Secure-authjs.session-token" : "authjs.session-token"; let failures = 0; const ok = (cond: boolean, msg: string) => { console.log(`${cond ? "✓" : "✗"} ${msg}`); if (!cond) failures++; }; async function cookieFor(email: string, slug: string): Promise { const identity = await prisma.identity.findUnique({ where: { email } }); if (!identity) throw new Error(`identity ${email} not found (seed?)`); const user = await finalizeIdentityLogin(identity.id, slug); if (!user) throw new Error(`no active 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: 60 * 30 })}`; } type Res = { status: number; body: string; json: unknown; headers: Headers }; async function call(path: string, init: RequestInit & { cookie?: string } = {}): Promise { const headers = new Headers(init.headers); if (init.cookie) headers.set("cookie", init.cookie); const res = await fetch(BASE + path, { ...init, headers, redirect: "manual" }); const body = await res.text(); let json: unknown = null; try { json = JSON.parse(body); } catch { json = null; } return { status: res.status, body, json, headers: res.headers }; } const errCode = (r: Res) => (r.json as { error?: { code?: string } } | null)?.error?.code; const sameOrigin = { origin: BASE, "sec-fetch-site": "same-origin", "content-type": "application/json" }; async function main() { console.log("\n== anonym"); let r = await call("/api/v1/openapi.json"); ok(r.status === 401 && errCode(r) === "unauthorized", `GET /api/v1/openapi.json ohne Sitzung → 401 (${r.status})`); r = await call("/api/v1/sync", { method: "POST", headers: sameOrigin, body: "{}" }); ok(r.status === 401 && errCode(r) === "unauthorized", `POST /api/v1/sync ohne Sitzung → 401 (${r.status})`); console.log("\n== admin@demo.example (demo)"); const admin = await cookieFor("admin@demo.example", "demo"); r = await call("/api/v1/openapi.json", { cookie: admin }); const spec = r.json as { openapi?: string; paths?: Record } | null; ok(r.status === 200 && spec?.openapi?.startsWith("3.1") === true && Object.keys(spec.paths ?? {}).length >= 23, `OpenAPI 3.1 mit ${Object.keys(spec?.paths ?? {}).length} Pfaden`); r = await call("/api/v1/customers?pageSize=2", { cookie: admin }); const list = r.json as { data?: unknown[]; pagination?: { total: number } } | null; ok(r.status === 200 && Array.isArray(list?.data) && typeof list?.pagination?.total === "number", "GET /api/v1/customers → data + pagination"); r = await call("/api/v1/work-orders/zz-unknown", { cookie: admin }); ok(r.status === 404 && errCode(r) === "not_found", `GET /api/v1/work-orders/ → 404 not_found (${r.status})`); r = await call("/api/v1/reports/zz-unknown/pdf", { cookie: admin }); ok(r.status === 404 && errCode(r) === "not_found", `GET /api/v1/reports//pdf → 404 im einheitlichen Format (${r.status})`); r = await call("/api/v1/work-orders/zz-unknown/transition", { cookie: admin, method: "POST", headers: { origin: "https://evil.example", "content-type": "application/json" }, body: "{}" }); ok(r.status === 403 && errCode(r) === "forbidden", `POST transition mit fremdem Origin → 403 (${r.status})`); r = await call("/api/v1/customers", { cookie: admin, method: "POST", headers: sameOrigin, body: "[]" }); ok(r.status === 422 && errCode(r) === "invalid", `POST /api/v1/customers mit Array → 422 invalid (${r.status})`); r = await call("/api/v1/imports/zz-unknown/confirm", { cookie: admin, method: "POST", headers: sameOrigin, body: "{nope" }); ok(r.status === 422 && errCode(r) === "invalid", `POST imports/confirm mit kaputtem JSON → 422 (vorher 400, anderes Format) (${r.status})`); r = await call("/settings/lotse", { cookie: admin }); ok(r.status === 200 && r.body.includes("Monatliches KI-Kontingent"), "/settings/lotse zeigt das KI-Kontingent"); r = await call("/dashboard", { cookie: admin }); ok(r.status === 200 && r.body.includes("Menü öffnen") && r.body.includes('aria-controls="backoffice-sidebar"'), "Backoffice-Layout mit Menü-Button (einklappbare Sidebar)"); r = await call("/settings/audit?action=read", { cookie: admin }); ok(r.status === 200, "/settings/audit mit Filter action=read"); r = await call("/work-orders/conflicts", { cookie: admin }); ok(r.status === 200 && r.body.includes("abgesendete Berichte"), "Konfliktliste mit angepasstem Hinweis"); console.log("\n== monteur@demo.example (demo)"); const tech = await cookieFor("monteur@demo.example", "demo"); r = await call("/api/v1/field/bundle", { cookie: tech }); const bundle = r.json as { orders?: { id: string; mySession?: unknown }[] } | null; ok(r.status === 200 && Array.isArray(bundle?.orders) && (bundle!.orders!.length === 0 || bundle!.orders!.every((o) => "mySession" in o)), `Bundle mit mySession je Auftrag (${bundle?.orders?.length ?? 0} Aufträge)`); r = await call("/api/v1/sync", { cookie: tech, method: "POST", headers: sameOrigin, body: JSON.stringify({ deviceId: "smoke", operations: [] }) }); ok(r.status === 422 && errCode(r) === "invalid", `POST /api/v1/sync leerer Batch → 422 invalid (${r.status})`); const orderId = bundle?.orders?.[0]?.id ?? "zz-unknown"; r = await call("/api/v1/sync", { cookie: tech, method: "POST", headers: sameOrigin, body: JSON.stringify({ deviceId: "smoke", operations: [{ clientOpId: randomUUID(), opType: "report.submit", payload: { workOrderId: orderId }, baseVersion: 1, clientCreatedAt: new Date().toISOString() }] }), }); const result = (r.json as { results?: { status: string; errorCode?: string }[] } | null)?.results?.[0]; ok(r.status === 200 && result?.status === "rejected" && result.errorCode === "invalid", "Sync report.submit ohne reportId → rejected invalid (Op registriert, Payload validiert)"); r = await call("/m", { cookie: tech }); ok(r.status === 200, "/m rendert"); r = await call("/dashboard", { cookie: tech }); ok(r.status === 307 || r.status === 308, `Monteur /dashboard → Redirect (${r.status})`); console.log("\n== admin2@demo.example (demo2)"); const admin2 = await cookieFor("admin2@demo.example", "demo2"); const demoTenant = await prisma.tenant.findUnique({ where: { slug: "demo" }, select: { id: true } }); const demoOrder = demoTenant ? await prisma.workOrder.findFirst({ where: { tenantId: demoTenant.id }, select: { id: true } }) : null; if (demoOrder) { r = await call(`/api/v1/work-orders/${demoOrder.id}`, { cookie: admin2 }); ok(r.status === 404 && errCode(r) === "not_found", `Mandant demo2: Auftrag von demo → 404 (${r.status})`); } else { console.log("(kein Demo-Auftrag im Seed — Mandantentest übersprungen)"); } await prisma.$disconnect(); console.log(failures ? `\n${failures} Fehler` : "\nOK"); process.exit(failures ? 1 : 0); } main().catch((err) => { console.error(err); process.exit(1); }); void HOST;