L10b Betrieb & Aufräumen: Lane-Bericht und HTTP-Smoke

- docs/craftvia/lanes/betrieb.md: Umfang a–k, Entscheidungen (b: report.submit übernehmbar, h: Audit nach Commit umgesetzt), Verhaltensänderungen (Fehlerformat, 422), Dateien, Tests (api 150, sync 36, audit 48), Gate 52/52, RLS_ENFORCED-Lauf 50/52 (zwei Owner-Modus-Tests unverändert), Docker-Build, offene Punkte.
- scripts/smoke-betrieb.ts: HTTP-Smoke gegen den Dev-Server (Session-Cookies ohne Passworteingabe) für Fehlerformat, OpenAPI, Sync-Ops, Bundle, Lotse-Kontingent, Sidebar; 18/18 grün auf :3111.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 18:25:36 +02:00
co-authored by Claude Opus 5
parent cadaedc6cc
commit 5343174136
2 changed files with 238 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
/**
* 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<string> {
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<Res> {
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<string, unknown> } | 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/<unbekannt> → 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/<unbekannt>/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;