Files
craftvia/scripts/smoke-auth.ts
T
msolarczekandClaude Opus 5 bf92100661 Merge lane/planung in feature/craftvia-mvp
Konflikte gelöst (additiv): Benachrichtigungstexte (Zeiterfassung + Planung, JSON zusammengeführt),
Event-Typen, Navigation, Empfänger-Felder, handle-event (reason inkl. rejectionReason + Planungsfelder),
Smoke-Prüfungen (Teamleiter: Zeiten + Planung).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:15:47 +02:00

284 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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" },
// L12 Zeiterfassung
{ path: "/work-orders/time-approvals", mustContain: "Zeiten zur Freigabe" },
{ path: "/dashboard", mustContain: "Zeiten zur Freigabe" },
{ 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] },
// L12 Zeiterfassung
{ path: "/m/time", mustContain: ["Meine Zeiten", "Zeit nachtragen"] },
{ path: "/m/approvals", mustContain: "Zeiten freigeben" },
{ path: "/m/profile", mustContain: "Zeiten freigeben" },
// 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", "Meine Zeiten"] },
// L12 Zeiterfassung
{ path: "/m", mustContain: ["Laufende Zeiterfassung", "Für heute beenden"] },
{ path: "/m/time", mustContain: ["Meine Zeiten", "Zeit nachtragen"] },
{ path: `/m/time?date=${new Date(Date.now() - 86400_000).toISOString().slice(0, 10)}`, mustContain: "Meine Zeiten" },
{ path: "/m/time/new", mustContain: ["Zeit nachtragen", "Begründung"] },
{ path: "/m/approvals", mustContain: "nicht freigeschaltet" },
{ path: m(x.d07, "/time"), mustContain: x.d07.number },
...(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);
});