From 1a75c28bb431337c594f219f0e1fd09ce701a8c7 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 17:58:22 +0200 Subject: [PATCH 1/6] =?UTF-8?q?L10a=20Qualit=C3=A4t=20&=20Abnahmetests:=20?= =?UTF-8?q?Demo-Seed=20(Teams,=20Kunden,=20Objekte,=2020=20Auftr=C3=A4ge,?= =?UTF-8?q?=20Berichte,=20Notdienst,=20demo2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- prisma/seed.ts | 39 +++- scripts/lib/demo-seed.ts | 410 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 446 insertions(+), 3 deletions(-) create mode 100644 scripts/lib/demo-seed.ts diff --git a/prisma/seed.ts b/prisma/seed.ts index c068b18..4a47836 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -8,7 +8,9 @@ import { provisionTenant } from "../src/server/provision"; /** * Craftvia-Demo-Seed (Fundament): Plattform-Admin + Mandant `demo` mit je einem Nutzer * pro Standardrolle + zweiter Mandant `demo2` (Isolations-/Tenant-Switch-Tests) + - * Multi-Membership-Fixture. KEINE Fachdaten (Kunden/Aufträge liefern die Module). + * Multi-Membership-Fixture. Fachliche Demo-Daten (Teams, Kunden, Objekte, Aufträge, Berichte, + * Notdienst) kommen aus scripts/lib/demo-seed.ts — nur mit SEED_DEMO=true oder außerhalb von + * Production (SEED_DEMO=false schaltet sie ab). * * Idempotent (upserts) — beliebig oft ausführbar. Passwort: SEED_PASSWORD oder Default. */ @@ -66,8 +68,13 @@ async function main() { { email: "backoffice@demo.example", name: "Bernd Backoffice", roles: ["backoffice"] }, { email: "teamleiter@demo.example", name: "Tina Teamleiter", roles: ["team-lead"] }, { email: "monteur@demo.example", name: "Max Monteur", roles: ["technician"] }, + // L10a Demo-Teams: Team Nord (Tina + Max + Nora), Team Süd (Sven + Paul) + { email: "teamleiter2@demo.example", name: "Sven Südfeld", roles: ["team-lead"] }, + { email: "monteur2@demo.example", name: "Nora Nordmann", roles: ["technician"] }, + { email: "monteur3@demo.example", name: "Paul Petersen", roles: ["technician"] }, ]; - for (const u of demoUsers) await ensureMember(demo.id, u, passwordHash); + const members: Record = {}; + for (const u of demoUsers) members[u.email] = (await ensureMember(demo.id, u, passwordHash)).id; console.log(`✔ Mandant "demo" (${PERMISSIONS.length} Permissions) mit ${demoUsers.length} Nutzern (Passwort: ${DEMO_PASSWORD})`); // 2. Zweiter Mandant „demo2" — Grundlage für Isolations- und Tenant-Switch-Tests. @@ -93,10 +100,36 @@ async function main() { create: { email: "platform@demo.example", name: "Paula Plattform", passwordHash, status: "ACTIVE" }, }); console.log(`✔ Plattform-Admin platform@demo.example (/platform/login)`); + + // 5. L10a: fachliche Demo-Daten (Teams, Kunden, Objekte, 20 Aufträge, Berichte, Notdienst). + // Nur mit SEED_DEMO=true oder außerhalb von Production; SEED_DEMO=false schaltet sie ab. + const demoData = process.env.SEED_DEMO === "true" || (process.env.NODE_ENV !== "production" && process.env.SEED_DEMO !== "false"); + if (demoData) { + const { seedDemoTenant, seedDemo2Tenant, closeDemoSeed } = await import("../scripts/lib/demo-seed"); + const res = await seedDemoTenant(demo.id, { + admin: members["admin@demo.example"], + backoffice: members["backoffice@demo.example"], + lead: members["teamleiter@demo.example"], + lead2: members["teamleiter2@demo.example"], + tech: members["monteur@demo.example"], + tech2: members["monteur2@demo.example"], + tech3: members["monteur3@demo.example"], + }); + const admin2 = await prisma.user.findUniqueOrThrow({ where: { tenantId_email: { tenantId: demo2.id, email: "admin2@demo.example" } } }); + await seedDemo2Tenant(demo2.id, admin2.id); + await closeDemoSeed(); + console.log(`✔ Demo-Fachdaten: ${res.orders} Demo-Aufträge in "demo" (${res.created} neu angelegt), 1 Auftrag in "demo2"`); + } else { + console.log("• Demo-Fachdaten übersprungen (SEED_DEMO=false bzw. Production ohne SEED_DEMO=true)"); + } } main() - .then(() => prisma.$disconnect()) + .then(async () => { + await prisma.$disconnect(); + // Services may leave queue/redis handles open (events, image jobs) — end the seed explicitly. + process.exit(0); + }) .catch(async (e) => { console.error(e); await prisma.$disconnect(); diff --git a/scripts/lib/demo-seed.ts b/scripts/lib/demo-seed.ts new file mode 100644 index 0000000..768bc75 --- /dev/null +++ b/scripts/lib/demo-seed.ts @@ -0,0 +1,410 @@ +// L10a Demo-Seed: fachliche Demo-Daten für die Mandanten `demo` und `demo2`. +// +// Wird von prisma/seed.ts aufgerufen (nur mit SEED_DEMO=true oder außerhalb von Production). +// Alle Datensätze entstehen über die ECHTEN Fachservices (Statusmaschine, Zuweisung, Sessions, +// Berichte, Notdienst) – damit sind Statushistorie, Audit, Versionen und Berichts-Snapshots +// konsistent wie im Echtbetrieb. Idempotent: Kunden über feste Kundennummern (K-D…), Objekte über +// Kunde + Name, Aufträge über die externe Auftragsnummer DEMO-…, Notdienst über feste Client-IDs. +// Alle Namen, Adressen und Telefonnummern sind frei erfunden (keine echten personenbezogenen Daten). + +import { randomUUID } from "node:crypto"; +import sharp from "sharp"; +import { prisma, dbForTenant } from "../../src/server/db"; +import { ROLE_DEFS, type RoleKey } from "../../src/server/rbac"; +import type { ServiceCtx } from "../../src/server/services/context"; +import { localDateKey } from "../../src/lib/reports/dates"; +import { + checklistTogglePayload, + materialUpsertPayload, + noteCreatePayload, + photoAttachPayload, + sessionControlPayload, + sessionStartPayload, + type NoteKind, +} from "../../src/lib/sync/ops"; +import { createCustomer } from "../../src/server/services/customers/customers"; +import { createContact } from "../../src/server/services/customers/contacts"; +import { createSite } from "../../src/server/services/sites/sites"; +import { createTeam } from "../../src/server/services/teams/teams"; +import { createChecklistTemplate, ensureDefaultOrderTypes } from "../../src/server/services/work-orders/settings"; +import { createWorkOrder } from "../../src/server/services/work-orders/create"; +import { assignWorkOrder } from "../../src/server/services/work-orders/assign"; +import { transitionWorkOrder } from "../../src/server/services/work-orders/transition"; +import { markBilled, releaseForBilling } from "../../src/server/services/work-orders/release-billing"; +import { endSession, pauseSession, startSession } from "../../src/server/services/field/sessions"; +import { createNote } from "../../src/server/services/field/notes"; +import { upsertMaterialUsage } from "../../src/server/services/field/materials"; +import { toggleChecklistItem } from "../../src/server/services/field/checklist"; +import { storeFieldUpload } from "../../src/server/services/field/uploads"; +import { attachPhoto } from "../../src/server/services/field/photos"; +import { storeFile } from "../../src/server/services/documents/store"; +import { createCompletionReport, createDailyReport } from "../../src/server/services/reports/create"; +import { updateReportTexts } from "../../src/server/services/reports/edit"; +import { captureSignature } from "../../src/server/services/reports/signature"; +import { submitReport } from "../../src/server/services/reports/submit"; +import { approveReport } from "../../src/server/services/reports/approve"; +import { createEmergencyOrder } from "../../src/server/services/emergency/create"; +import type { CreateWorkOrderInput } from "../../src/lib/work-orders/schemas"; + +export type DemoUsers = Record<"admin" | "backoffice" | "lead" | "lead2" | "tech" | "tech2" | "tech3", string>; + +const TZ = "Europe/Berlin"; +const noPdf = { dispatchPdf: async () => undefined }; + +function ctxOf(tenantId: string, userId: string, role: RoleKey): ServiceCtx { + return { db: dbForTenant(tenantId), tenantId, userId, permissions: new Set(ROLE_DEFS[role].permissions) }; +} + +/** Local date relative to today (server time zone), e.g. day(-1, 7, 30) = yesterday 07:30. */ +function day(offset: number, h = 8, m = 0): Date { + const x = new Date(); + x.setDate(x.getDate() + offset); + x.setHours(h, m, 0, 0); + return x; +} + +async function demoJpeg(label: string): Promise { + const svg = `${label}`; + return sharp(Buffer.from(svg)).jpeg({ quality: 80 }).toBuffer(); +} + +async function signaturePng(): Promise { + const svg = ``; + return sharp(Buffer.from(svg)).png().toBuffer(); +} + +export async function seedDemoTenant(tenantId: string, users: DemoUsers): Promise<{ orders: number; created: number }> { + const admin = ctxOf(tenantId, users.admin, "tenant-admin"); + const bo = ctxOf(tenantId, users.backoffice, "backoffice"); + const lead = ctxOf(tenantId, users.lead, "team-lead"); + const lead2 = ctxOf(tenantId, users.lead2, "team-lead"); + const tech = ctxOf(tenantId, users.tech, "technician"); + const tech2 = ctxOf(tenantId, users.tech2, "technician"); + const tech3 = ctxOf(tenantId, users.tech3, "technician"); + + // ---------- Auftragsarten + Checklisten-Vorlagen ---------- + await ensureDefaultOrderTypes(admin.db, tenantId); + const orderTypes = new Map((await admin.db.orderType.findMany({ select: { id: true, key: true } })).map((t) => [t.key, t.id])); + const type = (key: string) => orderTypes.get(key) ?? null; + + const templates: Parameters[1][] = [ + { + name: "Montage Heizung/Sanitär", + orderTypeId: type("montage"), + items: [ + { key: "arbeitsbereich_abgesichert", label: "Arbeitsbereich abgesichert", required: true }, + { key: "material_geprueft", label: "Material geprüft", required: false }, + { key: "dichtheit_geprueft", label: "Dichtheitsprüfung durchgeführt", required: true }, + { key: "funktionspruefung", label: "Funktionsprüfung durchgeführt", required: true }, + { key: "kunde_eingewiesen", label: "Kunde eingewiesen", required: false }, + ], + requiredPhotos: [ + { key: "ausgangszustand", label: "Ausgangszustand" }, + { key: "typenschild", label: "Typenschild" }, + { key: "fertige_montage", label: "Fertige Montage" }, + ], + }, + { + name: "Wartung Heizungsanlage", + orderTypeId: type("wartung"), + items: [ + { key: "brenner_gereinigt", label: "Brenner gereinigt", required: true }, + { key: "abgaswerte_gemessen", label: "Abgaswerte gemessen", required: true }, + { key: "anlagendruck_geprueft", label: "Anlagendruck geprüft", required: true }, + ], + requiredPhotos: [{ key: "typenschild", label: "Typenschild" }], + }, + ]; + for (const tpl of templates) { + if (!(await admin.db.checklistTemplate.findFirst({ where: { name: tpl.name }, select: { id: true } }))) await createChecklistTemplate(admin, tpl); + } + + // ---------- Teams ---------- + async function team(name: string, leaderUserId: string, memberIds: string[], extra: { vehicle: string; area: string; phone: string }) { + const existing = await admin.db.team.findFirst({ where: { name, deletedAt: null }, select: { id: true } }); + if (existing) return existing.id; + const validFrom = day(-120, 0, 0).toISOString(); + return (await createTeam(admin, { name, leaderUserId, ...extra, members: memberIds.map((userId) => ({ userId, validFrom })) })).id; + } + const nord = await team("Team Nord", users.lead, [users.tech, users.tech2], { vehicle: "HH-MB 101", area: "Hamburg Nord, Norderstedt", phone: "040 555 0110" }); + const sued = await team("Team Süd", users.lead2, [users.tech3], { vehicle: "HH-MB 202", area: "Hamburg Süd, Harburg", phone: "040 555 0120" }); + + // ---------- Kunden, Ansprechpartner, Objekte ---------- + type CustomerSeed = { number: string; companyName?: string; firstName?: string; lastName?: string; street: string; houseNumber: string; postalCode: string; city: string; phone: string; email?: string; contact?: { name: string; role: string; phone: string } }; + const customerSeeds: CustomerSeed[] = [ + { number: "K-D001", companyName: "Hausverwaltung Elbblick GmbH", street: "Große Elbstraße", houseNumber: "140", postalCode: "22767", city: "Hamburg", phone: "040 555 0201", email: "service@elbblick-hv.example", contact: { name: "Frauke Albers", role: "Objektbetreuung", phone: "040 555 0202" } }, + { number: "K-D002", companyName: "Bäckerei Morgenrot KG", street: "Osterstraße", houseNumber: "38", postalCode: "20259", city: "Hamburg", phone: "040 555 0211", contact: { name: "Hannes Korn", role: "Inhaber", phone: "0171 555 0212" } }, + { number: "K-D003", companyName: "Wohnbau Alstertal eG", street: "Wellingsbütteler Landstraße", houseNumber: "7", postalCode: "22391", city: "Hamburg", phone: "040 555 0221", email: "technik@alstertal-eg.example", contact: { name: "Deniz Yilmaz", role: "Technik", phone: "040 555 0222" } }, + { number: "K-D004", companyName: "Praxis am Stadtpark", street: "Jarrestraße", houseNumber: "12", postalCode: "22303", city: "Hamburg", phone: "040 555 0231" }, + { number: "K-D005", firstName: "Greta", lastName: "Musterfrau", street: "Lindenallee", houseNumber: "4", postalCode: "21073", city: "Hamburg", phone: "040 555 0241", email: "g.musterfrau@example.org" }, + { number: "K-D006", companyName: "Logistikpark Harburg GmbH", street: "Seehafenstraße", houseNumber: "20", postalCode: "21079", city: "Hamburg", phone: "040 555 0251", contact: { name: "Sören Petersen", role: "Facility Management", phone: "0160 555 0252" } }, + { number: "K-D007", companyName: "Kita Sonnenblume e. V.", street: "Hallerstraße", houseNumber: "55", postalCode: "20146", city: "Hamburg", phone: "040 555 0261" }, + ]; + const customers = new Map(); + for (const c of customerSeeds) { + let row = await bo.db.customer.findFirst({ where: { customerNumber: c.number }, select: { id: true } }); + if (!row) { + row = await createCustomer( + bo, + { customerNumber: c.number, companyName: c.companyName, firstName: c.firstName, lastName: c.lastName, street: c.street, houseNumber: c.houseNumber, postalCode: c.postalCode, city: c.city, phone: c.phone, email: c.email, notes: "Demo-Datensatz" }, + { acknowledgeDuplicates: true }, + ); + } + let contactId: string | null = null; + if (c.contact) { + const existing = await bo.db.contact.findFirst({ where: { customerId: row.id, name: c.contact.name }, select: { id: true } }); + contactId = existing?.id ?? (await createContact(bo, row.id, { ...c.contact, preferredChannel: "phone" })).id; + } + customers.set(c.number, { id: row.id, contactId }); + } + + type SiteSeed = { customer: string; name: string; street: string; houseNumber: string; postalCode: string; city: string; accessNotes: string; parkingNotes?: string; safetyNotes?: string; technicalNotes?: string }; + const siteSeeds: SiteSeed[] = [ + { customer: "K-D001", name: "Wohnanlage Elbblick – Haus A", street: "Große Elbstraße", houseNumber: "140a", postalCode: "22767", city: "Hamburg", accessNotes: "Schlüssel im Schlüsseltresor neben dem Eingang, Code beim Büro erfragen.", parkingNotes: "Besucherparkplätze im Hof.", technicalNotes: "Heizungsraum im Keller, Gas-Brennwertkessel Baujahr 2014." }, + { customer: "K-D001", name: "Wohnanlage Elbblick – Haus B", street: "Große Elbstraße", houseNumber: "140b", postalCode: "22767", city: "Hamburg", accessNotes: "Hausmeister Tel. 040 555 0203 (Mo–Fr 7–15 Uhr).", safetyNotes: "Asbestverdacht im Altbau-Keller – vor Bohrarbeiten Büro informieren." }, + { customer: "K-D002", name: "Backstube Osterstraße", street: "Osterstraße", houseNumber: "38", postalCode: "20259", city: "Hamburg", accessNotes: "Lieferanteneingang hinten, ab 4 Uhr besetzt.", parkingNotes: "Ladezone vor dem Haus maximal 30 Minuten." }, + { customer: "K-D002", name: "Filiale Eppendorf", street: "Eppendorfer Baum", houseNumber: "9", postalCode: "20249", city: "Hamburg", accessNotes: "Nur außerhalb der Öffnungszeiten (ab 18 Uhr).", technicalNotes: "Wasseraufbereitung unter der Theke." }, + { customer: "K-D003", name: "Quartier Poppenbüttel – Block 3", street: "Saseler Damm", houseNumber: "21", postalCode: "22391", city: "Hamburg", accessNotes: "Zugang über Tiefgarage, Transponder beim Hausmeister.", safetyNotes: "Dachzugang nur mit Absturzsicherung." }, + { customer: "K-D003", name: "Quartier Poppenbüttel – Block 5", street: "Saseler Damm", houseNumber: "25", postalCode: "22391", city: "Hamburg", accessNotes: "Hauptschlüssel im Büro der eG abholen." }, + { customer: "K-D004", name: "Praxisräume 2. OG", street: "Jarrestraße", houseNumber: "12", postalCode: "22303", city: "Hamburg", accessNotes: "Anmeldung am Empfang, Arbeiten nur mittwochs nachmittags.", safetyNotes: "Röntgenraum nicht betreten." }, + { customer: "K-D005", name: "Einfamilienhaus Lindenallee", street: "Lindenallee", houseNumber: "4", postalCode: "21073", city: "Hamburg", accessNotes: "Kundin ist vormittags zu Hause, Hund im Garten.", parkingNotes: "Einfahrt frei halten." }, + { customer: "K-D006", name: "Halle 1 – Kühllager", street: "Seehafenstraße", houseNumber: "20", postalCode: "21079", city: "Hamburg", accessNotes: "Anmeldung an der Pforte, Besucherausweis erforderlich.", safetyNotes: "Warnweste und Sicherheitsschuhe Pflicht, Staplerverkehr." }, + { customer: "K-D006", name: "Verwaltungsgebäude", street: "Seehafenstraße", houseNumber: "22", postalCode: "21079", city: "Hamburg", accessNotes: "Haustechnikraum im EG, Schlüssel an der Pforte.", technicalNotes: "Lüftungsanlage mit Wärmerückgewinnung." }, + { customer: "K-D007", name: "Kita-Gebäude Hallerstraße", street: "Hallerstraße", houseNumber: "55", postalCode: "20146", city: "Hamburg", accessNotes: "Nur nach Absprache mit der Leitung, Kinder im Haus.", safetyNotes: "Werkzeug nie unbeaufsichtigt lassen." }, + { customer: "K-D007", name: "Gartenhaus / Außenanlage", street: "Hallerstraße", houseNumber: "55", postalCode: "20146", city: "Hamburg", accessNotes: "Tor zum Garten mit Zahlenschloss, Code im Auftrag." }, + ]; + const sites = new Map(); + for (const s of siteSeeds) { + const customer = customers.get(s.customer)!; + const existing = await bo.db.site.findFirst({ where: { customerId: customer.id, name: s.name, deletedAt: null }, select: { id: true } }); + const { customer: _c, ...fields } = s; + void _c; + sites.set(s.name, existing?.id ?? (await createSite(bo, { ...fields, customerId: customer.id, contactId: customer.contactId })).id); + } + + // ---------- Helfer für den Einsatz ---------- + let created = 0; + const jpeg = await demoJpeg("Demo-Foto"); + const signature = await signaturePng(); + + const at = (d: Date) => d.toISOString(); + const work = (c: ServiceCtx, id: string, d: Date) => startSession(c, sessionStartPayload.parse({ workOrderId: id, mode: "work", at: at(d), deviceInfo: "Demo-Seed" })); + const travel = (c: ServiceCtx, id: string, d: Date) => startSession(c, sessionStartPayload.parse({ workOrderId: id, mode: "travel", at: at(d), deviceInfo: "Demo-Seed" })); + const pause = (c: ServiceCtx, id: string, d: Date) => pauseSession(c, sessionControlPayload.parse({ workOrderId: id, at: at(d) })); + const end = (c: ServiceCtx, id: string, d: Date) => endSession(c, sessionControlPayload.parse({ workOrderId: id, at: at(d) })); + const note = (c: ServiceCtx, id: string, kind: NoteKind, text: string) => createNote(c, noteCreatePayload.parse({ workOrderId: id, kind, text })); + const checkAll = async (c: ServiceCtx, id: string) => { + for (const item of await c.db.checklistItem.findMany({ where: { workOrderId: id, checked: false }, select: { id: true } })) { + await toggleChecklistItem(c, checklistTogglePayload.parse({ workOrderId: id, itemId: item.id, checked: true })); + } + }; + const photo = async (c: ServiceCtx, id: string, opts: { phase: "before" | "during" | "after"; requirementKey?: string; comment?: string; takenAt?: Date }) => { + const upload = await storeFieldUpload(c, { clientId: randomUUID(), workOrderId: id, kind: "photo" }, { bytes: jpeg, name: `${opts.requirementKey ?? opts.phase}.jpg`, type: "image/jpeg" }, { bytes: jpeg, name: "thumb.jpg", type: "image/jpeg" }); + const req = opts.requirementKey ? await c.db.photoRequirement.findFirst({ where: { workOrderId: id, key: opts.requirementKey }, select: { id: true } }) : null; + await attachPhoto(c, photoAttachPayload.parse({ workOrderId: id, documentId: upload.documentId, phase: opts.phase, photoRequirementId: req?.id ?? null, comment: opts.comment, takenAt: opts.takenAt ? at(opts.takenAt) : undefined })); + }; + const material = (c: ServiceCtx, id: string, input: Record) => upsertMaterialUsage(c, materialUpsertPayload.parse({ workOrderId: id, ...input })); + const planMaterial = async (c: ServiceCtx, id: string, index: number, quantity: number, usageStatus: "fully_used" | "partially_used" | "not_used", deviationReason?: string) => { + const plan = (await c.db.materialPlan.findMany({ where: { workOrderId: id }, orderBy: { sortOrder: "asc" }, select: { id: true, unit: true } }))[index]; + if (plan) await material(c, id, { materialPlanId: plan.id, quantity, unit: plan.unit, usageStatus, deviationReason }); + }; + + /** Completion report with signature outcome and optional approvals (team lead → backoffice). */ + const complete = async ( + c: ServiceCtx, + id: string, + opts: { reportDate: Date; workPerformed: string; outcome: "signed" | "customer_absent" | "later"; signer?: string; approve: "none" | "team" | "final"; teamLead: ServiceCtx }, + ) => { + const { report } = await createCompletionReport(c, { workOrderId: id, reportDate: localDateKey(opts.reportDate, TZ) }); + await updateReportTexts(c, { reportId: report.id, texts: { workPerformed: opts.workPerformed, hints: "Anlage läuft störungsfrei.", nextSteps: "Nächste Wartung in 12 Monaten." } }); + const confirmationText = "Ich bestätige die Ausführung der aufgeführten Arbeiten."; + if (opts.outcome === "signed") { + const img = await storeFile(c, { bytes: signature, fileName: "unterschrift.png", declaredMime: "image/png", category: "signature", visibility: "customer_report", links: { workOrderId: id } }); + await captureSignature(c, { reportId: report.id, outcome: "signed", signerName: opts.signer ?? "Frauke Albers", signerRole: "Objektbetreuung", imageDocumentId: img.id, confirmationText }); + } else { + await captureSignature(c, { reportId: report.id, outcome: opts.outcome, reason: opts.outcome === "later" ? "Ansprechpartnerin nicht vor Ort, Unterschrift wird nachgereicht." : "Kunde trotz Terminabsprache nicht angetroffen.", confirmationText }); + } + await submitReport(c, { reportId: report.id }); + if (opts.approve !== "none") await approveReport(opts.teamLead, { reportId: report.id }, noPdf); + if (opts.approve === "final") await approveReport(bo, { reportId: report.id }, noPdf); + return report.id; + }; + + type OrderSeed = Omit & { ext: string; customer: string; site?: string }; + const order = async (seed: OrderSeed, drive: (id: string) => Promise) => { + const existing = await bo.db.workOrder.findFirst({ where: { externalOrderNumber: seed.ext }, select: { id: true } }); + if (existing) return existing.id; + const { ext, customer, site, ...input } = seed; + const cust = customers.get(customer)!; + const wo = await createWorkOrder(bo, { ...input, externalOrderNumber: ext, customerId: cust.id, siteId: site ? sites.get(site)! : null, contactId: cust.contactId }); + await drive(wo.id); + created++; + return wo.id; + }; + const assign = (id: string, teamId: string, userIds: string[]) => assignWorkOrder(bo, { workOrderId: id, teamId, userIds }); + + const heating = [ + { name: "Gas-Brennwerttherme 24 kW", articleNumber: "GBT-24", plannedQuantity: 1, unit: "Stk" }, + { name: "Kupferrohr 18 mm", articleNumber: "CU-18", plannedQuantity: 12, unit: "m" }, + { name: "Pressfitting 18 mm", articleNumber: "PF-18", plannedQuantity: 10, unit: "Stk" }, + ]; + const service = [ + { name: "Wartungsset Brenner", articleNumber: "WS-B1", plannedQuantity: 1, unit: "Satz" }, + { name: "Dichtungssatz", articleNumber: "DS-12", plannedQuantity: 2, unit: "Stk" }, + ]; + + // ---------- 20 Aufträge über alle Statusgruppen ---------- + // Neu + await order({ ext: "DEMO-01", customer: "K-D004", site: "Praxisräume 2. OG", title: "Angebot Waschtisch-Armaturen prüfen", orderTypeId: type("besichtigung"), description: "Besichtigung der Behandlungsräume, Aufnahme der Armaturen.", status: "draft", plannedStart: day(6, 14), plannedEnd: day(6, 16) }, async () => undefined); + await order({ ext: "DEMO-02", customer: "K-D003", site: "Quartier Poppenbüttel – Block 5", title: "Strangsanierung Trinkwasser – Aufmaß", orderTypeId: type("montage"), description: "Aus Auftragsbestätigung übernommen, Prüfung erforderlich.", status: "review_required", plannedStart: day(10, 7), plannedEnd: day(14, 16), materials: heating.slice(1) }, async () => undefined); + // Geplant / kommend + await order({ ext: "DEMO-03", customer: "K-D006", site: "Verwaltungsgebäude", title: "Filterwechsel Lüftungsanlage", orderTypeId: type("wartung"), status: "planned", plannedStart: day(1, 8), plannedEnd: day(1, 12), materials: service }, async () => undefined); + await order({ ext: "DEMO-10", customer: "K-D005", site: "Einfamilienhaus Lindenallee", title: "Heizkörper Wohnzimmer tauschen", orderTypeId: type("montage"), priority: "normal", plannedStart: day(2, 8), plannedEnd: day(2, 15), materials: [{ name: "Flachheizkörper 22/600/1200", articleNumber: "HK-22-12", plannedQuantity: 1, unit: "Stk" }] }, (id) => assign(id, sued, [users.tech3]).then(() => undefined)); + await order({ ext: "DEMO-11", customer: "K-D007", site: "Kita-Gebäude Hallerstraße", title: "Jahreswartung Gasheizung", orderTypeId: type("wartung"), plannedStart: day(5, 9), plannedEnd: day(5, 13), materials: service, technicianNotes: "Arbeiten nur in der Mittagsruhe (12–14 Uhr) mit Lärm." }, (id) => assign(id, nord, [users.tech]).then(() => undefined)); + // Heute + await order({ ext: "DEMO-04", customer: "K-D002", site: "Backstube Osterstraße", title: "Wasserenthärtung warten", orderTypeId: type("wartung"), plannedStart: day(0, 13), plannedEnd: day(0, 15), materials: service }, (id) => assign(id, nord, [users.tech]).then(() => undefined)); + await order({ ext: "DEMO-05", customer: "K-D001", site: "Wohnanlage Elbblick – Haus B", title: "Zirkulationspumpe tauschen", orderTypeId: type("reparatur"), priority: "high", plannedStart: day(0, 10), plannedEnd: day(0, 12), materials: [{ name: "Zirkulationspumpe 25-40", articleNumber: "ZP-2540", plannedQuantity: 1, unit: "Stk" }] }, async (id) => { + await assign(id, nord, [users.tech2]); + await transitionWorkOrder(tech2, { workOrderId: id, to: "accepted" }); + }); + await order({ ext: "DEMO-06", customer: "K-D006", site: "Halle 1 – Kühllager", title: "Störung Kältemittelleitung", orderTypeId: type("stoerung"), priority: "urgent", plannedStart: day(0, 9), plannedEnd: day(0, 13) }, async (id) => { + await assign(id, sued, [users.tech3]); + await travel(tech3, id, day(0, 8, 30)); + }); + await order({ ext: "DEMO-07", customer: "K-D003", site: "Quartier Poppenbüttel – Block 3", title: "Montage Gas-Brennwerttherme", orderTypeId: type("montage"), plannedStart: day(0, 7), plannedEnd: day(0, 17), materials: heating, scope: "Demontage Altgerät, Montage Neugerät, Abgasanschluss, Inbetriebnahme." }, async (id) => { + await assign(id, nord, [users.tech]); + await travel(tech, id, day(0, 6, 45)); + await work(tech, id, day(0, 7, 10)); + await photo(tech, id, { phase: "before", requirementKey: "ausgangszustand", comment: "Altgerät vor Demontage" }); + await note(tech, id, "work_done", "Altgerät demontiert, Anschlüsse verschlossen."); + await note(tech, id, "deviation", "Abgasrohr muss um 30 cm verlängert werden."); + await planMaterial(tech, id, 1, 14, "fully_used", "Leitungsführung um den Schacht"); + await material(tech, id, { clientId: randomUUID(), name: "Abgasrohr-Verlängerung DN80", articleNumber: "AG-80-05", quantity: 1, unit: "Stk", usageStatus: "additional", deviationReason: "Abgasrohr zu kurz" }); + }); + await order({ ext: "DEMO-08", customer: "K-D002", site: "Filiale Eppendorf", title: "Undichte Leitung unter der Theke", orderTypeId: type("reparatur"), plannedStart: day(0, 7), plannedEnd: day(0, 11) }, async (id) => { + await assign(id, sued, [users.tech3]); + await work(tech3, id, day(0, 7, 5)); + await note(tech3, id, "problem", "Leitung hinter Kühlvitrine schwer erreichbar."); + await pause(tech3, id, day(0, 9, 30)); + }); + await order({ ext: "DEMO-09", customer: "K-D007", site: "Gartenhaus / Außenanlage", title: "Außenwasserhahn frostsicher erneuern", orderTypeId: type("reparatur"), plannedStart: day(0, 8), plannedEnd: day(0, 12), materials: [{ name: "Frostsichere Außenarmatur", articleNumber: "FA-12", plannedQuantity: 1, unit: "Stk" }] }, async (id) => { + await assign(id, nord, [users.tech2]); + await work(tech2, id, day(0, 8, 15)); + await note(tech2, id, "problem", "Falsche Armatur geliefert (Länge 150 statt 250 mm)."); + await transitionWorkOrder(tech2, { workOrderId: id, to: "waiting_material" }); + await pause(tech2, id, day(0, 9)); + }); + // Überfällig + await order({ ext: "DEMO-12", customer: "K-D001", site: "Wohnanlage Elbblick – Haus B", title: "Thermostatventile Treppenhaus", orderTypeId: type("reparatur"), plannedStart: day(-4, 8), plannedEnd: day(-3, 16) }, (id) => assign(id, nord, [users.tech]).then(() => undefined)); + await order({ ext: "DEMO-13", customer: "K-D006", site: "Verwaltungsgebäude", title: "Druckminderer erneuern", orderTypeId: type("reparatur"), plannedStart: day(-3, 8), plannedEnd: day(-2, 12) }, async (id) => { + await assign(id, sued, [users.tech3]); + await transitionWorkOrder(tech3, { workOrderId: id, to: "accepted" }); + }); + // Mehrtägig mit Tagesbericht + await order({ ext: "DEMO-14", customer: "K-D003", site: "Quartier Poppenbüttel – Block 5", title: "Heizungsverteilung Tiefgarage erneuern (2 Tage)", orderTypeId: type("montage"), plannedStart: day(-1, 7), plannedEnd: day(0, 16), materials: heating.slice(1), applyTemplate: false, checklistItems: [{ label: "Anlage entleert", required: true }, { label: "Druckprobe durchgeführt", required: true }] }, async (id) => { + await assign(id, nord, [users.tech, users.tech2]); + await work(tech, id, day(-1, 7)); + await note(tech, id, "work_done", "Tag 1: Alte Verteilung demontiert, neue Konsolen gesetzt."); + await planMaterial(tech, id, 0, 8, "partially_used", "Rest wird an Tag 2 verbaut"); + await photo(tech, id, { phase: "during", comment: "Neue Konsolen", takenAt: day(-1, 14) }); + await end(tech, id, day(-1, 15, 30)); + const { report } = await createDailyReport(tech, { workOrderId: id, reportDate: localDateKey(day(-1), TZ) }); + await updateReportTexts(tech, { reportId: report.id, texts: { workPerformed: "Alte Verteilung demontiert, neue Konsolen gesetzt, Rohrleitungen vorbereitet.", openItems: "Anschluss der Heizkreise an Tag 2." } }); + await submitReport(tech, { reportId: report.id }); + await approveReport(lead, { reportId: report.id }, noPdf); + }); + // Dokumentation unvollständig / Zur Prüfung + await order({ ext: "DEMO-15", customer: "K-D004", site: "Praxisräume 2. OG", title: "Boiler Sterilisationsraum tauschen", orderTypeId: type("montage"), plannedStart: day(-2, 13), plannedEnd: day(-2, 17), applyTemplate: false, checklistItems: [{ label: "Funktionsprüfung durchgeführt", required: true }], materials: [{ name: "Untertischspeicher 10 l", articleNumber: "UTS-10", plannedQuantity: 1, unit: "Stk" }] }, async (id) => { + await assign(id, sued, [users.tech3]); + await work(tech3, id, day(-2, 13, 10)); + await checkAll(tech3, id); + await planMaterial(tech3, id, 0, 1, "fully_used"); + await photo(tech3, id, { phase: "after", comment: "Neuer Speicher montiert", takenAt: day(-2, 16) }); + await note(tech3, id, "work_done", "Speicher getauscht, Funktion geprüft."); + await end(tech3, id, day(-2, 16, 30)); + await complete(tech3, id, { reportDate: day(-2), workPerformed: "Untertischspeicher demontiert und neu montiert, Dichtheit und Funktion geprüft.", outcome: "later", approve: "none", teamLead: lead2 }); + }); + await order({ ext: "DEMO-16", customer: "K-D002", site: "Backstube Osterstraße", title: "Sicherheitsventil Warmwasserspeicher", orderTypeId: type("reparatur"), plannedStart: day(-1, 5), plannedEnd: day(-1, 8), applyTemplate: false, checklistItems: [{ label: "Anlage drucklos gemacht", required: true }] }, async (id) => { + await assign(id, nord, [users.tech2]); + await work(tech2, id, day(-1, 5, 10)); + await checkAll(tech2, id); + await note(tech2, id, "work_done", "Sicherheitsventil getauscht und Ansprechdruck geprüft."); + await material(tech2, id, { clientId: randomUUID(), name: "Sicherheitsventil 6 bar", articleNumber: "SV-6", quantity: 1, unit: "Stk", usageStatus: "additional", deviationReason: "Nicht in der Vorgabe enthalten" }); + await end(tech2, id, day(-1, 7, 20)); + await complete(tech2, id, { reportDate: day(-1), workPerformed: "Sicherheitsventil am Warmwasserspeicher getauscht, Funktion geprüft.", outcome: "customer_absent", approve: "team", teamLead: lead }); + }); + await order({ ext: "DEMO-17", customer: "K-D007", site: "Kita-Gebäude Hallerstraße", title: "Spülkasten Kinder-WC reparieren", orderTypeId: type("reparatur"), plannedStart: day(-1, 12), plannedEnd: day(-1, 14), applyTemplate: false, checklistItems: [{ label: "Spülung geprüft", required: true }] }, async (id) => { + await assign(id, nord, [users.tech]); + await work(tech, id, day(-1, 12, 5)); + await checkAll(tech, id); + await note(tech, id, "work_done", "Füllventil und Dichtung ersetzt."); + await end(tech, id, day(-1, 13, 10)); + await transitionWorkOrder(tech, { workOrderId: id, to: "technically_completed" }); + }); + // Zur Abrechnung / abgerechnet – beide mit freigegebenem Bericht in der Objekt-Historie von Haus A + const historySite = "Wohnanlage Elbblick – Haus A"; + await order({ ext: "DEMO-18", customer: "K-D001", site: historySite, title: "Jahreswartung Gas-Brennwertkessel", orderTypeId: type("wartung"), plannedStart: day(-8, 8), plannedEnd: day(-8, 12), applyTemplate: false, checklistItems: [{ label: "Brenner gereinigt", required: true }, { label: "Abgaswerte gemessen", required: true }], materials: service }, async (id) => { + await assign(id, nord, [users.tech]); + await work(tech, id, day(-8, 8, 5)); + await checkAll(tech, id); + await planMaterial(tech, id, 0, 1, "fully_used"); + await planMaterial(tech, id, 1, 1, "partially_used", "Zweite Dichtung war noch in Ordnung"); + await photo(tech, id, { phase: "after", comment: "Kessel nach Wartung", takenAt: day(-8, 11) }); + await note(tech, id, "work_done", "Brenner gereinigt, Zündelektrode eingestellt, Abgaswerte im Sollbereich."); + await note(tech, id, "recommendation", "Ausdehnungsgefäß beim nächsten Termin prüfen."); + await end(tech, id, day(-8, 11, 30)); + await complete(tech, id, { reportDate: day(-8), workPerformed: "Jahreswartung durchgeführt: Brenner gereinigt, Zündelektrode eingestellt, Abgasmessung ohne Befund.", outcome: "signed", approve: "final", teamLead: lead }); + await releaseForBilling(bo, { workOrderId: id }); + }); + await order({ ext: "DEMO-19", customer: "K-D001", site: historySite, title: "Heizungspumpe erneuern", orderTypeId: type("reparatur"), plannedStart: day(-21, 9), plannedEnd: day(-21, 13), applyTemplate: false, checklistItems: [{ label: "Anlage entlüftet", required: true }], materials: [{ name: "Hocheffizienzpumpe 25-60", articleNumber: "HP-2560", plannedQuantity: 1, unit: "Stk" }] }, async (id) => { + await assign(id, sued, [users.tech3]); + await work(tech3, id, day(-21, 9, 10)); + await checkAll(tech3, id); + await planMaterial(tech3, id, 0, 1, "fully_used"); + await photo(tech3, id, { phase: "after", comment: "Neue Pumpe eingebaut", takenAt: day(-21, 12) }); + await note(tech3, id, "work_done", "Heizungspumpe getauscht, Anlage entlüftet und hydraulisch abgeglichen."); + await note(tech3, id, "follow_up", "Thermostatköpfe im 3. OG klemmen – separaten Auftrag anlegen."); + await end(tech3, id, day(-21, 12, 40)); + await complete(tech3, id, { reportDate: day(-21), workPerformed: "Heizungspumpe durch Hocheffizienzpumpe ersetzt, Anlage entlüftet.", outcome: "signed", signer: "Frauke Albers", approve: "final", teamLead: lead2 }); + await releaseForBilling(bo, { workOrderId: id }); + await markBilled(bo, { workOrderId: id }); + }); + + // Notdienst mit vorläufigem Kunden (feste Client-IDs → idempotent) + const emergencyExisting = await bo.db.workSession.findFirst({ where: { clientId: "7c1d6a0e-3b1f-4c55-9d2a-00000000d020" }, select: { workOrderId: true } }); + if (!emergencyExisting) { + const r = await createEmergencyOrder(tech, { + clientIds: { workOrder: "7c1d6a0e-3b1f-4c55-9d2a-00000000a020", session: "7c1d6a0e-3b1f-4c55-9d2a-00000000d020", customer: "7c1d6a0e-3b1f-4c55-9d2a-00000000c020", site: "7c1d6a0e-3b1f-4c55-9d2a-00000000e020" }, + customer: { mode: "new", firstName: "Jonas", lastName: "Beispielmann", phone: "040 555 0299", street: "Heußweg", houseNumber: "71", postalCode: "20255", city: "Hamburg" }, + site: { mode: "new", name: "Mehrfamilienhaus Heußweg 71", street: "Heußweg", houseNumber: "71", postalCode: "20255", city: "Hamburg" }, + onSiteContact: { name: "Jonas Beispielmann", phone: "040 555 0299" }, + reason: "Wasserrohrbruch im Keller – Hauptabsperrung defekt", + startedAt: day(0, 6, 20).toISOString(), + deviceInfo: "Demo-Seed", + }); + await note(tech, r.workOrderId, "work_done", "Wasser abgesperrt, Leck provisorisch abgedichtet."); + await material(tech, r.workOrderId, { clientId: randomUUID(), name: "Reparaturschelle 1/2 Zoll", quantity: 1, unit: "Stk", usageStatus: "additional", deviationReason: "Notdienst" }); + created++; + } + + const orders = await bo.db.workOrder.count({ where: { OR: [{ externalOrderNumber: { startsWith: "DEMO-" } }, { isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch im Keller" } }] } }); + return { orders, created }; +} + +/** Isolation fixture for the second tenant: one customer + one work order. */ +export async function seedDemo2Tenant(tenantId: string, adminUserId: string): Promise { + const admin = ctxOf(tenantId, adminUserId, "tenant-admin"); + let customer = await admin.db.customer.findFirst({ where: { customerNumber: "K-N001" }, select: { id: true } }); + if (!customer) { + customer = await createCustomer(admin, { customerNumber: "K-N001", companyName: "Werft Nordlicht GmbH", street: "Am Hafen", houseNumber: "3", postalCode: "24103", city: "Kiel", phone: "0431 555 0101", notes: "Demo-Datensatz (Isolationsprüfung)" }, { acknowledgeDuplicates: true }); + } + if (!(await admin.db.workOrder.findFirst({ where: { externalOrderNumber: "DEMO2-01" }, select: { id: true } }))) { + const site = await createSite(admin, { customerId: customer.id, name: "Schaltanlage Halle 2", street: "Am Hafen", houseNumber: "3", postalCode: "24103", city: "Kiel", accessNotes: "Anmeldung beim Werkschutz." }); + await createWorkOrder(admin, { title: "Prüfung Unterverteilung nach DGUV V3", customerId: customer.id, siteId: site.id, externalOrderNumber: "DEMO2-01", status: "planned", plannedStart: day(3, 8), plannedEnd: day(3, 12) }); + } +} + +/** Close the owner client used by the services (the seed has its own client). */ +export async function closeDemoSeed(): Promise { + await prisma.$disconnect(); +} From 45078623757371aa55da544e38f68270c6f2d8a5 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:16:11 +0200 Subject: [PATCH 2/6] =?UTF-8?q?L10a=20Qualit=C3=A4t=20&=20Abnahmetests:=20?= =?UTF-8?q?E2E-Prozesstests=20(regul=C3=A4rer=20Auftrag,=20mehrt=C3=A4gig,?= =?UTF-8?q?=20Notdienst,=20Offline-Sync,=20Pflichtfotos/Unterschrift,=20Du?= =?UTF-8?q?bletten,=20Mandantentrennung=20systematisch)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- scripts/lib/e2e-fixture.ts | 229 ++++++++++++++++ scripts/test-e2e-emergency.ts | 122 +++++++++ scripts/test-e2e-guards.ts | 151 ++++++++++ scripts/test-e2e-import-duplicates.ts | 120 ++++++++ scripts/test-e2e-multiday-order.ts | 145 ++++++++++ scripts/test-e2e-offline-sync.ts | 153 +++++++++++ scripts/test-e2e-regular-order.ts | 240 ++++++++++++++++ scripts/test-e2e-tenant-isolation.ts | 379 ++++++++++++++++++++++++++ 8 files changed, 1539 insertions(+) create mode 100644 scripts/lib/e2e-fixture.ts create mode 100644 scripts/test-e2e-emergency.ts create mode 100644 scripts/test-e2e-guards.ts create mode 100644 scripts/test-e2e-import-duplicates.ts create mode 100644 scripts/test-e2e-multiday-order.ts create mode 100644 scripts/test-e2e-offline-sync.ts create mode 100644 scripts/test-e2e-regular-order.ts create mode 100644 scripts/test-e2e-tenant-isolation.ts diff --git a/scripts/lib/e2e-fixture.ts b/scripts/lib/e2e-fixture.ts new file mode 100644 index 0000000..ee3f108 --- /dev/null +++ b/scripts/lib/e2e-fixture.ts @@ -0,0 +1,229 @@ +// L10a shared fixture for the acceptance tests (scripts/test-e2e-*.ts, scripts/test-security-*.ts). +// Not a test itself (the runner only picks up scripts/test-*.ts at top level). +// +// createTenant(slug) builds a complete zz test tenant exactly like a provisioned one: tenant +// settings, the four standard roles WITH their permissions in the DB (needed by recipient +// resolution, conflict re-apply and requireApiContext), users per role, Team Nord (lead + 2 +// technicians), Team Süd (lead2 + outsider), a customer with contact and a site. +// cleanupTenants() removes every row of all tenant models (FK-safe order) and the test identities. + +import "dotenv/config"; +import sharp from "sharp"; +import { ZodError } from "zod"; +import { prisma, dbForTenant } from "../../src/server/db"; +import { ROLE_DEFS, type RoleKey } from "../../src/server/rbac"; +import { ServiceError, type ServiceCtx } from "../../src/server/services/context"; + +export const E2E_DOMAIN = "@zz-qualitaet.test"; + +let failureCount = 0; +export const failures = () => failureCount; + +export function ok(cond: boolean, msg: string): boolean { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failureCount++; + return cond; +} + +export function section(title: string) { + console.log(`\n— ${title} —`); +} + +/** Error code of a promise: "ok", a ServiceError code, or "error:". */ +export async function codeOf(p: Promise | (() => Promise)): Promise { + try { + await (typeof p === "function" ? p() : p); + return "ok"; + } catch (e) { + if (e instanceof ServiceError) return e.code; + // Zod validation errors surface as `invalid` in every adapter (toErrorResponse, action state) + if (e instanceof ZodError) return "invalid"; + const code = (e as { code?: unknown }).code; + if (typeof code === "string" && ["not_found", "forbidden", "invalid", "conflict", "blocked"].includes(code)) return code; + return `error:${(e as Error).message?.split("\n")[0]}`; + } +} + +export async function expectCode(fn: () => Promise, codes: string | string[], msg: string): Promise { + const got = await codeOf(fn); + const wanted = Array.isArray(codes) ? codes : [codes]; + ok(wanted.includes(got), `${msg}${wanted.includes(got) ? "" : ` — erhalten ${got}, erwartet ${wanted.join("|")}`}`); + return got; +} + +export function ctxFor(tenantId: string, userId: string, role: RoleKey): ServiceCtx { + return { db: dbForTenant(tenantId), tenantId, userId, permissions: new Set(ROLE_DEFS[role].permissions) }; +} + +const PERSONAS = { + admin: "tenant-admin", + backoffice: "backoffice", + lead: "team-lead", + lead2: "team-lead", + tech: "technician", + tech2: "technician", + outsider: "technician", +} as const satisfies Record; +export type Persona = keyof typeof PERSONAS; + +export type TenantFixture = { + slug: string; + tenantId: string; + users: Record; + ctx: Record; + teamId: string; + team2Id: string; + customerId: string; + contactId: string; + siteId: string; +}; + +export async function createTenant(slug: string, opts: { orgName?: string } = {}): Promise { + const tenant = await prisma.tenant.create({ data: { name: opts.orgName ?? `ZZ Qualität ${slug}`, slug } }); + await prisma.tenantSettings.create({ data: { tenantId: tenant.id, orgName: opts.orgName ?? `ZZ Qualität ${slug} GmbH`, timezone: "Europe/Berlin", address: "Prüfweg 1, 20095 Hamburg" } }); + + const roleIds = {} as Record; + for (const key of Object.keys(ROLE_DEFS) as RoleKey[]) { + const perms = await Promise.all(ROLE_DEFS[key].permissions.map((p) => prisma.permission.upsert({ where: { key: p }, update: {}, create: { key: p } }))); + const role = await prisma.role.create({ + data: { tenantId: tenant.id, key, name: ROLE_DEFS[key].name, rolePermissions: { create: perms.map((p) => ({ permissionId: p.id })) } }, + }); + roleIds[key] = role.id; + } + + const users = {} as TenantFixture["users"]; + const ctx = {} as TenantFixture["ctx"]; + for (const persona of Object.keys(PERSONAS) as Persona[]) { + const email = `${persona}-${slug}${E2E_DOMAIN}`; + const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } }); + const user = await prisma.user.create({ + data: { tenantId: tenant.id, identityId: identity.id, email, name: `${persona} ${slug}`, userRoles: { create: [{ roleId: roleIds[PERSONAS[persona]] }] } }, + }); + users[persona] = { id: user.id, email, identityId: identity.id }; + ctx[persona] = ctxFor(tenant.id, user.id, PERSONAS[persona]); + } + + const since = new Date(Date.now() - 30 * 86400_000); + const team = await prisma.team.create({ data: { tenantId: tenant.id, name: "Team Nord", leaderUserId: users.lead.id } }); + await prisma.teamMember.createMany({ + data: [users.tech.id, users.tech2.id].map((userId) => ({ tenantId: tenant.id, teamId: team.id, userId, validFrom: since })), + }); + const team2 = await prisma.team.create({ data: { tenantId: tenant.id, name: "Team Süd", leaderUserId: users.lead2.id } }); + await prisma.teamMember.create({ data: { tenantId: tenant.id, teamId: team2.id, userId: users.outsider.id, validFrom: since } }); + + const customer = await prisma.customer.create({ + data: { tenantId: tenant.id, customerNumber: `K-${slug}`, companyName: `Hausverwaltung ${slug} GmbH`, street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg", phone: "040 555 1000", email: `kontakt@${slug}.example` }, + }); + const contact = await prisma.contact.create({ data: { tenantId: tenant.id, customerId: customer.id, name: "Frau Prüf", role: "Objektbetreuung", phone: "040 555 1001" } }); + const site = await prisma.site.create({ + data: { tenantId: tenant.id, customerId: customer.id, contactId: contact.id, name: `Wohnanlage ${slug}`, street: "Am Kaiserkai", houseNumber: "30", postalCode: "20457", city: "Hamburg", accessNotes: "Schlüssel beim Hausmeister" }, + }); + + return { slug, tenantId: tenant.id, users, ctx, teamId: team.id, team2Id: team2.id, customerId: customer.id, contactId: contact.id, siteId: site.id }; +} + +/** Deletes all rows of the given tenants (all TENANT_MODELS, FK-safe) plus their test identities. */ +export async function cleanupTenants(slugs: string[]): Promise { + const tenants = await prisma.tenant.findMany({ where: { slug: { in: slugs } }, select: { id: true } }); + const ids = tenants.map((t) => t.id); + if (ids.length) { + const w = { where: { tenantId: { in: ids } } }; + await prisma.syncOperation.deleteMany(w); + await prisma.notification.deleteMany(w); + await prisma.notificationPreference.deleteMany(w); + await prisma.mailLog.deleteMany(w); + await prisma.authToken.deleteMany(w); + await prisma.aiGeneration.deleteMany(w); + await prisma.signature.deleteMany(w); + await prisma.report.deleteMany(w); + await prisma.photo.deleteMany(w); + await prisma.activityNote.deleteMany(w); + await prisma.voiceNote.deleteMany(w); + await prisma.timeEntry.deleteMany(w); + await prisma.materialUsage.deleteMany(w); + await prisma.workSession.deleteMany(w); + await prisma.materialPlan.deleteMany(w); + await prisma.photoRequirement.deleteMany(w); + await prisma.checklistItem.deleteMany(w); + await prisma.workOrderStatusChange.deleteMany(w); + await prisma.workOrderAssignee.deleteMany(w); + await prisma.document.updateMany({ ...w, data: { workOrderId: null, siteId: null, customerId: null } }); + await prisma.workOrder.deleteMany(w); + await prisma.importJob.deleteMany(w); + await prisma.document.deleteMany(w); + await prisma.teamMember.deleteMany(w); + await prisma.team.deleteMany(w); + await prisma.site.deleteMany(w); + await prisma.contact.deleteMany(w); + await prisma.customer.deleteMany(w); + await prisma.checklistTemplate.deleteMany(w); + await prisma.orderType.deleteMany(w); + await prisma.numberSequence.deleteMany(w); + await prisma.tenantModule.deleteMany(w); + await prisma.tenantSettings.deleteMany(w); + await prisma.auditLog.deleteMany(w); + await prisma.userRole.deleteMany({ where: { user: { tenantId: { in: ids } } } }); + await prisma.user.deleteMany(w); + await prisma.rolePermission.deleteMany({ where: { role: { tenantId: { in: ids } } } }); + await prisma.role.deleteMany(w); + await prisma.tenant.deleteMany({ where: { id: { in: ids } } }); + } + await prisma.identity.deleteMany({ where: { email: { endsWith: E2E_DOMAIN }, memberships: { none: {} } } }); +} + +/** Runs `body` between setup/cleanup, prints the summary and exits with the right code. */ +export async function runSuite(name: string, slugs: string[], body: () => Promise): Promise { + let crashed = false; + try { + await cleanupTenants(slugs); + await body(); + } catch (err) { + crashed = true; + console.error(`\n✗ FEHLER ${name} abgebrochen:`, err); + } finally { + await cleanupTenants(slugs).catch((e) => console.error("cleanup failed", e)); + try { + const { closeJobQueues } = await import("../../src/server/jobs/queues"); + await closeJobQueues(); + } catch { + /* queues optional */ + } + try { + const { closeQueues } = await import("../../src/server/mail/queue"); + await closeQueues(); + } catch { + /* mail queue optional */ + } + await prisma.$disconnect(); + } + const n = failures() + (crashed ? 1 : 0); + console.log(n === 0 ? `\nOK — ${name}: alle Nachweise erfüllt.` : `\n${n} FEHLER in ${name}.`); + process.exit(n === 0 ? 0 : 1); +} + +// ---------- binary fixtures ---------- + +export async function jpegBytes(label = "Foto"): Promise { + const svg = `${label}`; + return sharp(Buffer.from(svg)).jpeg({ quality: 70 }).toBuffer(); +} + +export async function pngBytes(): Promise { + return sharp({ create: { width: 300, height: 100, channels: 3, background: { r: 255, g: 255, b: 255 } } }).png().toBuffer(); +} + +/** Local calendar day key (Europe/Berlin) with an offset in days. */ +export function dayKey(offsetDays = 0): string { + const d = new Date(Date.now() + offsetDays * 86400_000); + return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", year: "numeric", month: "2-digit", day: "2-digit" }).format(d); +} + +/** Instant at a given Berlin wall-clock hour of the day `offsetDays` from today. */ +export function berlinAt(offsetDays: number, hh: number, mm = 0): Date { + const key = dayKey(offsetDays); + // Europe/Berlin is UTC+1 or UTC+2; resolve the offset of that day at noon. + const probe = new Date(`${key}T12:00:00Z`); + const berlinNoon = Number(new Intl.DateTimeFormat("en-GB", { timeZone: "Europe/Berlin", hour: "2-digit", hour12: false }).format(probe)); + const offsetHours = berlinNoon - 12; + return new Date(`${key}T${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}:00${offsetHours >= 0 ? "+" : "-"}${String(Math.abs(offsetHours)).padStart(2, "0")}:00`); +} diff --git a/scripts/test-e2e-emergency.ts b/scripts/test-e2e-emergency.ts new file mode 100644 index 0000000..538237e --- /dev/null +++ b/scripts/test-e2e-emergency.ts @@ -0,0 +1,122 @@ +// L10a E2E §43.3 / §39 „Notdiensteinsatz" — Monteur legt Notdienst mit vorläufigem Kunden an → Einsatz → +// Abschluss ohne Unterschrift (Kunde abwesend) → Backoffice wird informiert → Backoffice ordnet den +// bestehenden Kunden zu, bestätigt das Objekt, ergänzt den Auftrag → Berichtsfreigabe → Abrechnung. +// Dazu Rollen (Monteur/Teamleiter dürfen nicht prüfen) und Mandantentrennung. +// +// Lauf: npx tsx scripts/test-e2e-emergency.ts + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import { createEmergencyOrder } from "../src/server/services/emergency/create"; +import { + assignEmergencyToCustomer, + completeEmergencyOrderData, + correctEmergencySite, + getEmergencyReview, + listEmergencyReviews, + releaseEmergencyForBilling, +} from "../src/server/services/emergency/review"; +import { markBilled } from "../src/server/services/work-orders/release-billing"; +import { endSession } from "../src/server/services/field/sessions"; +import { createNote } from "../src/server/services/field/notes"; +import { upsertMaterialUsage } from "../src/server/services/field/materials"; +import { storeFieldUpload } from "../src/server/services/field/uploads"; +import { attachPhoto } from "../src/server/services/field/photos"; +import { createCompletionReport } from "../src/server/services/reports/create"; +import { updateReportTexts } from "../src/server/services/reports/edit"; +import { captureSignature } from "../src/server/services/reports/signature"; +import { submitReport } from "../src/server/services/reports/submit"; +import { approveReport } from "../src/server/services/reports/approve"; +import { getCustomer } from "../src/server/services/customers/customers"; +import { getSiteHistory } from "../src/server/services/sites/history"; +import { materialUpsertPayload, noteCreatePayload, photoAttachPayload, sessionControlPayload } from "../src/lib/sync/ops"; +import { codeOf, createTenant, expectCode, jpegBytes, ok, runSuite, section } from "./lib/e2e-fixture"; + +const SLUG_A = "zz-q-e2e-nd-a"; +const SLUG_B = "zz-q-e2e-nd-b"; +const noPdf = { dispatchPdf: async () => undefined }; + +runSuite("E2E Notdienst", [SLUG_A, SLUG_B], async () => { + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const bo = A.ctx.backoffice; + const tech = A.ctx.tech; + + section("1. Monteur legt Notdienst mit vorläufigem Kunden an"); + const startedAt = new Date(Date.now() - 2 * 3600_000); + const r = await createEmergencyOrder(tech, { + clientIds: { workOrder: randomUUID(), session: randomUUID(), customer: randomUUID(), site: randomUUID() }, + // same phone + postal code as the existing customer → duplicate candidate for the backoffice + customer: { mode: "new", lastName: "Hausverwaltung Hafen", phone: "040 5551000", street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg" }, + site: { mode: "new", name: "Keller", street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg" }, + onSiteContact: { name: "Hausmeister Hafen", phone: "0171 5550000" }, + reason: "Heizung ausgefallen, Wohnungen ohne Warmwasser", + startedAt: startedAt.toISOString(), + }); + const wo = await prisma.workOrder.findUniqueOrThrow({ where: { id: r.workOrderId }, include: { customer: true, site: true } }); + ok(/^N-\d+$/.test(wo.number) && wo.isEmergency && wo.status === "in_progress", `Notdienstauftrag ${wo.number} in Arbeit`); + ok(wo.customer.status === "provisional" && wo.customer.customerNumber === null && wo.site?.status === "provisional", "Kunde und Objekt vorläufig (ohne Kundennummer)"); + ok((await prisma.workSession.count({ where: { workOrderId: wo.id, status: "running" } })) === 1, "Einsatz läuft (Arbeitszeit gestartet)"); + ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.backoffice.id, type: "emergency.created", entityId: wo.id } })) === 1, "Backoffice erhält „Notdienst angelegt“"); + await expectCode(() => getCustomer(A.ctx.outsider, wo.customerId), "not_found", "anderer Monteur sieht den vorläufigen Kunden nicht"); + await expectCode(() => getCustomer(B.ctx.admin, wo.customerId), "not_found", "Mandant B sieht den vorläufigen Kunden nicht"); + + section("2. Dokumentation und Abschluss ohne Unterschrift"); + await createNote(tech, noteCreatePayload.parse({ workOrderId: wo.id, kind: "work_done", text: "Umwälzpumpe getauscht, Anlage wieder in Betrieb." })); + await upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: wo.id, clientId: randomUUID(), name: "Umwälzpumpe 25-40", quantity: 1, unit: "Stk", usageStatus: "additional", deviationReason: "Pumpe defekt" })); + const up = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: wo.id, kind: "photo" }, { bytes: await jpegBytes("Pumpe"), name: "pumpe.jpg", type: "image/jpeg" }); + await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: wo.id, documentId: up.documentId, phase: "after" })); + await endSession(tech, sessionControlPayload.parse({ workOrderId: wo.id })); + const report = (await createCompletionReport(tech, { workOrderId: wo.id })).report; + await updateReportTexts(tech, { reportId: report.id, texts: { workPerformed: "Umwälzpumpe getauscht, Warmwasser wieder verfügbar." } }); + await expectCode(() => captureSignature(tech, { reportId: report.id, outcome: "customer_absent", confirmationText: "x" }), "invalid", "Kunde abwesend ohne Begründung → invalid"); + await captureSignature(tech, { reportId: report.id, outcome: "customer_absent", reason: "Nachts niemand erreichbar", confirmationText: "Bestätigt" }); + await submitReport(tech, { reportId: report.id }); + ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } })).status === "in_review", "Abschlussbericht eingereicht → Zur Prüfung"); + ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.backoffice.id, type: "emergency.completed", entityId: wo.id } })) === 1, "Backoffice erhält „Notdienst abgeschlossen“ (In-App)"); + ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.backoffice.id, type: "work_order.signature_missing", entityId: wo.id } })) === 1, "Backoffice erhält Hinweis „Unterschrift fehlt“"); + const mails = await prisma.mailLog.count({ where: { tenantId: A.tenantId, template: "craftvia_emergency" } }); + ok(mails >= 1, `Pflicht-E-Mail an das Backoffice eingereiht/protokolliert (${mails})`); + + section("3. Rollen und Mandantentrennung der Nachbearbeitung"); + await expectCode(() => listEmergencyReviews(tech), "forbidden", "Monteur: Prüfliste → forbidden"); + await expectCode(() => getEmergencyReview(A.ctx.lead, wo.id), "forbidden", "Teamleiter: Prüfung → forbidden"); + await expectCode(() => getEmergencyReview(B.ctx.backoffice, wo.id), "not_found", "Mandant B: Prüfung → not_found"); + await expectCode(() => assignEmergencyToCustomer(B.ctx.backoffice, { workOrderId: wo.id, targetCustomerId: A.customerId }), "not_found", "Mandant B: Kunde zuordnen → not_found"); + ok(!(await listEmergencyReviews(B.ctx.backoffice, { filter: "all" })).some((x) => x.id === wo.id), "Mandant B: Notdienst nicht in der Liste"); + + section("4. Backoffice prüft am nächsten Arbeitstag die Stammdaten"); + const listed = (await listEmergencyReviews(bo)).find((x) => x.id === wo.id); + ok(!!listed && listed.progress.count === 0 && listed.progress.total === 5, "in „Zu prüfen“ mit 0 von 5 Prüfschritten"); + const review = await getEmergencyReview(bo, wo.id); + ok(review.duplicates.some((d) => d.customerId === A.customerId), "bestehender Kunde als Dublettenkandidat vorgeschlagen"); + await expectCode(() => releaseEmergencyForBilling(bo, { workOrderId: wo.id }), "blocked", "Abrechnung mit vorläufigen Stammdaten → blocked"); + const assigned = await assignEmergencyToCustomer(bo, { workOrderId: wo.id, targetCustomerId: A.customerId }); + const provisional = await prisma.customer.findUniqueOrThrow({ where: { id: assigned.sourceId } }); + const afterAssign = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id }, include: { site: true } }); + ok(afterAssign.customerId === A.customerId && provisional.status === "merged" && provisional.mergedIntoId === A.customerId, "Auftrag dem bestehenden Kunden zugeordnet, vorläufiger Kunde zusammengeführt"); + ok(afterAssign.site?.customerId === A.customerId && afterAssign.version > wo.version, "Einsatzort mit umgehängt, Auftragsversion erhöht (Offline-Schutz)"); + await correctEmergencySite(bo, { workOrderId: wo.id, patch: { name: "Heizungskeller Hafenstraße 12", accessNotes: "Hausmeister öffnet" }, confirm: true }); + ok((await prisma.site.findUniqueOrThrow({ where: { id: afterAssign.siteId! } })).status === "active", "Objekt korrigiert und bestätigt"); + const notdienst = await prisma.orderType.findFirstOrThrow({ where: { tenantId: A.tenantId, key: "notdienst" } }); + await completeEmergencyOrderData(bo, { workOrderId: wo.id, title: "Notdienst: Ausfall Heizung", orderTypeId: notdienst.id, billingType: "time_material" }); + await expectCode(() => releaseEmergencyForBilling(bo, { workOrderId: wo.id }), "blocked", "Abrechnung ohne freigegebenen Bericht → blocked"); + await approveReport(A.ctx.lead, { reportId: report.id }, noPdf); + await approveReport(bo, { reportId: report.id }, noPdf); + ok((await getEmergencyReview(bo, wo.id)).progress.count === 4, "4 von 5 Prüfschritten erledigt"); + + section("5. Auftrag wird zur Abrechnung freigegeben"); + const released = await releaseEmergencyForBilling(bo, { workOrderId: wo.id }); + ok(released.status === "released_for_billing", "Notdienst zur Abrechnung freigegeben"); + ok((await getEmergencyReview(bo, wo.id)).progress.count === 5, "5 von 5 Prüfschritten erledigt"); + ok(!(await listEmergencyReviews(bo)).some((x) => x.id === wo.id) && (await listEmergencyReviews(bo, { filter: "all" })).some((x) => x.id === wo.id), "aus „Zu prüfen“ entfernt, unter „Alle“ sichtbar"); + ok((await codeOf(markBilled(tech, { workOrderId: wo.id }))) === "forbidden", "Monteur darf nicht abrechnen"); + ok((await markBilled(bo, { workOrderId: wo.id })).status === "billed", "abgerechnet"); + const audits = await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "emergency", entityId: wo.id } }); + ok(audits >= 4, `Prüfschritte auditiert (${audits})`); + + const hist = await getSiteHistory(bo, afterAssign.siteId!); + const entry = hist.items.find((i) => i.workOrderId === wo.id); + ok(!!entry && entry.isEmergency && entry.approvedReports.length === 1 && !entry.signed, "Objekt-Historie: Notdienst mit freigegebenem Bericht, ohne Unterschrift"); +}); diff --git a/scripts/test-e2e-guards.ts b/scripts/test-e2e-guards.ts new file mode 100644 index 0000000..482cd67 --- /dev/null +++ b/scripts/test-e2e-guards.ts @@ -0,0 +1,151 @@ +// L10a E2E §43.3 „Fehlende Pflichtfotos" und „Fehlende Unterschrift". +// +// Pflichtfotos: Pflichtfoto + Checklistenpunkt „mit Foto" blockieren technisch abschließen, +// Abschlussbericht und Sync-Op mit strukturierter Blocker-Liste; Blocker verschwinden erst mit den +// passenden Fotos. Unterschrift: ohne Unterschrift → „Unterschrift ausstehend“ (kein Weg zur Prüfung +// oder Abrechnung), Ablehnung/Abwesenheit nur mit Grund (→ Prüfung + Hinweis ans Backoffice), +// „später“ + Nachreichen, „nicht erforderlich“ nur ohne Pflicht, erfasste Unterschrift unveränderlich. +// +// Lauf: npx tsx scripts/test-e2e-guards.ts + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import type { CompletionBlocker } from "../src/lib/work-orders/status"; +import { ServiceError } from "../src/server/services/context"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; +import { assignWorkOrder } from "../src/server/services/work-orders/assign"; +import { transitionWorkOrder } from "../src/server/services/work-orders/transition"; +import { getCompletionBlockers } from "../src/server/services/work-orders/completion"; +import { releaseForBilling } from "../src/server/services/work-orders/release-billing"; +import { endSession, startSession } from "../src/server/services/field/sessions"; +import { toggleChecklistItem } from "../src/server/services/field/checklist"; +import { storeFieldUpload } from "../src/server/services/field/uploads"; +import { attachPhoto } from "../src/server/services/field/photos"; +import { applyOperations } from "../src/server/services/sync/apply"; +import { storeFile } from "../src/server/services/documents/store"; +import { createCompletionReport } from "../src/server/services/reports/create"; +import { updateReportTexts } from "../src/server/services/reports/edit"; +import { captureSignature } from "../src/server/services/reports/signature"; +import { submitReport } from "../src/server/services/reports/submit"; +import { contentOf } from "../src/server/services/reports/common"; +import { checklistTogglePayload, photoAttachPayload, sessionControlPayload, sessionStartPayload } from "../src/lib/sync/ops"; +import { codeOf, createTenant, expectCode, jpegBytes, ok, pngBytes, runSuite, section, type TenantFixture } from "./lib/e2e-fixture"; + +const SLUG_A = "zz-q-e2e-guard-a"; +const SLUG_B = "zz-q-e2e-guard-b"; + +async function blockersOf(fn: () => Promise): Promise { + try { + await fn(); + return "ok"; + } catch (err) { + return err instanceof ServiceError && err.code === "blocked" ? (err.details as CompletionBlocker[]) : String((err as { code?: string }).code ?? (err as Error).message); + } +} + +/** Order assigned to tech, worked and ended; ready for the completion step. */ +async function workedOrder(A: TenantFixture, input: { signatureRequired: boolean; title: string; photoRequirements?: { key: string; label: string }[]; checklistItems?: { label: string; required: boolean; requiresPhoto?: boolean }[] }) { + const wo = await createWorkOrder(A.ctx.backoffice, { customerId: A.customerId, siteId: A.siteId, applyTemplate: false, ...input }); + await assignWorkOrder(A.ctx.backoffice, { workOrderId: wo.id, teamId: A.teamId, userIds: [A.users.tech.id] }); + await startSession(A.ctx.tech, sessionStartPayload.parse({ workOrderId: wo.id, mode: "work", at: new Date(Date.now() - 3600_000).toISOString() })); + await endSession(A.ctx.tech, sessionControlPayload.parse({ workOrderId: wo.id })); + return wo.id; +} + +async function readyReport(A: TenantFixture, workOrderId: string) { + const { report } = await createCompletionReport(A.ctx.tech, { workOrderId }); + await updateReportTexts(A.ctx.tech, { reportId: report.id, texts: { workPerformed: "Arbeiten ausgeführt." } }); + return report.id; +} + +const status = async (id: string) => (await prisma.workOrder.findUniqueOrThrow({ where: { id } })).status; + +runSuite("E2E Pflichtfotos und Unterschrift", [SLUG_A, SLUG_B], async () => { + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const tech = A.ctx.tech; + const jpeg = await jpegBytes("Nachweis"); + + // ================= Pflichtfotos ================= + section("Fehlende Pflichtfotos blockieren den Abschluss"); + const w1 = await workedOrder(A, { + title: "Wärmepumpe montieren", + signatureRequired: true, + photoRequirements: [{ key: "typenschild", label: "Typenschild" }], + checklistItems: [{ label: "Funktionsprüfung durchgeführt", required: true, requiresPhoto: true }], + }); + const b1 = await blockersOf(() => transitionWorkOrder(tech, { workOrderId: w1, to: "technically_completed" })); + ok(Array.isArray(b1) && b1.some((b) => b.kind === "photo_requirement" && b.label === "Typenschild") && b1.some((b) => b.kind === "checklist_item"), "technisch abschließen → blocked mit Pflichtfoto + Checklistenpunkt"); + const b2 = await blockersOf(() => createCompletionReport(tech, { workOrderId: w1 })); + ok(Array.isArray(b2) && b2.length === 2, "Abschlussbericht → blocked mit derselben Liste"); + const listed = await getCompletionBlockers(tech, w1); + ok(listed.length === 2, "Blocker-Liste für die UI abrufbar"); + await expectCode(() => getCompletionBlockers(B.ctx.tech, w1), "not_found", "Mandant B: Blocker-Liste → not_found"); + await expectCode(() => getCompletionBlockers(A.ctx.outsider, w1), "not_found", "Monteur ohne Zuweisung: Blocker-Liste → not_found"); + const wo1 = await prisma.workOrder.findUniqueOrThrow({ where: { id: w1 }, include: { checklistItems: true, photoRequirements: true } }); + const viaSync = await applyOperations(tech, { deviceId: "g", operations: [{ clientOpId: randomUUID(), opType: "work_order.transition", baseVersion: wo1.version, payload: { workOrderId: w1, to: "technically_completed" }, clientCreatedAt: new Date().toISOString() }] }); + ok(viaSync.results[0].status === "rejected" && viaSync.results[0].errorCode === "blocked", "auch offline (Sync) → rejected blocked"); + + await toggleChecklistItem(tech, checklistTogglePayload.parse({ workOrderId: w1, itemId: wo1.checklistItems[0].id, checked: true })); + const b3 = await getCompletionBlockers(tech, w1); + ok(b3.some((b) => b.kind === "checklist_item"), "abgehakt ohne Foto → Checklistenpunkt „mit Foto“ bleibt offen"); + const notAnImage = Buffer.from("%PDF-1.4 kein Foto"); + await expectCode(() => storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: w1, kind: "photo" }, { bytes: notAnImage, name: "fake.jpg", type: "image/jpeg" }), "invalid", "Nicht-Bild als Foto → invalid"); + const p1 = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: w1, kind: "photo" }, { bytes: jpeg, name: "funktion.jpg", type: "image/jpeg" }); + await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: w1, documentId: p1.documentId, checklistItemId: wo1.checklistItems[0].id, phase: "after" })); + const b4 = await getCompletionBlockers(tech, w1); + ok(b4.length === 1 && b4[0].kind === "photo_requirement", "Foto am Checklistenpunkt → nur noch Pflichtfoto offen"); + const p2 = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: w1, kind: "photo" }, { bytes: jpeg, name: "typenschild.jpg", type: "image/jpeg" }); + await expectCode(() => attachPhoto(A.ctx.tech2, photoAttachPayload.parse({ workOrderId: w1, documentId: p2.documentId, photoRequirementId: wo1.photoRequirements[0].id })), ["not_found", "forbidden"], "Foto eines anderen Nutzers kann nicht angehängt werden"); + await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: w1, documentId: p2.documentId, photoRequirementId: wo1.photoRequirements[0].id, phase: "after" })); + ok((await getCompletionBlockers(tech, w1)).length === 0, "alle Pflichtfotos vorhanden → keine Blocker"); + ok((await codeOf(createCompletionReport(tech, { workOrderId: w1 }))) === "ok", "Abschlussbericht jetzt möglich"); + + // ================= Unterschrift ================= + section("Fehlende Unterschrift: ohne Erfassung bleibt der Auftrag „Unterschrift ausstehend“"); + const w2 = await workedOrder(A, { title: "Ohne Unterschrift", signatureRequired: true }); + const r2 = await readyReport(A, w2); + await submitReport(tech, { reportId: r2 }); + ok((await status(w2)) === "signature_pending", "Abschluss ohne Unterschrift → signature_pending"); + ok((await prisma.notification.count({ where: { tenantId: A.tenantId, type: "work_order.signature_missing", entityId: w2, userId: A.users.backoffice.id } })) >= 1, "Backoffice erhält „Unterschrift fehlt“"); + const b5 = await blockersOf(() => transitionWorkOrder(tech, { workOrderId: w2, to: "in_review" })); + ok(Array.isArray(b5) && b5.some((b) => b.kind === "missing_field" && b.field === "signature"), "zur Prüfung ohne Unterschrift → blocked (signature)"); + await expectCode(() => releaseForBilling(A.ctx.backoffice, { workOrderId: w2 }), "invalid", "Abrechnung aus „Unterschrift ausstehend“ nicht möglich"); + const img2 = await storeFile(tech, { bytes: await pngBytes(), fileName: "u.png", declaredMime: "image/png", category: "signature", visibility: "customer_report", links: { workOrderId: w2 } }); + await captureSignature(tech, { reportId: r2, outcome: "signed", signerName: "Herr Nachtrag", imageDocumentId: img2.id, confirmationText: "Bestätigt" }); + ok((await status(w2)) === "in_review", "nachgereichte Unterschrift → automatisch zur Prüfung"); + await expectCode(() => captureSignature(tech, { reportId: r2, outcome: "refused", reason: "doch nicht", confirmationText: "x" }), "conflict", "erfasste Unterschrift kann nicht überschrieben werden"); + + section("Unterschrift verweigert / Kunde abwesend: nur mit Grund"); + const w3 = await workedOrder(A, { title: "Kunde verweigert", signatureRequired: true }); + const r3 = await readyReport(A, w3); + await expectCode(() => captureSignature(tech, { reportId: r3, outcome: "refused", confirmationText: "x" }), "invalid", "Verweigerung ohne Grund → invalid"); + await expectCode(() => captureSignature(tech, { reportId: r3, outcome: "customer_absent", reason: " ", confirmationText: "x" }), "invalid", "Abwesenheit mit leerem Grund → invalid"); + await expectCode(() => captureSignature(tech, { reportId: r3, outcome: "signed", signerName: "Ohne Bild", confirmationText: "x" }), "invalid", "Unterschrift ohne Bild → invalid"); + await expectCode(() => captureSignature(tech, { reportId: r3, outcome: "signed", signerName: "Fremdes Bild", imageDocumentId: img2.id, confirmationText: "x" }), "invalid", "Unterschriftsbild eines anderen Auftrags → invalid"); + await captureSignature(tech, { reportId: r3, outcome: "refused", reason: "Kunde mit Ausführung nicht einverstanden", confirmationText: "Bestätigt" }); + await submitReport(tech, { reportId: r3 }); + const rep3 = await prisma.report.findUniqueOrThrow({ where: { id: r3 } }); + ok((await status(w3)) === "in_review", "Verweigerung mit Grund → Zur Prüfung"); + ok(contentOf(rep3).signature?.outcome === "refused" && contentOf(rep3).signature?.reason === "Kunde mit Ausführung nicht einverstanden", "Grund im Berichts-Snapshot"); + ok((await prisma.notification.count({ where: { tenantId: A.tenantId, type: "work_order.signature_missing", entityId: w3 } })) >= 1, "Backoffice wird über die fehlende Unterschrift informiert"); + await expectCode(() => captureSignature(B.ctx.tech, { reportId: r3, outcome: "later", reason: "x", confirmationText: "x" }), "not_found", "Mandant B kann keine Unterschrift erfassen"); + await expectCode(() => captureSignature(A.ctx.outsider, { reportId: r3, outcome: "later", reason: "x", confirmationText: "x" }), "not_found", "Monteur ohne Zuweisung kann keine Unterschrift erfassen"); + + section("„Später“ und „nicht erforderlich“"); + const w4 = await workedOrder(A, { title: "Später unterschreiben", signatureRequired: true }); + const r4 = await readyReport(A, w4); + await expectCode(() => captureSignature(tech, { reportId: r4, outcome: "not_required", confirmationText: "x" }), "forbidden", "Monteur: „nicht erforderlich“ bei Pflicht-Unterschrift → forbidden"); + await captureSignature(tech, { reportId: r4, outcome: "later", reason: "Ansprechpartner erst morgen da", confirmationText: "Bestätigt" }); + await submitReport(tech, { reportId: r4 }); + ok((await status(w4)) === "signature_pending", "„später“ → Unterschrift ausstehend"); + await captureSignature(tech, { reportId: r4, outcome: "customer_absent", reason: "Auch am Folgetag nicht erreichbar", confirmationText: "Bestätigt" }); + ok((await status(w4)) === "in_review", "Nachtrag „Kunde abwesend“ mit Grund → Zur Prüfung"); + + const w5 = await workedOrder(A, { title: "Besichtigung ohne Unterschrift", signatureRequired: false }); + const r5 = await readyReport(A, w5); + await captureSignature(tech, { reportId: r5, outcome: "not_required", confirmationText: "Bestätigt" }); + await submitReport(tech, { reportId: r5 }); + ok((await status(w5)) === "in_review", "Auftrag ohne Unterschriftspflicht: „nicht erforderlich“ → Zur Prüfung"); +}); diff --git a/scripts/test-e2e-import-duplicates.ts b/scripts/test-e2e-import-duplicates.ts new file mode 100644 index 0000000..c61aa53 --- /dev/null +++ b/scripts/test-e2e-import-duplicates.ts @@ -0,0 +1,120 @@ +// L10a E2E §43.3 „Dublettenprüfung" beim Import (Spec §7.3, US-003) — Kandidaten werden gefunden +// (Kundennummer, Firmenname, Adresse Straße/Str., E-Mail unabhängig von Groß-/Kleinschreibung, +// Telefon unabhängig vom Format), aber NIE automatisch zugeordnet oder zusammengeführt; die +// Entscheidung (bestehend / neu) trifft das Backoffice und wird protokolliert. Manuelle Anlage +// zeigt dieselben Kandidaten. Mandant B findet nichts von A, Monteure haben keinen Zugriff. +// +// Lauf: npx tsx scripts/test-e2e-import-duplicates.ts + +import "dotenv/config"; +import { prisma } from "../src/server/db"; +import type { WorkOrderExtraction } from "../src/server/ai/providers"; +import { FakeExtractionProvider } from "../src/server/ai/extraction/fake"; +import { createImport } from "../src/server/services/imports/upload"; +import { processImport } from "../src/server/services/imports/process"; +import { confirmImport } from "../src/server/services/imports/confirm"; +import { getImportDetail } from "../src/server/services/imports/queries"; +import { extractionToForm } from "../src/lib/imports/review"; +import { createCustomer } from "../src/server/services/customers/customers"; +import { findDuplicateCustomers } from "../src/server/services/customers/duplicates"; +import { mergeCustomers } from "../src/server/services/customers/merge"; +import type { ServiceCtx } from "../src/server/services/context"; +import { buildPdf } from "./make-sample-pdfs"; +import { codeOf, createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture"; + +const SLUG_A = "zz-q-e2e-dup-a"; +const SLUG_B = "zz-q-e2e-dup-b"; + +type Candidate = { customerId: string; score: number; reasons: string[] }; + +async function importWith(ctx: ServiceCtx, extraction: Partial) { + const pdf = buildPdf([[{ text: "Auftrag" }]]); + const job = await createImport(ctx, { bytes: pdf, fileName: "auftrag.pdf", mimeType: "application/pdf" }, { dispatch: async () => undefined }); + const processed = await processImport(ctx, job.id, { provider: new FakeExtractionProvider({ extraction }), loadBytes: async () => pdf }); + return { job, candidates: (processed.duplicateCandidates ?? []) as Candidate[] }; +} + +runSuite("E2E Dublettenprüfung beim Import", [SLUG_A, SLUG_B], async () => { + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const bo = A.ctx.backoffice; + + const firm = await prisma.customer.create({ + data: { tenantId: A.tenantId, customerNumber: "K-50001", companyName: "Musterbau Haustechnik GmbH", street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg", phone: "+49 40 5551234" }, + }); + const person = await prisma.customer.create({ + data: { tenantId: A.tenantId, customerNumber: "K-50002", firstName: "Erika", lastName: "Beispiel", email: "erika@beispiel.example", street: "Lindenallee", houseNumber: "4", postalCode: "21073", city: "Hamburg" }, + }); + const foreign = await prisma.customer.create({ data: { tenantId: B.tenantId, customerNumber: "K-50001", companyName: "Musterbau Haustechnik GmbH", postalCode: "20457", phone: "040 5551234" } }); + const firmBefore = await prisma.customer.findUniqueOrThrow({ where: { id: firm.id } }); + + section("1. Firmenkunde: Nummer, Name, Adresse (Str.), Telefon in anderem Format"); + const i1 = await importWith(bo, { + customerNumber: { value: "K-50001", confidence: 0.9 }, + companyName: { value: "Musterbau Haustechnik GmbH", confidence: 0.95 }, + customerAddress: { value: { street: "Hafenstr.", houseNumber: "12", postalCode: "20457", city: "Hamburg" }, confidence: 0.9 }, + phone: { value: "040 / 555 12 34", confidence: 0.9 }, + title: { value: "Wartung Lüftung", confidence: 0.9 }, + }); + const top = i1.candidates[0]; + ok(top?.customerId === firm.id, "bestehender Firmenkunde ist bester Kandidat"); + ok(!!top && ["customer_number", "company_name", "address", "phone"].every((r) => top.reasons.includes(r)), `Gründe: ${top?.reasons.join(", ")}`); + ok(!i1.candidates.some((c) => c.customerId === foreign.id), "Kandidaten nie aus Mandant B"); + ok((await prisma.workOrder.count({ where: { tenantId: A.tenantId } })) === 0, "keine automatische Zuordnung, kein Auftrag ohne Bestätigung"); + ok((await prisma.customer.findUniqueOrThrow({ where: { id: firm.id } })).updatedAt.getTime() === firmBefore.updatedAt.getTime(), "bestehender Kunde unverändert"); + + section("2. Privatkunde: E-Mail in anderer Schreibweise"); + const i2 = await importWith(bo, { + customerFirstName: { value: "Erika", confidence: 0.9 }, + customerLastName: { value: "Beispiel", confidence: 0.9 }, + email: { value: "ERIKA@Beispiel.example", confidence: 0.9 }, + title: { value: "Reparatur Mischbatterie", confidence: 0.9 }, + }); + ok(i2.candidates.some((c) => c.customerId === person.id && c.reasons.includes("email")), "Privatkunde über E-Mail gefunden"); + + section("3. Neukunde ohne Überschneidung"); + const i3 = await importWith(bo, { + companyName: { value: "Kieler Förde Wohnbau AG", confidence: 0.9 }, + customerAddress: { value: { street: "Holtenauer Straße", houseNumber: "200", postalCode: "24105", city: "Kiel" }, confidence: 0.9 }, + title: { value: "Neubau Heizzentrale", confidence: 0.9 }, + }); + ok(i3.candidates.length === 0, "kein Kandidat bei Neukunde"); + + section("4. Entscheidung des Backoffice"); + const countBefore = await prisma.customer.count({ where: { tenantId: A.tenantId } }); + const d2 = await getImportDetail(bo, i2.job.id); + const c2 = await confirmImport(bo, i2.job.id, extractionToForm(d2.extraction.fields, { customerId: person.id })); + ok(c2.customerId === person.id && (await prisma.customer.count({ where: { tenantId: A.tenantId } })) === countBefore, "Kandidat übernommen → kein neuer Kunde"); + const d1 = await getImportDetail(bo, i1.job.id); + const f1 = extractionToForm(d1.extraction.fields); + ok(f1.customerMode === "new", "Formular schlägt ohne Entscheidung keinen bestehenden Kunden automatisch vor"); + await expectCode(() => confirmImport(bo, i1.job.id, f1), "conflict", "„neu anlegen“ mit vergebener Kundennummer → conflict, keine Teilanlage"); + f1.customer!.customerNumber = ""; + const c1 = await confirmImport(bo, i1.job.id, f1); + const newFirm = await prisma.customer.findUniqueOrThrow({ where: { id: c1.customerId } }); + ok(newFirm.id !== firm.id && /^K-\d+$/.test(newFirm.customerNumber ?? ""), `bewusst neu angelegt mit eigener Nummer (${newFirm.customerNumber})`); + ok((await prisma.customer.count({ where: { tenantId: A.tenantId, status: "merged" } })) === 0, "keine automatische Zusammenführung"); + const job1 = await prisma.importJob.findUniqueOrThrow({ where: { id: i1.job.id } }); + ok((job1.corrections as { decisions?: { customerMode?: string } }).decisions?.customerMode === "new", "Entscheidung „neu“ am Import protokolliert"); + const d3 = await getImportDetail(bo, i3.job.id); + const c3 = await confirmImport(bo, i3.job.id, extractionToForm(d3.extraction.fields)); + ok((await prisma.customer.findUniqueOrThrow({ where: { id: c3.customerId } })).companyName === "Kieler Förde Wohnbau AG", "Neukunde angelegt"); + + section("5. Manuelle Anlage und Zusammenführung nur mit Bestätigung"); + const dupErr = await codeOf(createCustomer(bo, { companyName: "Musterbau Haustechnik GmbH", phone: "040-5551234" })); + ok(dupErr === "conflict", "manuelle Anlage → „Mögliche Dublette“ (conflict)"); + const manual = await createCustomer(bo, { companyName: "Musterbau Haustechnik GmbH", phone: "040-5551234" }, { acknowledgeDuplicates: true }); + ok(manual.status === "active", "„Trotzdem neu anlegen“ möglich"); + await expectCode(() => mergeCustomers(bo, { sourceId: manual.id, targetId: firm.id, confirm: false } as never), "invalid", "Zusammenführen ohne Bestätigung → invalid"); + await expectCode(() => mergeCustomers(A.ctx.tech, { sourceId: manual.id, targetId: firm.id, confirm: true }), "forbidden", "Monteur darf nicht zusammenführen"); + await expectCode(() => mergeCustomers(B.ctx.backoffice, { sourceId: foreign.id, targetId: firm.id, confirm: true }), "not_found", "Mandant B kann nicht mit Kunden von A zusammenführen"); + const merged = await mergeCustomers(bo, { sourceId: manual.id, targetId: firm.id, confirm: true }); + ok(!!merged && (await prisma.customer.findUniqueOrThrow({ where: { id: manual.id } })).mergedIntoId === firm.id, "mit Bestätigung zusammengeführt"); + + section("6. Mandantentrennung und Rollen"); + const fromB = await findDuplicateCustomers(B.ctx.backoffice, { customerNumber: "K-50001", companyName: "Musterbau Haustechnik GmbH", email: "erika@beispiel.example" }); + ok(fromB.every((c) => c.customerId === foreign.id), "Dublettenprüfung in Mandant B findet nur B-Kunden"); + ok((await findDuplicateCustomers(A.ctx.outsider, { customerNumber: "K-50001" })).length === 0, "Monteur ohne Aufträge findet keine Kunden"); + await expectCode(() => getImportDetail(A.ctx.tech, i3.job.id), "forbidden", "Monteur: Prüfmaske → forbidden"); + await expectCode(() => confirmImport(B.ctx.backoffice, i3.job.id, extractionToForm(d3.extraction.fields)), "not_found", "Mandant B: Import von A bestätigen → not_found"); +}); diff --git a/scripts/test-e2e-multiday-order.ts b/scripts/test-e2e-multiday-order.ts new file mode 100644 index 0000000..ddf88c1 --- /dev/null +++ b/scripts/test-e2e-multiday-order.ts @@ -0,0 +1,145 @@ +// L10a E2E §43.3 „Mehrtägiger Auftrag mit Tagesbericht" — zwei Einsatztage, zwei Tagesberichte, Abschluss. +// +// Tag 1 (vorgestern) und Tag 2 (gestern) werden mit echten Zeitstempeln erfasst (Session-Segmente, +// Fotos mit Aufnahmezeit; Notizen/Material werden als „am Einsatztag erfasst" zurückdatiert, weil +// die Services den Erfassungszeitpunkt selbst setzen). Nachweise: Tagesbericht enthält nur die Daten +// seines Tages, Auftrag bleibt offen (daily_report_created → in_progress am nächsten Tag), ein +// Tagesbericht je Tag, Abschlussbericht fasst beide Tage zusammen, Freigabe + Abrechnung, +// Mandantentrennung und Scope. +// +// Lauf: npx tsx scripts/test-e2e-multiday-order.ts + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; +import { assignWorkOrder } from "../src/server/services/work-orders/assign"; +import { releaseForBilling } from "../src/server/services/work-orders/release-billing"; +import { endSession, startSession } from "../src/server/services/field/sessions"; +import { createNote } from "../src/server/services/field/notes"; +import { upsertMaterialUsage } from "../src/server/services/field/materials"; +import { toggleChecklistItem } from "../src/server/services/field/checklist"; +import { storeFieldUpload } from "../src/server/services/field/uploads"; +import { attachPhoto } from "../src/server/services/field/photos"; +import { storeFile } from "../src/server/services/documents/store"; +import { createCompletionReport, createDailyReport } from "../src/server/services/reports/create"; +import { updateReportTexts } from "../src/server/services/reports/edit"; +import { submitReport } from "../src/server/services/reports/submit"; +import { approveReport } from "../src/server/services/reports/approve"; +import { captureSignature } from "../src/server/services/reports/signature"; +import { contentOf } from "../src/server/services/reports/common"; +import { listReports } from "../src/server/services/reports/queries"; +import { getSiteHistory } from "../src/server/services/sites/history"; +import type { ServiceCtx } from "../src/server/services/context"; +import { checklistTogglePayload, materialUpsertPayload, noteCreatePayload, photoAttachPayload, sessionControlPayload, sessionStartPayload } from "../src/lib/sync/ops"; +import { berlinAt, createTenant, dayKey, expectCode, jpegBytes, ok, pngBytes, runSuite, section } from "./lib/e2e-fixture"; + +const SLUG_A = "zz-q-e2e-multi-a"; +const SLUG_B = "zz-q-e2e-multi-b"; +const noPdf = { dispatchPdf: async () => undefined }; + +runSuite("E2E mehrtägiger Auftrag", [SLUG_A, SLUG_B], async () => { + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const bo = A.ctx.backoffice; + const DAY1 = dayKey(-2); + const DAY2 = dayKey(-1); + const jpeg = await jpegBytes("Baustelle"); + + const wo = await createWorkOrder(bo, { + title: "Heizungsverteilung erneuern (2 Tage)", + customerId: A.customerId, + siteId: A.siteId, + plannedStart: berlinAt(-2, 7), + plannedEnd: berlinAt(-1, 16), + signatureRequired: true, + applyTemplate: false, + checklistItems: [{ label: "Druckprobe durchgeführt", required: true }], + materials: [{ name: "Kupferrohr 22 mm", articleNumber: "CU-22", plannedQuantity: 20, unit: "m" }], + }); + await assignWorkOrder(bo, { workOrderId: wo.id, teamId: A.teamId, userIds: [A.users.tech.id, A.users.tech2.id] }); + const plan = await prisma.materialPlan.findFirstOrThrow({ where: { workOrderId: wo.id } }); + + /** Note/material recorded on a past field day (the services stamp "now"; the device was on site then). */ + async function noteOn(ctx: ServiceCtx, when: Date, text: string) { + const { noteId } = await createNote(ctx, noteCreatePayload.parse({ workOrderId: wo.id, kind: "work_done", text })); + await prisma.activityNote.update({ where: { id: noteId }, data: { createdAt: when } }); + } + async function photoOn(ctx: ServiceCtx, when: Date, comment: string) { + const up = await storeFieldUpload(ctx, { clientId: randomUUID(), workOrderId: wo.id, kind: "photo" }, { bytes: jpeg, name: "foto.jpg", type: "image/jpeg" }); + await attachPhoto(ctx, photoAttachPayload.parse({ workOrderId: wo.id, documentId: up.documentId, phase: "during", comment, takenAt: when.toISOString() })); + } + + // ---------- Tag 1 ---------- + section(`Tag 1 (${DAY1}): Einsatz und Tagesbericht`); + const tech = A.ctx.tech; + await startSession(tech, sessionStartPayload.parse({ workOrderId: wo.id, mode: "work", at: berlinAt(-2, 7).toISOString() })); + await noteOn(tech, berlinAt(-2, 11), "Tag 1: alte Verteilung demontiert"); + const u1 = await upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: wo.id, materialPlanId: plan.id, quantity: 8, unit: "m", usageStatus: "partially_used", deviationReason: "Rest folgt an Tag 2" })); + await prisma.materialUsage.update({ where: { id: u1.usageId }, data: { createdAt: berlinAt(-2, 12) } }); + await photoOn(tech, berlinAt(-2, 13), "Tag 1 Konsolen"); + await endSession(tech, sessionControlPayload.parse({ workOrderId: wo.id, at: berlinAt(-2, 15).toISOString() })); + + await expectCode(() => createDailyReport(B.ctx.tech, { workOrderId: wo.id, reportDate: DAY1 }), "not_found", "Mandant B: Tagesbericht am Auftrag von A → not_found"); + await expectCode(() => createDailyReport(A.ctx.outsider, { workOrderId: wo.id, reportDate: DAY1 }), "not_found", "Monteur ohne Zuweisung: Tagesbericht → not_found"); + const d1 = (await createDailyReport(tech, { workOrderId: wo.id, reportDate: DAY1 })).report; + const c1 = contentOf(d1); + ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } })).status === "daily_report_created", "Auftrag → Tagesbericht erstellt (bleibt offen)"); + ok(c1.time.totalMinutes === 480, `Tagesbericht 1: nur Arbeitszeit von Tag 1 (${c1.time.totalMinutes} min)`); + ok(c1.photos.length === 1 && c1.texts.workPerformed.includes("Tag 1") && !c1.texts.workPerformed.includes("Tag 2"), "Tagesbericht 1: Fotos und Tätigkeiten nur von Tag 1"); + ok(c1.materials.used.length === 1 && c1.materials.used[0].deviationReason === "Rest folgt an Tag 2", "Tagesbericht 1: Material von Tag 1 mit Abweichungsgrund"); + await submitReport(tech, { reportId: d1.id }); + await approveReport(A.ctx.lead, { reportId: d1.id }, noPdf); + const d1Approved = await approveReport(bo, { reportId: d1.id }, noPdf); + ok(d1Approved.status === "approved", "Tagesbericht 1 eingereicht, vom Teamleiter geprüft und vom Backoffice freigegeben"); + await expectCode(() => createDailyReport(tech, { workOrderId: wo.id, reportDate: DAY1 }), "conflict", "zweiter Tagesbericht für denselben Tag → conflict"); + + // ---------- Tag 2 ---------- + section(`Tag 2 (${DAY2}): zweiter Monteur, zweiter Tagesbericht`); + const tech2 = A.ctx.tech2; + const s2 = await startSession(tech2, sessionStartPayload.parse({ workOrderId: wo.id, mode: "work", at: berlinAt(-1, 7).toISOString() })); + ok(s2.workOrderStatus === "in_progress", "Arbeitsbeginn am Folgetag → Auftrag wieder in Arbeit"); + await noteOn(tech2, berlinAt(-1, 10), "Tag 2: Heizkreise angeschlossen, Druckprobe bestanden"); + const u2 = await upsertMaterialUsage(tech2, materialUpsertPayload.parse({ workOrderId: wo.id, clientId: randomUUID(), name: "Kugelhahn 22 mm", quantity: 4, unit: "Stk", usageStatus: "additional", deviationReason: "Absperrung je Heizkreis" })); + await prisma.materialUsage.update({ where: { id: u2.usageId }, data: { createdAt: berlinAt(-1, 11) } }); + await photoOn(tech2, berlinAt(-1, 12), "Tag 2 fertig"); + const item = await prisma.checklistItem.findFirstOrThrow({ where: { workOrderId: wo.id } }); + await toggleChecklistItem(tech2, checklistTogglePayload.parse({ workOrderId: wo.id, itemId: item.id, checked: true })); + await endSession(tech2, sessionControlPayload.parse({ workOrderId: wo.id, at: berlinAt(-1, 14).toISOString() })); + const d2 = (await createDailyReport(tech2, { workOrderId: wo.id, reportDate: DAY2 })).report; + const c2 = contentOf(d2); + ok(c2.time.totalMinutes === 420 && c2.photos.length === 1, `Tagesbericht 2: nur Zeiten/Fotos von Tag 2 (${c2.time.totalMinutes} min)`); + ok(c2.texts.workPerformed.includes("Tag 2") && !c2.texts.workPerformed.includes("Tag 1"), "Tagesbericht 2: nur Tätigkeiten von Tag 2"); + ok(c2.materials.additional.length === 1 && c2.materials.used.length === 0, "Tagesbericht 2: nur Material von Tag 2"); + ok(c2.reportNumber !== c1.reportNumber && d2.lineageId !== d1.lineageId, `eigene Berichtsnummer je Tag (${c1.reportNumber}, ${c2.reportNumber})`); + await submitReport(tech2, { reportId: d2.id }); + + // ---------- Abschluss ---------- + section("Abschlussbericht über beide Tage, Freigabe, Abrechnung"); + const completion = (await createCompletionReport(tech2, { workOrderId: wo.id, reportDate: DAY2 })).report; + const cc = contentOf(completion); + ok(cc.workDates.join(",") === `${DAY1},${DAY2}` && cc.time.totalMinutes === 900, `Abschlussbericht: beide Einsatztage, Gesamtzeit ${cc.time.totalMinutes} min`); + ok(cc.photos.length === 2 && cc.materials.used.length === 1 && cc.materials.additional.length === 1, "Abschlussbericht: alle Fotos und Materialien"); + await updateReportTexts(tech2, { reportId: completion.id, texts: { workPerformed: "Heizungsverteilung in zwei Tagen erneuert, Druckprobe bestanden." } }); + const img = await storeFile(tech2, { bytes: await pngBytes(), fileName: "unterschrift.png", declaredMime: "image/png", category: "signature", visibility: "customer_report", links: { workOrderId: wo.id } }); + await captureSignature(tech2, { reportId: completion.id, outcome: "signed", signerName: "Frau Prüf", imageDocumentId: img.id, confirmationText: "Bestätigt." }); + await submitReport(tech2, { reportId: completion.id }); + ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } })).status === "in_review", "Auftrag nach Abschluss „Zur Prüfung“"); + for (const r of [d2.id, completion.id]) { + await approveReport(A.ctx.lead, { reportId: r }, noPdf); + await approveReport(bo, { reportId: r }, noPdf); + } + const released = await releaseForBilling(bo, { workOrderId: wo.id }); + ok(released.status === "released_for_billing", "zur Abrechnung freigegeben"); + + const reports = await listReports(bo, { status: "all" }); + const mine = reports.items.filter((r) => r.workOrder.id === wo.id); + ok(mine.filter((r) => r.type === "daily").length === 2 && mine.filter((r) => r.type === "completion").length === 1, "Berichtsliste: 2 Tagesberichte + 1 Abschlussbericht"); + ok((await listReports(B.ctx.backoffice, { status: "all" })).items.every((r) => r.workOrder.id !== wo.id), "Mandant B sieht keine Berichte von A"); + ok((await listReports(A.ctx.outsider, { status: "all" })).items.every((r) => r.workOrder.id !== wo.id), "Monteur ohne Zuweisung sieht keine Berichte des Auftrags"); + + const hist = await getSiteHistory(bo, A.siteId); + const entry = hist.items.find((i) => i.workOrderId === wo.id); + ok(!!entry && entry.approvedReports.length === 3 && entry.workDone.length === 2, "Objekt-Historie: 3 freigegebene Berichte, Tätigkeiten beider Tage"); + ok(!!entry && entry.date.getTime() === berlinAt(-2, 7).getTime(), "Objekt-Historie: Einsatzbeginn = erster Einsatztag"); +}); diff --git a/scripts/test-e2e-offline-sync.ts b/scripts/test-e2e-offline-sync.ts new file mode 100644 index 0000000..de9e7a2 --- /dev/null +++ b/scripts/test-e2e-offline-sync.ts @@ -0,0 +1,153 @@ +// L10a E2E §43.3 „Offline-Erfassung und Synchronisation" — derselbe Pfad wie POST /api/v1/sync: +// ein offline gesammelter Batch (12 Ops, 2 Tage alt) → alle applied; Wiederholung → duplicate; +// Pflichtfoto fehlt → blocked; veraltete Statusänderung → conflict (nichts überschrieben, Event +// sync.failed, Backoffice-Konfliktliste) → Backoffice übernimmt bzw. verwirft; ungültige/nicht +// verfügbare Ops; fremde clientOpId; Mandantentrennung und Scope. +// +// Lauf: npx tsx scripts/test-e2e-offline-sync.ts + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import type { SyncOperationInput } from "../src/lib/sync/envelope"; +import { applyOperations } from "../src/server/services/sync/apply"; +import { storeFieldUpload } from "../src/server/services/field/uploads"; +import { getFieldBundle } from "../src/server/services/field/queries"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; +import { assignWorkOrder } from "../src/server/services/work-orders/assign"; +import { updateWorkOrder } from "../src/server/services/work-orders/update"; +import { applySyncConflict, discardSyncConflict, listSyncConflicts } from "../src/server/services/work-orders/conflicts"; +import { createTenant, expectCode, jpegBytes, ok, runSuite, section } from "./lib/e2e-fixture"; + +const SLUG_A = "zz-q-e2e-sync-a"; +const SLUG_B = "zz-q-e2e-sync-b"; + +function op(opType: SyncOperationInput["opType"], payload: Record, extra: Partial = {}, at = new Date()): SyncOperationInput { + return { clientOpId: randomUUID(), opType, payload, clientCreatedAt: at.toISOString(), ...extra }; +} + +runSuite("E2E Offline-Sync", [SLUG_A, SLUG_B], async () => { + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const bo = A.ctx.backoffice; + const tech = A.ctx.tech; + + const created = await createWorkOrder(bo, { + title: "Offline-Einsatz Tiefgarage", + customerId: A.customerId, + siteId: A.siteId, + plannedStart: new Date(), + applyTemplate: false, + checklistItems: [{ label: "Absperrung gesetzt", required: true }], + photoRequirements: [{ key: "fertige_montage", label: "Fertige Montage" }], + materials: [{ name: "Brandschutzmanschette", plannedQuantity: 4, unit: "Stk" }], + }); + await assignWorkOrder(bo, { workOrderId: created.id, teamId: A.teamId, userIds: [A.users.tech.id] }); + + section("1. Vorab-Download und Offline-Erfassung"); + const bundle = await getFieldBundle(tech); + const offline = bundle.orders.find((o) => o.id === created.id)!; + ok(!!offline, "Auftrag im Offline-Bundle des Monteurs"); + const photoDoc = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: created.id, kind: "photo" }, { bytes: await jpegBytes("offline"), name: "offline.jpg", type: "image/jpeg" }); + const t0 = Date.now() - 2 * 86400_000; + const at = (min: number) => new Date(t0 + min * 60_000); + const ids = { travel: randomUUID(), note1: randomUUID(), note2: randomUUID(), extra: randomUUID(), photo: randomUUID() }; + const w = created.id; + const batch: SyncOperationInput[] = [ + op("work_order.transition", { workOrderId: w, to: "accepted" }, { baseVersion: offline.version }, at(0)), + op("session.start", { workOrderId: w, mode: "travel", clientId: ids.travel, offline: true, at: at(1).toISOString() }, {}, at(1)), + op("session.start", { workOrderId: w, mode: "work", at: at(30).toISOString() }, {}, at(30)), + op("note.create", { workOrderId: w, clientId: ids.note1, kind: "work_done", text: "Manschetten gesetzt (offline)" }, {}, at(40)), + op("checklist.toggle", { workOrderId: w, itemId: offline.checklistItems[0].id, checked: true }, {}, at(41)), + op("material.upsert", { workOrderId: w, materialPlanId: offline.materialPlans[0].id, quantity: 4, unit: "Stk", usageStatus: "fully_used" }, {}, at(42)), + op("material.upsert", { workOrderId: w, clientId: ids.extra, name: "Brandschutzkitt", quantity: 1, unit: "Stk", usageStatus: "additional", deviationReason: "Fuge zu breit" }, {}, at(43)), + op("photo.attach", { workOrderId: w, clientId: ids.photo, documentId: photoDoc.documentId, phase: "during" }, {}, at(44)), + op("session.pause", { workOrderId: w, at: at(60).toISOString() }, {}, at(60)), + op("session.resume", { workOrderId: w, at: at(75).toISOString() }, {}, at(75)), + op("note.create", { workOrderId: w, clientId: ids.note2, kind: "general", text: "Keine Auffälligkeiten" }, {}, at(80)), + op("session.end", { workOrderId: w, at: at(120).toISOString() }, {}, at(120)), + ]; + ok((await prisma.activityNote.count({ where: { workOrderId: w } })) === 0, "offline: noch nichts auf dem Server"); + + section("2. Verbindung da: ein Batch"); + const res = await applyOperations(tech, { deviceId: "e2e-device", operations: batch }); + const statuses = res.results.map((x) => x.status); + ok(statuses.every((s) => s === "applied"), `alle 12 Ops applied (${statuses.join(",")})`); + if (!statuses.every((s) => s === "applied")) console.log(res.results.filter((x) => x.status !== "applied")); + ok(!!res.results[1].idMap?.[ids.travel] && !!res.results[3].idMap?.[ids.note1] && !!res.results[7].idMap?.[ids.photo], "idMap bildet Client-IDs auf Server-IDs ab"); + ok(typeof res.results[0].entityVersion === "number" && res.results[0].entityVersion! > offline.version, "Statusänderung liefert neue Auftragsversion"); + const wo1 = await prisma.workOrder.findUniqueOrThrow({ where: { id: w } }); + ok(wo1.status === "in_progress", `Auftragsstatus nach Sync: in Arbeit (${wo1.status})`); + ok((await prisma.activityNote.count({ where: { workOrderId: w } })) === 2 && (await prisma.materialUsage.count({ where: { workOrderId: w } })) === 2 && (await prisma.photo.count({ where: { workOrderId: w } })) === 1, "Notizen, Material und Foto angelegt"); + const session = await prisma.workSession.findFirstOrThrow({ where: { workOrderId: w }, include: { entries: { orderBy: { startedAt: "asc" } } } }); + ok(session.status === "ended" && session.startedOffline && session.entries.map((e) => e.type).join(",") === "travel,work,break,work", "Session offline gestartet, Segmente Anfahrt/Arbeit/Pause/Arbeit"); + ok(session.startedAt.getTime() === at(1).getTime() && session.endedAt?.getTime() === at(120).getTime(), "Zeitstempel des Geräts übernommen (2 Tage alt)"); + const stored = await prisma.syncOperation.findMany({ where: { tenantId: A.tenantId, clientOpId: { in: batch.map((b) => b.clientOpId) } } }); + ok(stored.length === 12 && stored.every((s) => s.status === "applied" && s.userId === A.users.tech.id), "12 SyncOperations protokolliert"); + + section("3. Wiederholung (Verbindungsabbruch nach dem Senden)"); + const replay = await applyOperations(tech, { deviceId: "e2e-device", operations: batch }); + ok(replay.results.every((x) => x.status === "duplicate"), "gleicher Batch erneut → 12× duplicate"); + ok(replay.results[3].idMap?.[ids.note1] === res.results[3].idMap?.[ids.note1], "duplicate liefert gespeicherte idMap"); + ok((await prisma.activityNote.count({ where: { workOrderId: w } })) === 2 && (await prisma.materialUsage.count({ where: { workOrderId: w } })) === 2, "keine Doppelanlagen"); + const stolen = await applyOperations(A.ctx.tech2, { deviceId: "other", operations: [batch[3]] }); + ok(stolen.results[0].status === "rejected" && stolen.results[0].errorCode === "invalid", "fremde clientOpId eines anderen Nutzers → rejected"); + + section("4. Pflichtfoto fehlt → Abschluss offline abgelehnt"); + const blocked = await applyOperations(tech, { deviceId: "e2e-device", operations: [op("work_order.transition", { workOrderId: w, to: "technically_completed" }, { baseVersion: wo1.version })] }); + ok(blocked.results[0].status === "rejected" && blocked.results[0].errorCode === "blocked" && /photo_requirement/.test(blocked.results[0].message ?? ""), "technisch abschließen ohne Pflichtfoto → rejected blocked (Blocker-Liste)"); + ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: w } })).status === "in_progress", "Status unverändert"); + + section("5. Konflikt: Büro ändert den Auftrag während der Monteur offline ist"); + const stale = (await prisma.workOrder.findUniqueOrThrow({ where: { id: w } })).version; + await updateWorkOrder(bo, w, { technicianNotes: "Bitte zusätzlich Revisionsklappe prüfen" }); + const conflictBatch = [ + op("work_order.transition", { workOrderId: w, to: "waiting_material" }, { baseVersion: stale }), + op("note.create", { workOrderId: w, clientId: randomUUID(), kind: "problem", text: "Material fehlt" }), + ]; + const conflict = await applyOperations(tech, { deviceId: "e2e-device", operations: conflictBatch }); + ok(conflict.results[0].status === "conflict" && conflict.results[0].errorCode === "conflict", "veraltete Statusänderung → conflict"); + ok(conflict.results[1].status === "applied", "unabhängige Notiz trotzdem applied"); + const wo2 = await prisma.workOrder.findUniqueOrThrow({ where: { id: w } }); + ok(wo2.status === "in_progress" && wo2.technicianNotes === "Bitte zusätzlich Revisionsklappe prüfen", "nichts überschrieben (Status + Büroänderung erhalten)"); + const conflictOp = await prisma.syncOperation.findFirstOrThrow({ where: { tenantId: A.tenantId, clientOpId: conflictBatch[0].clientOpId } }); + ok(conflictOp.status === "conflict" && conflictOp.baseVersion === stale, "Konflikt mit Basisversion gespeichert"); + ok((await prisma.notification.count({ where: { tenantId: A.tenantId, type: "sync.failed", userId: A.users.tech.id } })) >= 1, "Monteur wird über den Konflikt informiert"); + ok((await prisma.notification.count({ where: { tenantId: A.tenantId, type: "sync.failed", userId: A.users.backoffice.id } })) >= 1, "Backoffice wird über den Konflikt informiert"); + const list = await listSyncConflicts(bo); + ok(list.some((c) => c.id === conflictOp.id && c.workOrder?.id === w && c.userName === `tech ${SLUG_A}`), "Konflikt in der Backoffice-Liste (Auftrag, Gerätenutzer)"); + await expectCode(() => listSyncConflicts(tech), "forbidden", "Monteur: Konfliktliste → forbidden"); + + section("6. Backoffice löst Konflikte"); + await expectCode(() => applySyncConflict(B.ctx.backoffice, conflictOp.id), "not_found", "Mandant B kann den Konflikt nicht übernehmen"); + await expectCode(() => discardSyncConflict(B.ctx.backoffice, conflictOp.id), "not_found", "Mandant B kann den Konflikt nicht verwerfen"); + ok(!(await listSyncConflicts(B.ctx.backoffice)).some((c) => c.id === conflictOp.id), "Mandant B sieht den Konflikt nicht"); + await applySyncConflict(bo, conflictOp.id); + const wo3 = await prisma.workOrder.findUniqueOrThrow({ where: { id: w } }); + const resolved = await prisma.syncOperation.findUniqueOrThrow({ where: { id: conflictOp.id } }); + ok(wo3.status === "waiting_material" && resolved.status === "applied" && resolved.resolvedById === A.users.backoffice.id, "Übernehmen: Änderung als Gerätenutzer angewendet, Konflikt erledigt"); + const second = await applyOperations(tech, { deviceId: "e2e-device", operations: [op("work_order.transition", { workOrderId: w, to: "in_progress" }, { baseVersion: stale })] }); + const secondOp = await prisma.syncOperation.findFirstOrThrow({ where: { tenantId: A.tenantId, clientOpId: second.results[0].clientOpId } }); + ok(second.results[0].status === "conflict", "zweiter veralteter Stand → conflict"); + await discardSyncConflict(bo, secondOp.id); + ok((await prisma.syncOperation.findUniqueOrThrow({ where: { id: secondOp.id } })).errorCode === "discarded" && (await prisma.workOrder.findUniqueOrThrow({ where: { id: w } })).status === "waiting_material", "Verwerfen: nichts angewendet, als verworfen markiert"); + await expectCode(() => discardSyncConflict(bo, secondOp.id), "not_found", "erledigter Konflikt kann nicht erneut bearbeitet werden"); + ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "sync_operation", entityId: { in: [conflictOp.id, secondOp.id] } } })) >= 4, "Konflikte und Lösungen auditiert"); + + section("7. Ungültige Ops, Scope, Mandantentrennung"); + const invalid = await applyOperations(tech, { deviceId: "e2e-device", operations: [op("note.create", { workOrderId: w, text: "" })] }); + ok(invalid.results[0].status === "rejected" && invalid.results[0].errorCode === "invalid", "ungültige Payload → rejected invalid"); + // signature.capture is not registered in services/sync/external-ops.ts (reports lane uses server actions) + const unavailable = op("signature.capture", { reportId: "x" }); + const na = await applyOperations(tech, { deviceId: "e2e-device", operations: [unavailable] }); + ok(na.results[0].status === "rejected" && (await prisma.syncOperation.count({ where: { clientOpId: unavailable.clientOpId } })) === 0, "nicht verfügbare Op → rejected, nicht gespeichert (später wiederholbar)"); + const notesBefore = await prisma.activityNote.count({ where: { workOrderId: w } }); + const outsider = await applyOperations(A.ctx.outsider, { deviceId: "o", operations: [op("note.create", { workOrderId: w, text: "fremd" }), op("session.start", { workOrderId: w, mode: "work" })] }); + ok(outsider.results.every((x) => x.status === "rejected" && x.errorCode === "not_found"), "Monteur ohne Zuweisung → not_found"); + const foreign = await applyOperations(B.ctx.tech, { deviceId: "b", operations: [op("note.create", { workOrderId: w, text: "Mandant B" }), op("work_order.transition", { workOrderId: w, to: "cancelled", reason: "x" }, { baseVersion: wo3.version })] }); + ok(foreign.results.every((x) => x.status === "rejected" && x.errorCode === "not_found"), "Mandant B → not_found (kein Versions-Leak)"); + ok(foreign.results.every((x) => x.entityVersion === undefined), "Mandant B erhält keine Versionsnummer"); + await expectCode(() => storeFieldUpload(B.ctx.tech, { clientId: randomUUID(), workOrderId: w, kind: "photo" }, { bytes: Buffer.from("xx"), name: "b.jpg", type: "image/jpeg" }), "not_found", "Mandant B kann keine Datei an den Auftrag hochladen"); + ok((await prisma.activityNote.count({ where: { workOrderId: w } })) === notesBefore && (await prisma.workOrder.findUniqueOrThrow({ where: { id: w } })).status === "waiting_material", "Auftrag von A unverändert"); + ok(!(await getFieldBundle(B.ctx.tech)).orders.some((o) => o.id === w), "Mandant B erhält den Auftrag nicht im Bundle"); +}); diff --git a/scripts/test-e2e-regular-order.ts b/scripts/test-e2e-regular-order.ts new file mode 100644 index 0000000..3e2248c --- /dev/null +++ b/scripts/test-e2e-regular-order.ts @@ -0,0 +1,240 @@ +// L10a E2E §43.3 / §38 „Regulärer Auftrag vom Import bis zur Freigabe" — Service-Ebene gegen die DB. +// +// Import (Fake-Provider) → Dublettenkandidat + Objektkandidat → Prüfung/Bestätigung → Zuweisung → +// Vorab-Download (Bundle) → Annehmen/Losfahren/Arbeit → Material/Fotos/Zeiten/Notizen/Checkliste → +// Abschlussbericht → Unterschrift → Teamleiter- und Backoffice-Freigabe → PDF (skip-fähig ohne +// Chromium) → Abrechnung → abgerechnet → Objekt-Historie. Dazu Mandantentrennung (Mandant B) und +// Rollen/Scope (Monteur ohne Zuweisung) an jedem Schritt. +// +// Lauf: npx tsx scripts/test-e2e-regular-order.ts (auch mit RLS_ENFORCED=true lauffähig) + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import type { WorkOrderExtraction } from "../src/server/ai/providers"; +import { FakeExtractionProvider } from "../src/server/ai/extraction/fake"; +import { createImport } from "../src/server/services/imports/upload"; +import { processImport } from "../src/server/services/imports/process"; +import { confirmImport } from "../src/server/services/imports/confirm"; +import { getImportDetail } from "../src/server/services/imports/queries"; +import { extractionToForm } from "../src/lib/imports/review"; +import { assignWorkOrder } from "../src/server/services/work-orders/assign"; +import { addChecklistItem, addPhotoRequirement } from "../src/server/services/work-orders/checklist"; +import { transitionWorkOrder } from "../src/server/services/work-orders/transition"; +import { markBilled, releaseForBilling } from "../src/server/services/work-orders/release-billing"; +import { getWorkOrderDetail } from "../src/server/services/work-orders/detail"; +import { getFieldBundle, getFieldOrderDetail } from "../src/server/services/field/queries"; +import { endSession, pauseSession, resumeSession, startSession } from "../src/server/services/field/sessions"; +import { createNote } from "../src/server/services/field/notes"; +import { upsertMaterialUsage } from "../src/server/services/field/materials"; +import { toggleChecklistItem } from "../src/server/services/field/checklist"; +import { storeFieldUpload } from "../src/server/services/field/uploads"; +import { attachPhoto } from "../src/server/services/field/photos"; +import { storeFile } from "../src/server/services/documents/store"; +import { authorizeDocumentAccess } from "../src/server/services/documents/access"; +import { createCompletionReport } from "../src/server/services/reports/create"; +import { updateReportTexts } from "../src/server/services/reports/edit"; +import { captureSignature } from "../src/server/services/reports/signature"; +import { submitReport } from "../src/server/services/reports/submit"; +import { approveReport } from "../src/server/services/reports/approve"; +import { generateReportPdf } from "../src/server/services/reports/pdf"; +import { openReportFile } from "../src/server/services/reports/files"; +import { contentOf, requireVisibleReport } from "../src/server/services/reports/common"; +import { pdfRendererAvailable } from "../src/server/pdf/render"; +import { readStoredBytes } from "../src/server/services/documents/read"; +import { getSiteHistory } from "../src/server/services/sites/history"; +import { + checklistTogglePayload, + materialUpsertPayload, + noteCreatePayload, + photoAttachPayload, + sessionControlPayload, + sessionStartPayload, +} from "../src/lib/sync/ops"; +import { buildPdf } from "./make-sample-pdfs"; +import { codeOf, createTenant, expectCode, jpegBytes, ok, pngBytes, runSuite, section } from "./lib/e2e-fixture"; + +const SLUG_A = "zz-q-e2e-reg-a"; +const SLUG_B = "zz-q-e2e-reg-b"; + +runSuite("E2E regulärer Auftrag", [SLUG_A, SLUG_B], async () => { + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const bo = A.ctx.backoffice; + const tech = A.ctx.tech; + + // ---------- 1. Import ---------- + section("1. Import einer Auftragsbestätigung (Fake-Provider)"); + const pdf = buildPdf([[{ text: "Auftragsbestätigung AB-2026-4711" }, { text: `Hausverwaltung ${SLUG_A} GmbH, Hafenstraße 12, 20457 Hamburg` }]]); + const extraction: Partial = { + orderNumber: { value: "AB-2026-4711", confidence: 0.97 }, + customerNumber: { value: `K-${SLUG_A}`, confidence: 0.95 }, + companyName: { value: `Hausverwaltung ${SLUG_A} GmbH`, confidence: 0.95 }, + customerAddress: { value: { street: "Hafenstrasse", houseNumber: "12", postalCode: "20457", city: "Hamburg", country: "DE" }, confidence: 0.9 }, + siteName: { value: `Wohnanlage ${SLUG_A}`, confidence: 0.75, source: "Bauvorhaben" }, + siteAddress: { value: { street: "Am Kaiserkai", houseNumber: "30", postalCode: "20457", city: "Hamburg" }, confidence: 0.9 }, + title: { value: "Austausch Heizungspumpe", confidence: 0.88 }, + plannedStart: { value: "01.10.2026", confidence: 0.9 }, + plannedEnd: { value: "02.10.2026", confidence: 0.9 }, + positions: { + value: [ + { name: "Hocheffizienzpumpe 25-60", articleNumber: "HP-2560", quantity: 1, unit: "Stk", isMaterial: true }, + { name: "Montagearbeiten", quantity: 3, unit: "Std", isMaterial: false }, + ], + confidence: 0.86, + }, + }; + const dispatched: unknown[] = []; + const job = await createImport(bo, { bytes: pdf, fileName: "AB-2026-4711.pdf", mimeType: "application/pdf" }, { dispatch: async (p) => void dispatched.push(p) }); + ok(job.status === "uploaded" && dispatched.length === 1, "Upload angenommen, Extraktion eingereiht"); + const processed = await processImport(bo, job.id, { provider: new FakeExtractionProvider({ extraction, text: "AB-2026-4711" }), loadBytes: async () => pdf }); + ok(processed.status === "review_required", "Extraktion → Prüfung erforderlich"); + ok((await prisma.workOrder.count({ where: { tenantId: A.tenantId } })) === 0, "kein Auftrag ohne Bestätigung"); + await expectCode(() => getImportDetail(B.ctx.backoffice, job.id), "not_found", "Mandant B sieht den Import von A nicht"); + await expectCode(() => getImportDetail(tech, job.id), "forbidden", "Monteur hat keinen Zugriff auf die Prüfmaske"); + + // ---------- 2. Prüfung / Bestätigung ---------- + section("2. Prüfung: bestehender Kunde + bestehendes Objekt zugeordnet"); + const detail = await getImportDetail(bo, job.id); + ok(detail.customerCandidates[0]?.customerId === A.customerId, "Dublettenprüfung schlägt den bestehenden Kunden vor"); + const siteCandidates = (detail.extraction as unknown as { siteCandidates?: { siteId: string }[] }).siteCandidates ?? []; + ok(siteCandidates.some((s) => s.siteId === A.siteId), "bestehendes Objekt an gleicher Adresse als Kandidat"); + const form = extractionToForm(detail.extraction.fields, { customerId: A.customerId, siteId: A.siteId }); + form.order!.title = "Heizungspumpe Wohnanlage tauschen"; + const confirmed = await confirmImport(bo, job.id, form); + const wo0 = await prisma.workOrder.findUniqueOrThrow({ where: { id: confirmed.workOrderId }, include: { materialPlans: true } }); + ok(wo0.status === "planned" && wo0.customerId === A.customerId && wo0.siteId === A.siteId && wo0.sourceImportId === job.id, "Auftrag geplant, Kunde/Objekt zugeordnet, Import verknüpft"); + ok(wo0.materialPlans.length === 1 && wo0.materialPlans[0].articleNumber === "HP-2560", "Materialvorgabe aus Positionen übernommen"); + const woId = wo0.id; + + // Backoffice ergänzt Pflichtpunkt + Pflichtfoto. + const item = await addChecklistItem(bo, woId, { label: "Anlage entlüftet", required: true }); + await addPhotoRequirement(bo, woId, { key: "typenschild", label: "Typenschild neue Pumpe" }); + + // ---------- 3. Zuweisung ---------- + section("3. Zuweisung an Team Nord"); + await expectCode(() => assignWorkOrder(tech, { workOrderId: woId, teamId: A.teamId, userIds: [A.users.tech.id] }), "forbidden", "Monteur darf nicht zuweisen"); + await expectCode(() => assignWorkOrder(B.ctx.backoffice, { workOrderId: woId, teamId: B.teamId }), "not_found", "Mandant B kann den Auftrag von A nicht zuweisen"); + const assigned = await assignWorkOrder(bo, { workOrderId: woId, teamId: A.teamId, userIds: [A.users.tech.id] }); + ok(assigned.status === "assigned", "Auftrag zugewiesen"); + ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.tech.id, type: "work_order.assigned", entityId: woId } })) === 1, "Monteur erhält Benachrichtigung „Auftrag zugewiesen“"); + + // ---------- 4. Vorab-Download ---------- + section("4. Team lädt Auftrag und Dokumente (Bundle)"); + const bundle = await getFieldBundle(tech); + const bundled = bundle.orders.find((o) => o.id === woId); + ok(!!bundled && bundled.site?.accessNotes === "Schlüssel beim Hausmeister" && bundled.materialPlans.length === 1 && bundled.photoRequirements.length === 1, "Bundle enthält Auftrag mit Zugangshinweis, Material, Pflichtfoto"); + ok(!(await getFieldBundle(A.ctx.outsider)).orders.some((o) => o.id === woId), "Monteur ohne Zuweisung (anderes Team) erhält den Auftrag nicht"); + ok(!(await getFieldBundle(B.ctx.tech)).orders.some((o) => o.id === woId), "Mandant B erhält den Auftrag nicht"); + ok(!!bundled && !bundled.documents.some((d) => d.category === "order_confirmation"), "interne Auftragsbestätigung (backoffice_only) nicht im Monteur-Bundle"); + await expectCode(() => getFieldOrderDetail(A.ctx.outsider, woId), "not_found", "Auftragsdetail für fremden Monteur → not_found"); + + // ---------- 5. Einsatz ---------- + section("5. Einsatz: Annehmen, Losfahren, Arbeiten, Material, Fotos, Zeiten, Notizen"); + const started = new Date(Date.now() - 3 * 3600_000); + await transitionWorkOrder(tech, { workOrderId: woId, to: "accepted" }); + await startSession(tech, sessionStartPayload.parse({ workOrderId: woId, mode: "travel", at: started.toISOString() })); + const workStart = await startSession(tech, sessionStartPayload.parse({ workOrderId: woId, mode: "work", at: new Date(started.getTime() + 30 * 60_000).toISOString() })); + ok(workStart.workOrderStatus === "in_progress", "Arbeit gestartet → in Arbeit"); + await expectCode(() => startSession(A.ctx.outsider, sessionStartPayload.parse({ workOrderId: woId, mode: "work" })), "not_found", "fremder Monteur kann keinen Einsatz starten"); + await expectCode(() => startSession(B.ctx.tech, sessionStartPayload.parse({ workOrderId: woId, mode: "work" })), "not_found", "Mandant B kann keinen Einsatz starten"); + + const jpeg = await jpegBytes("Typenschild"); + const before = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: woId, kind: "photo" }, { bytes: jpeg, name: "vorher.jpg", type: "image/jpeg" }); + await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: woId, documentId: before.documentId, phase: "before", comment: "Alte Pumpe" })); + await createNote(tech, noteCreatePayload.parse({ workOrderId: woId, kind: "work_done", text: "Alte Pumpe demontiert, neue Pumpe eingesetzt." })); + await pauseSession(tech, sessionControlPayload.parse({ workOrderId: woId, at: new Date(started.getTime() + 90 * 60_000).toISOString() })); + ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: woId } })).status === "paused", "Pause → Auftrag pausiert"); + await resumeSession(tech, sessionControlPayload.parse({ workOrderId: woId, at: new Date(started.getTime() + 105 * 60_000).toISOString() })); + await upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: woId, materialPlanId: wo0.materialPlans[0].id, quantity: 1, unit: "Stk", usageStatus: "fully_used" })); + await upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: woId, clientId: randomUUID(), name: "Flachdichtung 1 Zoll", quantity: 2, unit: "Stk", usageStatus: "additional", deviationReason: "Alte Dichtungen porös" })); + await expectCode( + () => upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: woId, clientId: randomUUID(), name: "Ohne Grund", quantity: 1, unit: "Stk", usageStatus: "additional" })), + "invalid", + "Zusatzmaterial ohne Begründung → invalid", + ); + const req = await prisma.photoRequirement.findFirstOrThrow({ where: { workOrderId: woId } }); + const plate = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: woId, kind: "photo" }, { bytes: jpeg, name: "typenschild.jpg", type: "image/jpeg" }); + await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: woId, documentId: plate.documentId, phase: "after", photoRequirementId: req.id })); + await toggleChecklistItem(tech, checklistTogglePayload.parse({ workOrderId: woId, itemId: item.id, checked: true })); + + await expectCode(() => createCompletionReport(tech, { workOrderId: woId }), "blocked", "Abschlussbericht bei laufender Arbeitszeit → blocked"); + const ended = await endSession(tech, sessionControlPayload.parse({ workOrderId: woId })); + ok(ended.workSeconds > 0 && ended.breakSeconds >= 14 * 60 && ended.travelSeconds >= 29 * 60, `Zeiten berechnet (Arbeit ${Math.round(ended.workSeconds / 60)} min, Pause ${Math.round(ended.breakSeconds / 60)} min, Anfahrt ${Math.round(ended.travelSeconds / 60)} min)`); + + // ---------- 6. Abschlussbericht + Unterschrift ---------- + section("6. Abschlussbericht und Kundenunterschrift"); + const { report } = await createCompletionReport(tech, { workOrderId: woId }); + const content = contentOf(report); + ok(content.photos.length === 2 && content.materials.used.length === 1 && content.materials.additional.length === 1, "Berichts-Snapshot enthält Fotos und Material (Soll/Ist + Zusatz)"); + ok(content.time.totalMinutes > 0 && content.site?.name === `Wohnanlage ${SLUG_A}`, "Berichts-Snapshot enthält Arbeitszeit und Objekt"); + ok(content.texts.workPerformed.includes("Alte Pumpe demontiert"), "„Ausgeführte Leistungen“ aus den Tätigkeitsnotizen vorbelegt"); + await updateReportTexts(tech, { reportId: report.id, texts: { workPerformed: "" } }); + await expectCode(() => submitReport(tech, { reportId: report.id }), "blocked", "Absenden ohne „Ausgeführte Leistungen“ → blocked"); + await updateReportTexts(tech, { reportId: report.id, texts: { workPerformed: "Heizungspumpe getauscht, Anlage entlüftet und geprüft." } }); + const sigImg = await storeFile(tech, { bytes: await pngBytes(), fileName: "unterschrift.png", declaredMime: "image/png", category: "signature", visibility: "customer_report", links: { workOrderId: woId } }); + await captureSignature(tech, { reportId: report.id, outcome: "signed", signerName: "Frau Prüf", signerRole: "Objektbetreuung", imageDocumentId: sigImg.id, confirmationText: "Arbeiten ordnungsgemäß ausgeführt." }); + await expectCode(() => captureSignature(B.ctx.tech, { reportId: report.id, outcome: "later", reason: "x", confirmationText: "x" }), "not_found", "Mandant B kann keine Unterschrift erfassen"); + await submitReport(tech, { reportId: report.id }); + const afterSubmit = await prisma.workOrder.findUniqueOrThrow({ where: { id: woId } }); + ok(afterSubmit.status === "in_review", "Unterschrift erfasst → Auftrag „Zur Prüfung“"); + ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.lead.id, type: "report.submitted", entityId: report.id } })) === 1, "Teamleiter wird zur Prüfung benachrichtigt"); + + // ---------- 7. Freigabe ---------- + section("7. Teamleiter- und Backoffice-Freigabe"); + await expectCode(() => approveReport(tech, { reportId: report.id }, { dispatchPdf: async () => undefined }), "forbidden", "Monteur darf nicht freigeben"); + await expectCode(() => approveReport(B.ctx.backoffice, { reportId: report.id }, { dispatchPdf: async () => undefined }), "not_found", "Mandant B kann nicht freigeben"); + await expectCode(() => releaseForBilling(bo, { workOrderId: woId }), "blocked", "Abrechnung vor Berichtsfreigabe → blocked"); + const teamApproved = await approveReport(A.ctx.lead, { reportId: report.id }, { dispatchPdf: async () => undefined }); + ok(teamApproved.status === "team_approved", "Teamleiter prüft → team_approved"); + const pdfJobs: string[] = []; + const approved = await approveReport(bo, { reportId: report.id }, { dispatchPdf: async (_c, id) => void pdfJobs.push(id) }); + ok(approved.status === "approved" && pdfJobs[0] === report.id, "Backoffice gibt frei → approved, PDF-Job angestoßen"); + await expectCode(() => updateReportTexts(tech, { reportId: report.id, texts: { workPerformed: "nachträglich geändert" } }), ["conflict", "invalid", "blocked"], "freigegebener Bericht ist unveränderlich"); + + // ---------- 8. PDF ---------- + section("8. PDF-Arbeitsnachweis"); + const renderer = await pdfRendererAvailable(); + if (!renderer.ok) { + console.log(`⚠ PDF-Erzeugung übersprungen: kein Chromium startbar (${renderer.reason})`); + } else { + const pdfRes = await generateReportPdf(bo, report.id); + const stored = await prisma.report.findUniqueOrThrow({ where: { id: report.id } }); + const doc = await prisma.document.findUniqueOrThrow({ where: { id: pdfRes.documentId } }); + ok(!pdfRes.skipped && stored.pdfDocumentId === doc.id && stored.pdfChecksum === doc.checksum, "PDF erzeugt, Prüfsumme am Bericht"); + ok(doc.category === "completion_report" && doc.siteId === A.siteId && doc.workOrderId === woId, "PDF als Abschlussbericht an Auftrag und Objekt abgelegt"); + const bytes = await readStoredBytes(doc.storageKey); + if (bytes) ok(Buffer.from(bytes).subarray(0, 4).toString() === "%PDF", "Datei beginnt mit %PDF"); + await expectCode(() => openReportFile(B.ctx.backoffice, report.id, "pdf"), "not_found", "Mandant B kann das PDF nicht laden"); + await expectCode(() => authorizeDocumentAccess(A.ctx.outsider, doc.id), "not_found", "fremder Monteur kann das PDF nicht laden"); + } + + // ---------- 9. Abrechnung ---------- + section("9. Abrechnungsfreigabe und abgerechnet"); + await expectCode(() => releaseForBilling(tech, { workOrderId: woId }), "forbidden", "Monteur darf nicht zur Abrechnung freigeben"); + await expectCode(() => releaseForBilling(A.ctx.lead, { workOrderId: woId }), "forbidden", "Teamleiter darf nicht zur Abrechnung freigeben"); + const released = await releaseForBilling(bo, { workOrderId: woId }); + ok(released.status === "released_for_billing", "Auftrag zur Abrechnung freigegeben"); + const billed = await markBilled(bo, { workOrderId: woId }); + ok(billed.status === "billed", "Auftrag abgerechnet"); + ok((await codeOf(transitionWorkOrder(bo, { workOrderId: woId, to: "cancelled", reason: "zu spät" }))) === "invalid", "abgerechneter Auftrag kann nicht storniert werden"); + + const history = await prisma.workOrderStatusChange.findMany({ where: { workOrderId: woId }, orderBy: { createdAt: "asc" }, select: { toStatus: true } }); + const path = history.map((h) => h.toStatus).join(">"); + ok(path === "planned>assigned>accepted>en_route>in_progress>paused>in_progress>technically_completed>in_review>released_for_billing>billed", `Statushistorie vollständig (${path})`); + ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "work_order", entityId: woId } })) >= 10, "jede Statusänderung auditiert"); + const woDetail = await getWorkOrderDetail(bo, woId); + ok(woDetail.id === woId, "Backoffice-Detail lesbar"); + + // ---------- 10. Objekt-Historie ---------- + section("10. Einsatz erscheint in der Objekt-Historie"); + const hist = await getSiteHistory(bo, A.siteId); + const entry = hist.items.find((i) => i.workOrderId === woId); + ok(!!entry && entry.approvedReports.length === 1 && entry.signed && entry.photoCount === 2, "Historie: freigegebener Bericht, Unterschrift, Fotos"); + ok(!!entry && entry.materials.some((m) => m.name === "Hocheffizienzpumpe 25-60") && entry.workDone.length === 1, "Historie: Material und durchgeführte Arbeiten"); + const techHistory = await getSiteHistory(A.ctx.tech2, A.siteId); + ok(techHistory.onlyApproved && techHistory.items.some((i) => i.workOrderId === woId), "Teamkollege sieht den freigegebenen Einsatz in der Objekt-Historie"); + await expectCode(() => getSiteHistory(A.ctx.outsider, A.siteId), "not_found", "Monteur ohne Auftrag am Objekt → not_found"); + await expectCode(() => getSiteHistory(B.ctx.backoffice, A.siteId), "not_found", "Mandant B → not_found"); + await expectCode(() => requireVisibleReport(B.ctx.backoffice, report.id), "not_found", "Mandant B kann den Bericht nicht lesen"); +}); diff --git a/scripts/test-e2e-tenant-isolation.ts b/scripts/test-e2e-tenant-isolation.ts new file mode 100644 index 0000000..9d2212b --- /dev/null +++ b/scripts/test-e2e-tenant-isolation.ts @@ -0,0 +1,379 @@ +// L10a E2E §43.3 „Mandantentrennung" — systematisch über ALLE Fachmodelle und Fachservices. +// +// Teil 1 (Datenzugriff): Für JEDES Modell aus TENANT_MODELS (src/server/backup/topology.ts, Spiegel von +// db.ts) wird in Mandant A eine Zeile angelegt; aus Mandant B (dbForTenant) werden findMany, +// findFirst, findUnique, count, update, updateMany, delete, deleteMany auf genau diese Zeile +// versucht → nichts gelesen, nichts geändert. Ein neues Modell ohne Fixture lässt den Test scheitern. +// create mit fremder tenantId landet im eigenen Mandanten. +// Teil 2 (Postgres-RLS): dieselben Zeilen über die Rolle craftvia_app mit Kontext B → 0 Zeilen sichtbar, +// UPDATE/DELETE wirkungslos; Kontext A sieht die Zeile (skip, falls craftvia_app nicht verbinden kann). +// Teil 3 (Fachservices): jeder id-basierte Lese-/Schreibpfad (Kunden, Objekte, Teams, Aufträge, Einsatz, +// Dokumente, Berichte, Import, Notdienst, Sync, Benachrichtigungen, Audit, Lotse) mit IDs aus A → +// not_found (bzw. invalid bei Referenzen), Listen/Suche/Dashboard enthalten keine A-Daten, A unverändert. +// +// Lauf: npx tsx scripts/test-e2e-tenant-isolation.ts (zusätzlich mit RLS_ENFORCED=true) + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { Prisma, PrismaClient } from "@prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { prisma, dbForTenant } from "../src/server/db"; +import { TENANT_MODELS, buildTenantTopology } from "../src/server/backup/topology"; +import { getCustomer, updateCustomer, deleteCustomer, confirmProvisionalCustomer, listCustomers, listCustomerWorkOrders } from "../src/server/services/customers/customers"; +import { createContact } from "../src/server/services/customers/contacts"; +import { mergeCustomers } from "../src/server/services/customers/merge"; +import { getSite, updateSite, deleteSite, createSite, listSites } from "../src/server/services/sites/sites"; +import { getSiteHistory } from "../src/server/services/sites/history"; +import { getTeam, updateTeam, deleteTeam, listTeams } from "../src/server/services/teams/teams"; +import { getWorkOrderDetail } from "../src/server/services/work-orders/detail"; +import { updateWorkOrder } from "../src/server/services/work-orders/update"; +import { transitionWorkOrder } from "../src/server/services/work-orders/transition"; +import { assignWorkOrder } from "../src/server/services/work-orders/assign"; +import { cancelWorkOrder } from "../src/server/services/work-orders/cancel"; +import { addMaterialPlan } from "../src/server/services/work-orders/materials"; +import { addChecklistItem, addPhotoRequirement } from "../src/server/services/work-orders/checklist"; +import { getCompletionBlockers } from "../src/server/services/work-orders/completion"; +import { markBilled, releaseForBilling } from "../src/server/services/work-orders/release-billing"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; +import { uploadWorkOrderDocument } from "../src/server/services/work-orders/documents"; +import { listWorkOrders } from "../src/server/services/work-orders/list"; +import { searchAll } from "../src/server/services/work-orders/search"; +import { getDashboardTiles } from "../src/server/services/work-orders/dashboard"; +import { applySyncConflict, discardSyncConflict, listSyncConflicts } from "../src/server/services/work-orders/conflicts"; +import { startSession } from "../src/server/services/field/sessions"; +import { createNote } from "../src/server/services/field/notes"; +import { upsertMaterialUsage } from "../src/server/services/field/materials"; +import { toggleChecklistItem } from "../src/server/services/field/checklist"; +import { storeFieldUpload } from "../src/server/services/field/uploads"; +import { attachPhoto } from "../src/server/services/field/photos"; +import { attachVoiceNote } from "../src/server/services/field/voice"; +import { correctTimeEntry } from "../src/server/services/field/time-correction"; +import { getFieldBundle, getFieldOrderDetail } from "../src/server/services/field/queries"; +import { storeFile } from "../src/server/services/documents/store"; +import { authorizeDocumentAccess, deleteDocument, listDocuments, openDocumentContent, updateDocumentMeta } from "../src/server/services/documents/access"; +import { createCompletionReport, createDailyReport } from "../src/server/services/reports/create"; +import { updateReportTexts } from "../src/server/services/reports/edit"; +import { submitReport } from "../src/server/services/reports/submit"; +import { approveReport } from "../src/server/services/reports/approve"; +import { rejectReport } from "../src/server/services/reports/reject"; +import { captureSignature } from "../src/server/services/reports/signature"; +import { createNewVersion } from "../src/server/services/reports/new-version"; +import { openReportFile } from "../src/server/services/reports/files"; +import { generateReportPdf } from "../src/server/services/reports/pdf"; +import { requireVisibleReport } from "../src/server/services/reports/common"; +import { getReportDetail, listReports } from "../src/server/services/reports/queries"; +import { getImportDetail, listImports } from "../src/server/services/imports/queries"; +import { confirmImport, discardImport, retryImport } from "../src/server/services/imports/confirm"; +import { getEmergencyReview, confirmEmergencyCustomer, listEmergencyReviews } from "../src/server/services/emergency/review"; +import { applyOperations } from "../src/server/services/sync/apply"; +import { listNotifications, markRead } from "../src/server/services/notifications/inbox"; +import { getAuditEntry, queryAuditLog } from "../src/server/services/audit/viewer"; +import { getAiGenerationContent, listAiGenerations } from "../src/server/services/lotse/protocol"; +import { getLotseReportState } from "../src/server/services/lotse/state"; +import { noteCreatePayload, sessionStartPayload, materialUpsertPayload, checklistTogglePayload, photoAttachPayload, voiceAttachPayload } from "../src/lib/sync/ops"; +import type { ServiceCtx } from "../src/server/services/context"; +import { codeOf, createTenant, jpegBytes, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture"; + +const SLUG_A = "zz-q-e2e-iso-a"; +const SLUG_B = "zz-q-e2e-iso-b"; +const MARK = "HACKED-BY-B"; + +type Rows = Record }>; + +/** One row per tenant model in tenant A (owner client) + the field a foreign update would try to change. */ +async function createModelRows(A: TenantFixture): Promise { + const t = A.tenantId; + const uid = A.users.tech.id; + const rows: Rows = {}; + const put = (model: string, id: string, update: Record) => (rows[model] = { id, update }); + + put("User", uid, { name: MARK }); + put("Role", (await prisma.role.findFirstOrThrow({ where: { tenantId: t } })).id, { name: MARK }); + put("AuditLog", (await prisma.auditLog.create({ data: { tenantId: t, action: "create", entity: "zz", entityId: "x", after: { v: 1 } } })).id, { entity: MARK }); + put("MailLog", (await prisma.mailLog.create({ data: { tenantId: t, to: "a@zz-qualitaet.test", template: "zz" } })).id, { status: MARK }); + put("NotificationPreference", (await prisma.notificationPreference.create({ data: { tenantId: t, userId: uid, eventType: "zz.event" } })).id, { eventType: MARK }); + put("AuthToken", (await prisma.authToken.create({ data: { principalType: "tenant_user", principalId: uid, tenantId: t, type: "password_reset", tokenHash: randomUUID(), expiresAt: new Date(Date.now() + 3600_000) } })).id, { type: MARK }); + put("TenantSettings", (await prisma.tenantSettings.findFirstOrThrow({ where: { tenantId: t } })).id, { orgName: MARK }); + put("TenantModule", (await prisma.tenantModule.create({ data: { tenantId: t, moduleKey: "lotse", enabled: true } })).id, { moduleKey: MARK }); + put("NumberSequence", (await prisma.numberSequence.create({ data: { tenantId: t, key: "zz", prefix: "Z-" } })).id, { prefix: MARK }); + const orderType = await prisma.orderType.create({ data: { tenantId: t, key: "zz_typ", name: "ZZ Typ" } }); + put("OrderType", orderType.id, { name: MARK }); + put("ChecklistTemplate", (await prisma.checklistTemplate.create({ data: { tenantId: t, name: "ZZ Vorlage" } })).id, { name: MARK }); + put("Customer", A.customerId, { companyName: MARK }); + put("Contact", A.contactId, { name: MARK }); + put("Site", A.siteId, { name: MARK }); + put("Team", A.teamId, { name: MARK }); + put("TeamMember", (await prisma.teamMember.findFirstOrThrow({ where: { tenantId: t, userId: uid } })).id, { validTo: new Date(0) }); + + const wo = await prisma.workOrder.create({ data: { tenantId: t, number: "ZZ-ISO-1", customerId: A.customerId, siteId: A.siteId, title: "Isolation", status: "in_progress", assignedTeamId: A.teamId } }); + put("WorkOrder", wo.id, { title: MARK }); + put("WorkOrderAssignee", (await prisma.workOrderAssignee.create({ data: { tenantId: t, workOrderId: wo.id, userId: uid } })).id, { userId: A.users.tech2.id }); + put("WorkOrderStatusChange", (await prisma.workOrderStatusChange.create({ data: { tenantId: t, workOrderId: wo.id, toStatus: "in_progress", actorId: uid } })).id, { reason: MARK }); + put("ChecklistItem", (await prisma.checklistItem.create({ data: { tenantId: t, workOrderId: wo.id, key: "zz", label: "ZZ" } })).id, { label: MARK }); + put("PhotoRequirement", (await prisma.photoRequirement.create({ data: { tenantId: t, workOrderId: wo.id, key: "zz", label: "ZZ" } })).id, { label: MARK }); + const plan = await prisma.materialPlan.create({ data: { tenantId: t, workOrderId: wo.id, name: "ZZ", plannedQuantity: new Prisma.Decimal(1), unit: "Stk" } }); + put("MaterialPlan", plan.id, { name: MARK }); + put("MaterialUsage", (await prisma.materialUsage.create({ data: { tenantId: t, workOrderId: wo.id, materialPlanId: plan.id, name: "ZZ", actualQuantity: new Prisma.Decimal(1), unit: "Stk", usageStatus: "fully_used" } })).id, { notes: MARK }); + const session = await prisma.workSession.create({ data: { tenantId: t, workOrderId: wo.id, userId: uid, status: "ended", startedAt: new Date(Date.now() - 3600_000), endedAt: new Date() } }); + put("WorkSession", session.id, { deviceInfo: MARK }); + put("TimeEntry", (await prisma.timeEntry.create({ data: { tenantId: t, workSessionId: session.id, userId: uid, startedAt: session.startedAt, endedAt: new Date() } })).id, { correctionReason: MARK }); + put("ActivityNote", (await prisma.activityNote.create({ data: { tenantId: t, workOrderId: wo.id, authorId: uid, text: "ZZ" } })).id, { text: MARK }); + const doc = (key: string) => + prisma.document.create({ data: { tenantId: t, workOrderId: wo.id, category: "photo", fileName: `${key}.jpg`, storageKey: `${t}/${key}`, mimeType: "image/jpeg", fileSize: 1, checksum: "0".repeat(64), lineageId: randomUUID(), visibility: "team" } }); + const d1 = await doc("iso-photo"); + const d2 = await doc("iso-voice"); + const d3 = await doc("iso-import"); + put("Document", d1.id, { title: MARK }); + put("Photo", (await prisma.photo.create({ data: { tenantId: t, workOrderId: wo.id, documentId: d1.id, takenAt: new Date() } })).id, { comment: MARK }); + put("VoiceNote", (await prisma.voiceNote.create({ data: { tenantId: t, workOrderId: wo.id, documentId: d2.id, recordedAt: new Date() } })).id, { transcript: MARK }); + const report = await prisma.report.create({ data: { tenantId: t, workOrderId: wo.id, type: "daily", reportDate: new Date(), lineageId: randomUUID(), content: {} } }); + put("Report", report.id, { rejectionReason: MARK }); + put("Signature", (await prisma.signature.create({ data: { tenantId: t, reportId: report.id, outcome: "later", reason: "ZZ", signedAt: new Date() } })).id, { reason: MARK }); + put("ImportJob", (await prisma.importJob.create({ data: { tenantId: t, documentId: d3.id, status: "uploaded" } })).id, { errorMessage: MARK }); + put("Notification", (await prisma.notification.create({ data: { tenantId: t, userId: uid, type: "zz", title: "ZZ", message: "ZZ" } })).id, { title: MARK }); + put("SyncOperation", (await prisma.syncOperation.create({ data: { tenantId: t, userId: uid, clientOpId: randomUUID(), opType: "note.create", payload: {}, status: "conflict", clientCreatedAt: new Date() } })).id, { errorCode: MARK }); + put("AiGeneration", (await prisma.aiGeneration.create({ data: { tenantId: t, kind: "report_draft", provider: "fake", model: "zz" } })).id, { model: MARK }); + return rows; +} + +type Delegate = Record Promise>; +const lc = (m: string) => m.charAt(0).toLowerCase() + m.slice(1); +const snapshotRow = async (model: string, id: string) => JSON.stringify(await (prisma as unknown as Record)[lc(model)].findUnique({ where: { id } }), (_k, v) => (typeof v === "bigint" ? v.toString() : v)); + +async function threw(fn: () => Promise): Promise { + try { + await fn(); + return false; + } catch { + return true; + } +} + +function defaultRlsUrl(): string { + const base = new URL(process.env.DATABASE_URL ?? "postgresql://localhost:5432/craftvia?schema=public"); + base.username = "craftvia_app"; + base.password = "craftvia_app_local"; + return base.toString(); +} + +runSuite("E2E Mandantentrennung (systematisch)", [SLUG_A, SLUG_B], async () => { + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + console.log(`RLS_ENFORCED=${process.env.RLS_ENFORCED === "true" ? "true" : "false"}`); + + // ================= Teil 1: alle Tenant-Modelle ================= + section("Teil 1: jedes Tenant-Modell aus Mandant B unerreichbar"); + const rows = await createModelRows(A); + const missing = TENANT_MODELS.filter((m) => !rows[m]); + ok(missing.length === 0, `Fixture deckt alle ${TENANT_MODELS.length} Tenant-Modelle ab${missing.length ? ` — fehlt: ${missing.join(", ")}` : ""}`); + const dbB = dbForTenant(B.tenantId) as unknown as Record; + for (const model of TENANT_MODELS) { + const row = rows[model]; + if (!row) continue; + const d = dbB[lc(model)]; + const before = await snapshotRow(model, row.id); + const where = { id: row.id }; + const results = { + findMany: ((await d.findMany({ where })) as unknown[]).length === 0, + findFirst: (await d.findFirst({ where })) === null, + findUnique: (await d.findUnique({ where }).catch(() => null)) === null, + count: (await d.count({ where })) === 0, + updateMany: ((await d.updateMany({ where, data: row.update })) as { count: number }).count === 0, + deleteMany: ((await d.deleteMany({ where })) as { count: number }).count === 0, + update: await threw(() => d.update({ where, data: row.update })), + delete: await threw(() => d.delete({ where })), + }; + const after = await snapshotRow(model, row.id); + const failed = Object.entries(results).filter(([, v]) => !v).map(([k]) => k); + ok(failed.length === 0 && before === after && after !== "null", `${model}: lesen/ändern/löschen aus B abgewiesen, Zeile unverändert${failed.length ? ` — durchgelassen: ${failed.join(", ")}` : ""}${before !== after ? " — ZEILE GEÄNDERT" : ""}`); + } + const injected = await dbForTenant(B.tenantId).customer.create({ data: { tenantId: A.tenantId, companyName: "Untergeschoben" } }); + ok(injected.tenantId === B.tenantId, "create mit fremder tenantId landet im eigenen Mandanten (B)"); + const auditA = await prisma.auditLog.findFirstOrThrow({ where: { tenantId: A.tenantId, entity: "zz" } }); + ok((await threw(() => dbForTenant(B.tenantId).auditLog.update({ where: { id: auditA.id }, data: { after: { v: 2 } } }))) && (await prisma.auditLog.findUniqueOrThrow({ where: { id: auditA.id } })).entity === "zz", "Audit-Log von A aus B nicht manipulierbar"); + + // ================= Teil 2: Postgres-RLS ================= + section("Teil 2: Postgres Row Level Security (Rolle craftvia_app)"); + const app = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.RLS_DATABASE_URL ?? defaultRlsUrl() }) }); + try { + await app.$queryRawUnsafe("SELECT 1"); + const topo = buildTenantTopology(); + for (const model of TENANT_MODELS) { + const row = rows[model]; + if (!row) continue; + const table = topo.nodes.get(model)!.table; + const res = await app.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.tenant_id', ${B.tenantId}, true)`; + const seen = await tx.$queryRawUnsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM "${table}" WHERE id = $1`, row.id); + const updated = await tx.$executeRawUnsafe(`UPDATE "${table}" SET id = id WHERE id = $1`, row.id); + const deleted = await tx.$executeRawUnsafe(`DELETE FROM "${table}" WHERE id = $1`, row.id); + return { seen: seen[0].n, updated, deleted }; + }); + const own = await app.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.tenant_id', ${A.tenantId}, true)`; + return (await tx.$queryRawUnsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM "${table}" WHERE id = $1`, row.id))[0].n; + }); + ok(res.seen === 0 && res.updated === 0 && res.deleted === 0 && own === 1, `RLS ${table}: Kontext B sieht/ändert/löscht nichts, Kontext A sieht die Zeile`); + } + const noCtx = (await app.$queryRawUnsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM "work_orders" WHERE tenant_id = $1`, A.tenantId))[0].n; + ok(noCtx === 0, "RLS ohne Mandantenkontext: 0 Zeilen (fail-closed)"); + } catch (err) { + if (process.env.RLS_TEST_REQUIRED === "true") throw err; + console.log(`⚠ Teil 2 übersprungen: craftvia_app nicht verbindbar (${(err as Error).message.split("\n")[0]})`); + } finally { + await app.$disconnect(); + } + + // ================= Teil 3: Fachservices ================= + section("Teil 3: Fachservices mit IDs aus Mandant A"); + // real objects in A (built through the services) + const woA = await createWorkOrder(A.ctx.backoffice, { title: "Isolation real", customerId: A.customerId, siteId: A.siteId, applyTemplate: false, checklistItems: [{ label: "Prüfen", required: false }], materials: [{ name: "Rohr", plannedQuantity: 2, unit: "m" }] }); + await assignWorkOrder(A.ctx.backoffice, { workOrderId: woA.id, teamId: A.teamId, userIds: [A.users.tech.id] }); + await startSession(A.ctx.tech, sessionStartPayload.parse({ workOrderId: woA.id, mode: "work", at: new Date(Date.now() - 7200_000).toISOString() })); + const photoA = await storeFieldUpload(A.ctx.tech, { clientId: randomUUID(), workOrderId: woA.id, kind: "photo" }, { bytes: await jpegBytes("A"), name: "a.jpg", type: "image/jpeg" }); + await createNote(A.ctx.tech, noteCreatePayload.parse({ workOrderId: woA.id, kind: "work_done", text: "Arbeit in A" })); + const dailyA = (await createDailyReport(A.ctx.tech, { workOrderId: woA.id })).report; + const itemA = await prisma.checklistItem.findFirstOrThrow({ where: { workOrderId: woA.id } }); + const planA = await prisma.materialPlan.findFirstOrThrow({ where: { workOrderId: woA.id } }); + const teA = await prisma.timeEntry.findFirstOrThrow({ where: { tenantId: A.tenantId, userId: A.users.tech.id, workSession: { workOrderId: woA.id } } }); + const docA = await prisma.document.findUniqueOrThrow({ where: { id: photoA.documentId } }); + const provisional = await prisma.customer.create({ data: { tenantId: A.tenantId, lastName: "Vorläufig", status: "provisional", isProvisional: true } }); + const ids = { ...Object.fromEntries(Object.entries(rows).map(([k, v]) => [k, v.id])), woA: woA.id, dailyA: dailyA.id, docA: docA.id }; + + const watched = async () => + JSON.stringify( + await Promise.all([ + prisma.customer.findUnique({ where: { id: A.customerId } }), + prisma.site.findUnique({ where: { id: A.siteId } }), + prisma.team.findUnique({ where: { id: A.teamId }, include: { members: true } }), + prisma.workOrder.findUnique({ where: { id: woA.id }, include: { assignees: true, checklistItems: true, materialPlans: true, materialUsages: true, notes: true, photos: true } }), + prisma.report.findUnique({ where: { id: dailyA.id } }), + prisma.document.findUnique({ where: { id: docA.id } }), + prisma.importJob.findUnique({ where: { id: rows.ImportJob.id } }), + prisma.timeEntry.findUnique({ where: { id: teA.id } }), + prisma.notification.findUnique({ where: { id: rows.Notification.id } }), + prisma.syncOperation.findUnique({ where: { id: rows.SyncOperation.id } }), + ]), + ); + const beforeAll = await watched(); + + const bo: ServiceCtx = B.ctx.backoffice; + const admin: ServiceCtx = B.ctx.admin; + const tech: ServiceCtx = B.ctx.tech; + const NF = ["not_found"]; + const REF = ["not_found", "invalid"]; + const checks: [string, () => Promise, string[]][] = [ + // Kunden / Objekte / Teams + ["getCustomer", () => getCustomer(bo, A.customerId), NF], + ["updateCustomer", () => updateCustomer(bo, A.customerId, { companyName: MARK }), NF], + ["deleteCustomer", () => deleteCustomer(bo, A.customerId), NF], + ["confirmProvisionalCustomer", () => confirmProvisionalCustomer(bo, provisional.id), NF], + ["createContact (Kunde A)", () => createContact(bo, A.customerId, { name: MARK }), NF], + ["listCustomerWorkOrders (Kunde A)", async () => { const r = await listCustomerWorkOrders(bo, A.customerId); if (JSON.stringify(r).includes(woA.id)) throw new Error("leak"); throw Object.assign(new Error("empty"), { code: "not_found" }); }, NF], + ["mergeCustomers (Ziel A)", () => mergeCustomers(admin, { sourceId: B.customerId, targetId: A.customerId, confirm: true }), NF], + ["getSite", () => getSite(bo, A.siteId), NF], + ["updateSite", () => updateSite(bo, A.siteId, { name: MARK }), NF], + ["deleteSite", () => deleteSite(bo, A.siteId), NF], + ["getSiteHistory", () => getSiteHistory(bo, A.siteId), NF], + ["createSite (Kunde A)", () => createSite(bo, { customerId: A.customerId, name: MARK }), REF], + ["getTeam", () => getTeam(admin, A.teamId), NF], + ["updateTeam", () => updateTeam(admin, A.teamId, { name: MARK }), NF], + ["deleteTeam", () => deleteTeam(admin, A.teamId), NF], + // Aufträge + ["getWorkOrderDetail", () => getWorkOrderDetail(bo, woA.id), NF], + ["updateWorkOrder", () => updateWorkOrder(bo, woA.id, { title: MARK }), NF], + ["transitionWorkOrder", () => transitionWorkOrder(bo, { workOrderId: woA.id, to: "cancelled", reason: MARK }), NF], + ["assignWorkOrder", () => assignWorkOrder(bo, { workOrderId: woA.id, teamId: B.teamId }), NF], + ["cancelWorkOrder", () => cancelWorkOrder(bo, { workOrderId: woA.id, reason: MARK }), NF], + ["addMaterialPlan", () => addMaterialPlan(bo, woA.id, { name: MARK, plannedQuantity: 1, unit: "Stk" }), NF], + ["addChecklistItem", () => addChecklistItem(bo, woA.id, { label: MARK }), NF], + ["addPhotoRequirement", () => addPhotoRequirement(bo, woA.id, { label: MARK }), NF], + ["getCompletionBlockers", () => getCompletionBlockers(bo, woA.id), NF], + ["releaseForBilling", () => releaseForBilling(bo, { workOrderId: woA.id }), NF], + ["markBilled", () => markBilled(bo, { workOrderId: woA.id }), NF], + ["createWorkOrder (Kunde A)", () => createWorkOrder(bo, { title: MARK, customerId: A.customerId }), REF], + ["uploadWorkOrderDocument", async () => uploadWorkOrderDocument(bo, { workOrderId: woA.id, bytes: await jpegBytes("B"), fileName: "b.jpg", declaredMime: "image/jpeg", category: "other", visibility: "team" }), NF], + // Einsatz + ["getFieldOrderDetail", () => getFieldOrderDetail(tech, woA.id), NF], + ["startSession", () => startSession(tech, sessionStartPayload.parse({ workOrderId: woA.id, mode: "work" })), NF], + ["createNote", () => createNote(tech, noteCreatePayload.parse({ workOrderId: woA.id, text: MARK })), NF], + ["upsertMaterialUsage", () => upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: woA.id, materialPlanId: planA.id, quantity: 9, unit: "m", usageStatus: "fully_used" })), NF], + ["toggleChecklistItem", () => toggleChecklistItem(tech, checklistTogglePayload.parse({ workOrderId: woA.id, itemId: itemA.id, checked: true })), NF], + ["storeFieldUpload", async () => storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: woA.id, kind: "photo" }, { bytes: await jpegBytes("B"), name: "b.jpg", type: "image/jpeg" }), NF], + ["attachPhoto (Dokument A)", () => attachPhoto(tech, photoAttachPayload.parse({ workOrderId: woA.id, documentId: docA.id })), NF], + ["attachVoiceNote (Dokument A)", () => attachVoiceNote(tech, voiceAttachPayload.parse({ workOrderId: woA.id, documentId: rows.Document.id })), NF], + ["correctTimeEntry", () => correctTimeEntry(admin, { timeEntryId: teA.id, startedAt: new Date(Date.now() - 600_000), reason: MARK }), NF], + // Dokumente + ["authorizeDocumentAccess", () => authorizeDocumentAccess(bo, docA.id), NF], + ["openDocumentContent", () => openDocumentContent(bo, docA.id), NF], + ["updateDocumentMeta", () => updateDocumentMeta(bo, docA.id, { title: MARK }), NF], + ["deleteDocument", () => deleteDocument(bo, docA.id), NF], + ["storeFile (Auftrag A)", async () => storeFile(bo, { bytes: await jpegBytes("B"), fileName: "b.jpg", declaredMime: "image/jpeg", category: "photo", visibility: "team", links: { workOrderId: woA.id } }), NF], + ["storeFile (Version von A)", async () => storeFile(bo, { bytes: await jpegBytes("B"), fileName: "b.jpg", declaredMime: "image/jpeg", category: "photo", visibility: "team", lineageId: docA.lineageId }), REF], + // Berichte + ["requireVisibleReport", () => requireVisibleReport(bo, dailyA.id), NF], + ["getReportDetail", () => getReportDetail(bo, dailyA.id), NF], + ["createDailyReport", () => createDailyReport(tech, { workOrderId: woA.id }), NF], + ["createCompletionReport", () => createCompletionReport(tech, { workOrderId: woA.id }), NF], + ["updateReportTexts", () => updateReportTexts(tech, { reportId: dailyA.id, texts: { hints: MARK } }), NF], + ["submitReport", () => submitReport(tech, { reportId: dailyA.id }), NF], + ["approveReport", () => approveReport(bo, { reportId: dailyA.id }, { dispatchPdf: async () => undefined }), NF], + ["rejectReport", () => rejectReport(bo, { reportId: dailyA.id, reason: MARK }), NF], + ["captureSignature", () => captureSignature(tech, { reportId: dailyA.id, outcome: "later", reason: MARK, confirmationText: MARK }), NF], + ["createNewVersion", () => createNewVersion(bo, { reportId: dailyA.id }), NF], + ["openReportFile", () => openReportFile(bo, dailyA.id, docA.id), NF], + ["generateReportPdf", () => generateReportPdf(bo, dailyA.id), NF], + ["getLotseReportState", () => getLotseReportState(tech, dailyA.id), NF], + // Import / Notdienst / Sync / Benachrichtigungen / Audit / KI + ["getImportDetail", () => getImportDetail(bo, rows.ImportJob.id), NF], + ["confirmImport", () => confirmImport(bo, rows.ImportJob.id, {}), REF], + ["discardImport", () => discardImport(bo, rows.ImportJob.id), NF], + ["retryImport", () => retryImport(bo, rows.ImportJob.id, { dispatch: async () => undefined }), NF], + ["getEmergencyReview", () => getEmergencyReview(bo, woA.id), NF], + ["confirmEmergencyCustomer", () => confirmEmergencyCustomer(bo, woA.id), NF], + ["applySyncConflict", () => applySyncConflict(bo, rows.SyncOperation.id), NF], + ["discardSyncConflict", () => discardSyncConflict(bo, rows.SyncOperation.id), NF], + ["markRead (Benachrichtigung A)", () => markRead(tech, rows.Notification.id), NF], + ["getAuditEntry", () => getAuditEntry(admin, rows.AuditLog.id), NF], + ["getAiGenerationContent", () => getAiGenerationContent(admin, rows.AiGeneration.id), NF], + ]; + for (const [name, fn, expected] of checks) { + const got = await codeOf(fn); + ok(expected.includes(got), `${name} aus Mandant B → ${got}`); + } + const sync = await applyOperations(tech, { + deviceId: "b", + operations: [ + { clientOpId: randomUUID(), opType: "note.create", payload: { workOrderId: woA.id, text: MARK }, clientCreatedAt: new Date().toISOString() }, + { clientOpId: randomUUID(), opType: "work_order.transition", baseVersion: 1, payload: { workOrderId: woA.id, to: "paused" }, clientCreatedAt: new Date().toISOString() }, + ], + }); + ok(sync.results.every((r) => r.status === "rejected" && r.errorCode === "not_found"), "Sync-Ops auf Auftrag von A → rejected not_found"); + + section("Listen, Suche, Dashboard enthalten keine Daten von A"); + const aIds = Object.values(ids); + const noLeak = (label: string, value: unknown) => { + const text = JSON.stringify(value, (_k, v) => (typeof v === "bigint" ? v.toString() : v)); + const leaked = aIds.filter((id) => text.includes(id)); + ok(leaked.length === 0 && !text.includes(`Hausverwaltung ${SLUG_A}`), `${label}: keine A-Daten${leaked.length ? ` — enthält ${leaked.length} IDs` : ""}`); + }; + noLeak("listWorkOrders", await listWorkOrders(bo, { sort: "plannedStart", dir: "asc", page: 1, pageSize: 100 })); + noLeak("listReports", await listReports(bo, { status: "all" })); + noLeak("listDocuments", await listDocuments(bo, { pageSize: 500 })); + noLeak("listCustomers", await listCustomers(bo, { status: "all", pageSize: 100 })); + noLeak("listSites", await listSites(bo, { status: "all", pageSize: 100 })); + noLeak("listTeams", await listTeams(bo, { includeInactive: true })); + noLeak("searchAll", await searchAll(bo, { q: SLUG_A.slice(0, 12) })); + noLeak("searchAll (Auftragstitel)", await searchAll(bo, { q: "Isolation" })); + noLeak("listImports", await listImports(bo)); + noLeak("listEmergencyReviews", await listEmergencyReviews(bo, { filter: "all" })); + noLeak("listSyncConflicts", await listSyncConflicts(bo)); + noLeak("listNotifications", await listNotifications(tech, {})); + noLeak("queryAuditLog", await queryAuditLog(admin, {})); + noLeak("listAiGenerations", await listAiGenerations(admin)); + noLeak("getFieldBundle", await getFieldBundle(tech)); + const tiles = await getDashboardTiles(bo, {}); + ok(tiles.open === 0 && tiles.running === 0 && tiles.syncConflicts === 0 && tiles.reportsToReview === 0, "Dashboard von B zählt keine Aufträge/Konflikte/Berichte von A"); + + ok((await watched()) === beforeAll, "Daten von Mandant A nach allen Versuchen unverändert"); +}); From c297cfbe832e56e9dc343bafdaac64264611210e Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:25:55 +0200 Subject: [PATCH 3/6] =?UTF-8?q?L10a=20Qualit=C3=A4t=20&=20Abnahmetests:=20?= =?UTF-8?q?Sicherheitstests=20(Rollen-Matrix,=20sch=C3=A4dliche=20Uploads,?= =?UTF-8?q?=20Login-Sperre/Rate-Limit/Sessions)=20und=20Audit-Log=20append?= =?UTF-8?q?-only=20f=C3=BCr=20craftvia=5Fapp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- .../migration.sql | 7 + scripts/test-e2e-tenant-isolation.ts | 40 ++++- scripts/test-security-auth.ts | 164 ++++++++++++++++++ scripts/test-security-roles.ts | 138 +++++++++++++++ scripts/test-security-uploads.ts | Bin 0 -> 11223 bytes 5 files changed, 340 insertions(+), 9 deletions(-) create mode 100644 prisma/migrations/20260914200000_qualitaet_audit_append_only/migration.sql create mode 100644 scripts/test-security-auth.ts create mode 100644 scripts/test-security-roles.ts create mode 100644 scripts/test-security-uploads.ts diff --git a/prisma/migrations/20260914200000_qualitaet_audit_append_only/migration.sql b/prisma/migrations/20260914200000_qualitaet_audit_append_only/migration.sql new file mode 100644 index 0000000..35add57 --- /dev/null +++ b/prisma/migrations/20260914200000_qualitaet_audit_append_only/migration.sql @@ -0,0 +1,7 @@ +-- L10a Qualität & Abnahmetests: Audit-Log append-only für die eingeschränkte App-Rolle. +-- Spec §26 (revisionsfähige Protokollierung) und §43.4 („Audit-Log-Manipulation"): +-- Die App schreibt Audit-Einträge ausschließlich per INSERT (writeAuditLog, Owner-Client) und liest sie +-- im Audit-Viewer. Mit RLS_ENFORCED=true soll auch ein kompromittierter Fachpfad über die Rolle +-- craftvia_app bestehende Einträge weder ändern noch löschen können. Backup/Restore und DSGVO-Löschung +-- laufen über die Owner-Rolle und sind nicht betroffen. +REVOKE UPDATE, DELETE, TRUNCATE ON TABLE audit_logs FROM craftvia_app; diff --git a/scripts/test-e2e-tenant-isolation.ts b/scripts/test-e2e-tenant-isolation.ts index 9d2212b..e1c4472 100644 --- a/scripts/test-e2e-tenant-isolation.ts +++ b/scripts/test-e2e-tenant-isolation.ts @@ -149,6 +149,15 @@ async function threw(fn: () => Promise): Promise { } } +/** Bulk write without effect: count 0, or refused by the database (append-only audit_logs under RLS). */ +async function noEffect(fn: () => Promise): Promise { + try { + return ((await fn()) as { count: number }).count === 0; + } catch (err) { + return /permission denied/i.test((err as Error).message); + } +} + function defaultRlsUrl(): string { const base = new URL(process.env.DATABASE_URL ?? "postgresql://localhost:5432/craftvia?schema=public"); base.username = "craftvia_app"; @@ -178,8 +187,8 @@ runSuite("E2E Mandantentrennung (systematisch)", [SLUG_A, SLUG_B], async () => { findFirst: (await d.findFirst({ where })) === null, findUnique: (await d.findUnique({ where }).catch(() => null)) === null, count: (await d.count({ where })) === 0, - updateMany: ((await d.updateMany({ where, data: row.update })) as { count: number }).count === 0, - deleteMany: ((await d.deleteMany({ where })) as { count: number }).count === 0, + updateMany: await noEffect(() => d.updateMany({ where, data: row.update })), + deleteMany: await noEffect(() => d.deleteMany({ where })), update: await threw(() => d.update({ where, data: row.update })), delete: await threw(() => d.delete({ where })), }; @@ -202,13 +211,26 @@ runSuite("E2E Mandantentrennung (systematisch)", [SLUG_A, SLUG_B], async () => { const row = rows[model]; if (!row) continue; const table = topo.nodes.get(model)!.table; - const res = await app.$transaction(async (tx) => { - await tx.$executeRaw`SELECT set_config('app.tenant_id', ${B.tenantId}, true)`; - const seen = await tx.$queryRawUnsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM "${table}" WHERE id = $1`, row.id); - const updated = await tx.$executeRawUnsafe(`UPDATE "${table}" SET id = id WHERE id = $1`, row.id); - const deleted = await tx.$executeRawUnsafe(`DELETE FROM "${table}" WHERE id = $1`, row.id); - return { seen: seen[0].n, updated, deleted }; - }); + /** Affected rows under tenant context B; a missing privilege (append-only audit_logs) counts as 0. */ + const asB = async (sql: string): Promise => { + try { + return await app.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.tenant_id', ${B.tenantId}, true)`; + return tx.$executeRawUnsafe(sql, row.id); + }); + } catch (err) { + if (/permission denied/i.test((err as Error).message)) return 0; + throw err; + } + }; + const res = { + seen: await app.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.tenant_id', ${B.tenantId}, true)`; + return (await tx.$queryRawUnsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM "${table}" WHERE id = $1`, row.id))[0].n; + }), + updated: await asB(`UPDATE "${table}" SET id = id WHERE id = $1`), + deleted: await asB(`DELETE FROM "${table}" WHERE id = $1`), + }; const own = await app.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.tenant_id', ${A.tenantId}, true)`; return (await tx.$queryRawUnsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM "${table}" WHERE id = $1`, row.id))[0].n; diff --git a/scripts/test-security-auth.ts b/scripts/test-security-auth.ts new file mode 100644 index 0000000..5929453 --- /dev/null +++ b/scripts/test-security-auth.ts @@ -0,0 +1,164 @@ +// L10a Sicherheitstest §43.4 „Rate Limiting", „Session-Handling", „Audit-Log-Manipulation" (Service-/DB-Ebene). +// +// Login: 5 Fehlversuche → Identität gesperrt (auch das richtige Passwort wird abgewiesen), Fehlversuche +// und Sperre auditiert, unbekannte E-Mail ohne Orakel, Ausnahme „letzter aktiver Mandanten-Admin" +// (keine DoS-Sperre, aber protokolliert). Rate-Limit der Wiederherstellungs-Abläufe je IP und Konto. +// Session-Kill-Switch (isTokenStillValid, fail-closed ohne iat). Audit-Log: kein Fachpfad (Services, +// Actions, Route Handler) ändert oder löscht Audit-Einträge (statische Prüfung), die App-Rolle +// craftvia_app hat auf audit_logs weder UPDATE noch DELETE (Migration *_qualitaet_audit_append_only), +// der Mandanten-Guard verhindert fremde Zugriffe. Passwort-Reset/MFA-Härtung: test-reset-flow.ts, +// test-mfa-hardening.ts; Session-Cookies über HTTP: test-security-http.ts. +// +// Lauf: npx tsx scripts/test-security-auth.ts + +import "dotenv/config"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { PrismaClient } from "@prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { prisma, dbForTenant } from "../src/server/db"; +import { hashPassword } from "../src/server/password"; +import { authorizeTenantCredentials } from "../src/server/auth"; +import { checkRateLimit, resetRateLimits } from "../src/server/rate-limit"; +import { isTokenStillValid } from "../src/server/sessions"; +import { writeAuditLog } from "../src/server/audit"; +import { createTenant, ok, runSuite, section } from "./lib/e2e-fixture"; + +const SLUG = "zz-q-sec-auth"; +const SLUG_B = "zz-q-sec-auth-b"; +const PASSWORD = "Pruef-Passwort-2026!"; + +function walk(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const p = join(dir, name); + return statSync(p).isDirectory() ? walk(p) : /\.(ts|tsx)$/.test(name) ? [p] : []; + }); +} + +function defaultRlsUrl(): string { + const base = new URL(process.env.DATABASE_URL ?? "postgresql://localhost:5432/craftvia?schema=public"); + base.username = "craftvia_app"; + base.password = "craftvia_app_local"; + return base.toString(); +} + +runSuite("Sicherheit: Login-Sperre, Rate-Limit, Sessions, Audit-Log", [SLUG, SLUG_B], async () => { + const A = await createTenant(SLUG); + const B = await createTenant(SLUG_B); + const hash = await hashPassword(PASSWORD); + await prisma.identity.updateMany({ where: { id: { in: [A.users.tech.identityId, A.users.admin.identityId] } }, data: { passwordHash: hash, failedLogins: 0, lockedUntil: null } }); + const techEmail = A.users.tech.email; + + section("Login: Sperre nach Fehlversuchen"); + ok((await authorizeTenantCredentials({ email: techEmail, password: PASSWORD, tenant: SLUG })) !== null, "richtiges Passwort → Anmeldung möglich"); + for (let i = 1; i <= 5; i++) { + const res = await authorizeTenantCredentials({ email: techEmail, password: `falsch-${i}`, tenant: SLUG }); + if (res !== null) ok(false, `Fehlversuch ${i} wurde angenommen`); + } + const locked = await prisma.identity.findUniqueOrThrow({ where: { id: A.users.tech.identityId } }); + ok(!!locked.lockedUntil && locked.lockedUntil.getTime() > Date.now() + 10 * 60_000, `nach 5 Fehlversuchen gesperrt bis ${locked.lockedUntil?.toISOString()}`); + ok((await authorizeTenantCredentials({ email: techEmail, password: PASSWORD, tenant: SLUG })) === null, "gesperrt: auch das richtige Passwort wird abgewiesen"); + const denied = await prisma.auditLog.findMany({ where: { tenantId: A.tenantId, entity: "login", action: "denied", entityId: A.users.tech.identityId } }); + ok(denied.length >= 6 && denied.some((d) => (d.after as { locked?: boolean } | null)?.locked === true) && denied.some((d) => (d.after as { reason?: string } | null)?.reason === "locked"), `Fehlversuche, Sperre und Versuch während der Sperre auditiert (${denied.length})`); + await prisma.identity.update({ where: { id: locked.id }, data: { lockedUntil: new Date(Date.now() - 1000) } }); + ok((await authorizeTenantCredentials({ email: techEmail, password: PASSWORD, tenant: SLUG })) !== null, "nach Ablauf der Sperre wieder anmeldbar, Zähler zurückgesetzt"); + ok((await prisma.identity.findUniqueOrThrow({ where: { id: locked.id } })).failedLogins === 0, "Fehlversuchszähler nach erfolgreicher Anmeldung 0"); + ok((await authorizeTenantCredentials({ email: `unbekannt${Date.now()}@zz-qualitaet.test`, password: PASSWORD, tenant: SLUG })) === null, "unbekannte E-Mail → gleiche Antwort (null), kein Konto-Orakel"); + ok((await authorizeTenantCredentials({ email: techEmail, password: PASSWORD, tenant: SLUG_B })) === null, "Anmeldung in einem Mandanten ohne Mitgliedschaft abgewiesen"); + + section("Login: letzter aktiver Mandanten-Admin wird nicht ausgesperrt (DoS-Schutz)"); + for (let i = 0; i < 6; i++) await authorizeTenantCredentials({ email: A.users.admin.email, password: "falsch", tenant: SLUG }); + const admin = await prisma.identity.findUniqueOrThrow({ where: { id: A.users.admin.identityId } }); + ok(!admin.lockedUntil || admin.lockedUntil.getTime() < Date.now(), "letzter Admin nicht gesperrt"); + ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "login", action: "denied", entityId: admin.id } })) >= 6, "Fehlversuche des Admins trotzdem auditiert"); + console.log(" Hinweis: Das Login zählt Fehlversuche je Identität (Sperre); ein IP-basiertes Limit gegen Password-Spraying über viele Konten gibt es nur für Reset/Einladung (siehe Bericht)."); + + section("Rate-Limit Wiederherstellung (je IP und je Konto)"); + resetRateLimits(); + const results = Array.from({ length: 6 }, () => checkRateLimit("passwordResetRequest", { ip: "203.0.113.7", account: "opfer@zz-qualitaet.test" }).allowed); + ok(results.slice(0, 5).every(Boolean) && results[5] === false, "Reset-Anfrage: 5 erlaubt, 6. blockiert"); + const blocked = checkRateLimit("passwordResetRequest", { ip: "203.0.113.7", account: "anderes@zz-qualitaet.test" }); + ok(!blocked.allowed && blocked.retryAfterSeconds > 0, "gleiche IP, anderes Konto → weiter blockiert (mit Wartezeit)"); + ok(!checkRateLimit("passwordResetRequest", { ip: "198.51.100.1", account: "opfer@zz-qualitaet.test" }).allowed, "anderes Konto-Ziel über neue IP → Konto-Zähler blockiert"); + ok(checkRateLimit("passwordResetRequest", { ip: "198.51.100.2", account: "frei@zz-qualitaet.test" }).allowed, "unbeteiligte IP + Konto → erlaubt"); + resetRateLimits(); + const redeem = Array.from({ length: 11 }, () => checkRateLimit("passwordResetRedeem", { ip: "203.0.113.9" }).allowed); + ok(redeem.filter(Boolean).length === 10 && !redeem[10], "Reset-Einlösung: 10 Versuche je IP, dann blockiert"); + resetRateLimits(); + + section("Session-Kill-Switch"); + const now = Math.floor(Date.now() / 1000); + ok(isTokenStillValid(now, null), "ohne Invalidierungsmarke gültig"); + ok(!isTokenStillValid(now - 60, new Date(now * 1000)), "Token vor der Marke ausgestellt → ungültig"); + ok(isTokenStillValid(now + 1, new Date(now * 1000)), "Token nach der Marke → gültig"); + ok(!isTokenStillValid(undefined, new Date()), "Token ohne iat bei gesetzter Marke → fail-closed"); + + section("Audit-Log: kein Fachpfad ändert oder löscht Einträge"); + const root = process.cwd(); + const scanned = ["src/app", "src/server/actions", "src/server/services", "src/server/api", "src/server/jobs", "src/lib"].flatMap((d) => walk(join(root, d))); + const offenders = scanned.filter((file) => { + const text = readFileSync(file, "utf8"); + return /auditLog\s*\.\s*(update|updateMany|delete|deleteMany|upsert)\b/.test(text) || /(UPDATE|DELETE\s+FROM|TRUNCATE)\s+"?audit_logs/i.test(text); + }); + ok(scanned.length > 100 && offenders.length === 0, `${scanned.length} Dateien geprüft, keine Mutation von audit_logs${offenders.length ? ` — ${offenders.map((f) => relative(root, f)).join(", ")}` : ""}`); + const apiRoutes = walk(join(root, "src/app/api")); + ok(!apiRoutes.some((f) => /audit/i.test(relative(root, f))), "keine /api-Route für Audit-Einträge"); + const viewer = readFileSync(join(root, "src/server/services/audit/viewer.ts"), "utf8"); + ok(!/\.(create|update|delete|upsert)\w*\(/.test(viewer.replace(/writeAuditLog/g, "")), "Audit-Viewer-Service ist rein lesend"); + + const privileges = await prisma.$queryRawUnsafe<{ sel: boolean; ins: boolean; upd: boolean; del: boolean }[]>( + `SELECT has_table_privilege('craftvia_app', 'audit_logs', 'SELECT') AS sel, has_table_privilege('craftvia_app', 'audit_logs', 'INSERT') AS ins, + has_table_privilege('craftvia_app', 'audit_logs', 'UPDATE') AS upd, has_table_privilege('craftvia_app', 'audit_logs', 'DELETE') AS del`, + ); + ok(privileges[0].sel && privileges[0].ins, "App-Rolle darf Audit-Einträge lesen und anlegen"); + ok(!privileges[0].upd && !privileges[0].del, "App-Rolle darf Audit-Einträge weder ändern noch löschen (append-only)"); + + await writeAuditLog({ tenantId: A.tenantId, actorId: A.users.backoffice.id, action: "update", entity: "zz_audit_probe", entityId: "p1", before: { v: 1 }, after: { v: 2 } }); + const probe = await prisma.auditLog.findFirstOrThrow({ where: { tenantId: A.tenantId, entity: "zz_audit_probe" } }); + ok(probe.actorId === A.users.backoffice.id && (probe.before as { v: number }).v === 1, "writeAuditLog protokolliert Akteur und before/after"); + let foreignBlocked = false; + try { + await dbForTenant(B.tenantId).auditLog.update({ where: { id: probe.id }, data: { entity: "manipuliert" } }); + } catch { + foreignBlocked = true; + } + ok(foreignBlocked && (await prisma.auditLog.count({ where: { tenantId: B.tenantId, entity: "manipuliert" } })) === 0, "fremder Mandant kann den Eintrag nicht ändern (Tenant-Guard)"); + let deletedByB = -1; + try { + deletedByB = (await dbForTenant(B.tenantId).auditLog.deleteMany({ where: { id: probe.id } })).count; + } catch (err) { + // RLS_ENFORCED=true: the app role has no DELETE privilege on audit_logs at all + if (/permission denied/i.test((err as Error).message)) deletedByB = 0; + else throw err; + } + ok(deletedByB === 0, "fremder Mandant kann den Eintrag nicht löschen"); + + const app = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.RLS_DATABASE_URL ?? defaultRlsUrl() }) }); + try { + await app.$queryRawUnsafe("SELECT 1"); + const attempt = async (sql: string) => { + try { + await app.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.tenant_id', ${A.tenantId}, true)`; + await tx.$executeRawUnsafe(sql, probe.id); + }); + return "executed"; + } catch (err) { + return /permission denied/i.test((err as Error).message) ? "denied" : `error:${(err as Error).message.split("\n")[0]}`; + } + }; + ok((await attempt(`UPDATE audit_logs SET entity = 'manipuliert' WHERE id = $1`)) === "denied", "craftvia_app im eigenen Mandanten: UPDATE audit_logs → permission denied"); + ok((await attempt(`DELETE FROM audit_logs WHERE id = $1`)) === "denied", "craftvia_app im eigenen Mandanten: DELETE audit_logs → permission denied"); + const readable = await app.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.tenant_id', ${A.tenantId}, true)`; + return tx.auditLog.count({ where: { id: probe.id } }); + }); + ok(readable === 1, "craftvia_app liest den eigenen Eintrag weiterhin"); + } catch (err) { + if (process.env.RLS_TEST_REQUIRED === "true") throw err; + console.log(`⚠ DB-Rollenprüfung übersprungen: craftvia_app nicht verbindbar (${(err as Error).message.split("\n")[0]})`); + } finally { + await app.$disconnect(); + } + ok((await prisma.auditLog.findUniqueOrThrow({ where: { id: probe.id } })).entity === "zz_audit_probe", "Audit-Eintrag nach allen Versuchen unverändert"); +}); diff --git a/scripts/test-security-roles.ts b/scripts/test-security-roles.ts new file mode 100644 index 0000000..6c8e0f5 --- /dev/null +++ b/scripts/test-security-roles.ts @@ -0,0 +1,138 @@ +// L10a Sicherheitstest §43.4 „Unzulässige Rollenaktionen" — Matrix aus src/server/rbac.ts. +// +// Für jede Permission des Katalogs mit einem fachlichen Schreib-/Lesepfad wird ein repräsentativer +// Service-Aufruf mit allen vier Standardrollen ausgeführt. Erwartung wird UNABHÄNGIG aus ROLE_DEFS +// abgeleitet: fehlt die Permission → `forbidden` (bzw. bei Sichtbarkeitsrechten `not_found`), ist sie +// vorhanden → niemals `forbidden`. Neue Permissions ohne Matrix-Eintrag lassen den Test scheitern +// (bewusste Ausnahmen sind unten begründet). +// +// Lauf: npx tsx scripts/test-security-roles.ts + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import { PERMISSIONS, ROLE_DEFS, ROLE_KEYS, type Permission, type RoleKey } from "../src/server/rbac"; +import type { ServiceCtx } from "../src/server/services/context"; +import { createCustomer, listCustomers } from "../src/server/services/customers/customers"; +import { mergeCustomers } from "../src/server/services/customers/merge"; +import { createSite, listSites } from "../src/server/services/sites/sites"; +import { createTeam, listTeams } from "../src/server/services/teams/teams"; +import { getWorkOrderDetail } from "../src/server/services/work-orders/detail"; +import { updateWorkOrder } from "../src/server/services/work-orders/update"; +import { assignWorkOrder } from "../src/server/services/work-orders/assign"; +import { releaseForBilling } from "../src/server/services/work-orders/release-billing"; +import { cancelWorkOrder } from "../src/server/services/work-orders/cancel"; +import { createOrderType } from "../src/server/services/work-orders/settings"; +import { listImports } from "../src/server/services/imports/queries"; +import { createNote } from "../src/server/services/field/notes"; +import { correctTimeEntry } from "../src/server/services/field/time-correction"; +import { createDailyReport } from "../src/server/services/reports/create"; +import { approveReport } from "../src/server/services/reports/approve"; +import { listReports } from "../src/server/services/reports/queries"; +import { searchCustomersForEmergency } from "../src/server/services/emergency/lookup"; +import { listEmergencyReviews } from "../src/server/services/emergency/review"; +import { authorizeDocumentAccess, updateDocumentMeta } from "../src/server/services/documents/access"; +import { listNotifications } from "../src/server/services/notifications/inbox"; +import { queryAuditLog } from "../src/server/services/audit/viewer"; +import { getAiGenerationContent } from "../src/server/services/lotse/protocol"; +import { checkCompleteness } from "../src/server/services/lotse/completeness"; +import { noteCreatePayload } from "../src/lib/sync/ops"; +import { codeOf, createTenant, ok, runSuite, section, type Persona, type TenantFixture } from "./lib/e2e-fixture"; + +const SLUG = "zz-q-sec-roles"; + +/** Permissions whose enforcement lives outside the domain services (covered by other test scripts). */ +const COVERED_ELSEWHERE: Partial> = { + "user:read": "Nutzerverwaltung (Fundament) — scripts/test-tenant-users-authz.ts", + "user:manage": "Nutzerverwaltung (Fundament) — scripts/test-tenant-users-authz.ts", + "role:manage": "Rollenverwaltung (Fundament) — scripts/test-tenant-users-authz.ts", + "document:read": "Download-Route /files/ (requireApiContext) — scripts/test-security-http.ts", + "report:read": "Lesepfad über reportScope — unten als Sichtbarkeitsprüfung", + "work_order:read_team": "Scope-Recht — unten als Sichtbarkeitsprüfung (read_all vs. read_team)", +}; + +const PERSONA_OF: Record = { "tenant-admin": "admin", backoffice: "backoffice", "team-lead": "lead", technician: "tech" }; + +type Probe = { permission: Permission; label: string; run: (ctx: ServiceCtx, role: RoleKey) => Promise; denied?: string }; + +async function rawOrder(A: TenantFixture, status: string, extra: Record = {}) { + return prisma.workOrder.create({ + data: { tenantId: A.tenantId, number: `R-${randomUUID().slice(0, 8)}`, customerId: A.customerId, siteId: A.siteId, title: "Rollenprüfung", status: status as never, assignedTeamId: A.teamId, teamLeadUserId: A.users.lead.id, ...extra }, + }); +} + +runSuite("Sicherheit: Rollen-Matrix", [SLUG], async () => { + const A = await createTenant(SLUG); + const shared = await rawOrder(A, "in_progress", { assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }] } }); + const session = await prisma.workSession.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, userId: A.users.tech.id, status: "ended", startedAt: new Date(Date.now() - 7200_000), endedAt: new Date(Date.now() - 3600_000) } }); + const entry = await prisma.timeEntry.create({ data: { tenantId: A.tenantId, workSessionId: session.id, userId: A.users.tech.id, startedAt: session.startedAt, endedAt: session.endedAt } }); + const draftReport = await prisma.report.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, type: "daily", reportDate: new Date(), lineageId: randomUUID(), status: "draft", content: {} } }); + const teamDoc = await prisma.document.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, category: "photo", fileName: "a.jpg", storageKey: `${A.tenantId}/a.jpg`, mimeType: "image/jpeg", fileSize: 1, checksum: "0".repeat(64), lineageId: randomUUID(), visibility: "team" } }); + const internalDoc = await prisma.document.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, category: "order_confirmation", fileName: "intern.pdf", storageKey: `${A.tenantId}/intern.pdf`, mimeType: "application/pdf", fileSize: 1, checksum: "0".repeat(64), lineageId: randomUUID(), visibility: "backoffice_only" } }); + const leadDoc = await prisma.document.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, category: "other", fileName: "tl.pdf", storageKey: `${A.tenantId}/tl.pdf`, mimeType: "application/pdf", fileSize: 1, checksum: "0".repeat(64), lineageId: randomUUID(), visibility: "team_lead" } }); + const unassigned = await prisma.workOrder.create({ data: { tenantId: A.tenantId, number: "R-FREE", customerId: A.customerId, title: "Ohne Team", status: "planned" } }); + + const probes: Probe[] = [ + { permission: "tenant:manage", label: "KI-Protokoll mit Inhalten", run: (c) => getAiGenerationContent(c, "unknown-id") }, + { permission: "audit:read", label: "Audit-Protokoll lesen", run: (c) => queryAuditLog(c, {}) }, + { permission: "customer:read", label: "Kundenliste", run: (c) => listCustomers(c) }, + { permission: "customer:write", label: "Kunde anlegen", run: (c, r) => createCustomer(c, { companyName: `Rollen ${r} ${randomUUID()}` }, { acknowledgeDuplicates: true }) }, + { + permission: "customer:merge", + label: "Kunden zusammenführen", + run: async (c) => { + const s = await prisma.customer.create({ data: { tenantId: A.tenantId, companyName: `Quelle ${randomUUID()}` } }); + const t = await prisma.customer.create({ data: { tenantId: A.tenantId, companyName: `Ziel ${randomUUID()}` } }); + return mergeCustomers(c, { sourceId: s.id, targetId: t.id, confirm: true }); + }, + }, + { permission: "site:read", label: "Objektliste", run: (c) => listSites(c) }, + { permission: "site:write", label: "Objekt anlegen", run: (c) => createSite(c, { customerId: A.customerId, name: `Objekt ${randomUUID()}` }) }, + { permission: "team:read", label: "Teamliste", run: (c) => listTeams(c) }, + { permission: "team:manage", label: "Team anlegen", run: (c) => createTeam(c, { name: `Team ${randomUUID()}` }) }, + { permission: "work_order:read_all", label: "Auftrag ohne Team/Zuweisung lesen", run: (c) => getWorkOrderDetail(c, unassigned.id), denied: "not_found" }, + { permission: "work_order:write", label: "Auftrag bearbeiten", run: (c) => updateWorkOrder(c, shared.id, { technicianNotes: `Rolle ${randomUUID()}` }) }, + { permission: "work_order:assign", label: "Auftrag zuweisen", run: async (c) => assignWorkOrder(c, { workOrderId: (await rawOrder(A, "planned")).id, teamId: A.teamId }) }, + { permission: "work_order:release_billing", label: "Zur Abrechnung freigeben", run: async (c) => releaseForBilling(c, { workOrderId: (await rawOrder(A, "in_review")).id }) }, + { permission: "work_order:cancel", label: "Auftrag stornieren", run: async (c) => cancelWorkOrder(c, { workOrderId: (await rawOrder(A, "assigned")).id, reason: "Rollenprüfung" }) }, + { permission: "import:write", label: "Importe", run: (c) => listImports(c) }, + { permission: "field:execute", label: "Tätigkeitsnotiz erfassen", run: (c) => createNote(c, noteCreatePayload.parse({ workOrderId: shared.id, text: "Rollenprüfung" })) }, + { permission: "field:correct_time", label: "Arbeitszeit korrigieren", run: (c) => correctTimeEntry(c, { timeEntryId: entry.id, startedAt: session.startedAt, endedAt: session.endedAt, reason: "Rollenprüfung" }) }, + { permission: "report:write", label: "Tagesbericht anlegen", run: async (c) => createDailyReport(c, { workOrderId: (await rawOrder(A, "planned")).id }) }, + { permission: "report:approve_team", label: "Bericht freigeben (Teamleiter oder Backoffice)", run: (c) => approveReport(c, { reportId: draftReport.id }, { dispatchPdf: async () => undefined }) }, + { permission: "emergency:create", label: "Notdienst-Kundensuche", run: (c) => searchCustomersForEmergency(c, "Hausverwaltung") }, + { permission: "emergency:review", label: "Notdienst-Prüfliste", run: (c) => listEmergencyReviews(c) }, + { permission: "document:write", label: "Dokument-Metadaten ändern", run: (c) => updateDocumentMeta(c, teamDoc.id, { title: `Titel ${randomUUID()}` }) }, + { permission: "document:read_internal", label: "internes Dokument (backoffice_only) öffnen", run: (c) => authorizeDocumentAccess(c, internalDoc.id), denied: "not_found" }, + { permission: "notification:read", label: "Benachrichtigungen", run: (c) => listNotifications(c, {}) }, + { permission: "settings:templates", label: "Auftragsart anlegen", run: (c) => createOrderType(c, { key: `rolle_${randomUUID().slice(0, 8).replace(/-/g, "")}`, name: "Rollenprüfung" }) }, + { permission: "lotse:use", label: "Lotse-Vollständigkeitsprüfung", run: (c) => checkCompleteness(c, shared.id) }, + ]; + + section("Matrix-Abdeckung"); + const probed = new Set(probes.map((p) => p.permission)); + probed.add("report:approve"); // shares the approval probe (report:approve OR report:approve_team) + const uncovered = PERMISSIONS.filter((p) => !probed.has(p) && !COVERED_ELSEWHERE[p]); + ok(uncovered.length === 0, `jede Permission hat eine Prüfung oder begründete Ausnahme${uncovered.length ? ` — fehlt: ${uncovered.join(", ")}` : ""}`); + + for (const probe of probes) { + section(`${probe.permission} — ${probe.label}`); + for (const role of ROLE_KEYS) { + const perms = ROLE_DEFS[role].permissions as readonly string[]; + const allowed = probe.permission === "report:approve_team" ? perms.includes("report:approve_team") || perms.includes("report:approve") : perms.includes(probe.permission); + const got = await codeOf(probe.run(A.ctx[PERSONA_OF[role]], role)); + const denied = probe.denied ?? "forbidden"; + ok(allowed ? got !== "forbidden" && got !== denied : got === denied, `${role.padEnd(12)} ${allowed ? "erlaubt " : "verboten"} → ${got}`); + } + } + + section("Sichtbarkeit: read_team / report:read / Teamleiter-Dokumente"); + ok((await codeOf(getWorkOrderDetail(A.ctx.tech, shared.id))) === "ok", "Monteur mit Zuweisung sieht den Auftrag"); + ok((await codeOf(getWorkOrderDetail(A.ctx.outsider, shared.id))) === "not_found", "Monteur ohne Zuweisung (anderes Team) → not_found"); + ok((await codeOf(getWorkOrderDetail(A.ctx.lead2, shared.id))) === "not_found", "Teamleiter eines anderen Teams → not_found"); + ok((await listReports(A.ctx.outsider, { status: "all" })).items.length === 0, "Berichtsliste des fremden Monteurs leer"); + ok((await listReports(A.ctx.tech, { status: "all" })).items.some((r) => r.id === draftReport.id), "Berichtsliste des zugewiesenen Monteurs enthält den Bericht"); + ok((await codeOf(authorizeDocumentAccess(A.ctx.lead, leadDoc.id))) === "ok", "Teamleiter öffnet Teamleiter-Dokument"); + ok((await codeOf(authorizeDocumentAccess(A.ctx.tech, leadDoc.id))) === "not_found", "Monteur öffnet Teamleiter-Dokument nicht"); + ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: shared.id } })).status === "in_progress", "Auftrag durch verbotene Aktionen nicht verändert"); +}); diff --git a/scripts/test-security-uploads.ts b/scripts/test-security-uploads.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b70db6188607a91887997e6e06b5e2e692ca815 GIT binary patch literal 11223 zcmc&)+j85+8Sb^8V$Jc?U_t_v4@pYP^2oMiJCSTnD5sss8W{wZBusF@>@G!8ie_@v z2k7)N)81sJZ_q1W)o+p~>G$tq0q~&6c9O}okqKZA|Ne*XKWz}Oy^U7LI!QF(a=;U< zG*_Da?>~2L`*&D?AGbcokDq_|S0@_0`zcOoAbUAZvoKaQy#FWbaCw?Uyt&imf-^5n zQ#YV_&n(Fr_oecM}Zjo=tuC$kW&^8$^#|}amjW>JQ01C#rWL|Qw5?& z>~KD2KmYw7EbR9AG~tSid+Z=f=lwL(&Bxd>;exjE`tRPRI_Yz;r7uD~k?d0ziarMe zL|ukzpjN=ImBO+q#wDa{>+s~gL?mTA?lQh=Yx6jp>8zyV!iGU*?R%DLpsoDpY+RGNn}Jmpe_Y0%Mp!sVHvPOxNT zhlvmy8+M6Ij>0ri375LTI{7i0U-3EXrab9t%%MH@LlK8Ua{&^6>Ls&#Myqr;lta?1 z=8hVMX{eJ7Y~K&lX()N~A@5B@%;-M>?r6rPBtkF=Z0;`U?p++7W&&C#Vh-8_BqGh3L^l*er%!uO$~F_@8PjUUR3wwJR>2}$CsxhD*=yGl zqmHZ=XE@<$5M`p5^lSIr`*S9-Up9LA^4Vi{!Fn#RC*EE6>fi&4HG zC>aG%TL>hGyVaq&ZS6>z$p(WWK_{)bJ<_vFR-lDokBVp{6|1gSm|g=4WyE@BEb%#{ zebsVt7EMSQRbW=BF8e4AN86{lxEhddKZzdBH80*6%E{!NNF9q74_}a8*~{ucb+`Nw z1z=o7crU@wAQxtJp(@Z^wTP9!%9p9G(w)G@58__ALNE%4ys5&`IOWZ8+*8%zcGE*H zU@HywX3YDgrw~JRw%S80I*!eZaw49rN84IT&V6Z2=`aQg7OmYw@Sj~tn2C)oBi9#I> zmlj#*bi|iv+&L>rA$9oYT^@CdUn|M{Kq%C4O0XY(pHDUsbj6XpSMyj_PI@w(UoH=6AzrnDu(FZ=%M2 zp3-N#iiH+`n#th{cqJUu_)?E{WGoJK*aoM7U{JniaT|-e-vETk2+pI;YA>4`wFZkO z5ZH)IJFI+yLP9O=Tl)*Zl;{+Wxn>=HFnN?s;6?A}{Li`MKnA*i zuiiPEA_l@S*6&IyR~9weOe;bvZKtUKJW3m~9~vnqNU!vRxze zVhT~(z~KyiB~6R8K<)^H`k9R1Q+TBclQ{jeVcm;I0NCA6Mi+0E!t_{R=epoGMmE z0$YoL4reV!Su^4@j_(gAJ%nIT2?0iUNBJZ9bFa4l?W@^eTCZj;`tNrBuXzSTYTZPH za$c)9+!~04v&g%Qw&EGp$E=-|oxw2)^=5<(=@%Hp}Yt#PiX7PM$(*{{v zVTwS~s~BH4On7}W$UkoecC?9%w`vsus#6kRRU$Vx>D_FU3U$9mL2x?=rqiiEz3peR zAKYrSS^-AXmiFGL?uPGMH?0chp4O}F+LS?>lwklvMg+N>TvRFR@*Ywe5NFlU0glzK zZ0LFReePSW6EJq^b+ZP8;z4~RFiary_hAC0PT_crWXr_M0lli%Vbb^E+=;sQS{hfjeWzf6v+E#&MWBU+*Q{j?Q62V0Zt z`*1Zmc>csl=HT%zLWdzj2g=4jT0X!NkYW@cRFxc|hs$w>HmAA-5%VELlyGPf%%UX- zKwSV8BTCQ9k_?Itc`2rv+Y{I#MkoM!xolGfW3v9I3$DpIbNbR?q>oisJVpuV%L2Pg zSa$1xP#Qv2qIH%Sny z0W}H~mWb`;YE{lKZ>4UujlvY^T3#}$SD8aJ9IQF)QY2y&1h_^qKzaJhJX3`Q2TLqi z@(t2{z?e z(ND<5RBf76jA+t$D!g@3WlTmRDr$;!1uH{?;Vm~AmEfjW%|*NU$nMV_(Z1?q0Cq3$xPUP~ew z?-s){>nXH~Z);@_lQQ30hAjeDvw-yD)ZCLs{7VpX(?Xqii&dovBxzj;YdPhrfE|`>$S2 z6V%|R%KY#Xk+?Z{Upy9*g0QQybfP&n(1XB5;Di&!?_x0JR)3=O?3H{~#BbGvtLBE@ z3^%)5Q9G{j?|TpWpAZQ*v1EACJ&8`@6MoV=>7N+F+>9DR*yeHc)@eP%|?8u2YP%Qu6oAcpH_uIZ*G_aW~Gt z?7VpHTTvywc|P&NW|cS2S7+FjFA57-9r60cd2LohS$2$epLeI#s$URWUf2DUqs=y8 zn2GyisTySye5C!P)*; z;l0bq(BM+8qb7iB+A8X$I=c)+4i<_{d}r;}F;{dPt;?uY&)_LQTMEt?&bWVEK{LYGscG%tIs zL_9laVubpvY3t#NdAq1lD74c%V~Lio$aHeX#X?|CC2Xjgt@6`D9#pUM9EL@^)YY;Z zcr>0|TVw(-s*IQ2NDoIW`T_K5nxRzQU_)RfeB^il(sIw3N1(?25NhDFD~O-KGgXd{mm1YM=wyGG)YcqnL13$zSO7p(5myp2Xe6uy zmT!+n1xTBxnCzuk!-%nrc$imKH~_ko=!_;xp>>GcEV%N3($=E=8uAx?#qVdK3yiUW z(kxhI5b=dz#GxisZWqe<2SCGMiRgVhO;)tl+!K{WJ92@(n2K zBpku&OZk5lEvN+IMlJfg3h6bYfX1~|#6-yu$sn94n?Wj!nr&Dh5d7r!}YDz=o_{^5zEYS#zyrm86@Nq+P>x1J4 zRLc8Y{5EVFQofZt?qAKN_iss7U>#v|H;GZ$AfL5{CtneTNGII!jY@9YOiXq8*#wug zsvOBf8EPftRukGmq*jHkl{L`PAQRCuQTjsP7BkLG$PQhiaHWa6S9DbhR4OdEn6$z` zvtirZa6=2pCfS%*PO%px@M;IGB-uQq1>L1K&URnCeEyg~f?Zq{ZJ0dW#=W?o6<%6+ zK@@@{+nuZk4@!zqW?H!VMV4K{u2n9}%89^%qCtq*9894K(84t%ueO1!i7ot(ZY!V} zx^WjN#0Q*%*m+s#Xpd2$pg0N5LTT(|BEnPLJt68yfxby37x}l9t8B)%R4%YFZ_O%s zG*}ilOO>$+nz^M=sung~y#q&aDFD)NIOteXftk1hi8e>6c@*fN5}32K!?o*B<}Sf} z-^=<4lSPaOe_r(j?#9UC?Gd1@y&vG*1Go~JD@D*gTk0^;nBpmvo-SF~85EIfIicHU zh$j6}I0h{^@D>YwH^LTVtX7!VH+~i?5T66}IzRS4M4MX)1v%v4mI;XsAu-}*r&z`I zK1X^u_dI+u#9EH}gHX{clTqbd_7#GClh>7#ZH5aGzTvnaTTSq6eg{Vf3E3N$7NWQ5 YMRQxENat7&*=xmW@|&F&o5|(>A13Fy^#A|> literal 0 HcmV?d00001 From 388ca85f548948a41b2cbc0edcf6a3c01c254b40 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:34:29 +0200 Subject: [PATCH 4/6] =?UTF-8?q?L10a=20Qualit=C3=A4t=20&=20Abnahmetests:=20?= =?UTF-8?q?HTTP-Sicherheitstest=20(manipulierte=20IDs=20aller=20/api/v1-Ro?= =?UTF-8?q?uten,=20Datei-Routen,=20Uploads,=20CSRF,=20Sessions,=20Login-Sp?= =?UTF-8?q?erre,=20Header)=20gegen=20next=20start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- scripts/test-e2e-offline-sync.ts | 18 +- scripts/test-security-http.ts | 383 +++++++++++++++++++++++++++++++ 2 files changed, 396 insertions(+), 5 deletions(-) create mode 100644 scripts/test-security-http.ts diff --git a/scripts/test-e2e-offline-sync.ts b/scripts/test-e2e-offline-sync.ts index de9e7a2..8119270 100644 --- a/scripts/test-e2e-offline-sync.ts +++ b/scripts/test-e2e-offline-sync.ts @@ -9,7 +9,8 @@ import "dotenv/config"; import { randomUUID } from "node:crypto"; import { prisma } from "../src/server/db"; -import type { SyncOperationInput } from "../src/lib/sync/envelope"; +import { CONFLICTING_OPS, SYNC_OP_TYPES, type SyncOperationInput } from "../src/lib/sync/envelope"; +import { EXTERNAL_OPS } from "../src/server/services/sync/external-ops"; import { applyOperations } from "../src/server/services/sync/apply"; import { storeFieldUpload } from "../src/server/services/field/uploads"; import { getFieldBundle } from "../src/server/services/field/queries"; @@ -137,10 +138,17 @@ runSuite("E2E Offline-Sync", [SLUG_A, SLUG_B], async () => { section("7. Ungültige Ops, Scope, Mandantentrennung"); const invalid = await applyOperations(tech, { deviceId: "e2e-device", operations: [op("note.create", { workOrderId: w, text: "" })] }); ok(invalid.results[0].status === "rejected" && invalid.results[0].errorCode === "invalid", "ungültige Payload → rejected invalid"); - // signature.capture is not registered in services/sync/external-ops.ts (reports lane uses server actions) - const unavailable = op("signature.capture", { reportId: "x" }); - const na = await applyOperations(tech, { deviceId: "e2e-device", operations: [unavailable] }); - ok(na.results[0].status === "rejected" && (await prisma.syncOperation.count({ where: { clientOpId: unavailable.clientOpId } })) === 0, "nicht verfügbare Op → rejected, nicht gespeichert (später wiederholbar)"); + // An op type without server handler (neither field handler nor registered in services/sync/external-ops.ts) + // must be answered without being stored, so devices can retry after the owning lane is deployed. + const FIELD_OPS = ["session.start", "session.pause", "session.resume", "session.end", "work_order.transition", "note.create", "checklist.toggle", "material.upsert", "photo.attach", "voice.attach"]; + const unregistered = SYNC_OP_TYPES.find((t) => !FIELD_OPS.includes(t) && !EXTERNAL_OPS[t] && !CONFLICTING_OPS.includes(t)); + if (unregistered) { + const unavailable = op(unregistered, { reportId: "x" }); + const na = await applyOperations(tech, { deviceId: "e2e-device", operations: [unavailable] }); + ok(na.results[0].status === "rejected" && (await prisma.syncOperation.count({ where: { clientOpId: unavailable.clientOpId } })) === 0, `nicht verfügbare Op ${unregistered} → rejected, nicht gespeichert (später wiederholbar)`); + } else { + console.log("• alle Sync-Op-Typen sind registriert — Prüfung „nicht verfügbare Op“ entfällt"); + } const notesBefore = await prisma.activityNote.count({ where: { workOrderId: w } }); const outsider = await applyOperations(A.ctx.outsider, { deviceId: "o", operations: [op("note.create", { workOrderId: w, text: "fremd" }), op("session.start", { workOrderId: w, mode: "work" })] }); ok(outsider.results.every((x) => x.status === "rejected" && x.errorCode === "not_found"), "Monteur ohne Zuweisung → not_found"); diff --git a/scripts/test-security-http.ts b/scripts/test-security-http.ts new file mode 100644 index 0000000..e3d7f12 --- /dev/null +++ b/scripts/test-security-http.ts @@ -0,0 +1,383 @@ +// L10a Sicherheitstest §43.4 über HTTP gegen den echten Next.js-Server (Route Handler, Proxy, Auth.js-Cookies). +// +// Server: SECURITY_BASE=http://localhost:3110 (laufender Server) — sonst wird der Produktions-Build (.next, +// z. B. aus `npm run gate`) per `next start` auf einem freien Port gestartet. Ohne Build und ohne +// SECURITY_BASE wird der Test mit Hinweis übersprungen (Exit 0). +// Sessions werden wie in scripts/smoke-auth.ts ohne Passworteingabe ausgestellt (finalizeIdentityLogin + +// Auth.js encode); nur der Login-Sperrtest nutzt ein Test-Passwort einer zz-Test-Identität. +// +// Nachweise: (1) ohne Session: /api/v1 → 401, Datei-Routen liefern keine Bytes; (2) manipulierte IDs: +// jede /api/v1-Route und jede Datei-Route mit IDs aus Mandant A (Session aus Mandant B bzw. Monteur ohne +// Zuweisung) → 404, nie 200/403, keine Daten im Body; unsinnige IDs → 404 statt 500; (3) unzulässige +// Rollenaktionen → 403 + Audit „denied“; (4) schädliche Uploads über alle Upload-Routen; Polyglot wird nur +// als Anhang mit nosniff ausgeliefert; Pfad-Traversal/CRLF im Dateinamen; Übergröße; Server bleibt +// erreichbar; (5) CSRF: fremder Origin → 403; (6) Session-Handling: manipuliertes/abgelaufenes/fremd +// signiertes Cookie, Kill-Switch, deaktiviertes Konto; (7) Audit-Log nicht über HTTP änderbar; +// (8) Login-Sperre über den echten Credentials-Endpunkt; (9) Sicherheits-Header. +// Statuscodes: `invalid` wird als 400 oder 422 akzeptiert, `blocked` als 409 oder 422 (Vereinheitlichung L10b). +// +// Lauf: npx tsx scripts/test-security-http.ts (oder SECURITY_BASE=http://localhost:3110 npx tsx …) + +import "dotenv/config"; +import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync } from "node:fs"; +import { createServer } from "node:net"; +import { randomUUID } from "node:crypto"; +import { encode } from "next-auth/jwt"; +import { prisma } from "../src/server/db"; +import { finalizeIdentityLogin } from "../src/server/auth"; +import { hashPassword } from "../src/server/password"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; +import { assignWorkOrder } from "../src/server/services/work-orders/assign"; +import { startSession } from "../src/server/services/field/sessions"; +import { storeFieldUpload } from "../src/server/services/field/uploads"; +import { createNote } from "../src/server/services/field/notes"; +import { createDailyReport } from "../src/server/services/reports/create"; +import { createImport } from "../src/server/services/imports/upload"; +import { noteCreatePayload, sessionStartPayload } from "../src/lib/sync/ops"; +import { buildPdf } from "./make-sample-pdfs"; +import { createTenant, jpegBytes, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture"; + +const SLUG_A = "zz-q-sec-http-a"; +const SLUG_B = "zz-q-sec-http-b"; +const MARK_A = `Hausverwaltung ${SLUG_A}`; +const INVALID = [400, 422]; + +async function freePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer(); + srv.once("error", reject); + srv.listen(0, () => { + const port = (srv.address() as { port: number }).port; + srv.close(() => resolve(port)); + }); + }); +} + +async function startServer(): Promise<{ base: string; stop: () => void; logs: () => string } | null> { + if (process.env.SECURITY_BASE) return { base: process.env.SECURITY_BASE.replace(/\/$/, ""), stop: () => undefined, logs: () => "" }; + if (!existsSync(".next/BUILD_ID")) return null; + const port = await freePort(); + let output = ""; + const child: ChildProcess = spawn(process.execPath, ["node_modules/next/dist/bin/next", "start", "-p", String(port)], { + env: { ...process.env, NODE_ENV: "production", PORT: String(port) }, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout?.on("data", (d) => (output += d)); + child.stderr?.on("data", (d) => (output += d)); + const base = `http://localhost:${port}`; + for (let i = 0; i < 120; i++) { + try { + const res = await fetch(`${base}/api/v1/work-orders`, { signal: AbortSignal.timeout(2000) }); + if (res.status === 401) return { base, stop: () => child.kill("SIGTERM"), logs: () => output }; + } catch { + /* not ready yet */ + } + await new Promise((r) => setTimeout(r, 500)); + } + child.kill("SIGTERM"); + throw new Error(`next start did not become ready on ${base}:\n${output.slice(-2000)}`); +} + +type Res = { status: number; text: string; headers: Headers }; + +runSuite("Sicherheit über HTTP", [SLUG_A, SLUG_B], async () => { + const server = await startServer(); + if (!server) { + console.log("⚠ ÜBERSPRUNGEN: kein Produktions-Build (.next/BUILD_ID) und kein SECURITY_BASE gesetzt. `npm run build` oder SECURITY_BASE=http://localhost:3110."); + return; + } + const { base } = server; + console.log(`Server: ${base}`); + const cookieName = base.startsWith("https") ? "__Secure-authjs.session-token" : "authjs.session-token"; + + try { + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + + async function cookieFor(f: TenantFixture, persona: keyof TenantFixture["users"], opts: { secret?: string; maxAge?: number } = {}) { + const user = await finalizeIdentityLogin(f.users[persona].identityId, f.slug); + if (!user) throw new Error(`no session user for ${persona}`); + 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: opts.secret ?? process.env.AUTH_SECRET!, salt: cookieName, maxAge: opts.maxAge ?? 30 * 60 }); + return `${cookieName}=${value}`; + } + + async function call(method: string, path: string, opts: { cookie?: string; json?: unknown; form?: FormData; origin?: string | null; accept?: string } = {}): Promise { + const headers: Record = {}; + if (opts.cookie) headers.cookie = opts.cookie; + if (opts.accept) headers.accept = opts.accept; + const origin = opts.origin === undefined ? (method === "GET" ? null : base) : opts.origin; + if (origin) headers.origin = origin; + let body: BodyInit | undefined; + if (opts.json !== undefined) { + headers["content-type"] = "application/json"; + body = JSON.stringify(opts.json); + } else if (opts.form) body = opts.form; + const res = await fetch(base + path, { method, headers, body, redirect: "manual", signal: AbortSignal.timeout(60_000) }); + return { status: res.status, text: await res.text(), headers: res.headers }; + } + + const leaks = (r: Res) => r.text.includes(MARK_A) || /prisma|stack|at \/|node_modules|PrismaClient|SELECT /i.test(r.text); + const expectStatus = (label: string, r: Res, allowed: number[]) => + ok(allowed.includes(r.status) && !leaks(r), `${label} → ${r.status}${allowed.includes(r.status) ? "" : ` (erwartet ${allowed.join("|")})`}${leaks(r) ? " — Body enthält Daten/Interna" : ""}`); + + // ---------- Daten in Mandant A ---------- + const bo = A.ctx.backoffice; + const wo = await createWorkOrder(bo, { title: "HTTP-Sicherheit A", customerId: A.customerId, siteId: A.siteId, applyTemplate: false, materials: [{ name: "Rohr", plannedQuantity: 1, unit: "m" }] }); + await assignWorkOrder(bo, { workOrderId: wo.id, teamId: A.teamId, userIds: [A.users.tech.id] }); + const free = await createWorkOrder(bo, { title: "Unzugeordnet A", customerId: A.customerId }); + await startSession(A.ctx.tech, sessionStartPayload.parse({ workOrderId: wo.id, mode: "work", at: new Date(Date.now() - 3600_000).toISOString() })); + const jpeg = await jpegBytes("HTTP"); + const photo = await storeFieldUpload(A.ctx.tech, { clientId: randomUUID(), workOrderId: wo.id, kind: "photo" }, { bytes: jpeg, name: "a.jpg", type: "image/jpeg" }); + await createNote(A.ctx.tech, noteCreatePayload.parse({ workOrderId: wo.id, kind: "work_done", text: "HTTP-Test" })); + const report = (await createDailyReport(A.ctx.tech, { workOrderId: wo.id })).report; + const pdf = buildPdf([[{ text: "HTTP-Sicherheit" }]]); + const imp = await createImport(bo, { bytes: pdf, fileName: "auftrag.pdf", mimeType: "application/pdf" }, { dispatch: async () => undefined }); + + const cA = { bo: await cookieFor(A, "backoffice"), admin: await cookieFor(A, "admin"), tech: await cookieFor(A, "tech"), outsider: await cookieFor(A, "outsider"), lead: await cookieFor(A, "lead") }; + const cB = { bo: await cookieFor(B, "backoffice"), admin: await cookieFor(B, "admin"), tech: await cookieFor(B, "tech") }; + + // ================= (1) ohne Session ================= + section("(1) Ohne Session"); + const anonymous: [string, string][] = [ + ["GET", "/api/v1/work-orders"], ["POST", "/api/v1/work-orders"], ["GET", `/api/v1/work-orders/${wo.id}`], ["POST", `/api/v1/work-orders/${wo.id}/transition`], + ["GET", "/api/v1/customers"], ["GET", `/api/v1/customers/${A.customerId}`], ["GET", `/api/v1/sites/${A.siteId}/history`], ["POST", "/api/v1/sync"], + ["POST", "/api/v1/uploads"], ["GET", "/api/v1/field/bundle"], ["GET", `/api/v1/field/documents/${photo.documentId}`], ["GET", `/api/v1/reports/${report.id}/pdf`], + ["GET", `/api/v1/imports/${imp.id}`], ["POST", "/api/v1/work-orders/import"], + ]; + for (const [m, p] of anonymous) { + const r = await call(m, p, { json: m === "POST" ? {} : undefined }); + ok(r.status === 401 && !leaks(r) && /unauthorized/.test(r.text), `${m} ${p} ohne Session → ${r.status}`); + } + for (const p of [`/files/${photo.documentId}`, `/imports/${imp.id}/file`, "/dashboard", "/m"]) { + const r = await call("GET", p); + ok([302, 303, 307].includes(r.status) && (r.headers.get("location") ?? "").includes("/login") && r.text.length < 2000 && !r.text.includes("JFIF"), `GET ${p} ohne Session → Umleitung zur Anmeldung, keine Datei`); + } + + // ================= (2) manipulierte IDs ================= + section("(2) Manipulierte IDs: Mandant B greift auf IDs aus Mandant A zu"); + const jpegForm = () => { + const f = new FormData(); + f.set("file", new File([new Uint8Array(jpeg)], "b.jpg", { type: "image/jpeg" })); + return f; + }; + const uploadForm = (workOrderId: string, bytes: Uint8Array = jpeg, name = "b.jpg", type = "image/jpeg") => { + const f = new FormData(); + f.set("file", new File([new Uint8Array(bytes)], name, { type })); + f.set("clientId", randomUUID()); + f.set("workOrderId", workOrderId); + f.set("kind", "photo"); + return f; + }; + const validReviewForm = { customerMode: "new", customer: { companyName: "B-Kunde" }, siteMode: "none", site: {}, contact: {}, order: { title: "B" }, positions: [] }; + const foreign: [string, string, Parameters[2]][] = [ + ["GET", `/api/v1/work-orders/${wo.id}`, { cookie: cB.bo }], + ["PATCH", `/api/v1/work-orders/${wo.id}`, { cookie: cB.bo, json: { title: "B" } }], + ["POST", `/api/v1/work-orders/${wo.id}/assign`, { cookie: cB.bo, json: { teamId: B.teamId } }], + ["POST", `/api/v1/work-orders/${wo.id}/transition`, { cookie: cB.bo, json: { to: "cancelled", reason: "B" } }], + ["GET", `/api/v1/work-orders/${wo.id}/materials`, { cookie: cB.bo }], + ["POST", `/api/v1/work-orders/${wo.id}/materials`, { cookie: cB.bo, json: { name: "B", plannedQuantity: 1, unit: "Stk" } }], + ["POST", `/api/v1/work-orders/${wo.id}/documents`, { cookie: cB.bo, form: jpegForm() }], + ["POST", `/api/v1/work-orders/${wo.id}/daily-report`, { cookie: cB.tech, json: {} }], + ["POST", `/api/v1/work-orders/${wo.id}/completion-report`, { cookie: cB.tech, json: {} }], + ["POST", `/api/v1/reports/${report.id}/approve`, { cookie: cB.bo }], + ["GET", `/api/v1/reports/${report.id}/pdf`, { cookie: cB.bo }], + ["GET", `/api/v1/reports/${report.id}/files/${photo.documentId}`, { cookie: cB.bo }], + ["GET", `/api/v1/customers/${A.customerId}`, { cookie: cB.bo }], + ["PATCH", `/api/v1/customers/${A.customerId}`, { cookie: cB.bo, json: { companyName: "B" } }], + ["GET", `/api/v1/sites/${A.siteId}/history`, { cookie: cB.bo }], + ["GET", `/api/v1/imports/${imp.id}`, { cookie: cB.bo }], + ["POST", `/api/v1/imports/${imp.id}/confirm`, { cookie: cB.admin, json: validReviewForm }], + ["GET", `/api/v1/field/documents/${photo.documentId}`, { cookie: cB.tech }], + ["GET", `/api/v1/field/documents/${photo.documentId}?variant=preview`, { cookie: cB.tech }], + ["POST", "/api/v1/uploads", { cookie: cB.tech, form: uploadForm(wo.id) }], + ["GET", `/files/${photo.documentId}`, { cookie: cB.admin }], + ["GET", `/files/${imp.documentId}`, { cookie: cB.admin }], + ["GET", `/imports/${imp.id}/file`, { cookie: cB.admin }], + ]; + for (const [m, p, o] of foreign) expectStatus(`B: ${m} ${p.replace(/[a-z0-9]{20,}/g, "")}`, await call(m, p, o), [404]); + const syncB = await call("POST", "/api/v1/sync", { cookie: cB.tech, json: { deviceId: "b", operations: [{ clientOpId: randomUUID(), opType: "note.create", payload: { workOrderId: wo.id, text: "B" }, clientCreatedAt: new Date().toISOString() }] } }); + ok(syncB.status === 200 && /"not_found"/.test(syncB.text) && !/"applied"/.test(syncB.text), "B: POST /api/v1/sync mit Auftrag aus A → Op rejected not_found"); + const bundleB = await call("GET", "/api/v1/field/bundle", { cookie: cB.tech }); + ok(bundleB.status === 200 && !bundleB.text.includes(wo.id) && !leaks(bundleB), "B: Bundle enthält keine Aufträge aus A"); + const listB = await call("GET", "/api/v1/work-orders", { cookie: cB.bo }); + ok(listB.status === 200 && !listB.text.includes(wo.id) && !listB.text.includes(free.id), "B: Auftragsliste ohne Aufträge aus A"); + const customersB = await call("GET", `/api/v1/customers?q=${encodeURIComponent(SLUG_A)}`, { cookie: cB.bo }); + ok(customersB.status === 200 && !leaks(customersB), "B: Kundensuche nach A-Namen findet nichts"); + + section("(2b) Monteur ohne Zuweisung und unsinnige IDs"); + for (const [m, p, o] of [ + ["GET", `/api/v1/work-orders/${wo.id}`, { cookie: cA.outsider }], + ["POST", `/api/v1/work-orders/${wo.id}/transition`, { cookie: cA.outsider, json: { to: "paused" } }], + ["GET", `/api/v1/field/documents/${photo.documentId}`, { cookie: cA.outsider }], + ["POST", "/api/v1/uploads", { cookie: cA.outsider, form: uploadForm(wo.id) }], + ["GET", `/api/v1/customers/${A.customerId}`, { cookie: cA.outsider }], + ["GET", `/files/${photo.documentId}`, { cookie: cA.outsider }], + ] as [string, string, Parameters[2]][]) { + const r = await call(m, p, o); + ok(r.status === 404 && !r.text.includes("HTTP-Sicherheit A"), `Monteur ohne Zuweisung: ${m} ${p.replace(/[a-z0-9]{20,}/g, "")} → ${r.status}`); + } + for (const bad of ["..%2F..%2Fetc%2Fpasswd", "' OR '1'='1", "x".repeat(300), "%00", "../../api/v1/customers"]) { + const r1 = await call("GET", `/api/v1/work-orders/${encodeURIComponent(bad)}`, { cookie: cA.bo }); + const r2 = await call("GET", `/files/${encodeURIComponent(bad)}`, { cookie: cA.bo }); + ok([400, 404].includes(r1.status) && [400, 404].includes(r2.status) && !leaks(r1) && !leaks(r2), `unsinnige ID ${JSON.stringify(bad.slice(0, 24))} → ${r1.status}/${r2.status} (kein 500)`); + } + + // ================= (3) Rollen ================= + section("(3) Unzulässige Rollenaktionen über HTTP"); + const deniedBefore = await prisma.auditLog.count({ where: { tenantId: A.tenantId, actorId: A.users.tech.id, action: "denied" } }); + const roleCases: [string, string, string, Parameters[2]][] = [ + ["Monteur legt Kunden an", "POST", "/api/v1/customers", { cookie: cA.tech, json: { companyName: "Monteur GmbH" } }], + ["Monteur ändert Kunden", "PATCH", `/api/v1/customers/${A.customerId}`, { cookie: cA.tech, json: { companyName: "Monteur" } }], + ["Monteur legt Auftrag an", "POST", "/api/v1/work-orders", { cookie: cA.tech, json: { title: "x", customerId: A.customerId } }], + ["Monteur weist zu", "POST", `/api/v1/work-orders/${wo.id}/assign`, { cookie: cA.tech, json: { teamId: A.teamId } }], + ["Monteur ändert Materialvorgabe", "POST", `/api/v1/work-orders/${wo.id}/materials`, { cookie: cA.tech, json: { name: "x", plannedQuantity: 1, unit: "Stk" } }], + ["Monteur gibt Bericht frei", "POST", `/api/v1/reports/${report.id}/approve`, { cookie: cA.tech }], + ["Monteur öffnet Import", "GET", `/api/v1/imports/${imp.id}`, { cookie: cA.tech }], + ["Monteur lädt Auftragsbestätigung hoch", "POST", "/api/v1/work-orders/import", { cookie: cA.tech, form: (() => { const f = new FormData(); f.set("file", new File([new Uint8Array(pdf)], "a.pdf", { type: "application/pdf" })); return f; })() }], + ["Teamleiter legt Objekt an", "POST", "/api/v1/sites", { cookie: cA.lead, json: { customerId: A.customerId, name: "Teamleiter-Objekt" } }], + ["Backoffice nutzt Einsatz-Upload", "POST", "/api/v1/uploads", { cookie: cA.bo, form: uploadForm(wo.id) }], + ]; + for (const [label, m, p, o] of roleCases) expectStatus(label, await call(m, p, o), [403]); + ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, actorId: A.users.tech.id, action: "denied" } })) > deniedBefore, "abgewiesene Rollenaktionen als „denied“ auditiert"); + const techList = await call("GET", "/api/v1/work-orders", { cookie: cA.tech }); + ok(techList.status === 200 && techList.text.includes(wo.id) && !techList.text.includes(free.id), "Monteur: Auftragsliste nur mit eigenen Aufträgen"); + + // ================= (4) schädliche Uploads ================= + section("(4) Schädliche Uploads über HTTP"); + const docForm = (bytes: Uint8Array, name: string, type: string) => { + const f = new FormData(); + f.set("file", new File([new Uint8Array(bytes)], name, { type })); + f.set("category", "other"); + f.set("visibility", "team"); + f.set("customerId", A.customerId); + return f; + }; + const exe = Buffer.concat([Buffer.from("MZ\x90\x00", "binary"), Buffer.alloc(256, 0x41)]); + const html = Buffer.from(""); + const up = (bytes: Uint8Array, name: string, type: string, extra: Parameters[2] = {}) => call("POST", "/documents/upload", { cookie: cA.bo, form: docForm(bytes, name, type), accept: "application/json", ...extra }); + const rExe = await up(exe, "rechnung.pdf", "application/pdf"); + ok(INVALID.includes(rExe.status) && /type_mismatch/.test(rExe.text), `EXE als rechnung.pdf → ${rExe.status} type_mismatch`); + const rHtml = await up(html, "bild.jpg", "image/jpeg"); + ok(INVALID.includes(rHtml.status) && /type_mismatch/.test(rHtml.text), `HTML als bild.jpg → ${rHtml.status}`); + const rSvg = await up(Buffer.from(""), "logo.svg", "image/svg+xml"); + ok(INVALID.includes(rSvg.status) && /unsupported_type/.test(rSvg.text), `SVG mit Skript → ${rSvg.status}`); + const rPoly = await up(Buffer.concat([jpeg, html]), "polyglot.jpg", "image/jpeg"); + const polyId = (JSON.parse(rPoly.text || "{}") as { data?: { id?: string } }).data?.id; + ok(rPoly.status === 201 && !!polyId, "Polyglot (JPEG + HTML) als Bild angenommen"); + if (polyId) { + const dl = await call("GET", `/files/${polyId}`, { cookie: cA.bo }); + ok(dl.status === 200 && dl.headers.get("content-type") === "image/jpeg" && /^attachment;/.test(dl.headers.get("content-disposition") ?? "") && dl.headers.get("x-content-type-options") === "nosniff", "Polyglot-Download: image/jpeg, attachment, nosniff (kein Ausführen als HTML)"); + ok(/default-src 'self'/.test(dl.headers.get("content-security-policy") ?? ""), "Datei-Auslieferung mit Content-Security-Policy"); + await expectStatus("Polyglot aus Mandant B", await call("GET", `/files/${polyId}`, { cookie: cB.admin }), [404]); + } + const rTrav = await up(pdf, "../../../etc/passwd.pdf", "application/pdf"); + const travId = (JSON.parse(rTrav.text || "{}") as { data?: { id?: string } }).data?.id; + const travDoc = travId ? await prisma.document.findUnique({ where: { id: travId } }) : null; + ok(rTrav.status === 201 && !!travDoc && !/[\\/]/.test(travDoc.fileName) && travDoc.storageKey.startsWith(`${A.tenantId}/`) && !travDoc.storageKey.includes(".."), `Pfad-Traversal im Dateinamen → gespeichert als ${travDoc?.fileName}, Key im Mandantenpräfix`); + const rCrlf = await up(pdf, "bericht\r\nX-Injected: 1.pdf", "application/pdf"); + const crlfId = (JSON.parse(rCrlf.text || "{}") as { data?: { id?: string } }).data?.id; + if (crlfId) { + const dl = await call("GET", `/files/${crlfId}`, { cookie: cA.bo }); + ok(dl.status === 200 && !dl.headers.has("x-injected") && !/[\r\n]/.test(dl.headers.get("content-disposition") ?? ""), "CR/LF im Dateinamen führt nicht zu Header-Injection beim Download"); + } else ok(INVALID.includes(rCrlf.status), `CR/LF-Dateiname abgewiesen (${rCrlf.status})`); + const big = Buffer.concat([pdf, Buffer.alloc(25 * 1024 * 1024 + 1024)]); + const rBig = await up(big, "gross.pdf", "application/pdf"); + ok([400, 413, 422].includes(rBig.status) && !leaks(rBig), `PDF > 25 MB → ${rBig.status}`); + const rUpExe = await call("POST", "/api/v1/uploads", { cookie: cA.tech, form: uploadForm(wo.id, exe, "foto.jpg", "image/jpeg") }); + ok(INVALID.includes(rUpExe.status) && !leaks(rUpExe), `Einsatz-Upload: EXE als Foto → ${rUpExe.status}`); + const rUpBig = await call("POST", "/api/v1/uploads", { cookie: cA.tech, form: uploadForm(wo.id, Buffer.concat([jpeg, Buffer.alloc(26 * 1024 * 1024)])) }); + ok([400, 413, 422].includes(rUpBig.status), `Einsatz-Upload > 25 MB → ${rUpBig.status}`); + const rImpExe = await call("POST", "/api/v1/work-orders/import", { cookie: cA.bo, form: (() => { const f = new FormData(); f.set("file", new File([new Uint8Array(exe)], "auftrag.pdf", { type: "application/pdf" })); return f; })() }); + ok(INVALID.includes(rImpExe.status) && !leaks(rImpExe), `Import: EXE als PDF → ${rImpExe.status}`); + const alive = await call("GET", "/api/v1/work-orders", { cookie: cA.bo }); + ok(alive.status === 200, "Server nach Übergrößen-Uploads weiter erreichbar"); + + // ================= (5) CSRF ================= + section("(5) CSRF: fremder Origin"); + const evil = "https://evil.example"; + expectStatus("Dokument-Upload mit fremdem Origin", await up(pdf, "csrf.pdf", "application/pdf", { origin: evil }), [403]); + expectStatus("PATCH Kunde mit fremdem Origin", await call("PATCH", `/api/v1/customers/${A.customerId}`, { cookie: cA.bo, json: { companyName: "CSRF" }, origin: evil }), [403]); + expectStatus("Sync mit fremdem Origin", await call("POST", "/api/v1/sync", { cookie: cA.tech, json: { deviceId: "x", operations: [] }, origin: evil }), [403]); + ok((await prisma.customer.findUniqueOrThrow({ where: { id: A.customerId } })).companyName === MARK_A + " GmbH", "Kunde durch CSRF-Versuche unverändert"); + + // ================= (6) Sessions ================= + section("(6) Session-Handling"); + const probe = (cookie?: string) => call("GET", "/api/v1/customers?pageSize=1", { cookie }); + ok((await probe(cA.bo)).status === 200, "gültige Session → 200"); + const value = cA.bo.split("=")[1]; + const tampered = `${cookieName}=${value.slice(0, -6)}${value.slice(-6).split("").reverse().join("")}`; + ok((await probe(tampered)).status === 401, "manipuliertes Session-Cookie → 401"); + ok((await probe(await cookieFor(A, "backoffice", { secret: "x".repeat(48) }))).status === 401, "mit fremdem Schlüssel signiertes Cookie → 401"); + ok((await probe(await cookieFor(A, "backoffice", { maxAge: -60 }))).status === 401, "abgelaufenes Session-Cookie → 401"); + const page = await call("GET", "/dashboard", { cookie: tampered }); + ok([302, 303, 307].includes(page.status) && (page.headers.get("location") ?? "").includes("/login"), "Seite mit manipuliertem Cookie → Anmeldung"); + await prisma.identity.update({ where: { id: A.users.backoffice.identityId }, data: { sessionsValidAfter: new Date(Date.now() + 5000) } }); + ok((await probe(cA.bo)).status === 401, "Kill-Switch (alle Sitzungen beendet) → alte Session 401"); + await prisma.identity.update({ where: { id: A.users.backoffice.identityId }, data: { sessionsValidAfter: null } }); + await prisma.user.update({ where: { id: A.users.backoffice.id }, data: { status: "DEACTIVATED" } }); + const deact = await probe(cA.bo); + ok([401, 403].includes(deact.status) && !leaks(deact), `deaktiviertes Konto mit gültigem Cookie → ${deact.status}`); + await prisma.user.update({ where: { id: A.users.backoffice.id }, data: { status: "ACTIVE" } }); + ok((await probe(cA.bo)).status === 200, "reaktiviert → wieder 200"); + + // ================= (7) Audit-Log ================= + section("(7) Audit-Log nicht über HTTP änderbar"); + const auditRow = await prisma.auditLog.findFirstOrThrow({ where: { tenantId: A.tenantId }, orderBy: { createdAt: "asc" } }); + const snapshot = JSON.stringify(auditRow); + for (const p of ["/api/v1/audit", "/api/v1/audit-logs", `/api/v1/audit-logs/${auditRow.id}`, `/api/v1/audit/${auditRow.id}`, `/settings/audit/${auditRow.id}`]) { + for (const m of ["PATCH", "PUT", "DELETE"]) { + const r = await call(m, p, { cookie: cA.admin, json: { entity: "manipuliert" } }); + if (r.status >= 200 && r.status < 300) ok(false, `${m} ${p} → ${r.status} (erwartet kein 2xx)`); + } + } + ok(JSON.stringify(await prisma.auditLog.findUniqueOrThrow({ where: { id: auditRow.id } })) === snapshot, "keine Route ändert/löscht Audit-Einträge (PATCH/PUT/DELETE auf Audit-Pfade ohne Wirkung)"); + const viewer = await call("GET", "/settings/audit", { cookie: cA.admin }); + // read-only by construction (no mutating service/route, see test-security-auth.ts static scan); the page renders + ok(viewer.status === 200 && viewer.text.includes(auditRow.entity), `Audit-Protokoll (Admin) rendert Einträge des eigenen Mandanten (${viewer.status})`); + const auditB = await call("GET", `/settings/audit?detail=${auditRow.id}`, { cookie: cB.admin }); + ok(auditB.status === 200 && !auditB.text.includes(auditRow.id.slice(0, 20) + "\"") && !auditB.text.includes(MARK_A), "Audit-Detail aus Mandant B zeigt keinen Eintrag von A"); + + // ================= (8) Login-Sperre ================= + section("(8) Login-Sperre über den Credentials-Endpunkt"); + const providers = await call("GET", "/api/auth/providers"); + if (providers.status !== 200 || !providers.text.includes('"credentials"')) { + console.log(`⚠ Login-Test übersprungen: Credentials-Provider nicht gefunden (${providers.status})`); + } else { + const password = "Http-Pruef-Passwort-2026!"; + await prisma.identity.update({ where: { id: A.users.tech2.identityId }, data: { passwordHash: await hashPassword(password), failedLogins: 0, lockedUntil: null } }); + const csrf = await fetch(`${base}/api/auth/csrf`); + const { csrfToken } = (await csrf.json()) as { csrfToken: string }; + const jar = csrf.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "); + const login = async (pw: string) => { + const res = await fetch(`${base}/api/auth/callback/credentials`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded", cookie: jar, origin: base }, + body: new URLSearchParams({ email: A.users.tech2.email, password: pw, tenant: SLUG_A, csrfToken }), + redirect: "manual", + }); + return res.headers.getSetCookie().some((c) => c.startsWith(`${cookieName}=`) && !c.startsWith(`${cookieName}=;`)); + }; + ok(await login(password), "Positivkontrolle: richtiges Passwort setzt ein Session-Cookie"); + for (let i = 0; i < 5; i++) ok(!(await login(`falsch-${i}`)), `Fehlversuch ${i + 1} → kein Session-Cookie`); + const locked = await prisma.identity.findUniqueOrThrow({ where: { id: A.users.tech2.identityId } }); + ok(!!locked.lockedUntil && locked.lockedUntil > new Date(), "nach 5 Fehlversuchen über HTTP gesperrt"); + ok(!(await login(password)), "gesperrt: richtiges Passwort erhält kein Session-Cookie"); + } + + // ================= (9) Header ================= + section("(9) Sicherheits-Header"); + const loginPage = await call("GET", "/login"); + const h = loginPage.headers; + ok(/frame-ancestors 'none'/.test(h.get("content-security-policy") ?? "") && h.get("x-frame-options") === "DENY", "Clickjacking-Schutz (CSP frame-ancestors, X-Frame-Options)"); + ok(h.get("x-content-type-options") === "nosniff" && /max-age=\d+/.test(h.get("strict-transport-security") ?? "") && !!h.get("referrer-policy"), "nosniff, HSTS, Referrer-Policy gesetzt"); + const apiErr = await call("GET", `/api/v1/work-orders/${wo.id}`, { cookie: cB.bo }); + ok(/no-store/.test(apiErr.headers.get("cache-control") ?? "") || apiErr.status === 404, "API-Antworten nicht cachebar"); + } finally { + server.stop(); + } +}); From 565cd4ef5c499b5552f62ef372cba4be71ed4f90 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:43:47 +0200 Subject: [PATCH 5/6] =?UTF-8?q?L10a=20Qualit=C3=A4t=20&=20Abnahmetests:=20?= =?UTF-8?q?Authentifizierter=20Durchstich=20aller=20Kernseiten=20je=20Roll?= =?UTF-8?q?e,=20Last-Seed=20(5=20000=20Auftr=C3=A4ge)=20mit=20Performance-?= =?UTF-8?q?Messung,=20Demo-Import=20f=C3=BCr=20die=20Pr=C3=BCfmaske?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- scripts/lib/demo-seed.ts | 31 +++++ scripts/seed-load.ts | 159 +++++++++++++++++++++++++ scripts/smoke-auth.ts | 246 ++++++++++++++++++++++++++++++++------- 3 files changed, 391 insertions(+), 45 deletions(-) create mode 100644 scripts/seed-load.ts diff --git a/scripts/lib/demo-seed.ts b/scripts/lib/demo-seed.ts index 768bc75..995ee55 100644 --- a/scripts/lib/demo-seed.ts +++ b/scripts/lib/demo-seed.ts @@ -8,7 +8,13 @@ // Alle Namen, Adressen und Telefonnummern sind frei erfunden (keine echten personenbezogenen Daten). import { randomUUID } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; import sharp from "sharp"; +import { FakeExtractionProvider } from "../../src/server/ai/extraction/fake"; +import { createImport } from "../../src/server/services/imports/upload"; +import { processImport } from "../../src/server/services/imports/process"; +import { buildPdf } from "../make-sample-pdfs"; import { prisma, dbForTenant } from "../../src/server/db"; import { ROLE_DEFS, type RoleKey } from "../../src/server/rbac"; import type { ServiceCtx } from "../../src/server/services/context"; @@ -387,6 +393,31 @@ export async function seedDemoTenant(tenantId: string, users: DemoUsers): Promis created++; } + // Import in Prüfung (Prüfmaske): Musterdokument + Extraktion über den Fake-Provider (keine KI nötig) + if (!(await bo.db.importJob.findFirst({ where: { extractedText: { startsWith: "DEMO-IMPORT" } }, select: { id: true } }))) { + const samplePath = join(process.cwd(), "docs", "craftvia", "samples", "02-elbblick-wartungsauftrag.pdf"); + const bytes = existsSync(samplePath) ? readFileSync(samplePath) : buildPdf([[{ text: "Wartungsauftrag Hausverwaltung Elbblick GmbH" }]]); + const job = await createImport(bo, { bytes, fileName: "wartungsauftrag-elbblick.pdf", mimeType: "application/pdf" }, { dispatch: async () => undefined }); + await processImport(bo, job.id, { + provider: new FakeExtractionProvider({ + text: "DEMO-IMPORT Wartungsauftrag Hausverwaltung Elbblick GmbH, Große Elbstraße 140, 22767 Hamburg", + extraction: { + orderNumber: { value: "WA-2026-117", confidence: 0.96 }, + customerNumber: { value: "K-D001", confidence: 0.91 }, + companyName: { value: "Hausverwaltung Elbblick GmbH", confidence: 0.95 }, + customerAddress: { value: { street: "Große Elbstr.", houseNumber: "140", postalCode: "22767", city: "Hamburg", country: "DE" }, confidence: 0.9 }, + siteName: { value: "Wohnanlage Elbblick – Haus B", confidence: 0.72, source: "Objekt: Haus B" }, + siteAddress: { value: { street: "Große Elbstraße", houseNumber: "140b", postalCode: "22767", city: "Hamburg" }, confidence: 0.88 }, + contactName: { value: "Frauke Albers", confidence: 0.83 }, + title: { value: "Wartung Warmwasserbereitung", confidence: 0.86 }, + plannedStart: { value: localDateKey(day(12), TZ), confidence: 0.64, source: "KW-Angabe unsicher" }, + positions: { value: [{ name: "Wartungsset Speicher", articleNumber: "WS-S2", quantity: 1, unit: "Satz", isMaterial: true }, { name: "Arbeitszeit", quantity: 2, unit: "Std", isMaterial: false }], confidence: 0.8 }, + }, + }), + loadBytes: async () => bytes, + }); + } + const orders = await bo.db.workOrder.count({ where: { OR: [{ externalOrderNumber: { startsWith: "DEMO-" } }, { isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch im Keller" } }] } }); return { orders, created }; } diff --git a/scripts/seed-load.ts b/scripts/seed-load.ts new file mode 100644 index 0000000..69ba19c --- /dev/null +++ b/scripts/seed-load.ts @@ -0,0 +1,159 @@ +/** + * L10a Last-Seed (Spec §34.1): füllt den Demo-Mandanten mit N Aufträgen (Default 5 000) für + * Performance-Messungen der Auftragsliste und des Dashboards. + * + * npx tsx scripts/seed-load.ts # 5 000 Aufträge (idempotent: fehlende werden ergänzt) + * LOAD_ORDERS=20000 npx tsx scripts/seed-load.ts + * npx tsx scripts/seed-load.ts --reset # Last-Daten wieder entfernen + * + * Datenbild: 250 Kunden mit je einem Objekt, Aufträge über alle Status verteilt (Schwerpunkt offen), + * Termine −90…+60 Tage, Teams Nord/Süd mit Einzelzuweisungen, je Auftrag 2 Materialvorgaben, jeder + * zehnte mit Statushistorie und eingereichtem Bericht. Schnell über createMany (Owner-Client, nur + * Testdaten) — fachliche Abläufe testet scripts/test-e2e-*.ts. Kennzeichen: Nummer `L-…`, + * externe Auftragsnummer `LOAD-…`, Kundennummer `K-L…`. + * + * Messung danach: BASE=http://localhost:3110 PERF=1 npx tsx scripts/smoke-auth.ts + */ +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { Prisma, PrismaClient, type WorkOrderStatus } from "@prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; + +const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) }); +const TARGET = Number(process.env.LOAD_ORDERS ?? 5000); +const CUSTOMERS = 250; +const CHUNK = 1000; + +// weighted status mix (open work dominates a real tenant) +const STATUS_MIX: [WorkOrderStatus, number][] = [ + ["draft", 3], ["review_required", 2], ["planned", 10], ["assigned", 12], ["accepted", 6], ["en_route", 2], + ["in_progress", 8], ["paused", 2], ["waiting_material", 2], ["daily_report_created", 2], ["technically_completed", 2], + ["signature_pending", 2], ["in_review", 5], ["released_for_billing", 8], ["billed", 30], ["cancelled", 4], +]; +const WEIGHT = STATUS_MIX.reduce((s, [, w]) => s + w, 0); + +function statusFor(i: number): WorkOrderStatus { + let x = (i * 7919) % WEIGHT; + for (const [s, w] of STATUS_MIX) { + if (x < w) return s; + x -= w; + } + return "planned"; +} + +async function reset(tenantId: string) { + const orders = await prisma.workOrder.findMany({ where: { tenantId, externalOrderNumber: { startsWith: "LOAD-" } }, select: { id: true } }); + const ids = orders.map((o) => o.id); + for (let i = 0; i < ids.length; i += CHUNK) { + const w = { workOrderId: { in: ids.slice(i, i + CHUNK) } }; + await prisma.report.deleteMany({ where: w }); + await prisma.workOrderStatusChange.deleteMany({ where: w }); + await prisma.workOrder.deleteMany({ where: { id: { in: ids.slice(i, i + CHUNK) } } }); + } + await prisma.site.deleteMany({ where: { tenantId, customer: { customerNumber: { startsWith: "K-L" } } } }); + const c = await prisma.customer.deleteMany({ where: { tenantId, customerNumber: { startsWith: "K-L" } } }); + console.log(`✔ Last-Daten entfernt: ${ids.length} Aufträge, ${c.count} Kunden`); +} + +async function main() { + const tenant = await prisma.tenant.findUnique({ where: { slug: "demo" } }); + if (!tenant) throw new Error('Mandant "demo" fehlt — zuerst `npx prisma db seed`.'); + const tenantId = tenant.id; + if (process.argv.includes("--reset")) return reset(tenantId); + + const teams = await prisma.team.findMany({ where: { tenantId, name: { in: ["Team Nord", "Team Süd"] } }, include: { members: true } }); + if (teams.length < 2) throw new Error("Demo-Teams fehlen — Seed mit Demo-Daten ausführen (SEED_DEMO)."); + const orderTypes = await prisma.orderType.findMany({ where: { tenantId }, select: { id: true } }); + const creator = await prisma.user.findFirstOrThrow({ where: { tenantId, email: "backoffice@demo.example" } }); + + // customers + sites + const existingCustomers = await prisma.customer.count({ where: { tenantId, customerNumber: { startsWith: "K-L" } } }); + if (existingCustomers < CUSTOMERS) { + const rows = Array.from({ length: CUSTOMERS }, (_, i) => ({ + id: randomUUID(), + tenantId, + customerNumber: `K-L${String(i + 1).padStart(4, "0")}`, + companyName: `Lastkunde ${String(i + 1).padStart(3, "0")} GmbH`, + street: "Lastweg", + houseNumber: String(i + 1), + postalCode: String(20000 + (i % 900)), + city: i % 2 ? "Hamburg" : "Lübeck", + })); + await prisma.customer.createMany({ data: rows, skipDuplicates: true }); + const customers = await prisma.customer.findMany({ where: { tenantId, customerNumber: { startsWith: "K-L" }, sites: { none: {} } }, select: { id: true, customerNumber: true } }); + await prisma.site.createMany({ data: customers.map((c) => ({ tenantId, customerId: c.id, name: `Objekt ${c.customerNumber}`, street: "Lastweg", city: "Hamburg", accessNotes: "Schlüssel beim Hausmeister" })) }); + } + const customers = await prisma.customer.findMany({ where: { tenantId, customerNumber: { startsWith: "K-L" } }, select: { id: true, sites: { select: { id: true }, take: 1 } }, orderBy: { customerNumber: "asc" } }); + + const existing = await prisma.workOrder.count({ where: { tenantId, externalOrderNumber: { startsWith: "LOAD-" } } }); + const missing = Math.max(0, TARGET - existing); + console.log(`Mandant demo: ${existing} Last-Aufträge vorhanden, lege ${missing} an …`); + const t0 = Date.now(); + const day = 86400_000; + for (let offset = existing; offset < existing + missing; offset += CHUNK) { + const n = Math.min(CHUNK, existing + missing - offset); + const orders: Prisma.WorkOrderCreateManyInput[] = []; + const assignees: Prisma.WorkOrderAssigneeCreateManyInput[] = []; + const plans: Prisma.MaterialPlanCreateManyInput[] = []; + const history: Prisma.WorkOrderStatusChangeCreateManyInput[] = []; + const reports: Prisma.ReportCreateManyInput[] = []; + for (let k = 0; k < n; k++) { + const i = offset + k; + const status = statusFor(i); + const team = teams[i % 2]; + const customer = customers[i % customers.length]; + const start = new Date(Date.now() + (((i * 37) % 150) - 90) * day + ((i % 9) + 7) * 3600_000); + const id = randomUUID(); + const planned = !["draft", "review_required"].includes(status); + orders.push({ + id, + tenantId, + number: `L-${String(i + 1).padStart(6, "0")}`, + externalOrderNumber: `LOAD-${i + 1}`, + customerId: customer.id, + siteId: customer.sites[0]?.id ?? null, + orderTypeId: orderTypes.length ? orderTypes[i % orderTypes.length].id : null, + priority: i % 17 === 0 ? "urgent" : i % 5 === 0 ? "high" : "normal", + status, + title: `Lastauftrag ${i + 1}: ${["Wartung Heizung", "Reparatur Leitung", "Montage Therme", "Störung Lüftung"][i % 4]}`, + description: "Automatisch erzeugter Auftrag für Lasttests.", + plannedStart: start, + plannedEnd: new Date(start.getTime() + 4 * 3600_000), + assignedTeamId: planned ? team.id : null, + teamLeadUserId: planned ? team.leaderUserId : null, + isEmergency: i % 97 === 0, + createdById: creator.id, + createdAt: new Date(start.getTime() - 14 * day), + }); + const member = team.members[i % Math.max(1, team.members.length)]; + if (planned && member) assignees.push({ tenantId, workOrderId: id, userId: member.userId }); + plans.push( + { tenantId, workOrderId: id, name: "Kupferrohr 15 mm", plannedQuantity: new Prisma.Decimal(10), unit: "m" }, + { tenantId, workOrderId: id, name: "Pressfitting 15 mm", plannedQuantity: new Prisma.Decimal(6), unit: "Stk", sortOrder: 1 }, + ); + if (i % 10 === 0) { + history.push({ tenantId, workOrderId: id, fromStatus: null, toStatus: "planned", actorId: creator.id }, { tenantId, workOrderId: id, fromStatus: "planned", toStatus: status, actorId: creator.id }); + if (["in_review", "released_for_billing", "billed"].includes(status)) { + reports.push({ tenantId, workOrderId: id, type: "completion", reportDate: start, lineageId: randomUUID(), status: status === "in_review" ? "submitted" : "approved", content: {}, createdById: creator.id }); + } + } + } + await prisma.workOrder.createMany({ data: orders }); + await prisma.workOrderAssignee.createMany({ data: assignees, skipDuplicates: true }); + await prisma.materialPlan.createMany({ data: plans }); + if (history.length) await prisma.workOrderStatusChange.createMany({ data: history }); + if (reports.length) await prisma.report.createMany({ data: reports }); + process.stdout.write(` ${offset + n - existing}/${missing}\r`); + } + await prisma.$executeRawUnsafe("ANALYZE work_orders, work_order_assignees, material_plans, reports, customers, sites"); + const total = await prisma.workOrder.count({ where: { tenantId } }); + console.log(`\n✔ ${missing} Last-Aufträge in ${((Date.now() - t0) / 1000).toFixed(1)} s angelegt — Mandant demo hat jetzt ${total} Aufträge.`); +} + +main() + .then(() => prisma.$disconnect()) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/scripts/smoke-auth.ts b/scripts/smoke-auth.ts index 86d3c33..2ef0bca 100644 --- a/scripts/smoke-auth.ts +++ b/scripts/smoke-auth.ts @@ -3,8 +3,15 @@ * 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. * - * Usage (from repo root, dev server running): - * BASE=http://localhost:3099 npx tsx scripts/smoke-auth.ts + * 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"; @@ -13,40 +20,163 @@ 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 }; +type Check = { path: string; expect?: number[]; mustContain?: string | string[]; mustNotContain?: string[]; redirectTo?: string; perf?: boolean }; +type Plan = { email: string; tenant: string; checks: Check[] }; -const PLAN: Record = { - "backoffice@demo.example": [ - { path: "/dashboard", expect: [200] }, - { path: "/work-orders", expect: [200] }, - { path: "/imports", expect: [200] }, - { path: "/customers", expect: [200] }, - { path: "/sites", expect: [200] }, - { path: "/teams", expect: [200] }, - { path: "/reports", expect: [200] }, - { path: "/documents", expect: [200] }, - { path: "/notifications", expect: [200] }, - { path: "/search?q=a", expect: [200] }, - { path: "/work-orders/emergency-review", expect: [200] }, - { path: "/settings/order-types", expect: [200] }, - { path: "/settings/audit", expect: [200, 403, 307] }, - ], - "monteur@demo.example": [ - { path: "/", expect: [307, 308] }, - { path: "/m", expect: [200] }, - { path: "/m/orders", expect: [200] }, - { path: "/m/emergency", expect: [200] }, - { path: "/m/sync", expect: [200] }, - { path: "/m/profile", expect: [200] }, - ], -}; +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 }; +} -async function cookieFor(email: string): Promise { +function plans(x: Awaited>): 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] }, + ], + }, + { + 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] }, + ], + }, + { + 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" }, + ], + }, + { + 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] }] : []), + ], + }, + ]; +} + +async function cookieFor(email: string, tenant: string): Promise { const identity = await prisma.identity.findUnique({ where: { email } }); if (!identity) throw new Error(`identity ${email} not found (seed?)`); - const user = await finalizeIdentityLogin(identity.id, "demo"); - if (!user) throw new Error(`no active membership for ${email}`); + 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, @@ -66,26 +196,52 @@ async function cookieFor(email: string): Promise { 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; - for (const [email, checks] of Object.entries(PLAN)) { - const cookie = await cookieFor(email); - console.log(`\n== ${email}`); - for (const c of checks) { - const res = await fetch(BASE + c.path, { headers: { cookie }, redirect: "manual" }); - const body = res.status === 200 ? await res.text() : ""; - const errorPage = /Application error|Internal Server Error|NEXT_REDIRECT|Unhandled Runtime Error/i.test(body); - const okStatus = !c.expect || c.expect.includes(res.status); - const okText = !c.mustContain || body.includes(c.mustContain); - const ok = okStatus && okText && !errorPage; - if (!ok) failures++; + 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"); - console.log(`${ok ? "✓" : "✗"} ${String(res.status).padEnd(3)} ${c.path}${loc ? ` → ${loc}` : ""}${errorPage ? " [error page]" : ""}`); + 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(); - console.log(failures ? `\n${failures} Fehler` : "\nOK"); - process.exit(failures ? 1 : 0); + 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) => { From 3769312bfb27f23ac92e7509f436ef0ab85b404a Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:49:54 +0200 Subject: [PATCH 6/6] =?UTF-8?q?L10a=20Qualit=C3=A4t=20&=20Abnahmetests:=20?= =?UTF-8?q?Lane-Bericht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- docs/craftvia/lanes/qualitaet.md | 103 +++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 docs/craftvia/lanes/qualitaet.md diff --git a/docs/craftvia/lanes/qualitaet.md b/docs/craftvia/lanes/qualitaet.md new file mode 100644 index 0000000..91a26be --- /dev/null +++ b/docs/craftvia/lanes/qualitaet.md @@ -0,0 +1,103 @@ +# Lane L10a – Qualität & Abnahmetests (`lane/qualitaet`) + +Stand: 2026-09-14 · Basis `a7d4b02` (`feature/craftvia-mvp`, L1–L9 integriert) · Spec §27, §34, §37–§40, §42, §43 + +## 1. Umfang / erfüllte Spec-Punkte + +| Liefergegenstand | Umsetzung | +|---|---| +| 1 Demo-Seed | `prisma/seed.ts` ruft `scripts/lib/demo-seed.ts` (nur `SEED_DEMO=true` oder außerhalb Production, `SEED_DEMO=false` schaltet ab). Alles über die echten Fachservices (Statushistorie, Audit, Berichts-Snapshots konsistent), idempotent (Kundennummern `K-D…`, externe Nummern `DEMO-…`, feste Client-IDs beim Notdienst). Mandant `demo`: Team Nord (Tina Teamleiter, Max Monteur, Nora Nordmann), Team Süd (Sven Südfeld, Paul Petersen), 7 Kunden + 1 vorläufiger (Notdienst), 12 Objekte mit Zugangshinweisen (+ vorläufiges Notdienst-Objekt), Auftragsarten + 2 Checklisten-Vorlagen, 20 Aufträge über alle Statusgruppen (Neu, geplant/kommend, heute, unterwegs, in Arbeit/pausiert/Material fehlt, überfällig, mehrtägig mit Tagesbericht, Unterschrift ausstehend, zur Prüfung, technisch abgeschlossen, zur Abrechnung, abgerechnet, Notdienst), Materialvorgaben, Zeiten/Notizen/Material/Fotos, 2 freigegebene Abschlussberichte mit Unterschrift in der Historie von „Wohnanlage Elbblick – Haus A“, 1 Import in Prüfung (Musterdokument + Fake-Extraktion). Mandant `demo2`: 1 Kunde, 1 Objekt, 1 Auftrag. Neue Seed-Nutzer: `teamleiter2@`, `monteur2@`, `monteur3@demo.example`. Alle Personen/Adressen/Telefonnummern erfunden. | +| 2 E2E-Prozesstests §43.3 | 7 Skripte `scripts/test-e2e-*.ts` (Service-Ebene, eigene zz-Mandanten, Fixture `scripts/lib/e2e-fixture.ts`) – siehe §4 | +| 3 Sicherheitstests §43.4 | 4 Skripte `scripts/test-security-*.ts` inkl. HTTP-Test gegen den echten Server; RLS-Lauf dokumentiert (§5) | +| 4 Authentifizierter Durchstich | `scripts/smoke-auth.ts` auf alle Kernseiten je Rolle (Admin, Backoffice, Teamleiter, Monteur Nord/Süd, zweiter Mandant) erweitert: IDs aus den Demo-Daten, Status + erwarteter Text + keine Fehlerseite + keine Fremddaten; `PERF=1` misst die Render-Zeit | +| 5 Performance §34.1 | `scripts/seed-load.ts` (5 000 Aufträge, idempotent, `--reset`), Messung dev + Production-Build, Query-Pläne geprüft – keine Index-Migration nötig (§6) | +| Sicherheitsbefund behoben | Migration `20260914200000_qualitaet_audit_append_only`: `REVOKE UPDATE, DELETE, TRUNCATE ON audit_logs FROM craftvia_app` (§3) | + +## 2. Dateien + +- `prisma/seed.ts` (Demo-Nutzer + Aufruf Demo-Daten, expliziter Prozess-Exit wegen offener Queue-Handles) +- `scripts/lib/demo-seed.ts`, `scripts/lib/e2e-fixture.ts` (neu) +- `scripts/seed-load.ts` (neu), `scripts/smoke-auth.ts` (erweitert) +- `scripts/test-e2e-{regular-order,multiday-order,emergency,offline-sync,guards,import-duplicates,tenant-isolation}.ts` (neu) +- `scripts/test-security-{roles,uploads,auth,http}.ts` (neu) +- `prisma/migrations/20260914200000_qualitaet_audit_append_only/migration.sql` (neu) +- `docs/craftvia/lanes/qualitaet.md` + +**Keine Änderungen an Fachcode, Fundament, `src/server/api/**`, Docker/CI.** Keine Fremd-Einzeiler, keine Stubs, keine neuen npm-Abhängigkeiten, keine Schemaänderung. + +## 3. Migration (begründet) + +`20260914200000_qualitaet_audit_append_only`: Die App schreibt Audit-Einträge nur per INSERT (`writeAuditLog`, Owner-Client) und liest sie im Viewer; kein Service/keine Action/keine Route ändert oder löscht sie (statisch geprüft in `test-security-auth.ts`). Bisher hatte die RLS-App-Rolle `craftvia_app` über `enable_tenant_rls` volle DML-Rechte auf `audit_logs` – mit `RLS_ENFORCED=true` hätte ein kompromittierter Fachpfad eigene Audit-Einträge manipulieren können (§43.4 „Audit-Log-Manipulation“). Backup/Restore und DSGVO-Löschung laufen über die Owner-Rolle und sind nicht betroffen. **Hinweis:** Ein späteres erneutes `SELECT enable_tenant_rls('audit_logs')` würde die Rechte wieder vergeben – dann die REVOKE-Zeile wiederholen. Keine TENANT_MODELS-/Topologie-Änderung. + +## 4. Tests + +`npm run gate` **grün**: prisma generate, tsc, lint (0 Fehler, 2 bestehende Warnungen außerhalb L10a), build inkl. Modul-Guard-Check, **60/60 Testskripte** (davon 11 neu). Prüfungen der neuen Skripte (✓-Zeilen im Gate-Lauf): + +| Skript | ✓ | Inhalt | +|---|---|---| +| `test-e2e-regular-order` | 57 | §38 komplett: Import (Fake-Provider) → Dubletten-/Objektkandidat → Bestätigung → Zuweisung (+ Benachrichtigung) → Bundle (Zugangshinweis, internes Dokument verborgen) → Annehmen/Losfahren/Arbeit/Pause → Material Soll/Ist + Zusatz (ohne Grund abgelehnt) → Fotos (Pflichtfoto) → Checkliste → Blocker bei laufender Zeit → Zeiten berechnet → Abschlussbericht (Vorbelegung, Pflichtangabe) → Unterschrift → Teamleiter-/Backoffice-Freigabe (Bericht danach unveränderlich) → PDF (skip ohne Chromium; lokal erzeugt, `%PDF`, Checksumme) → Abrechnung → abgerechnet → vollständige Statushistorie + Audit → Objekt-Historie (Backoffice, Teamkollege). Mandant B und Monteur ohne Zuweisung an jedem Schritt abgewiesen | +| `test-e2e-multiday-order` | 22 | 2 Einsatztage, 2 Monteure, Tagesbericht je Tag nur mit Daten seines Tages (Zeiten 480/420 min, Fotos, Notizen, Material), zweiter Tagesbericht desselben Tages → conflict, daily_report_created → in_progress am Folgetag, Abschlussbericht über beide Tage (900 min), Freigaben, Abrechnung, Berichtsliste, Historie (3 freigegebene Berichte) | +| `test-e2e-emergency` | 31 | §39: Monteur legt Notdienst mit vorläufigem Kunden an (N-Nummer, laufende Session, Event), Dokumentation, Abschluss „Kunde abwesend“ (Grund Pflicht) → Zur Prüfung, `emergency.completed` + `signature_missing` + Pflichtmail; Rollen (Monteur/Teamleiter forbidden) + Mandant B; Backoffice: Dublettenkandidat, Abrechnung blockiert (Stammdaten/Bericht), Kunde zuordnen (vorläufiger → merged, Version +1), Objekt bestätigen, Auftrag ergänzen, Freigabe 5/5, abgerechnet, Historie | +| `test-e2e-offline-sync` | 40 | 12 Ops (2 Tage alt) in einem Batch → applied inkl. idMap und Gerätezeitstempeln, Wiederholung → duplicate ohne Doppelanlage, fremde clientOpId → rejected, Pflichtfoto fehlt → rejected blocked, Konflikt (Büroänderung) → conflict ohne Überschreiben + unabhängige Notiz applied + `sync.failed` an Monteur/Backoffice + Konfliktliste, Übernehmen (als Gerätenutzer) / Verwerfen, ungültige und nicht registrierte Ops, Scope und Mandant B | +| `test-e2e-guards` | 31 | Pflichtfoto + Checklistenpunkt „mit Foto“ blockieren Abschluss, Bericht und Sync-Op; Blocker verschwinden erst mit passenden Fotos; Nicht-Bild/fremdes Foto abgewiesen. Unterschrift: ohne → signature_pending (kein Weg zur Prüfung/Abrechnung), Nachreichen → in_review, erfasste Unterschrift unveränderlich, Verweigerung/Abwesenheit nur mit Grund (Snapshot, Hinweis ans Backoffice), Bild fehlt/fremd → invalid, „später“, „nicht erforderlich“ nur ohne Pflicht | +| `test-e2e-import-duplicates` | 24 | Kandidaten über Kundennummer, Firmenname, Adresse (Str./Straße), Telefon (anderes Format), E-Mail (Groß-/Kleinschreibung); nie automatische Zuordnung/Zusammenführung; Entscheidung „neu“ protokolliert, vergebene Nummer → conflict; manuelle Anlage „Mögliche Dublette“; Zusammenführen nur mit Bestätigung + Recht; Mandant B / Monteur | +| `test-e2e-tenant-isolation` | 159 | **Systematisch:** alle 35 Modelle aus `TENANT_MODELS` (Test scheitert bei neuem Modell ohne Fixture) – aus Mandant B findMany/findFirst/findUnique/count/update/updateMany/delete/deleteMany wirkungslos, Zeile unverändert, create mit fremder tenantId landet in B; **Postgres-RLS** je Tabelle als `craftvia_app` (Kontext B sieht/ändert/löscht nichts, Kontext A sieht, ohne Kontext 0 Zeilen); **67 Fachservice-Pfade** (Kunden, Objekte, Teams, Aufträge, Einsatz, Dokumente, Berichte, Import, Notdienst, Sync, Benachrichtigungen, Audit, Lotse) → not_found bzw. invalid bei Referenzen; 15 Listen/Suche/Bundle/Dashboard ohne A-Daten; A danach unverändert | +| `test-security-roles` | 113 | Rollen-Matrix aus `rbac.ts`: 26 Permission-Proben × 4 Rollen, Erwartung unabhängig aus `ROLE_DEFS` (fehlt → forbidden bzw. not_found bei Sichtbarkeitsrechten, vorhanden → nie forbidden); Abdeckungsprüfung aller Permissions (begründete Ausnahmen: Nutzer-/Rollenverwaltung → `test-tenant-users-authz.ts`, `document:read` → HTTP-Test); Sichtbarkeit read_team/Teamleiter-Dokumente | +| `test-security-uploads` | 41 | EXE/ELF/ZIP/HTML/SVG mit harmloser Endung → type_mismatch/unsupported_type, nichts gespeichert; Polyglot nur mit erkanntem Bildtyp; Übergröße je Art (15/25 MB), leer, Import; Dateinamen (Traversal, Backslash, CR/LF, reservierte Zeichen, Länge) → normalisiert, Key im Mandantenpräfix; Einsatz-Uploads; Sichtbarkeits-Eskalation; Malware-Befund und nicht erreichbarer ClamAV → fail closed; Audit nur für gespeicherte Dateien | +| `test-security-auth` | 31 | Login-Sperre nach 5 Fehlversuchen (auch richtiges Passwort abgewiesen, auditiert, Entsperren), kein Konto-Orakel, letzter Admin nicht aussperrbar; Rate-Limit Reset je IP/Konto; Session-Kill-Switch; Audit-Log: statischer Scan (keine Mutation in app/actions/services/api/jobs/lib), keine /api-Audit-Route, Viewer rein lesend, DB-Rechte `craftvia_app` (SELECT/INSERT ja, UPDATE/DELETE nein), Tenant-Guard, als `craftvia_app` UPDATE/DELETE → permission denied | +| `test-security-http` | 108 | Gegen `next start` (Produktions-Build aus dem Gate, freier Port) oder `SECURITY_BASE`: ohne Session 14 /api/v1-Routen → 401, Datei-Routen/Seiten → Login ohne Bytes; **manipulierte IDs: alle /api/v1-Routen mit ID (work-orders inkl. assign/transition/materials/documents/daily-/completion-report, reports approve/pdf/files, customers, sites history, imports + confirm, field documents, uploads) sowie `/files/` und `/imports//file` → 404** ohne Daten im Body, Sync → rejected not_found, Listen/Bundle ohne A-Daten, Monteur ohne Zuweisung → 404, unsinnige IDs → 404 statt 500; Rollenaktionen → 403 + Audit „denied“; Uploads über `/documents/upload`, `/api/v1/uploads`, `/api/v1/work-orders/import` (EXE, HTML, SVG, Polyglot mit Download `image/jpeg` + `attachment` + `nosniff` + CSP, Traversal, CR/LF ohne Header-Injection, > 25 MB, Server bleibt erreichbar); CSRF (fremder Origin → 403); Sessions (manipuliert, fremder Schlüssel, abgelaufen, Kill-Switch, deaktiviert); PATCH/PUT/DELETE auf Audit-Pfade ohne Wirkung; Login-Sperre über `/api/auth/callback/credentials` (Positivkontrolle); Sicherheits-Header | + +Statuscodes: Die HTTP-Tests akzeptieren für `invalid` 400 **oder** 422 und prüfen sonst 401/403/404 exakt – damit gültig vor und nach der Vereinheitlichung durch L10b. Die Offline-Prüfung „nicht registrierte Op“ ermittelt den Op-Typ dynamisch aus `EXTERNAL_OPS` (L10b registriert `report.submit`/`report.save_draft`). Schreibende Requests senden den eigenen Origin (L10b-Same-Origin-Prüfung). Je Nutzer weit unter 300 Requests/min (L10b-Rate-Limit). + +## 5. RLS-Lauf (`RLS_ENFORCED=true`, `RLS_DATABASE_URL` auf die Lane-DB) + +- **Alle neuen E2E- und Sicherheitsskripte: 11/11 grün** (inkl. Migration append-only). +- **Gesamte Testsuite: 58/60 grün.** Die zwei Abweichungen liegen in Fundament-Tests und sind keine Isolationslücken: + - `test-garage-storage.ts`: Abbruch „RLS_ENFORCED=true, aber RLS_DATABASE_URL fehlt“ – das Skript lädt die Umgebung offenbar erst nach dem Import von `db.ts` (fail-secure greift). Testumgebungsthema. + - `test-tenant-isolation.ts` (F-02): „findUnique über Compound-Key (fremd) → Throw“ erwartet den Owner-Pfad; mit scharfer RLS ist die fremde Zeile unsichtbar und das Ergebnis `null` – ebenfalls fail-closed, kein Datenabfluss. Test sollte beide Ausprägungen akzeptieren. + +## 6. Performance (§34.1) – 5 020 Aufträge im Mandanten `demo` + +`scripts/seed-load.ts` (3,1 s für 5 000 Aufträge inkl. Zuweisungen, Materialvorgaben, Historie, Berichte) → `PERF=1 scripts/smoke-auth.ts` (zweiter, warmer Request): + +| Seite | Dev-Server | Production-Build (`next start`) | +|---|---|---| +| `/dashboard` (Backoffice) | 233 ms | 30 ms | +| `/work-orders` (Karten / Tabelle / überfällig / Gruppe / Suche / Seite 3) | 252–319 ms | 33–40 ms | +| `/reports` | 467 ms | 53 ms | +| `/search?q=` | 206 ms | 30 ms | +| `/m` / `/m/orders` (Teamleiter, Monteur mit ~2 500 Aufträgen im Team) | 724 / 1 536 ms | 63–67 / 104–111 ms | + +Alle Seiten deutlich < 2 s. Query-Pläne (EXPLAIN ANALYZE) der Liste mit Monteur-Scope, Dashboard-Zählungen (überfällig, Berichte zur Prüfung), Paginierung und Berichtsliste: < 2 ms, Index-Scans auf den bestehenden Indizes (`work_orders(tenant_id, planned_start|status)` u. a.). Kein N+1: Liste und Dashboard laden mit festen Abfragen (Dashboard 11 parallele Counts + 2). **Keine Index-Migration nötig.** Last-Daten danach mit `--reset` wieder entfernt. + +## 7. Authentifizierter Durchstich (Dev-Server :3110, Demo-Seed) + +`BASE=http://localhost:3110 npx tsx scripts/smoke-auth.ts` → **OK, 94/94 Prüfungen**: +- **Admin:** Dashboard, Einstellungen (Nutzer, Audit, E-Mail, Lotse + Protokoll, Auftragsarten, Checklisten, Nummernkreise), Konto, Teams. +- **Backoffice:** Startseite → `/dashboard`, Aufträge (Karten, Tabelle, überfällig, Statusgruppe, Suche, Seite 3), Auftragsdetail mit allen 9 Tabs, Konflikte, Notdienst-Prüfung (Liste + Detail), Importe + **Prüfmaske**, Kunden (+ vorläufig, Detail), Objekte (+ Detail, **Historie** mit freigegebenem Auftrag), Berichte (+ freigegeben, zur Prüfung), Dokumente, Benachrichtigungen, Suche, Teams. +- **Teamleiter:** `/` → `/m`, Heute, Aufträge, mehrtägiger Auftrag, Auftrag zur Prüfung, Berichte; Auftrag von Team Süd → 404. +- **Monteur (Nord):** `/` → `/m`, Heute, Aufträge (Tab laufend), Auftragsdetail (Zugangshinweis), Fotos, Material, Notizen, Checkliste, Zeiten, Tagesbericht, Abschlussbericht, **Unterschrift**, Notdienst-Auftrag, Notdienst-Erfassung, Sync, Offline, Profil, eigenes Foto; fremder Auftrag/Kunde → 404, `/dashboard` → `/m`. +- **Monteur (Süd):** Heute, Unterschrift ausstehend; Auftrag von Team Nord → 404. +- **Zweiter Mandant (admin2@):** Dashboard/Aufträge/Kunden/Suche ohne Demo-Daten; Auftrag, Kunde, Objekt, Bericht, Notdienst, Import, Datei aus `demo` → 404. + +Gefundene Fehler im Durchstich: **keine** (kein Fachcode geändert). Visuelle Prüfung im Browser (375/768/1024 px) nicht durchgeführt: der Browser-Login würde eine Passworteingabe bzw. das Einschleusen eines Session-Tokens durch den Agenten erfordern; die mobilen Seiten wurden in L4/L7 visuell geprüft. + +## 8. Stubs / Abhängigkeiten + +Keine Stubs. Genutzt werden ausschließlich die integrierten Services von L1–L9. Kompatibilität mit L10b siehe §4 (Statuscodes, Same-Origin, Rate-Limit, registrierte Sync-Ops). + +## 9. Befunde, bekannte Lücken, Hinweise an den Architekten + +1. **Login ohne IP-basiertes Limit** (nur Sperre je Identität nach 5 Fehlversuchen; Reset/Einladung haben IP+Konto-Limits). Password-Spraying über viele Konten wird nicht gebremst → Kandidat für das L10b-Rate-Limit (auf `/api/auth/callback/credentials`). +2. **Polyglot-Dateien** (gültige Signatur + angehängter Fremdinhalt) passieren den Magic-Byte-Scanner. Mitigiert durch Speicherung mit erkanntem MIME-Typ, Auslieferung als `attachment` + `nosniff` + CSP (HTTP-Test); echter Inhaltsscan nur mit `CLAMAV_HOST`. +3. **Doppelte Audit-Einträge bei Einsatz-Uploads:** `storeFieldUpload` (L4) schreibt nach `storeFile` einen zweiten `document/create`-Eintrag. Harmlos, aber redundant. +4. **`confirmImport` validiert das Formular vor der Mandanten-/Existenzprüfung** → fremde ID mit ungültigem Formular ergibt `invalid` statt `not_found` (kein Datenabfluss; mit gültigem Formular 404). +5. **Mobile Listen ohne Paginierung** (`/m/orders` max. 200, „Heute“ max. 100 Aufträge je Abruf; §34.1 „Auftragslisten müssen paginiert werden“). Performance unkritisch; bei sehr großen Teams fallen Einträge aus der Liste. +6. **RLS-Fundament-Tests** (§5): `test-garage-storage.ts` (Env-Ladereihenfolge) und `test-tenant-isolation.ts` (Compound-Key erwartet Throw statt `null`) für `RLS_ENFORCED=true` anpassen. +7. **Demo-Termine sind relativ zum Seed-Zeitpunkt** („heute“, „überfällig“); der Seed ist idempotent und verschiebt bestehende Demo-Aufträge nicht. Für frische Demo-Daten Mandant neu aufsetzen. +8. **Seed in Production:** `prisma/seed.ts` lädt `scripts/lib/demo-seed.ts` nur bei aktivierten Demo-Daten dynamisch; ein Production-Image ohne `scripts/` braucht `SEED_DEMO` ungesetzt (Default in Production: aus). +9. Der HTTP-Sicherheitstest startet im Gate den Build per `next start` („does not work with output: standalone“ ist nur eine Warnung). Ohne Build und ohne `SECURITY_BASE` wird er übersprungen (Exit 0). + +## 10. Screens / Routen + +Keine neuen Routen. Geprüft (Smoke/HTTP): siehe §7 und §4 (`test-security-http`).