Files
craftvia/scripts/smoke-auth.ts
msolarczekandClaude Opus 5 8a20c17444 Website-Beispiel ohne Demo-Kundennamen, Smoke-Test mit dynamischem Fremdkunden
Der Lotse-Beispielchat nannte „Wohnanlage Elbblick“ aus den Demo-Daten; die
Mandantentrennungs-Prüfung für demo2 schlug deshalb an. Dazu Grammatik korrigiert.
smoke-auth sucht den für den Monteur fremden Kunden jetzt über die Aufträge statt fest K-D004,
weil Aufträge beim Ausprobieren der Plantafel umgeplant werden.

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

347 lines
21 KiB
TypeScript
Raw Permalink 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" } });
// "Fremder" Kunde für den Monteur: keiner seiner Aufträge (Team oder Einzelzuweisung) gehört dazu.
// Dynamisch statt fest K-D004, weil Aufträge beim Ausprobieren der Plantafel umgeplant werden.
const tech = await prisma.user.findFirstOrThrow({ where: { tenantId: demo.id, email: "monteur@demo.example" }, select: { id: true } });
const techTeams = (await prisma.teamMember.findMany({ where: { tenantId: demo.id, userId: tech.id }, select: { teamId: true } })).map((m) => m.teamId);
const foreignCustomer = await prisma.customer.findFirstOrThrow({
where: {
tenantId: demo.id,
deletedAt: null,
workOrders: { none: { OR: [{ assignedTeamId: { in: techTeams } }, { assignees: { some: { userId: tech.id } } }] } },
},
orderBy: { customerNumber: "asc" },
});
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 } });
// L14: open billing record (DEMO-18 released for billing → event hook / scripts/billing-backfill.ts) + smoke milestone on DEMO-07
const billing = await prisma.billingRecord.findFirst({ where: { tenantId: demo.id, workOrderId: d18.id, status: "open" }, select: { id: true } });
const milestone =
(await prisma.workOrderMilestone.findFirst({ where: { tenantId: demo.id, workOrderId: d07.id, title: "Smoke-Meilenstein", deletedAt: null }, select: { id: true } })) ??
(await prisma.workOrderMilestone.create({ data: { tenantId: demo.id, workOrderId: d07.id, title: "Smoke-Meilenstein", sortOrder: 10 }, select: { id: true } }));
return { d07, d08, d14, d15, d16, d17, d18, emergency, customer, foreignCustomer, site, approved, inReview, imp, photo, billing, milestone };
}
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", "billing", "documents", "history"];
// L14 Abrechnungsübersicht
const billingBackoffice: Check[] = [
{ path: "/billing", mustContain: ["Abrechnungsübersicht", "Offen"] },
{ path: "/billing?status=billed", mustContain: "Abgerechnet" },
{ path: "/dashboard", mustContain: "Bereit zur Abrechnung" },
{ path: wo(x.d07, "?tab=billing"), mustContain: ["Smoke-Meilenstein", "Abrechnungseinträge"] },
...(x.billing
? [
{ path: `/billing?q=${x.d18.number}`, mustContain: x.d18.number },
{ path: `/billing/${x.billing.id}`, mustContain: ["Abrechnungsblatt", x.d18.number, "Anfahrten", "Als abgerechnet markieren"] },
{ path: `/billing/print?ids=${x.billing.id}`, mustContain: ["Abrechnungsblatt", x.d18.number] },
]
: []),
];
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", mustContain: "Lotse-Chat für Monteure" },
// L17 Pakete: Paket nur lesend, Plätze + Verbrauch
{ path: "/settings/lotse", mustContain: ["Paket &amp; Lotse-Chat", "Profi", "3 von 3 Plätzen vergeben", "Max Monteur", "Platz entziehen", "Chats"] },
{ path: "/settings", mustContain: "Freigeschaltete Module" },
{ 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" },
...billingBackoffice,
// 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"] },
// L17 Pakete: Teamleiterin ohne Chat-Platz → kein Navigationseintrag, freundlicher Hinweis
{ path: "/m", mustNotContain: ['href="/m/lotse"'] },
{ path: "/m/lotse", mustContain: "frag im Büro nach", mustNotContain: ['id="lotse-chat-input"'] },
...(x.d14 ? [{ path: m(x.d14), mustContain: x.d14.number, mustNotContain: [`href="/m/lotse?order=${x.d14.id}"`] }] : []),
],
},
{
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 },
// L16 Lotse-Chat für Monteure
{ path: "/m/lotse", mustContain: ["Lotse-Chat", "Neuer Chat", "Sprechen"] },
{ path: `/m/lotse?order=${x.d07.id}`, mustContain: [x.d07.number, "Lotse-Chat"] },
{ path: m(x.d07), mustContain: "Lotse fragen" },
{ path: "/m", mustContain: ['href="/m/lotse"', 'href="/m/emergency"'] }, // L17: Platz vergeben, Profi
...(x.d08 ? [{ path: `/m/lotse?order=${x.d08.id}`, expect: [404] }] : []),
// L14 Abrechnungsübersicht: Meilensteine mobil, Backoffice-Übersicht ohne Recht
{ path: m(x.d07), mustContain: ["Meilensteine", "Smoke-Meilenstein", "Erreicht melden"] },
// order numbers appear in the monteur's own notifications and all UI texts in the serialized messages → check the record link
{ path: "/billing", expect: [200, 307], mustContain: "nicht freigeschaltet", mustNotContain: x.billing ? [`/billing/${x.billing.id}`] : [] },
...(x.billing ? [{ path: `/billing/${x.billing.id}`, expect: [404, 307] }] : []),
...(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] },
// L17: demo2 is Basis → module layouts redirect before the (foreign) id is looked up
...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, expect: [307, 404] }] : []),
...(x.imp ? [{ path: `/imports/${x.imp.id}`, expect: [307, 404] }] : []),
...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [404] }] : []),
// L17 Pakete: demo2 = Basis → Planung, Abrechnung, Import, Notdienst, Lotse gesperrt (ruhiger Hinweis)
{ path: "/planning", expect: [307], redirectTo: "/dashboard" },
{ path: "/planning/live", expect: [307], redirectTo: "/dashboard" },
{ path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, expect: [403, 404] },
{ path: "/api/v1/planning/board", expect: [403] },
{ path: "/billing", expect: [307], redirectTo: "/dashboard" },
{ path: "/imports", expect: [307], redirectTo: "/dashboard" },
{ path: "/work-orders/emergency-review", expect: [307], redirectTo: "/dashboard" },
{ path: "/api/v1/billing", expect: [403] },
{ path: "/dashboard?module=profi", mustContain: ["Im Paket Profi enthalten", "Offene Aufträge"], mustNotContain: ['href="/planning"', 'href="/billing"', 'href="/imports"', 'href="/work-orders/emergency-review"'] },
{ path: "/settings/lotse", mustContain: ["Paket &amp; Lotse-Chat", "Basis", "im Paket Profi enthalten"] },
{ path: "/work-orders", mustContain: "DGUV" },
{ path: "/customers" },
{ path: "/reports" },
...(x.billing ? [{ path: `/billing/${x.billing.id}`, expect: [307, 404] }] : []),
{ path: wo(x.d07, "?tab=billing"), 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);
});