Merge lane/qualitaet in feature/craftvia-mvp

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 18:52:19 +02:00
co-authored by Claude Opus 5
18 changed files with 3201 additions and 48 deletions
+103
View File
@@ -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/<id>` und `/imports/<id>/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`).
@@ -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;
+36 -3
View File
@@ -8,7 +8,9 @@ import { provisionTenant } from "../src/server/provision";
/** /**
* Craftvia-Demo-Seed (Fundament): Plattform-Admin + Mandant `demo` mit je einem Nutzer * Craftvia-Demo-Seed (Fundament): Plattform-Admin + Mandant `demo` mit je einem Nutzer
* pro Standardrolle + zweiter Mandant `demo2` (Isolations-/Tenant-Switch-Tests) + * 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. * 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: "backoffice@demo.example", name: "Bernd Backoffice", roles: ["backoffice"] },
{ email: "teamleiter@demo.example", name: "Tina Teamleiter", roles: ["team-lead"] }, { email: "teamleiter@demo.example", name: "Tina Teamleiter", roles: ["team-lead"] },
{ email: "monteur@demo.example", name: "Max Monteur", roles: ["technician"] }, { 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<string, string> = {};
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})`); 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. // 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" }, create: { email: "platform@demo.example", name: "Paula Plattform", passwordHash, status: "ACTIVE" },
}); });
console.log(`✔ Plattform-Admin platform@demo.example (/platform/login)`); 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() 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) => { .catch(async (e) => {
console.error(e); console.error(e);
await prisma.$disconnect(); await prisma.$disconnect();
+441
View File
@@ -0,0 +1,441 @@
// 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 { 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";
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<string>(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<Buffer> {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="768"><rect width="1024" height="768" fill="#dfe6ee"/><rect x="80" y="120" width="864" height="528" fill="#9fb3c8"/><text x="512" y="400" font-size="56" font-family="sans-serif" text-anchor="middle" fill="#1f2d3d">${label}</text></svg>`;
return sharp(Buffer.from(svg)).jpeg({ quality: 80 }).toBuffer();
}
async function signaturePng(): Promise<Buffer> {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="600" height="200"><rect width="600" height="200" fill="#ffffff"/><path d="M40 140 C 90 40, 140 180, 190 110 S 290 60, 330 130 S 450 90, 560 120" stroke="#1f2d3d" stroke-width="4" fill="none"/></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<typeof createChecklistTemplate>[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<string, { id: string; contactId: string | null }>();
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<string, string>();
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<string, unknown>) => 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<CreateWorkOrderInput, "customerId" | "siteId" | "contactId" | "externalOrderNumber"> & { ext: string; customer: string; site?: string };
const order = async (seed: OrderSeed, drive: (id: string) => Promise<void>) => {
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++;
}
// 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 };
}
/** Isolation fixture for the second tenant: one customer + one work order. */
export async function seedDemo2Tenant(tenantId: string, adminUserId: string): Promise<void> {
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<void> {
await prisma.$disconnect();
}
+229
View File
@@ -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:<message>". */
export async function codeOf(p: Promise<unknown> | (() => Promise<unknown>)): Promise<string> {
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<unknown>, codes: string | string[], msg: string): Promise<string> {
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<string>(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<string, RoleKey>;
export type Persona = keyof typeof PERSONAS;
export type TenantFixture = {
slug: string;
tenantId: string;
users: Record<Persona, { id: string; email: string; identityId: string }>;
ctx: Record<Persona, ServiceCtx>;
teamId: string;
team2Id: string;
customerId: string;
contactId: string;
siteId: string;
};
export async function createTenant(slug: string, opts: { orgName?: string } = {}): Promise<TenantFixture> {
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<RoleKey, string>;
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<void> {
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<void>): Promise<never> {
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<Buffer> {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="480"><rect width="640" height="480" fill="#cfd8e3"/><text x="320" y="250" font-size="40" text-anchor="middle" font-family="sans-serif">${label}</text></svg>`;
return sharp(Buffer.from(svg)).jpeg({ quality: 70 }).toBuffer();
}
export async function pngBytes(): Promise<Buffer> {
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`);
}
+159
View File
@@ -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);
});
+201 -45
View File
@@ -3,8 +3,15 @@
* builds the session user via finalizeIdentityLogin (same code path as a real login after * 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. * password/MFA checks) and encodes the Auth.js session cookie with AUTH_SECRET.
* *
* Usage (from repo root, dev server running): * L10a: covers all core pages per role (admin, backoffice, team lead, technician Nord/Süd, second
* BASE=http://localhost:3099 npx tsx scripts/smoke-auth.ts * 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 "dotenv/config";
import { encode } from "next-auth/jwt"; 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 BASE = process.env.BASE ?? "http://localhost:3099";
const COOKIE = BASE.startsWith("https") ? "__Secure-authjs.session-token" : "authjs.session-token"; 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<string, Check[]> = { async function ids() {
"backoffice@demo.example": [ const demo = await prisma.tenant.findUniqueOrThrow({ where: { slug: "demo" } });
{ path: "/dashboard", expect: [200] }, const order = async (ext: string) => prisma.workOrder.findFirst({ where: { tenantId: demo.id, externalOrderNumber: ext }, select: { id: true, number: true } });
{ path: "/work-orders", expect: [200] }, 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));
{ path: "/imports", expect: [200] }, if (!d07 || !d18) throw new Error("Demo-Aufträge fehlen — `npx prisma db seed` (SEED_DEMO) ausführen.");
{ path: "/customers", expect: [200] }, const emergency = await prisma.workOrder.findFirst({ where: { tenantId: demo.id, isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch" } }, select: { id: true, number: true } });
{ path: "/sites", expect: [200] }, const customer = await prisma.customer.findFirstOrThrow({ where: { tenantId: demo.id, customerNumber: "K-D001" } });
{ path: "/teams", expect: [200] }, const foreignCustomer = await prisma.customer.findFirstOrThrow({ where: { tenantId: demo.id, customerNumber: "K-D004" } });
{ path: "/reports", expect: [200] }, const site = await prisma.site.findFirstOrThrow({ where: { tenantId: demo.id, name: "Wohnanlage Elbblick – Haus A" } });
{ path: "/documents", expect: [200] }, const approved = await prisma.report.findFirstOrThrow({ where: { workOrderId: d18.id, status: "approved" } });
{ path: "/notifications", expect: [200] }, const inReview = d16 ? await prisma.report.findFirst({ where: { workOrderId: d16.id } }) : null;
{ path: "/search?q=a", expect: [200] }, const imp = await prisma.importJob.findFirst({ where: { tenantId: demo.id, status: "review_required" }, orderBy: { createdAt: "desc" } });
{ path: "/work-orders/emergency-review", expect: [200] }, const photo = await prisma.photo.findFirst({ where: { workOrderId: d07.id }, select: { documentId: true } });
{ path: "/settings/order-types", expect: [200] }, return { d07, d08, d14, d15, d16, d17, d18, emergency, customer, foreignCustomer, site, approved, inReview, imp, photo };
{ 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 cookieFor(email: string): Promise<string> { function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
const wo = (o: { id: string } | null, suffix = "") => (o ? `/work-orders/${o.id}${suffix}` : "/work-orders");
const m = (o: { id: string } | null, suffix = "") => (o ? `/m/orders/${o.id}${suffix}` : "/m/orders");
const tabs = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "documents", "history"];
return [
{
email: "admin@demo.example",
tenant: "demo",
checks: [
{ path: "/dashboard", mustContain: ["Offene Aufträge", "Überfällig"] },
{ path: "/settings" },
{ path: "/settings/users", mustContain: "backoffice@demo.example" },
{ path: "/settings/audit" },
{ path: "/settings/email" },
{ path: "/settings/lotse" },
{ path: "/settings/lotse/protocol" },
{ path: "/settings/order-types", mustContain: "Montage" },
{ path: "/settings/checklists", mustContain: "Montage Heizung/Sanitär" },
{ path: "/settings/numbering" },
{ path: "/account" },
{ path: "/teams", mustContain: ["Team Nord", "Team Süd"] },
],
},
{
email: "backoffice@demo.example",
tenant: "demo",
checks: [
{ path: "/", expect: [307, 308], redirectTo: "/dashboard" },
{ path: "/dashboard", mustContain: "Berichte zur Prüfung", perf: true },
{ path: "/work-orders", perf: true },
{ path: "/work-orders?q=DEMO-07", mustContain: x.d07.number },
{ path: "/work-orders?view=table", perf: true },
{ path: "/work-orders?preset=overdue", perf: true },
{ path: "/work-orders?group=in_review", perf: true },
{ path: "/work-orders?q=Elbblick", perf: true },
{ path: "/work-orders?page=3", perf: true },
...tabs.map((t) => ({ path: wo(x.d07, `?tab=${t}`), mustContain: x.d07.number })),
{ path: wo(x.d18, "?tab=reports"), mustContain: x.d18.number },
{ path: "/work-orders/conflicts" },
{ path: "/work-orders/emergency-review", mustContain: x.emergency?.number },
...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, mustContain: x.emergency.number }] : []),
{ path: "/imports" },
...(x.imp ? [{ path: `/imports/${x.imp.id}`, mustContain: "Hausverwaltung Elbblick" }] : []),
{ path: "/customers", mustContain: "Hausverwaltung Elbblick" },
{ path: "/customers?status=provisional" },
{ path: `/customers/${x.customer.id}`, mustContain: "Hausverwaltung Elbblick" },
{ path: "/sites", mustContain: "Elbblick" },
{ path: `/sites/${x.site.id}`, mustContain: "Haus A" },
{ path: `/sites/${x.site.id}?tab=history`, mustContain: x.d18.number },
{ path: "/reports", perf: true },
{ path: `/reports/${x.approved.id}`, mustContain: x.d18.number },
...(x.inReview ? [{ path: `/reports/${x.inReview.id}` }] : []),
{ path: "/documents" },
{ path: "/notifications" },
{ path: "/search?q=Elbblick", mustContain: "Elbblick", perf: true },
{ path: "/teams" },
{ path: "/settings/users", expect: [200, 307, 403] },
],
},
{
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<string> {
const identity = await prisma.identity.findUnique({ where: { email } }); const identity = await prisma.identity.findUnique({ where: { email } });
if (!identity) throw new Error(`identity ${email} not found (seed?)`); if (!identity) throw new Error(`identity ${email} not found (seed?)`);
const user = await finalizeIdentityLogin(identity.id, "demo"); const user = await finalizeIdentityLogin(identity.id, tenant);
if (!user) throw new Error(`no active membership for ${email}`); if (!user) throw new Error(`no active membership for ${email} in ${tenant}`);
const token = { const token = {
sub: user.id, sub: user.id,
name: user.name, name: user.name,
@@ -66,26 +196,52 @@ async function cookieFor(email: string): Promise<string> {
return `${COOKIE}=${value}`; 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() { async function main() {
const only = process.env.ONLY;
let failures = 0; let failures = 0;
for (const [email, checks] of Object.entries(PLAN)) { const slow: string[] = [];
const cookie = await cookieFor(email); // PERF=1 runs only the perf-marked pages and ignores their content checks: with scripts/seed-load.ts data the
console.log(`\n== ${email}`); // demo orders are no longer guaranteed on the first page / in the capped mobile lists.
for (const c of checks) { const all = plans(await ids())
const res = await fetch(BASE + c.path, { headers: { cookie }, redirect: "manual" }); .filter((p) => !only || p.email.includes(only))
const body = res.status === 200 ? await res.text() : ""; .map((p) => (PERF ? { ...p, checks: p.checks.filter((c) => c.perf).map((c) => ({ ...c, mustContain: undefined })) } : p))
const errorPage = /Application error|Internal Server Error|NEXT_REDIRECT|Unhandled Runtime Error/i.test(body); .filter((p) => p.checks.length > 0);
const okStatus = !c.expect || c.expect.includes(res.status); let total = 0;
const okText = !c.mustContain || body.includes(c.mustContain); for (const plan of all) {
const ok = okStatus && okText && !errorPage; const cookie = await cookieFor(plan.email, plan.tenant);
if (!ok) failures++; console.log(`\n== ${plan.email} (${plan.tenant})`);
for (const c of plan.checks) {
total++;
let { res, body, ms } = await fetchTimed(c.path, cookie);
if (PERF && c.perf) ({ res, body, ms } = await fetchTimed(c.path, cookie)); // warm measurement
const expect = c.expect ?? [200];
const errorPage = /Application error|Internal Server Error|Unhandled Runtime Error|NEXT_REDIRECT|digest:/i.test(res.status === 200 ? body : "");
const okStatus = expect.includes(res.status);
const loc = res.headers.get("location"); const 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(); await prisma.$disconnect();
console.log(failures ? `\n${failures} Fehler` : "\nOK"); if (PERF) console.log(slow.length ? `\n⚠ ${slow.length} Seite(n) über ${BUDGET} ms:\n${slow.join("\n")}` : `\nPerformance: alle gemessenen Seiten ≤ ${BUDGET} ms`);
process.exit(failures ? 1 : 0); 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) => { main().catch((err) => {
+122
View File
@@ -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");
});
+151
View File
@@ -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<unknown>): Promise<CompletionBlocker[] | string> {
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");
});
+120
View File
@@ -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<WorkOrderExtraction>) {
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");
});
+145
View File
@@ -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");
});
+161
View File
@@ -0,0 +1,161 @@
// 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 { 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";
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<string, unknown>, extra: Partial<SyncOperationInput> = {}, 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");
// 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");
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");
});
+240
View File
@@ -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<WorkOrderExtraction> = {
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");
});
+401
View File
@@ -0,0 +1,401 @@
// 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<string, { id: string; update: Record<string, unknown> }>;
/** 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<Rows> {
const t = A.tenantId;
const uid = A.users.tech.id;
const rows: Rows = {};
const put = (model: string, id: string, update: Record<string, unknown>) => (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<string, (args: unknown) => Promise<unknown>>;
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<string, Delegate>)[lc(model)].findUnique({ where: { id } }), (_k, v) => (typeof v === "bigint" ? v.toString() : v));
async function threw(fn: () => Promise<unknown>): Promise<boolean> {
try {
await fn();
return false;
} catch {
return true;
}
}
/** Bulk write without effect: count 0, or refused by the database (append-only audit_logs under RLS). */
async function noEffect(fn: () => Promise<unknown>): Promise<boolean> {
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";
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<string, Delegate>;
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 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 })),
};
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;
/** Affected rows under tenant context B; a missing privilege (append-only audit_logs) counts as 0. */
const asB = async (sql: string): Promise<number> => {
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;
});
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<unknown>, 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");
});
+164
View File
@@ -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");
});
+383
View File
@@ -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<number> {
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<Res> {
const headers: Record<string, string> = {};
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<typeof call>[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, "<A-id>")}`, 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<typeof call>[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, "<id>")} → ${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<typeof call>[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("<html><script>alert(document.cookie)</script></html>");
const up = (bytes: Uint8Array, name: string, type: string, extra: Parameters<typeof call>[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("<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>"), "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();
}
});
+138
View File
@@ -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<Record<Permission, string>> = {
"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/<id> (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<RoleKey, Persona> = { "tenant-admin": "admin", backoffice: "backoffice", "team-lead": "lead", technician: "tech" };
type Probe = { permission: Permission; label: string; run: (ctx: ServiceCtx, role: RoleKey) => Promise<unknown>; denied?: string };
async function rawOrder(A: TenantFixture, status: string, extra: Record<string, unknown> = {}) {
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");
});
Binary file not shown.