L10b Betrieb & Aufräumen: Lane-Bericht und HTTP-Smoke
- docs/craftvia/lanes/betrieb.md: Umfang a–k, Entscheidungen (b: report.submit übernehmbar, h: Audit nach Commit umgesetzt), Verhaltensänderungen (Fehlerformat, 422), Dateien, Tests (api 150, sync 36, audit 48), Gate 52/52, RLS_ENFORCED-Lauf 50/52 (zwei Owner-Modus-Tests unverändert), Docker-Build, offene Punkte. - scripts/smoke-betrieb.ts: HTTP-Smoke gegen den Dev-Server (Session-Cookies ohne Passworteingabe) für Fehlerformat, OpenAPI, Sync-Ops, Bundle, Lotse-Kontingent, Sidebar; 18/18 grün auf :3111. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
|||||||
|
# Lane L10b – Betrieb & Aufräumen (`lane/betrieb`)
|
||||||
|
|
||||||
|
Stand: 2026-09-15 · Basis `a7d4b02` (`feature/craftvia-mvp`, L1–L9 integriert) · Spec §27, §31, §34, §42, §43 · ARCHITEKTUR §4.6, §4.8
|
||||||
|
|
||||||
|
## 1. Umfang / erfüllte Punkte
|
||||||
|
|
||||||
|
| Punkt | Umsetzung |
|
||||||
|
|---|---|
|
||||||
|
| **API-Doku** | `src/lib/api/openapi.ts` (OpenAPI 3.1, statisch gepflegt): alle 23 v1-Pfade mit 29 Operationen, `cookieAuth`, einheitliches Fehlerformat, Pagination, Idempotenz (`clientOpId`, Upload-`clientId`), Konflikte, Rate Limits, Recht/Modul je Operation (`x-craftvia-module`, `x-craftvia-permissions`). `GET /api/v1/openapi.json` (angemeldet). Kurzdoku `docs/craftvia/API.md`. Test prüft, dass jede `route.ts` dokumentiert ist. |
|
||||||
|
| **Rate Limiting** | `requireApiContext` zählt je Nutzer über `rate-limit.ts`: Bucket `api` (`API_RATE_LIMIT_PER_MINUTE`, Default 300/min) für alle Module, `apiField` (`API_FIELD_RATE_LIMIT_PER_MINUTE`, Default 1200/min) für `field` (sync, uploads, bundle, Dokument-Cache). Überschritten → 429 `rate_limited` + `Retry-After`. Offline-Outbox behandelt 429 als transient (Backoff). |
|
||||||
|
| **Deploy/Betrieb** | Compose (Coolify + prebuilt): Service `craftvia-worker` (Target `worker`, Chromium, `shm_size 1gb`, Härtung wie übrige Worker). `Dockerfile`: worker-Stage mit `HOME=/home/app` (Chromium-Profil als non-root, vorher startete Chromium nicht). Env-Beispiele mit allen Craftvia-Variablen. `docs/craftvia/DEPLOY.md` (Architektur, Domains, Secrets, Worker, Migrationen, RLS-Aktivierung, Backup/Restore, KI, Rate Limits, Aufbewahrung, Smoke, Update/Rollback, Go-Live-Checkliste). CI (`.github`, `.gitea`): Job `gate` mit Postgres (pgvector) + Redis-Service. `build-and-push-images.sh` baut `craftvia-worker`. Certvia-/ISMS-Dokumente → `docs/_certvia-archiv/` (mit README). |
|
||||||
|
| **a) API-Kontexte** | `imports/_context.ts`, `sync/api-context.ts`, `reports/http.ts`, `work-orders/_http.ts` entfernt; alle 22 Fachrouten nutzen `requireApiContext` + `withApi`/`toErrorResponse`. `withApi` prüft Same-Origin für jede Mutation vor der Anmeldung (fehlte vorher bei imports, reports, work-orders). |
|
||||||
|
| **b) Konflikt übernehmen** | Entscheidung: **`report.submit` wird unterstützt.** `sync-reapply.ts` delegiert an `apply.ts#reapplyOperation` (gleiche Payload-Validierung und Services wie der Sync, ohne `baseVersion`, als Gerätenutzer). Erlaubt: `work_order.transition`, `report.submit` (mit gespeichertem `aiReviewed`); sonst `invalid reapply_unsupported`. Hinweistext der Konfliktliste angepasst. Ältere gespeicherte Ops ohne `payload.workOrderId` nutzen `entityId`. |
|
||||||
|
| **c) Bundle + Offline** | `getFieldBundle` liefert je Auftrag `mySession` (`{ id, status, startedAt }` der eigenen aktiven Session oder `null`). `bundle-core.ts#initialSession` nutzt es; nur für Bundles ohne Feld (vor L10b gespeichert) weiter Näherung über den Auftragsstatus. |
|
||||||
|
| **d) mergeCustomers** | über `inTransaction` (sequenziell, geschützter Statuswechsel `status ≠ merged`), einbettbar in äußere Transaktionen. |
|
||||||
|
| **e) Audit `read`** | `AuditAction` + Label „Lesezugriff"/„Read access" im Audit-Viewer; Notdienst-Kunden-/Objektsuche protokolliert `read`. |
|
||||||
|
| **f) clientId je Mandant** | Migration `20260915090000_betrieb_client_id_per_tenant`: 8 Tabellen `@@unique([tenantId, clientId])`. Kein Code nutzte `findUnique` über `clientId` (Replays laufen über `findFirst` im Mandanten-Client). |
|
||||||
|
| **g) Backoffice mobil** | `components/backoffice-frame.tsx`: unter **1024 px** Sidebar als Drawer hinter Menü-Button (44 px, `aria-expanded`/`aria-controls`, schließt bei Navigation, Hintergrund, Escape; geschlossen `invisible` → nicht fokussierbar). Damit sind 768 px und 375 px abgedeckt; ab 1024 px unverändert statisch. Header kompakter (Name/Mandant ab `sm`). |
|
||||||
|
| **h) Audit nach Commit** | **Umgesetzt, nicht zu invasiv:** `writeAuditLog` puffert innerhalb `inTransaction` (AsyncLocalStorage in `audit.ts`, `withDeferredAudit`) und schreibt nach dem Commit; Rollback verwirft die Einträge, `denied` bleibt. Verschachtelte Transaktionen teilen den äußeren Puffer. Schreibfehler beim Flush werden geloggt (die Fachänderung ist schon committet). |
|
||||||
|
| **i) Berichtseditor** | mobiler `ReportEditor` hält Eingaben mit `useOfflineDraft("report:<reportId>")`; Wiederherstellung nur, solange die Servertexte die Basis des Entwurfs sind (sonst gewinnt der Server, z. B. nach Übernahme eines Lotse-Vorschlags); nach Speichern/Absenden gelöscht; Hinweis „Entwurf wiederhergestellt." |
|
||||||
|
| **j) report.submit offline** | `lib/sync/ops.ts`: Zod-Schemas `report.save_draft` `{ workOrderId, reportId, texts }` und `report.submit` `{ workOrderId, reportId, aiReviewed? }`; Registry-Einträge → `services/reports/sync-ops.ts`. `baseVersion` → `expectedWorkOrderVersion`, `aiReviewed` wird durchgereicht (ohne → `rejected invalid`, `field aiReviewed`). Bericht muss zum Auftrag der Op gehören. `signature.capture` bleibt unregistriert (s. Lücken). |
|
||||||
|
| **k) Lotse-Betrieb** | **Aufbewahrung:** `services/lotse/retention.ts` leert `input`/`output` und `createdById` älter als `AI_GENERATION_RETENTION_DAYS` (Default 180), Metadaten bleiben, Audit `ai_generation_retention` je Mandant; Queue/Processor `ai-retention`, täglicher BullMQ-Job-Scheduler beim Start von `craftvia-worker`. **Kontingent:** `services/lotse/budget.ts`, Tokens ein+aus je Kalendermonat (UTC); `TenantSettings.aiMonthlyTokenLimit` (Migration `20260915091000_betrieb_ai_token_limit`, nur Spalte) vor Env `AI_MONTHLY_TOKEN_LIMIT` (Default 0 = unbegrenzt). Lotse-Entwurf/Zusammenfassung → `blocked budget_exceeded` mit Klartext; Import-Extraktion → manuelle Erfassung + Hinweis `ai_budget_exceeded`. `/settings/lotse`: Kontingent setzen (leer = Plattform, 0 = unbegrenzt), Verbrauch + „Kontingent aufgebraucht" (Text + Icon). |
|
||||||
|
|
||||||
|
### Verhaltensänderungen (bewusst, dokumentiert in API.md/OpenAPI)
|
||||||
|
- Fehlerformat überall `{ error: { code, message, details? } }` (vorher bei imports/sync/reports teils `{ error: "code" }`).
|
||||||
|
- `invalid` → **422** (vorher 400 bei imports, sync, uploads, reports); `blocked` → **422** (vorher 409 bei L1-Routen und im Import-Adapter). Clients angepasst: Import-Uploader (neues Format), `lib/field/upload.ts` und Outbox (422 = endgültig ungültig).
|
||||||
|
- POST-Routen von imports/reports/work-orders verlangen jetzt Same-Origin (Server-zu-Server-Aufrufe ohne `Origin`/`Sec-Fetch-Site` sind weiter möglich).
|
||||||
|
- `test-einsatz-sync.ts`: Prüfung „nicht verfügbare Op" nutzt `signature.capture`, weil `report.save_draft` jetzt registriert ist (Begründung j).
|
||||||
|
|
||||||
|
## 2. Dateien
|
||||||
|
|
||||||
|
**Neu:** `src/lib/api/openapi.ts`, `src/app/api/v1/openapi.json/route.ts`, `src/server/services/reports/{dto,sync-ops}.ts`, `src/server/services/lotse/{retention,budget}.ts`, `src/server/jobs/processors/ai-retention.ts`, `src/components/backoffice-frame.tsx`, `prisma/migrations/20260915090000_betrieb_client_id_per_tenant/`, `prisma/migrations/20260915091000_betrieb_ai_token_limit/`, `docs/craftvia/{API,DEPLOY}.md`, `docs/_certvia-archiv/README.md`, `scripts/test-betrieb-{api,sync,audit}.ts`, `scripts/smoke-betrieb.ts`, dieser Bericht.
|
||||||
|
|
||||||
|
**Entfernt:** `src/app/api/v1/imports/_context.ts`, `src/app/api/v1/work-orders/_http.ts`, `src/server/services/sync/api-context.ts`, `src/server/services/reports/http.ts`.
|
||||||
|
|
||||||
|
**Geändert (Aufräumpunkte a–k):** `src/server/api/{respond,context}.ts`, `src/server/rate-limit.ts`, alle `src/app/api/v1/**/route.ts` außer customers/sites, `src/components/imports/uploader.tsx`, `src/lib/field/upload.ts`, `src/lib/offline/{outbox,bundle-core,types}.ts`, `src/lib/sync/ops.ts`, `src/server/services/sync/{apply,external-ops}.ts`, `src/server/services/work-orders/sync-reapply.ts`, `src/server/services/field/queries.ts`, `src/server/audit.ts`, `src/server/services/context.ts`, `src/server/services/customers/merge.ts`, `src/server/services/emergency/lookup.ts`, `src/server/services/lotse/{settings,draft-report,voice}.ts`, `src/server/services/imports/process.ts`, `src/lib/imports/extraction.ts`, `src/lib/lotse/action-state.ts`, `src/server/actions/lotse-settings.ts`, `src/app/(app)/settings/lotse/page.tsx`, `src/app/(app)/layout.tsx`, `src/components/reports/mobile/report-editor.tsx`, `src/server/jobs/{queues,processors/index}.ts`, `scripts/craftvia-worker.ts`, `prisma/schema.prisma`, `messages/{de,en}/{nav,notifications,workOrders,lotse,imports}.json`, `scripts/test-einsatz-sync.ts`.
|
||||||
|
|
||||||
|
**Deploy/Doku:** `Dockerfile`, `docker-compose.coolify.yml`, `docker-compose.coolify.prebuilt.yml`, `.env.example`, `.env.prod.example`, `.env.coolify.example`, `.github/workflows/ci.yml`, `.gitea/workflows/ci.yml`, `scripts/build-and-push-images.sh`, `docs/**` (Archiv-Verschiebung).
|
||||||
|
|
||||||
|
**Minimale Eingriffe außerhalb der Ownership (je 1–3 Zeilen):**
|
||||||
|
- `README.md` Abschnitt „Betrieb": Verweise auf `docs/craftvia/DEPLOY.md`/`API.md` statt archivierter Certvia-Docs, Target `worker`.
|
||||||
|
- Kommentar-Pfade auf `docs/_certvia-archiv/…`: `scripts/garage-provision.ts`, `scripts/test-auth-selfservice.ts`, `scripts/bootstrap-admin.ts`, `scripts/test-garage-storage.ts`, `deploy/garage.toml`.
|
||||||
|
- `src/server/jobs/processors/index.ts` (erlaubter Einzeiler `ai-retention`), `messages/{de,en}/nav.json` (Menü-Labels).
|
||||||
|
- `src/server/audit.ts` (Fundament) – ausdrücklich Aufräumpunkte e/h.
|
||||||
|
|
||||||
|
## 3. Tests
|
||||||
|
|
||||||
|
| Skript | Prüfungen | Inhalt |
|
||||||
|
|---|---|---|
|
||||||
|
| `test-betrieb-api.ts` | 150 | jede v1-Route: `requireApiContext`, keine lane-lokalen Kontexte, Fehler über respond.ts; ohne Sitzung 401 im einheitlichen Format (alle Methoden); jede Mutation mit fremdem Origin bzw. `Sec-Fetch-Site: cross-site` → 403 vor der Anmeldung; Fehler-Mapping (404/403/422/409/422 blocked mit details, ZodError mit Feldpfaden, 500 ohne interne Details); `readJsonObject`; Rate Limit je Nutzer (Standard/Einsatz getrennt, 429 + Retry-After, anderer Nutzer unabhängig); OpenAPI 3.1 deckt jede `route.ts` ab, keine veralteten Pfade, Route liefert das Dokument |
|
||||||
|
| `test-betrieb-sync.ts` | 36 | c) Bundle `mySession` (Monteur laufend/pausiert, Teamleiter ohne eigene Session → `null`, Offline-Ableitung inkl. altem Bundle), Mandant B und Monteur ohne Zuweisung sehen den Auftrag nicht; f) gleiche Session-/Notiz-`clientId` in A und B, Idempotenz je Mandant, DB-Unique im selben Mandanten; j) `report.save_draft`, `report.submit` ohne reportId/ohne `aiReviewed` → invalid, Mandant B/Monteur ohne Zuweisung → not_found, fremder Bericht über eigenen Auftrag → not_found, veraltete Version → conflict; b) Übernehmen: Mandant B → not_found, Monteur → forbidden, nicht konfliktbehaftete Op → invalid, Backoffice → Bericht submitted + resolved, zweites Übernehmen → not_found, unzulässiger Übergang → abgelehnt ohne Änderung |
|
||||||
|
| `test-betrieb-audit.ts` | 48 | h) aufgeschoben/nach Commit/Rollback (nur `denied` bleibt)/verschachtelt/direkt; d) Merge: Monteur forbidden, Mandant B not_found, Rollback in äußerer Transaktion (nicht zusammengeführt, Objekt nicht umgehängt, kein Audit), Erfolg + Audit Quelle/Ziel, doppelt → conflict; e) Suche → Audit `read`, Mandant B findet nichts, ohne `emergency:create` → forbidden; k) Aufbewahrung (Default/Env, Processor registriert, Inhalte + Personenbezug entfernt, Metadaten bleiben, junge Einträge unverändert, Mandant B unberührt, Audit, idempotent, längere Frist) und Kontingent (unbegrenzt, Monteur forbidden, Mandanten-Limit → blocked `budget_exceeded`, Audit, Einstellungsseite, Mandant B unabhängig, Env-Default, Vormonat zählt nicht, 0 = unbegrenzt, Feld weggelassen = unverändert, null = Plattform) |
|
||||||
|
|
||||||
|
**Gate (`npm run gate`) grün:** prisma generate, tsc, lint (0 Fehler, 3 Warnungen in fremden Dateien: `layout.tsx` ungenutzter Import `CraftviaLogo` – vorbestehend, `services/field/mime.ts`, u. a.), build inkl. Modul-Guard-Check, **52/52 Testskripte**. Lane-DB `craftvia_betrieb`, `RLS_DATABASE_URL` auf dieselbe DB.
|
||||||
|
|
||||||
|
**Lauf mit `RLS_ENFORCED=true npm run test`:** 50/52 grün. Die zwei Abweichungen betreffen Dateien, die L10b nicht verändert hat (Diff zu `a7d4b02` nur ein Kommentar in `test-garage-storage.ts`; `db.ts`/`storage` unverändert):
|
||||||
|
- `test-tenant-isolation.ts` erwartet für `findUnique` über einen fremden Compound-Key (Role `tenantId_key`) einen Throw des Owner-Guards; mit scharfer RLS liefert die Abfrage `null` (kein Datenabfluss, aber andere Semantik).
|
||||||
|
- `test-garage-storage.ts` lädt `storage/backup-store.ts` → `db.ts` bricht fail-secure ab („RLS_ENFORCED=true, aber RLS_DATABASE_URL fehlt") – der Test läuft ohne die RLS-Umgebung.
|
||||||
|
→ Beide Tests sind auf den Owner-Betrieb ausgelegt; Anpassung für einen RLS-Modus gehört ins Fundament/L10a.
|
||||||
|
|
||||||
|
**HTTP-Smoke** (Dev-Server :3111, Session-Cookies ohne Passworteingabe): `scripts/smoke-betrieb.ts` **18/18 grün** – anonym 401 JSON (`openapi.json`, `sync`); Admin: OpenAPI 3.1 (23 Pfade), customers mit Pagination, 404 `not_found` (work-orders, reports/pdf) im einheitlichen Format, fremder Origin → 403, Array-Body → 422, kaputtes JSON bei imports/confirm → 422, `/settings/lotse` mit KI-Kontingent, `/dashboard` mit Menü-Button, `/settings/audit?action=read`, Konfliktliste mit neuem Hinweis; Monteur: Bundle mit `mySession`, leerer Sync-Batch → 422, `report.submit` ohne reportId → `rejected invalid`, `/m` 200, `/dashboard` → 307. Mandantentest demo2 übersprungen (aktueller Seed enthält keine Aufträge; L10a liefert Demo-Daten). Zusätzlich `scripts/smoke-auth.ts` (Architekt) gegen :3111 **19/19 grün** (Backoffice-Seiten mit neuem Layout, Monteur-Seiten).
|
||||||
|
|
||||||
|
**Docker:** `docker build --target runner` (400 MB) und `--target worker` (2,68 GB) lokal erfolgreich; im Worker-Image startet Chromium mit `cap_drop ALL`/`no-new-privileges`/1 GB shm und erzeugt ein PDF. Kein Push.
|
||||||
|
|
||||||
|
## 4. Stubs / Abhängigkeiten
|
||||||
|
|
||||||
|
- Keine neuen Stubs. Der L2-Stub `sync-reapply.ts` ist durch den L4-Dispatcher ersetzt.
|
||||||
|
- Genutzt: L1 `requireApiContext`/respond.ts, L4 `applyOperations`/Registry, L5 `submitReport`/`updateReportTexts`/`requireVisibleReport`, L7 `useOfflineDraft`, L9 `applyLotseReview`, L2 `applySyncConflict`.
|
||||||
|
|
||||||
|
## 5. Bekannte Lücken / offene Punkte
|
||||||
|
|
||||||
|
1. **Rate Limit je App-Instanz** (In-Memory wie SEC2); bei mehreren Replikas zählt jede Instanz getrennt. Geteilter Redis-Zähler = SEC5.
|
||||||
|
2. **`/api/v1` nur mit Session-Cookie** – kein Token für Integrationen (unverändert).
|
||||||
|
3. **`signature.capture` offline** nicht registriert: `/api/v1/uploads` kennt keine Upload-Art für das Unterschriftsbild (`kind: signature`, PNG). Die mobile Berichts-/Unterschrift-UI nutzt weiterhin Server Actions; die neuen Ops `report.save_draft`/`report.submit` stehen der Outbox bereit, die UI ist aber noch nicht auf `submitOp` umgestellt (L5/L7).
|
||||||
|
4. **Events in Transaktionen:** `emitEvent` innerhalb von `inTransaction` wird weiterhin sofort ausgeführt (Benachrichtigung bei späterem Rollback möglich) – analog zu h lösbar.
|
||||||
|
5. **Kontingent:** Monatsgrenze UTC; ein laufender Aufruf kann das Limit einmalig überschreiten; Transkription (Audio) zählt nicht; keine Warnung vor Erreichen.
|
||||||
|
6. **Aufbewahrung:** Sprachnotiz-Zusammenfassungen älter als die Frist können nicht mehr übernommen werden (Ausgabe geleert); das KI-Protokoll zeigt dann `null`.
|
||||||
|
7. **Sidebar** bewusst ab < 1024 px eingeklappt (Anforderung ≤ 768 px ist enthalten); **visuelle Browser-Prüfung nicht durchgeführt** (Login im Browser hätte eine Sitzung/Credential-Eingabe erfordert) – geprüft per Server-Rendering (Markup, Labels). Bitte manuell bei 375/768/1024 px ansehen.
|
||||||
|
8. **`POST /api/v1/work-orders/{id}/documents`** gibt das Dokument inkl. `storageKey` zurück (L2-Verhalten, unverändert) – interner Schlüssel sollte nicht nach außen.
|
||||||
|
9. **413 vs. 422:** Größenprüfungen in Import-/Dokument-Services melden `invalid file_too_large` (422); nur die Route-eigenen Vorprüfungen (`/uploads`) antworten 413.
|
||||||
|
10. **CI-Job nicht ausgeführt** (kein Runner): offen, ob `prisma db execute --stdin` für das `craftvia_app`-Passwort und Chromium im Gitea-Runner verfügbar sind (PDF-Test skippt sonst).
|
||||||
|
11. **Prebuilt-Deploy** braucht ein gepushtes `craftvia-worker`-Image (Skript baut es jetzt, Push nicht ausgeführt).
|
||||||
|
12. **RLS-Modus-Tests** (`test-tenant-isolation`, `test-garage-storage`) s. §3.
|
||||||
|
|
||||||
|
## 6. Screens / Routen
|
||||||
|
|
||||||
|
| Route | Änderung |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/v1/openapi.json` | neu – OpenAPI 3.1 |
|
||||||
|
| alle `/api/v1/**` | einheitliches Fehlerformat, Same-Origin für Mutationen, Rate Limit |
|
||||||
|
| `POST /api/v1/sync` | Ops `report.save_draft`, `report.submit` |
|
||||||
|
| `GET /api/v1/field/bundle` | `orders[].mySession` |
|
||||||
|
| Backoffice-Layout (alle `(app)`-Seiten) | Menü-Button + Drawer unter 1024 px |
|
||||||
|
| `/settings/lotse` | monatliches KI-Kontingent + Verbrauch |
|
||||||
|
| `/settings/audit` | Aktion „Lesezugriff" |
|
||||||
|
| `/work-orders/conflicts` | Übernehmen auch für abgesendete Berichte |
|
||||||
|
| `/m/orders/[id]/report` | Offline-Entwurf im Berichtseditor |
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* L10b HTTP smoke against a running dev server (no password input — session cookies via
|
||||||
|
* finalizeIdentityLogin + Auth.js encode, like scripts/smoke-auth.ts):
|
||||||
|
* unified /api/v1 error format, 401/403/404/422, OpenAPI, sync ops, bundle mySession,
|
||||||
|
* Lotse budget settings, collapsible backoffice sidebar, tenant separation.
|
||||||
|
*
|
||||||
|
* Usage (dev server running, seeded DB): BASE=http://localhost:3111 npx tsx scripts/smoke-betrieb.ts
|
||||||
|
*/
|
||||||
|
import "dotenv/config";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { encode } from "next-auth/jwt";
|
||||||
|
import { prisma } from "../src/server/db";
|
||||||
|
import { finalizeIdentityLogin } from "../src/server/auth";
|
||||||
|
|
||||||
|
const BASE = process.env.BASE ?? "http://localhost:3111";
|
||||||
|
const HOST = new URL(BASE).host;
|
||||||
|
const COOKIE = BASE.startsWith("https") ? "__Secure-authjs.session-token" : "authjs.session-token";
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
const ok = (cond: boolean, msg: string) => {
|
||||||
|
console.log(`${cond ? "✓" : "✗"} ${msg}`);
|
||||||
|
if (!cond) failures++;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function cookieFor(email: string, slug: string): Promise<string> {
|
||||||
|
const identity = await prisma.identity.findUnique({ where: { email } });
|
||||||
|
if (!identity) throw new Error(`identity ${email} not found (seed?)`);
|
||||||
|
const user = await finalizeIdentityLogin(identity.id, slug);
|
||||||
|
if (!user) throw new Error(`no active membership for ${email} in ${slug}`);
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
return `${COOKIE}=${await encode({ token, secret: process.env.AUTH_SECRET!, salt: COOKIE, maxAge: 60 * 30 })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Res = { status: number; body: string; json: unknown; headers: Headers };
|
||||||
|
async function call(path: string, init: RequestInit & { cookie?: string } = {}): Promise<Res> {
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
if (init.cookie) headers.set("cookie", init.cookie);
|
||||||
|
const res = await fetch(BASE + path, { ...init, headers, redirect: "manual" });
|
||||||
|
const body = await res.text();
|
||||||
|
let json: unknown = null;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(body);
|
||||||
|
} catch {
|
||||||
|
json = null;
|
||||||
|
}
|
||||||
|
return { status: res.status, body, json, headers: res.headers };
|
||||||
|
}
|
||||||
|
const errCode = (r: Res) => (r.json as { error?: { code?: string } } | null)?.error?.code;
|
||||||
|
const sameOrigin = { origin: BASE, "sec-fetch-site": "same-origin", "content-type": "application/json" };
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("\n== anonym");
|
||||||
|
let r = await call("/api/v1/openapi.json");
|
||||||
|
ok(r.status === 401 && errCode(r) === "unauthorized", `GET /api/v1/openapi.json ohne Sitzung → 401 (${r.status})`);
|
||||||
|
r = await call("/api/v1/sync", { method: "POST", headers: sameOrigin, body: "{}" });
|
||||||
|
ok(r.status === 401 && errCode(r) === "unauthorized", `POST /api/v1/sync ohne Sitzung → 401 (${r.status})`);
|
||||||
|
|
||||||
|
console.log("\n== admin@demo.example (demo)");
|
||||||
|
const admin = await cookieFor("admin@demo.example", "demo");
|
||||||
|
r = await call("/api/v1/openapi.json", { cookie: admin });
|
||||||
|
const spec = r.json as { openapi?: string; paths?: Record<string, unknown> } | null;
|
||||||
|
ok(r.status === 200 && spec?.openapi?.startsWith("3.1") === true && Object.keys(spec.paths ?? {}).length >= 23, `OpenAPI 3.1 mit ${Object.keys(spec?.paths ?? {}).length} Pfaden`);
|
||||||
|
r = await call("/api/v1/customers?pageSize=2", { cookie: admin });
|
||||||
|
const list = r.json as { data?: unknown[]; pagination?: { total: number } } | null;
|
||||||
|
ok(r.status === 200 && Array.isArray(list?.data) && typeof list?.pagination?.total === "number", "GET /api/v1/customers → data + pagination");
|
||||||
|
r = await call("/api/v1/work-orders/zz-unknown", { cookie: admin });
|
||||||
|
ok(r.status === 404 && errCode(r) === "not_found", `GET /api/v1/work-orders/<unbekannt> → 404 not_found (${r.status})`);
|
||||||
|
r = await call("/api/v1/reports/zz-unknown/pdf", { cookie: admin });
|
||||||
|
ok(r.status === 404 && errCode(r) === "not_found", `GET /api/v1/reports/<unbekannt>/pdf → 404 im einheitlichen Format (${r.status})`);
|
||||||
|
r = await call("/api/v1/work-orders/zz-unknown/transition", { cookie: admin, method: "POST", headers: { origin: "https://evil.example", "content-type": "application/json" }, body: "{}" });
|
||||||
|
ok(r.status === 403 && errCode(r) === "forbidden", `POST transition mit fremdem Origin → 403 (${r.status})`);
|
||||||
|
r = await call("/api/v1/customers", { cookie: admin, method: "POST", headers: sameOrigin, body: "[]" });
|
||||||
|
ok(r.status === 422 && errCode(r) === "invalid", `POST /api/v1/customers mit Array → 422 invalid (${r.status})`);
|
||||||
|
r = await call("/api/v1/imports/zz-unknown/confirm", { cookie: admin, method: "POST", headers: sameOrigin, body: "{nope" });
|
||||||
|
ok(r.status === 422 && errCode(r) === "invalid", `POST imports/confirm mit kaputtem JSON → 422 (vorher 400, anderes Format) (${r.status})`);
|
||||||
|
r = await call("/settings/lotse", { cookie: admin });
|
||||||
|
ok(r.status === 200 && r.body.includes("Monatliches KI-Kontingent"), "/settings/lotse zeigt das KI-Kontingent");
|
||||||
|
r = await call("/dashboard", { cookie: admin });
|
||||||
|
ok(r.status === 200 && r.body.includes("Menü öffnen") && r.body.includes('aria-controls="backoffice-sidebar"'), "Backoffice-Layout mit Menü-Button (einklappbare Sidebar)");
|
||||||
|
r = await call("/settings/audit?action=read", { cookie: admin });
|
||||||
|
ok(r.status === 200, "/settings/audit mit Filter action=read");
|
||||||
|
r = await call("/work-orders/conflicts", { cookie: admin });
|
||||||
|
ok(r.status === 200 && r.body.includes("abgesendete Berichte"), "Konfliktliste mit angepasstem Hinweis");
|
||||||
|
|
||||||
|
console.log("\n== monteur@demo.example (demo)");
|
||||||
|
const tech = await cookieFor("monteur@demo.example", "demo");
|
||||||
|
r = await call("/api/v1/field/bundle", { cookie: tech });
|
||||||
|
const bundle = r.json as { orders?: { id: string; mySession?: unknown }[] } | null;
|
||||||
|
ok(r.status === 200 && Array.isArray(bundle?.orders) && (bundle!.orders!.length === 0 || bundle!.orders!.every((o) => "mySession" in o)), `Bundle mit mySession je Auftrag (${bundle?.orders?.length ?? 0} Aufträge)`);
|
||||||
|
r = await call("/api/v1/sync", { cookie: tech, method: "POST", headers: sameOrigin, body: JSON.stringify({ deviceId: "smoke", operations: [] }) });
|
||||||
|
ok(r.status === 422 && errCode(r) === "invalid", `POST /api/v1/sync leerer Batch → 422 invalid (${r.status})`);
|
||||||
|
const orderId = bundle?.orders?.[0]?.id ?? "zz-unknown";
|
||||||
|
r = await call("/api/v1/sync", {
|
||||||
|
cookie: tech,
|
||||||
|
method: "POST",
|
||||||
|
headers: sameOrigin,
|
||||||
|
body: JSON.stringify({ deviceId: "smoke", operations: [{ clientOpId: randomUUID(), opType: "report.submit", payload: { workOrderId: orderId }, baseVersion: 1, clientCreatedAt: new Date().toISOString() }] }),
|
||||||
|
});
|
||||||
|
const result = (r.json as { results?: { status: string; errorCode?: string }[] } | null)?.results?.[0];
|
||||||
|
ok(r.status === 200 && result?.status === "rejected" && result.errorCode === "invalid", "Sync report.submit ohne reportId → rejected invalid (Op registriert, Payload validiert)");
|
||||||
|
r = await call("/m", { cookie: tech });
|
||||||
|
ok(r.status === 200, "/m rendert");
|
||||||
|
r = await call("/dashboard", { cookie: tech });
|
||||||
|
ok(r.status === 307 || r.status === 308, `Monteur /dashboard → Redirect (${r.status})`);
|
||||||
|
|
||||||
|
console.log("\n== admin2@demo.example (demo2)");
|
||||||
|
const admin2 = await cookieFor("admin2@demo.example", "demo2");
|
||||||
|
const demoTenant = await prisma.tenant.findUnique({ where: { slug: "demo" }, select: { id: true } });
|
||||||
|
const demoOrder = demoTenant ? await prisma.workOrder.findFirst({ where: { tenantId: demoTenant.id }, select: { id: true } }) : null;
|
||||||
|
if (demoOrder) {
|
||||||
|
r = await call(`/api/v1/work-orders/${demoOrder.id}`, { cookie: admin2 });
|
||||||
|
ok(r.status === 404 && errCode(r) === "not_found", `Mandant demo2: Auftrag von demo → 404 (${r.status})`);
|
||||||
|
} else {
|
||||||
|
console.log("(kein Demo-Auftrag im Seed — Mandantentest übersprungen)");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$disconnect();
|
||||||
|
console.log(failures ? `\n${failures} Fehler` : "\nOK");
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
void HOST;
|
||||||
Reference in New Issue
Block a user