- 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>
142 lines
8.0 KiB
TypeScript
142 lines
8.0 KiB
TypeScript
/**
|
|
* 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;
|