Plantafel: Standard heute + nächste 4 Werktage (Kolonnen als Zeilen, heutige Spalte mit Live-Status), Heute mit Kolonnen als parallelen Spalten (6–20 Uhr), Woche/nächste Woche, Auslastung, Konflikte/Hinweise, Verzugs- und Gefährdungs-Badges, Drag & Drop (@dnd-kit/core) mit Bestätigungs-Popover und Tastatur-Alternative, ungeplante Aufträge mit Vorschlägen, früher fertig mit Vorziehen, Kolonnenkapazität-Popup. Live-Lage: Leaflet/OSM-Karte mit einem Marker je Kolonne, Liste, Polling 30 s, Hinweis keine GPS-Ortung; CSP img-src für Kacheln. Dashboard-Kacheln Planung heute und Konflikte diese Woche, Einsatz-Vorschläge im Auftragsdetail, Navigation mit Unterpunkten, Smoke-Prüfungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
270 lines
15 KiB
TypeScript
270 lines
15 KiB
TypeScript
/**
|
||
* Authenticated HTTP smoke against a running dev server — WITHOUT typing passwords:
|
||
* builds the session user via finalizeIdentityLogin (same code path as a real login after
|
||
* password/MFA checks) and encodes the Auth.js session cookie with AUTH_SECRET.
|
||
*
|
||
* L10a: covers all core pages per role (admin, backoffice, team lead, technician Nord/Süd, second
|
||
* tenant) on the demo data of prisma/seed.ts; ids are resolved from the DB at runtime. Each check
|
||
* verifies status, expected text and the absence of error pages/foreign data. PERF=1 measures the
|
||
* server render time (second, warm request) and flags pages above PERF_BUDGET_MS (default 2000).
|
||
*
|
||
* Usage (from repo root, dev server running, demo seed loaded):
|
||
* BASE=http://localhost:3110 npx tsx scripts/smoke-auth.ts
|
||
* BASE=http://localhost:3110 PERF=1 npx tsx scripts/smoke-auth.ts # with timings (after scripts/seed-load.ts)
|
||
* ONLY=monteur npx tsx scripts/smoke-auth.ts # filter by e-mail substring
|
||
*/
|
||
import "dotenv/config";
|
||
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:3099";
|
||
const COOKIE = BASE.startsWith("https") ? "__Secure-authjs.session-token" : "authjs.session-token";
|
||
const PERF = process.env.PERF === "1";
|
||
const BUDGET = Number(process.env.PERF_BUDGET_MS ?? 2000);
|
||
|
||
type Check = { path: string; expect?: number[]; mustContain?: string | string[]; mustNotContain?: string[]; redirectTo?: string; perf?: boolean };
|
||
type Plan = { email: string; tenant: string; checks: Check[] };
|
||
|
||
async function ids() {
|
||
const demo = await prisma.tenant.findUniqueOrThrow({ where: { slug: "demo" } });
|
||
const order = async (ext: string) => prisma.workOrder.findFirst({ where: { tenantId: demo.id, externalOrderNumber: ext }, select: { id: true, number: true } });
|
||
const [d07, d08, d14, d15, d16, d17, d18] = await Promise.all(["DEMO-07", "DEMO-08", "DEMO-14", "DEMO-15", "DEMO-16", "DEMO-17", "DEMO-18"].map(order));
|
||
if (!d07 || !d18) throw new Error("Demo-Aufträge fehlen — `npx prisma db seed` (SEED_DEMO) ausführen.");
|
||
const emergency = await prisma.workOrder.findFirst({ where: { tenantId: demo.id, isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch" } }, select: { id: true, number: true } });
|
||
const customer = await prisma.customer.findFirstOrThrow({ where: { tenantId: demo.id, customerNumber: "K-D001" } });
|
||
const foreignCustomer = await prisma.customer.findFirstOrThrow({ where: { tenantId: demo.id, customerNumber: "K-D004" } });
|
||
const site = await prisma.site.findFirstOrThrow({ where: { tenantId: demo.id, name: "Wohnanlage Elbblick – Haus A" } });
|
||
const approved = await prisma.report.findFirstOrThrow({ where: { workOrderId: d18.id, status: "approved" } });
|
||
const inReview = d16 ? await prisma.report.findFirst({ where: { workOrderId: d16.id } }) : null;
|
||
const imp = await prisma.importJob.findFirst({ where: { tenantId: demo.id, status: "review_required" }, orderBy: { createdAt: "desc" } });
|
||
const photo = await prisma.photo.findFirst({ where: { workOrderId: d07.id }, select: { documentId: true } });
|
||
return { d07, d08, d14, d15, d16, d17, d18, emergency, customer, foreignCustomer, site, approved, inReview, imp, photo };
|
||
}
|
||
|
||
function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
|
||
const wo = (o: { id: string } | null, suffix = "") => (o ? `/work-orders/${o.id}${suffix}` : "/work-orders");
|
||
const m = (o: { id: string } | null, suffix = "") => (o ? `/m/orders/${o.id}${suffix}` : "/m/orders");
|
||
const tabs = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "documents", "history"];
|
||
return [
|
||
{
|
||
email: "admin@demo.example",
|
||
tenant: "demo",
|
||
checks: [
|
||
{ path: "/dashboard", mustContain: ["Offene Aufträge", "Überfällig"] },
|
||
{ path: "/settings" },
|
||
{ path: "/settings/users", mustContain: "backoffice@demo.example" },
|
||
{ path: "/settings/audit" },
|
||
{ path: "/settings/email" },
|
||
{ path: "/settings/lotse" },
|
||
{ path: "/settings/lotse/protocol" },
|
||
{ path: "/settings/order-types", mustContain: "Montage" },
|
||
{ path: "/settings/checklists", mustContain: "Montage Heizung/Sanitär" },
|
||
{ path: "/settings/numbering" },
|
||
{ path: "/account" },
|
||
{ path: "/teams", mustContain: ["Team Nord", "Team Süd"] },
|
||
],
|
||
},
|
||
{
|
||
email: "backoffice@demo.example",
|
||
tenant: "demo",
|
||
checks: [
|
||
{ path: "/", expect: [307, 308], redirectTo: "/dashboard" },
|
||
{ path: "/dashboard", mustContain: "Berichte zur Prüfung", perf: true },
|
||
{ path: "/work-orders", perf: true },
|
||
{ path: "/work-orders?q=DEMO-07", mustContain: x.d07.number },
|
||
{ path: "/work-orders?view=table", perf: true },
|
||
{ path: "/work-orders?preset=overdue", perf: true },
|
||
{ path: "/work-orders?group=in_review", perf: true },
|
||
{ path: "/work-orders?q=Elbblick", perf: true },
|
||
{ path: "/work-orders?page=3", perf: true },
|
||
...tabs.map((t) => ({ path: wo(x.d07, `?tab=${t}`), mustContain: x.d07.number })),
|
||
{ path: wo(x.d18, "?tab=reports"), mustContain: x.d18.number },
|
||
{ path: "/work-orders/conflicts" },
|
||
{ path: "/work-orders/emergency-review", mustContain: x.emergency?.number },
|
||
...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, mustContain: x.emergency.number }] : []),
|
||
{ path: "/imports" },
|
||
...(x.imp ? [{ path: `/imports/${x.imp.id}`, mustContain: "Hausverwaltung Elbblick" }] : []),
|
||
{ path: "/customers", mustContain: "Hausverwaltung Elbblick" },
|
||
{ path: "/customers?status=provisional" },
|
||
{ path: `/customers/${x.customer.id}`, mustContain: "Hausverwaltung Elbblick" },
|
||
{ path: "/sites", mustContain: "Elbblick" },
|
||
{ path: `/sites/${x.site.id}`, mustContain: "Haus A" },
|
||
{ path: `/sites/${x.site.id}?tab=history`, mustContain: x.d18.number },
|
||
{ path: "/reports", perf: true },
|
||
{ path: `/reports/${x.approved.id}`, mustContain: x.d18.number },
|
||
...(x.inReview ? [{ path: `/reports/${x.inReview.id}` }] : []),
|
||
{ path: "/documents" },
|
||
{ path: "/notifications" },
|
||
{ path: "/search?q=Elbblick", mustContain: "Elbblick", perf: true },
|
||
{ path: "/teams" },
|
||
{ path: "/settings/users", expect: [200, 307, 403] },
|
||
// L13 Planung
|
||
{ path: "/planning", mustContain: ["Plantafel", 'data-planning-view="days"', 'data-day-count="5"', "Heute", "Team Nord", "Team Süd", "Ungeplante Aufträge"], perf: true },
|
||
{ path: "/planning?view=today", mustContain: ['data-planning-view="today"', "Ganztägig", "Team Nord", "Team Süd"] },
|
||
{ path: "/planning?view=week", mustContain: ['data-planning-view="week"', 'data-day-count="7"'] },
|
||
{ path: "/dashboard", mustContain: ["Planung heute", "Teams im Einsatz"] },
|
||
{ path: `/planning?schedule=${x.d07.id}`, mustContain: "Plantafel" },
|
||
{ path: "/planning/live", mustContain: ["Live-Lage", "keine GPS-Ortung", "OpenStreetMap"], mustNotContain: ["startLat", "startLng"], perf: true },
|
||
{ path: "/api/v1/planning/board", mustContain: ['"teams"', '"conflictCount"'] },
|
||
{ path: "/api/v1/planning/live", mustContain: '"technicians"', mustNotContain: ["startLat", "startLng", "deviceInfo"] },
|
||
{ path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, mustContain: '"recommendations"' },
|
||
{ path: "/dashboard", mustContain: "Konflikte diese Woche" },
|
||
],
|
||
},
|
||
{
|
||
email: "teamleiter@demo.example",
|
||
tenant: "demo",
|
||
checks: [
|
||
{ path: "/", expect: [307, 308], redirectTo: "/m" },
|
||
{ path: "/m", perf: true },
|
||
{ path: "/m/orders", perf: true },
|
||
{ path: m(x.d14), mustContain: x.d14?.number },
|
||
{ path: m(x.d16), mustContain: x.d16?.number },
|
||
{ path: "/reports" },
|
||
...(x.inReview ? [{ path: `/reports/${x.inReview.id}`, mustContain: x.d16?.number }] : []),
|
||
{ path: m(x.d08), expect: [404] },
|
||
// L13 Planung: team lead read-only, own team only
|
||
{ path: "/planning", mustContain: ["Plantafel", "Team Nord", "Nur Lesezugriff"], mustNotContain: ["Team Süd"] },
|
||
{ path: "/planning/live", mustContain: "keine GPS-Ortung", mustNotContain: ["Team Süd"] },
|
||
],
|
||
},
|
||
{
|
||
email: "monteur@demo.example",
|
||
tenant: "demo",
|
||
checks: [
|
||
{ path: "/", expect: [307, 308], redirectTo: "/m" },
|
||
{ path: "/m", mustContain: x.d07.number, perf: true },
|
||
{ path: "/m/orders", perf: true },
|
||
{ path: "/m/orders?tab=running", mustContain: x.d07.number },
|
||
{ path: m(x.d07), mustContain: [x.d07.number, "Schlüssel"] },
|
||
{ path: m(x.d07, "/photos") },
|
||
{ path: m(x.d07, "/materials"), mustContain: "Kupferrohr" },
|
||
{ path: m(x.d07, "/notes") },
|
||
{ path: m(x.d07, "/checklist") },
|
||
{ path: m(x.d07, "/time") },
|
||
{ path: m(x.d07, "/report?type=daily") },
|
||
{ path: m(x.d17, "/report?type=completion") },
|
||
{ path: m(x.d17, "/sign") },
|
||
...(x.emergency ? [{ path: m(x.emergency), mustContain: x.emergency.number }] : []),
|
||
{ path: "/m/emergency" },
|
||
{ path: "/m/sync" },
|
||
{ path: "/m/offline" },
|
||
{ path: "/m/profile", mustContain: "Max Monteur" },
|
||
...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [200] }] : []),
|
||
{ path: m(x.d08), expect: [404] },
|
||
{ path: `/customers/${x.foreignCustomer.id}`, expect: [404, 307] },
|
||
{ path: "/dashboard", expect: [307, 308], redirectTo: "/m" },
|
||
{ path: "/planning", expect: [404] },
|
||
{ path: "/planning/live", expect: [404] },
|
||
],
|
||
},
|
||
{
|
||
email: "monteur3@demo.example",
|
||
tenant: "demo",
|
||
checks: [
|
||
{ path: "/m", mustContain: x.d08?.number },
|
||
{ path: m(x.d15, "/sign"), mustContain: x.d15?.number },
|
||
{ path: m(x.d07), expect: [404] },
|
||
],
|
||
},
|
||
{
|
||
email: "admin2@demo.example",
|
||
tenant: "demo2",
|
||
checks: [
|
||
{ path: "/dashboard", mustContain: "Offene Aufträge", mustNotContain: ["Elbblick"] },
|
||
{ path: "/work-orders", mustContain: "DGUV", mustNotContain: [x.d07.number, "Elbblick"] },
|
||
{ path: "/customers", mustNotContain: ["Elbblick"] },
|
||
{ path: "/search?q=Elbblick", mustNotContain: ["Hausverwaltung Elbblick"] },
|
||
{ path: wo(x.d07), expect: [404] },
|
||
{ path: `/customers/${x.customer.id}`, expect: [404] },
|
||
{ path: `/sites/${x.site.id}`, expect: [404] },
|
||
{ path: `/reports/${x.approved.id}`, expect: [404] },
|
||
...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, expect: [404] }] : []),
|
||
...(x.imp ? [{ path: `/imports/${x.imp.id}`, expect: [404] }] : []),
|
||
...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [404] }] : []),
|
||
{ path: "/planning", mustNotContain: ["Team Nord", x.d07.number] },
|
||
{ path: "/planning/live", mustNotContain: ["Team Nord", "Max Monteur"] },
|
||
{ path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, expect: [404] },
|
||
],
|
||
},
|
||
];
|
||
}
|
||
|
||
async function cookieFor(email: string, tenant: 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, tenant);
|
||
if (!user) throw new Error(`no active membership for ${email} in ${tenant}`);
|
||
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,
|
||
};
|
||
const value = await encode({ token, secret: process.env.AUTH_SECRET!, salt: COOKIE, maxAge: 60 * 30 });
|
||
return `${COOKIE}=${value}`;
|
||
}
|
||
|
||
async function fetchTimed(path: string, cookie: string) {
|
||
const t0 = performance.now();
|
||
const res = await fetch(BASE + path, { headers: { cookie }, redirect: "manual", signal: AbortSignal.timeout(120_000) });
|
||
const body = res.status === 200 || res.status === 404 ? await res.text() : "";
|
||
return { res, body, ms: Math.round(performance.now() - t0) };
|
||
}
|
||
|
||
async function main() {
|
||
const only = process.env.ONLY;
|
||
let failures = 0;
|
||
const slow: string[] = [];
|
||
// PERF=1 runs only the perf-marked pages and ignores their content checks: with scripts/seed-load.ts data the
|
||
// demo orders are no longer guaranteed on the first page / in the capped mobile lists.
|
||
const all = plans(await ids())
|
||
.filter((p) => !only || p.email.includes(only))
|
||
.map((p) => (PERF ? { ...p, checks: p.checks.filter((c) => c.perf).map((c) => ({ ...c, mustContain: undefined })) } : p))
|
||
.filter((p) => p.checks.length > 0);
|
||
let total = 0;
|
||
for (const plan of all) {
|
||
const cookie = await cookieFor(plan.email, plan.tenant);
|
||
console.log(`\n== ${plan.email} (${plan.tenant})`);
|
||
for (const c of plan.checks) {
|
||
total++;
|
||
let { res, body, ms } = await fetchTimed(c.path, cookie);
|
||
if (PERF && c.perf) ({ res, body, ms } = await fetchTimed(c.path, cookie)); // warm measurement
|
||
const expect = c.expect ?? [200];
|
||
const errorPage = /Application error|Internal Server Error|Unhandled Runtime Error|NEXT_REDIRECT|digest:/i.test(res.status === 200 ? body : "");
|
||
const okStatus = expect.includes(res.status);
|
||
const loc = res.headers.get("location");
|
||
const okRedirect = !c.redirectTo || (loc ? new URL(loc, BASE).pathname === c.redirectTo : false);
|
||
const must = (Array.isArray(c.mustContain) ? c.mustContain : c.mustContain ? [c.mustContain] : []).filter(Boolean) as string[];
|
||
const missing = res.status === 200 ? must.filter((s) => !body.includes(s)) : [];
|
||
const leaked = (c.mustNotContain ?? []).filter((s) => body.includes(s));
|
||
const ok = okStatus && okRedirect && !errorPage && missing.length === 0 && leaked.length === 0;
|
||
if (!ok) failures++;
|
||
const perfNote = PERF && c.perf ? ` ${ms} ms${ms > BUDGET ? " ⚠ über Budget" : ""}` : ` (${ms} ms)`;
|
||
if (PERF && c.perf && ms > BUDGET) slow.push(`${plan.email} ${c.path} ${ms} ms`);
|
||
console.log(
|
||
`${ok ? "✓" : "✗"} ${String(res.status).padEnd(3)} ${c.path}${loc ? ` → ${loc}` : ""}${perfNote}${errorPage ? " [error page]" : ""}${missing.length ? ` [fehlt: ${missing.join(", ")}]` : ""}${leaked.length ? ` [fremde Daten: ${leaked.join(", ")}]` : ""}`,
|
||
);
|
||
}
|
||
}
|
||
await prisma.$disconnect();
|
||
if (PERF) console.log(slow.length ? `\n⚠ ${slow.length} Seite(n) über ${BUDGET} ms:\n${slow.join("\n")}` : `\nPerformance: alle gemessenen Seiten ≤ ${BUDGET} ms`);
|
||
console.log(failures ? `\n${failures} von ${total} Prüfungen fehlgeschlagen` : `\nOK — ${total} Prüfungen`);
|
||
process.exit(failures || (PERF && slow.length) ? 1 : 0);
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error(err);
|
||
process.exit(1);
|
||
});
|