Compare commits
124
Commits
No files matched your search
+48
-11
@@ -26,6 +26,7 @@ Ein NestJS-Modul pro fachlichem Bereich, Reihenfolge entspricht Baureihenfolge i
|
||||
| `DashboardModule` | Aggregations-Endpunkte | alle o.g. |
|
||||
| `SearchModule` | globale Suche, Filter-Bausteine | alle o.g. |
|
||||
| `ExportModule` | CSV/JSON/PDF-Export | alle o.g. |
|
||||
| `ErpExportModule` | Nur lesender Inventar-Spiegel für ERPNext (`/api/v1/export/inventar/...`, Recht `erp-inventory:read`), Vertrag in [`docs/erp-inventar-export.md`](docs/erp-inventar-export.md) | Products, Bottling, ProductionBatches, Stock, Harvests |
|
||||
|
||||
Jedes Modul: `*.controller.ts`, `*.service.ts`, `dto/`, `*.module.ts`, `*.spec.ts` (Unit), `test/*.e2e-spec.ts` (Integration). Rollenprüfung über `@Roles()`-Decorator + `RolesGuard`, angewendet pro Endpunkt – niemals nur UI-seitig.
|
||||
|
||||
@@ -59,11 +60,11 @@ Datenzugriff ausschließlich über TanStack Query gegen die Backend-API (kein di
|
||||
|
||||
## 3. Auth-Flow
|
||||
|
||||
1. Login (`POST /api/v1/auth/login`) mit E-Mail/Passwort → Access-Token (kurzlebig, ~15 min, JWT) + Refresh-Token (langlebig, ~7 Tage, in DB als Hash gespeichert, rotierend).
|
||||
2. Frontend hält Access-Token im Memory/React-Context, Refresh-Token als httpOnly-Cookie.
|
||||
3. Bei 401 automatischer Refresh über `POST /api/v1/auth/refresh`, danach Retry.
|
||||
4. Jeder geschützte Endpunkt: `JwtAuthGuard` (Authentifizierung) + `RolesGuard` (Autorisierung nach Rolle aus Abschnitt 3).
|
||||
5. API-Client-Rolle nutzt dieselben JWT-Endpunkte mit client-credentials-artigem Service-Account (kein OAuth2-Server im MVP, siehe Annahmen).
|
||||
1. Login (`POST /api/v1/auth/login`) mit E-Mail/Passwort → Access-Token (15 min, JWT HS256, `iss=apicore`, `aud=apicore:access`) + Refresh-Token (in DB als Hash, rotierend). Bei aktiver MFA zuerst ein Zwischen-Token (`aud=apicore:mfa-challenge`), das nie als Access-Token gilt.
|
||||
2. Frontend hält Access-Token im Memory/React-Context, Refresh-Token als httpOnly-Cookie (`Secure`, `SameSite=Lax`, Pfad `/api/v1/auth`).
|
||||
3. Bei 401 automatischer Refresh über `POST /api/v1/auth/refresh`, danach Retry. Rotation atomar; Wiederverwendung eines alten Tokens (nach 10 s Gnadenfrist für parallele Tabs) beendet alle Sitzungen. Spätestens `SESSION_MAX_AGE` (7 Tage) nach dem Login ist neu anzumelden.
|
||||
4. Guards in dieser Reihenfolge: `ThrottlerGuard` → `JwtAuthGuard` (Authentifizierung, Gültigkeit frisch aus der DB je Anfrage) → `PermissionsGuard` (Berechtigung, fail-closed).
|
||||
5. Maschinen-Zugriff (Wabenhain) nur per API-Schlüssel mit Scope-Allowlist und optionaler IP-Allowlist, siehe Annahme 14 und Abschnitt 9.
|
||||
|
||||
## 4. Datenfluss / Rückverfolgbarkeit
|
||||
|
||||
@@ -97,7 +98,21 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
||||
6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt.
|
||||
7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder.
|
||||
8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern.
|
||||
9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad (wie beim Webhooks-Modul) nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster mit minütlichem `@Cron`-Worker: Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen.
|
||||
9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad (wie beim Webhooks-Modul) nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster mit minütlichem `@Cron`-Worker: Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Die Outbox ist sicher bei mehreren Replicas: Jeder Eintrag wird vor der Arbeit per bedingtem Update (`claimedAt`) reserviert, ein Claim älter als 5 min gilt als verwaist und wird übernommen, Graph-Requests haben ein 30-s-Timeout. `revision` sorgt dafür, dass eine Änderung während des Syncs nicht verloren geht (Eintrag bleibt PENDING, dasselbe Event wird beim nächsten Lauf aktualisiert); ein Abbruch während des Syncs löscht das gerade geschriebene Event. Ein neues Graph-Event entsteht beim Aktualisieren nur, wenn das alte nicht mehr existiert (404/410); andere Fehler gelten als fehlgeschlagener Versuch. Jedes Anlegen schickt eine `transactionId` (aus Eintrag, Revision und ersetztem Event), damit Graph eine Wiederholung nach einem Timeout nicht doppelt anlegt; „Jetzt synchronisieren“ startet den Lauf im Hintergrund (202, `running` im Status) und setzt FAILED-Einträge auf PENDING zurück. `groupId` muss eine Entra-Objekt-ID (GUID) sein; `""` oder `null` löscht sie; sie wird in Graph-URLs kodiert. Fehler aus Prisma werden nur mit Name, Code und Modell geloggt (`src/common/loggable-error.ts`), nie mit Abfragewerten. **Offene Entscheidung (2026-10-12):** Laut Microsoft-Doku (`group-post-events`) unterstützt `POST /groups/{id}/events` keine Application-Permission, nur delegiertes `Group.ReadWrite.All`. Mit dem App-only-Token oben kann das Anlegen im Gruppenkalender deshalb in Produktion scheitern (nicht gegen einen echten Tenant geprüft). Auswege: Zielkalender auf ein Ressourcen- oder Benutzerpostfach umstellen (`/users/{upn}/calendar/events`, App-only erlaubt, per Application Access Policy begrenzt) oder delegierter Zugriff über ein Dienstkonto. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen.
|
||||
10. **Wartezeit manuell statt aus Wirkstoff-Datenbank abgeleitet**: `Treatment.withdrawalPeriodDays` wird pro Behandlung von Hand eingetragen (laut Packungsbeilage des verwendeten Mittels), statt automatisch aus Produktname/Wirkstoff hergeleitet zu werden. Eine hartkodierte Zuordnung Produkt→Wartezeit wäre eine faktische, rechtlich relevante Behauptung über Tierarzneimittel-Zulassungen, die im Code weder aktuell gehalten noch verlässlich verifiziert werden kann; die Verantwortung für den korrekten Wert bleibt beim Imker. Die App warnt in der Ernte-Erfassung nur, wenn ein Wert hinterlegt wurde und dessen Fenster (`endDate ?? date` + `withdrawalPeriodDays`) noch nicht abgelaufen ist – bezogen auf das Erntedatum (`GET /treatments/withdrawal-status?colonyIds=…&at=JJJJ-MM-TT`, ohne `at` gilt jetzt; Behandlungen, die erst nach diesem Datum beginnen, zählen nicht) – sie blockiert die Ernte nicht hart, da Ernte-Datensätze auch nachträglich für bereits erfolgte, reale Ernten erfasst werden.
|
||||
|
||||
11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt: Darum laufen alle Seiteneffekte nach dem Commit (`AutomationService.evaluate*`, `CalendarSyncService.enqueue`/`cancel`, `WebhooksService.dispatch`) best-effort über `runAfterCommit` (`src/common/after-commit.ts`) – sie loggen auf Error-Level und werfen nie. Was das Schreiben verhindern muss (z. B. `generateNumber`, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (`SELECT … FOR UPDATE`). Dauerhaft zugestellte Seiteneffekte bringt erst das Ereignis-Rückgrat (Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md`).
|
||||
12. **Automatische Aufgaben sind Vorschläge**: `Task.sourceAutomationTrigger` kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel. Die Varroa-Schwelle (Milben/Tag) wird nur mit Tagesraten des natürlichen Milbenfalls verglichen. Die Tagesrate wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt.
|
||||
14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`: `shop-sync:read`, `shop-sync:write`, `erp-inventory:read`); je angebundenem System ein eigener Schlüssel. Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt.
|
||||
13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar.
|
||||
15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10).
|
||||
16. **Überwinterung als eigene Einträge statt Volksstatus**: `WinterCheck` (eindeutig je Volk, `winterYear`, Saison) hält Ein- und Auswinterung; ab Juli gehört ein Datum zum Winter desselben Jahres. Verlustursachen sind bewusst nur die COLOSS-Kategorien, keine Diagnosen. Die Übersicht gruppiert nach dem aktuellen Stand des Volkes. Ein Verlust ändert den Volksstatus nur auf ausdrücklichen Wunsch (`markColonyDeceased`). Neue Rechte vergibt die Migration an Nicht-System-Rollen, weil der Katalog-Sync nur System-Rollen berechtigt.
|
||||
|
||||
17. **Schutz gegen Rechteausweitung in der Benutzerverwaltung**: Wer Benutzer pflegt, darf nur Konten verwalten (Passwort/MFA zurücksetzen, E-Mail ändern, (de)aktivieren) und Rollen vergeben, deren Rechte er selbst vollständig hat (`UsersService.assertWithinCallerPermissions`), analog zur Rollenbearbeitung.
|
||||
18. **SSO-Bindung an die IdP-Identität**: Die erste Microsoft-Anmeldung bindet das Konto an `"<iss>|<oid>"` (`User.ssoSubject`); danach zählt nur noch diese Identität, nicht der unverifizierte E-Mail-Claim. Mandantenübergreifende Issuer (`common`/`organizations`/`consumers`) schalten SSO ab. E-Mails werden ohne Groß-/Kleinschreibung verglichen.
|
||||
19. **Backups versiegelt**: AES-256-GCM über das gzip-Bundle, Schlüssel `BACKUP_ENCRYPTION_KEY` (sonst per HKDF aus `JWT_ACCESS_SECRET`). Nur versiegelte Dateien stellen vollständig wieder her (und überschreiben). Eine unversiegelte (alte v2-)Datei darf nur die 27 Stamm- und Feldtabellen der Erlaubt-Liste `LEGACY_UNSEALED_TABLES` ergänzen – fehlende Zeilen anlegen, bestehende nie ändern. Alles andere (Benutzer, Rollen, Webhooks, Audit-Log, Kalender-Sync, Backup- und Website-Einstellungen, Nummernkreise, Bestands- und Chargenbewegungen, Shop-Anfragen, Rohstoffe/Herstellung, Dokumente, Benachrichtigungen und jede künftige Tabelle) kommt nur aus versiegelten Dateien; die Oberfläche listet übersprungene Tabellen auf; Import verlangt Passwort (+ TOTP). Webhook-Secrets liegen verschlüsselt in der DB (Schlüssel aus `JWT_ACCESS_SECRET`) – für eine Wiederherstellung in einem anderen Deployment mit anderem `JWT_ACCESS_SECRET` müssen Webhook-Secrets und MFA neu eingerichtet werden.
|
||||
20. **Bearbeiten-Regel für `null`** (Grundsatz „alles bearbeitbar“): In jeder Bearbeiten-Anfrage bleibt ein weggelassenes Feld unverändert, `null` leert ein Feld, das leer sein darf, und `null` auf einem Pflichtfeld wird mit 400 abgelehnt (statt still ignoriert oder als 500). Umgesetzt über `PartialType(..., { skipNullProperties: false })` in den Update-DTOs, `@IsOptionalNotNull()` (`src/common/validation/is-optional-not-null.ts`) für weglassbare, aber nicht leerbare Felder und `optionalDate()` (`src/common/patch-values.ts`) für leerbare Datumsfelder; `update-dto-null-handling.spec.ts` hält den Vertrag je DTO fest. Das Frontend sendet geleerte optionale Felder über `emptyToNull` (`ApiCore-Frontend/src/lib/forms/empty-to-null.ts`) als `null`.
|
||||
21. **Lebende Völker**: „Lebend“ heißt jeder Status außer `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` – also auch `WEAK`, `CONSPICUOUS`, `QUEENLESS`. Backend-Abfragen filtern über `LIVING_COLONY_WHERE` (`colonies/colony-status.ts`), das Frontend über `isLivingColony` (`lib/colonies/status.ts`); `status = ACTIVE` steht nur dort, wo wirklich genau dieser Status gemeint ist.
|
||||
|
||||
## 7. Kubernetes
|
||||
|
||||
@@ -111,10 +126,32 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
||||
|
||||
**Erster Admin-Zugang:** `prisma/seed.ts` legt beim allerersten Start (leere `User`-Tabelle) einen Administrator an (`SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD`, Default `[email protected]`/`ChangeMe123!`) und gibt URL (`FRONTEND_ORIGIN`), Benutzername und Passwort einmalig im Container-Log aus. Existiert der User schon (auch deaktiviert), passiert nichts – kein erneutes Log, kein erneutes Anlegen, damit spätere Passwortänderungen nicht durch veraltete Logs verwirrend überschrieben wirken und Zugangsdaten nicht bei jedem Neustart in die Logs wandern. Ersetzen: neuen Admin über `/settings/users` anlegen, dann den alten deaktivieren (Löschen gibt es nicht, siehe "Historie statt Löschen") – die Lockout-Sperre in `UsersService.assertNotLastSystemRoleUser` verhindert ohnehin, den letzten aktiven Admin zu deaktivieren.
|
||||
|
||||
**Datensicherung:** Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Aktuell **noch nicht umgesetzt** (offener Punkt, siehe TASKS.md) – empfohlene Strategie:
|
||||
**Datensicherung:** Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Umgesetzt als anwendungsseitiges `BackupModule` (`ApiCore-Backend/src/backup/`) statt der ursprünglich hier vorgesehenen reinen `pg_dump`/PITR-Strategie:
|
||||
|
||||
- Tägliches logisches Backup via `pg_dump -Fc` (custom format, komprimiert, für `pg_restore` geeignet), z. B. als Kubernetes-`CronJob` gegen den `apicore-postgres`-Service, Ablage auf einem separaten PVC oder Objektspeicher außerhalb des Postgres-eigenen Volumes.
|
||||
- Kontinuierliches WAL-Archiving/PITR (Point-in-Time Recovery), falls die Storage-Class/das Backup-Tooling das unterstützt, für Wiederherstellung auf einen beliebigen Zeitpunkt zwischen den täglichen Dumps.
|
||||
- Restore-Test: `pg_restore` eines Dumps in eine isolierte Datenbank, danach `npx prisma migrate deploy` zur Kontrolle, dass der Migrationsstand konsistent ist (`prisma migrate status`).
|
||||
- **Export/Import:** `BackupService.exportAll()`/`.importAll()` sichert alle fachlichen und Konfigurationsmodelle (Betriebsdaten inkl. Herstellung, Rohstoffe, Bewegungsbuch und Shop-Anfragen, Nummernkreise, Benutzer/Rollen/Rechte, Audit-Log, Website-/Backup-/Kalender-Einstellungen samt Kalender-Outbox, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei (Bundle-Version 3; ältere Bundles bleiben einspielbar). Bewusst ausgenommen sind nur Permission (Katalog aus dem Code), RefreshToken, ConsumedToken, ApiKey (Annahme 14) und IdempotencyRecord (Annahme 11); die Liste mit Begründung steht in `MODELS_EXCLUDED_FROM_BACKUP`, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen über `key`/`resource+action` statt roher `id`, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen `$transaction`.
|
||||
- **Automatischer Zeitplan:** `BackupSchedulerService` registriert einen `@nestjs/schedule`-Cron-Job, der die Bundle-Datei periodisch auf `BACKUP_STORAGE_PATH` schreibt und alte Stände nach `BACKUP_RETENTION_COUNT` rotiert. Pfad/Zeitplan/Aufbewahrung sind sowohl per Env-Variable als auch über die GUI (Verwaltung > Backup) konfigurierbar – eine gesetzte Env-Variable hat dabei immer Vorrang vor dem GUI-Wert. Bei `replicas: 2` verhindert ein Datei-Mtime-Guard (kein DB-Lock nötig), dass beide Pods im selben Tick doppelt sichern; ohne gesetzten Pfad bleibt der Zeitplan inaktiv, manueller Export/Import über die UI funktioniert davon unabhängig immer.
|
||||
- **Restore:** Über `POST /backup/import` (bzw. den Import-Dialog unter Verwaltung > Backup) eine zuvor exportierte Bundle-Datei einspielen. Import-Limit `BACKUP_MAX_IMPORT_BYTES` (Standard 50 MB, höchstens 256 MB, weil der Upload vollständig im RAM des 512-Mi-Pods verarbeitet wird); Export und Zeitplan prüfen jede Sicherung dagegen und warnen in Log und UI (Verwaltung > Backup), bevor eine Datei entsteht, die sich im Notfall nicht mehr einspielen ließe.
|
||||
|
||||
**Restore-Ablauf (Kurzfassung):** 1) Zielinstanz/-datenbank bereitstellen. 2) `pg_restore` (oder WAL-Replay bis zum Zielzeitpunkt bei PITR). 3) `npx prisma migrate status` prüfen. 4) `npx prisma migrate deploy` falls seit dem Dump neue Migrationen hinzugekommen sind. 5) `GET /health/ready` gegen die wiederhergestellte Instanz aufrufen, um DB-Erreichbarkeit zu bestätigen, bevor Verkehr umgeschaltet wird.
|
||||
Ein rohes `pg_dump`/PITR-Backup auf Infrastrukturebene bleibt als zusätzliche, komplementäre Absicherung für den Katastrophenfall (z. B. korrupte Datenbankdateien, nicht nur fachliche Daten) sinnvoll, ist aber nicht mehr der primäre, hier dokumentierte Mechanismus – die in-App-Lösung deckt den fachlichen Anwendungsfall "Daten sichern/übertragen" bereits vollständig ab.
|
||||
|
||||
## 9. API-Sicherheit (Stand Oktober 2026)
|
||||
|
||||
Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung und Begründung je Punkt in `docs/pruefung-2026-09.md`, Abschnitt „API-Sicherheit“.
|
||||
|
||||
| Bereich | Maßnahme |
|
||||
|---|---|
|
||||
| Authentifizierung | JWT mit festem Algorithmus, Issuer und Audience je Zweck; MFA (TOTP) mit Replay-Schutz; Kontosperre 15 min nach 5 Fehlversuchen; gleiche Antwort für unbekannte/gesperrte Konten; bcrypt auf jedem Pfad |
|
||||
| Passwörter | mind. 12 Zeichen, max. 72 Byte, Abgleich mit Have I Been Pwned (k-Anonymität, abschaltbar per `PASSWORD_BREACH_CHECK=false`) |
|
||||
| Sitzungen | Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt |
|
||||
| Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) |
|
||||
| Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten |
|
||||
| Maschinen-Zugriff | API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (`shop-sync:read`, `shop-sync:write`, `erp-inventory:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben; nach 20 ungültigen Schlüsseln in 15 min je IP (IPv6: je /64) `429` für weitere ungültige – gültige Schlüssel nie, falsch aufgebaute Tokens zählen nicht; Warnung beim Start, wenn `TRUST_PROXY` in Produktion fehlt; Logs nur mit Schlüssel-Präfix |
|
||||
| Ausgehende Aufrufe | Webhooks: Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, 5 s Timeout, Signatur über Zeitstempel+ID+Body |
|
||||
| Transport/Antworten | `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies |
|
||||
| Rate-Limits | Throttler vor der Authentifizierung (100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` |
|
||||
| Daten | Backups verschlüsselt und signiert, Dateien 0600; CSV-Export ohne Formel-Injection; Logs ohne Query-Strings |
|
||||
|
||||
**Bekannte Restpunkte (bewusst nicht umgesetzt):**
|
||||
|
||||
- Rate-Limits zählen je Pod im Speicher; bei 2 Replikas verdoppelt sich das Limit. Brute-Force-Schutz hängt nicht daran, sondern an der Kontosperre in der DB. Ein gemeinsamer Speicher (Redis) wäre der nächste Schritt, falls Lastspitzen ein Thema werden.
|
||||
- Eine E-Mail-Änderung im eigenen Profil meldet „bereits vergeben“, wenn die Adresse existiert. Das erfordert eine Anmeldung plus aktuelles Passwort; eine generische Meldung würde Nutzer nur verwirren.
|
||||
@@ -9,9 +9,22 @@ JWT_ACCESS_SECRET="change-me-to-a-random-value-at-least-32-characters-long"
|
||||
JWT_REFRESH_SECRET="change-me-refresh-secret"
|
||||
JWT_ACCESS_EXPIRES_IN="15m"
|
||||
JWT_REFRESH_EXPIRES_IN="7d"
|
||||
# Hard limit from login, however often the session is refreshed.
|
||||
SESSION_MAX_AGE="7d"
|
||||
# New passwords are checked against known breaches (Have I Been Pwned,
|
||||
# k-anonymity: only 5 hash characters leave the server). "false" turns the
|
||||
# check off for deployments without internet access.
|
||||
# PASSWORD_BREACH_CHECK=true
|
||||
|
||||
PORT=3001
|
||||
FRONTEND_ORIGIN="http://localhost:3000"
|
||||
# Reverse-proxy hops in front of the backend whose X-Forwarded-For is
|
||||
# trusted (client IP for rate limits and API-key IP allowlists). "1" for the
|
||||
# single K8s ingress; a list such as "loopback, 10.0.0.0/8" also works.
|
||||
# Behind Cloudflare + Traefik use "loopback, <pod CIDR>, <Cloudflare ranges>"
|
||||
# (see deploy/k8s/backend/configmap.example.yaml); "1" would yield the
|
||||
# Cloudflare edge IP that many clients share.
|
||||
TRUST_PROXY=1
|
||||
|
||||
# Refresh-token cookie requires HTTPS by default. Set to "false" only for
|
||||
# local HTTP development - every real deployment must leave this unset (or
|
||||
@@ -33,11 +46,19 @@ DOCUMENTS_STORAGE_PATH="./uploads"
|
||||
# ihre eigenen Backup-Dateien und die Lauf-Erkennung zwischen Replicas greift
|
||||
# nicht.
|
||||
BACKUP_STORAGE_PATH="./backups"
|
||||
# Directory a backup path set in the admin UI must stay under (the env
|
||||
# variable above is not restricted). Default "./backups".
|
||||
# BACKUP_ROOT="./backups"
|
||||
# Cron-Ausdruck (Standard: taeglich um 03:00 Uhr).
|
||||
BACKUP_CRON_SCHEDULE="0 3 * * *"
|
||||
# Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach
|
||||
# jedem Lauf geloescht (Standard: 14).
|
||||
BACKUP_RETENTION_COUNT=14
|
||||
# Backups are encrypted (AES-256-GCM). Without this key they are sealed with a
|
||||
# key derived from JWT_ACCESS_SECRET and restore only into this deployment.
|
||||
# Set the same value on another deployment to restore there. At least 32
|
||||
# characters, e.g. "openssl rand -hex 32". Keep it outside the backup storage.
|
||||
# BACKUP_ENCRYPTION_KEY=""
|
||||
|
||||
# Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert
|
||||
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
|
||||
|
||||
@@ -6,6 +6,12 @@ COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
# Qualitaets-Gate: ein Image entsteht nur, wenn Lint, Typecheck und
|
||||
# Unit-Tests gruen sind. eslint laeuft hier ohne --fix (anders als
|
||||
# `npm run lint`), damit ein Verstoss den Build abbricht statt still
|
||||
# korrigiert zu werden. Die e2e-Tests brauchen eine Datenbank und laufen
|
||||
# deshalb nicht im Image-Build.
|
||||
RUN npx eslint "{src,test}/**/*.ts" && npm run typecheck && npm test -- --ci
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runtime
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Treatment" ADD COLUMN "withdrawalPeriodDays" INTEGER;
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "IdempotencyStatus" AS ENUM ('PENDING', 'COMPLETED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Task" ADD COLUMN "sourceAutomationTrigger" "AutomationTriggerType";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "IdempotencyRecord" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"scope" TEXT NOT NULL,
|
||||
"status" "IdempotencyStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"statusCode" INTEGER,
|
||||
"responseBody" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "IdempotencyRecord_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "IdempotencyRecord_createdAt_idx" ON "IdempotencyRecord"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "IdempotencyRecord_userId_key_key" ON "IdempotencyRecord"("userId", "key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Task_sourceAutomationTrigger_relatedType_relatedId_status_idx" ON "Task"("sourceAutomationTrigger", "relatedType", "relatedId", "status");
|
||||
@@ -0,0 +1,24 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ApiKey" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"prefix" TEXT NOT NULL,
|
||||
"keyHash" TEXT NOT NULL,
|
||||
"scopes" TEXT[],
|
||||
"createdByUserId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastUsedAt" TIMESTAMP(3),
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ApiKey_revokedAt_idx" ON "ApiKey"("revokedAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "LabelTemplate" ADD COLUMN "cropMarks" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "AutomationTriggerType" ADD VALUE 'TREATMENT_FOLLOW_UP';
|
||||
ALTER TYPE "AutomationTriggerType" ADD VALUE 'FEEDING_FOLLOW_UP';
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "SiteSettings" ADD COLUMN "holderAddress" TEXT,
|
||||
ADD COLUMN "holderName" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Treatment" ADD COLUMN "apiaryId" TEXT,
|
||||
ADD COLUMN "prescribingVet" TEXT,
|
||||
ADD COLUMN "purchaseProof" TEXT,
|
||||
ADD COLUMN "supplier" TEXT;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Treatment" ADD CONSTRAINT "Treatment_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WinterCheckSeason" AS ENUM ('AUTUMN', 'SPRING');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WinterOutcome" AS ENUM ('SURVIVED', 'WEAK', 'LOST');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ColonyLossCause" AS ENUM ('DEAD_OR_EMPTY', 'QUEEN_PROBLEM', 'NATURAL_DISASTER');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WinterCheck" (
|
||||
"id" TEXT NOT NULL,
|
||||
"colonyId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"winterYear" INTEGER NOT NULL,
|
||||
"season" "WinterCheckSeason" NOT NULL,
|
||||
"date" TIMESTAMP(3) NOT NULL,
|
||||
"occupiedSeams" INTEGER,
|
||||
"feedStockKg" DOUBLE PRECISION,
|
||||
"queenPresent" BOOLEAN,
|
||||
"outcome" "WinterOutcome",
|
||||
"lossCause" "ColonyLossCause",
|
||||
"notes" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WinterCheck_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WinterCheck_winterYear_idx" ON "WinterCheck"("winterYear");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "WinterCheck_colonyId_winterYear_season_key" ON "WinterCheck"("colonyId", "winterYear", "season");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WinterCheck" ADD CONSTRAINT "WinterCheck_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WinterCheck" ADD CONSTRAINT "WinterCheck_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- Permissions: the boot-time catalog sync only grants new permissions to
|
||||
-- system roles (ADMIN). Winter checks are everyday beekeeping records, so the
|
||||
-- other roles get the same access they have to inspections.
|
||||
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
|
||||
VALUES
|
||||
(gen_random_uuid()::text, 'winter-checks:read', 'winter-checks', 'read', 'Ansehen', 'Überwinterung', 850),
|
||||
(gen_random_uuid()::text, 'winter-checks:create', 'winter-checks', 'create', 'Anlegen', 'Überwinterung', 851),
|
||||
(gen_random_uuid()::text, 'winter-checks:update', 'winter-checks', 'update', 'Bearbeiten', 'Überwinterung', 852)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||
SELECT r.id, p.id, now()
|
||||
FROM "Role" r, "Permission" p
|
||||
WHERE p.resource = 'winter-checks'
|
||||
AND (
|
||||
r.key IN ('ADMIN', 'BEEKEEPER', 'STAFF')
|
||||
OR (r.key IN ('READONLY', 'API_CLIENT') AND p.action = 'read')
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
-- Microsoft sign-in is bound to the IdP identity ("<iss>|<oid>"), set on the
|
||||
-- first SSO login; the unverified email claim no longer picks the account.
|
||||
ALTER TABLE "User" ADD COLUMN "ssoSubject" TEXT;
|
||||
CREATE UNIQUE INDEX "User_ssoSubject_key" ON "User"("ssoSubject");
|
||||
|
||||
-- Emails are compared case-insensitively from now on. Store existing ones
|
||||
-- lower-case, except where that would collide with another account - those
|
||||
-- stay as they are and are still found by the case-insensitive lookup.
|
||||
UPDATE "User" AS u
|
||||
SET "email" = lower(u."email")
|
||||
WHERE u."email" <> lower(u."email")
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "User" AS o
|
||||
WHERE o."id" <> u."id" AND lower(o."email") = lower(u."email")
|
||||
);
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- TOTP replay protection and per-account lockout after failed attempts.
|
||||
ALTER TABLE "User" ADD COLUMN "mfaLastTimeStep" INTEGER;
|
||||
ALTER TABLE "User" ADD COLUMN "failedAuthAttempts" INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "User" ADD COLUMN "authLockedUntil" TIMESTAMP(3);
|
||||
ALTER TYPE "AuditAction" ADD VALUE 'ACCOUNT_LOCKED';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Absolute session lifetime: every rotated refresh token keeps the time
|
||||
-- the session was first opened. Existing sessions count from now.
|
||||
ALTER TABLE "RefreshToken" ADD COLUMN "sessionStartedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- API keys can be limited to source addresses (IP or CIDR).
|
||||
ALTER TABLE "ApiKey" ADD COLUMN "allowedIps" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
ALTER TABLE "ApiKey" ADD COLUMN "lastUsedIp" TEXT;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- One-time helper tokens (SSO completion, MFA challenge) are recorded once
|
||||
-- used; a second use of the same jti is refused.
|
||||
CREATE TABLE "ConsumedToken" (
|
||||
"jti" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "ConsumedToken_pkey" PRIMARY KEY ("jti")
|
||||
);
|
||||
CREATE INDEX "ConsumedToken_expiresAt_idx" ON "ConsumedToken"("expiresAt");
|
||||
@@ -0,0 +1,162 @@
|
||||
-- Bestandsabgleich mit dem Webshop (docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md):
|
||||
-- Bewegungsbuch je Charge, Herstellungschargen fuer Nicht-Honig-Artikel,
|
||||
-- idempotente Buchungsanfragen des Shops. Rein additiv.
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProductKind" AS ENUM ('BOTTLED_HONEY', 'MANUFACTURED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ShopCategory" AS ENUM ('HONIG', 'KERZEN', 'PROPOLIS', 'SONSTIGES');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BatchMovementType" AS ENUM ('SHOP_SALE', 'SHOP_RETURN', 'WITHDRAWAL', 'CORRECTION');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WithdrawalReason" AS ENUM ('HOFLADEN', 'EIGENBEDARF', 'BRUCH', 'PROBE', 'SONSTIGES');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ShopStockRequestKind" AS ENUM ('SALE', 'RETURN');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Product" ADD COLUMN "kind" "ProductKind" NOT NULL DEFAULT 'BOTTLED_HONEY',
|
||||
ADD COLUMN "shopCategory" "ShopCategory" NOT NULL DEFAULT 'HONIG';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProductionBatch" (
|
||||
"id" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"lotNumber" TEXT NOT NULL,
|
||||
"date" TIMESTAMP(3) NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"bestBeforeDate" TIMESTAMP(3),
|
||||
"notes" TEXT,
|
||||
"userId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ProductionBatch_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "BatchMovement" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bottlingBatchId" TEXT,
|
||||
"productionBatchId" TEXT,
|
||||
"type" "BatchMovementType" NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"reason" "WithdrawalReason",
|
||||
"notes" TEXT,
|
||||
"shopRequestId" TEXT,
|
||||
"userId" TEXT,
|
||||
"apiKeyId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "BatchMovement_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ShopStockRequest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"externalId" TEXT NOT NULL,
|
||||
"kind" "ShopStockRequestKind" NOT NULL,
|
||||
"bottlingBatchId" TEXT,
|
||||
"productId" TEXT,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"reversesExternalId" TEXT,
|
||||
"apiKeyId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ShopStockRequest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProductionBatch_lotNumber_key" ON "ProductionBatch"("lotNumber");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProductionBatch_productId_idx" ON "ProductionBatch"("productId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BatchMovement_bottlingBatchId_idx" ON "BatchMovement"("bottlingBatchId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BatchMovement_productionBatchId_idx" ON "BatchMovement"("productionBatchId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BatchMovement_shopRequestId_idx" ON "BatchMovement"("shopRequestId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ShopStockRequest_externalId_key" ON "ShopStockRequest"("externalId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ShopStockRequest_reversesExternalId_idx" ON "ShopStockRequest"("reversesExternalId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProductionBatch" ADD CONSTRAINT "ProductionBatch_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProductionBatch" ADD CONSTRAINT "ProductionBatch_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_bottlingBatchId_fkey" FOREIGN KEY ("bottlingBatchId") REFERENCES "BottlingBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_productionBatchId_fkey" FOREIGN KEY ("productionBatchId") REFERENCES "ProductionBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_shopRequestId_fkey" FOREIGN KEY ("shopRequestId") REFERENCES "ShopStockRequest"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Genau eine Charge je Bewegung, genau ein Ziel je Shop-Anfrage.
|
||||
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_exactly_one_batch"
|
||||
CHECK (("bottlingBatchId" IS NULL) <> ("productionBatchId" IS NULL));
|
||||
ALTER TABLE "ShopStockRequest" ADD CONSTRAINT "ShopStockRequest_exactly_one_target"
|
||||
CHECK (("bottlingBatchId" IS NULL) <> ("productId" IS NULL));
|
||||
ALTER TABLE "ShopStockRequest" ADD CONSTRAINT "ShopStockRequest_quantity_positive"
|
||||
CHECK ("quantity" > 0);
|
||||
ALTER TABLE "ProductionBatch" ADD CONSTRAINT "ProductionBatch_quantity_positive"
|
||||
CHECK ("quantity" > 0);
|
||||
|
||||
-- Permissions: the boot-time catalog sync only grants new permissions to
|
||||
-- ADMIN; the other built-in roles get them here, matching their existing
|
||||
-- bottling rights.
|
||||
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
|
||||
VALUES
|
||||
(gen_random_uuid()::text, 'bottling:withdraw', 'bottling', 'withdraw', 'Entnehmen und Inventur', 'Abfüllungen', 1604),
|
||||
(gen_random_uuid()::text, 'production-batch:read', 'production-batch', 'read', 'Ansehen', 'Herstellungschargen', 1650),
|
||||
(gen_random_uuid()::text, 'production-batch:create', 'production-batch', 'create', 'Anlegen', 'Herstellungschargen', 1651),
|
||||
(gen_random_uuid()::text, 'production-batch:update', 'production-batch', 'update', 'Bearbeiten', 'Herstellungschargen', 1652),
|
||||
(gen_random_uuid()::text, 'production-batch:delete', 'production-batch', 'delete', 'Löschen', 'Herstellungschargen', 1653),
|
||||
(gen_random_uuid()::text, 'production-batch:withdraw', 'production-batch', 'withdraw', 'Entnehmen und Inventur', 'Herstellungschargen', 1654),
|
||||
(gen_random_uuid()::text, 'shop-sync:write', 'shop-sync', 'write', 'Verkäufe und Rückbuchungen des Shops buchen', 'Shop-Synchronisation', 3301)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- Wer Abfuellungen bearbeiten darf, darf kuenftig auch entnehmen und
|
||||
-- Herstellungschargen pflegen; wer Abfuellungen sehen darf, sieht auch
|
||||
-- Herstellungschargen.
|
||||
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||
SELECT DISTINCT rp."roleId", p.id, now()
|
||||
FROM "RolePermission" rp
|
||||
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'bottling:update'
|
||||
JOIN "Permission" p ON p.key IN (
|
||||
'bottling:withdraw', 'production-batch:create', 'production-batch:update',
|
||||
'production-batch:delete', 'production-batch:withdraw'
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||
SELECT DISTINCT rp."roleId", p.id, now()
|
||||
FROM "RolePermission" rp
|
||||
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'bottling:read'
|
||||
JOIN "Permission" p ON p.key = 'production-batch:read'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Only ADMIN may hand out shop-sync:write to an API key (a key can only get
|
||||
-- permissions its creator holds). Inserted here, the row is no longer "new"
|
||||
-- for the boot-time catalog sync, so the grant has to happen here too.
|
||||
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||
SELECT r.id, p.id, now()
|
||||
FROM "Role" r, "Permission" p
|
||||
WHERE r.key = 'ADMIN' AND p.key = 'shop-sync:write'
|
||||
ON CONFLICT DO NOTHING;
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
-- Rohstoffe (Wachs, Propolis) mit eigenen Chargen und Bestand; Herstellungs-
|
||||
-- chargen verbrauchen sie (ProductionInput + Bewegung CONSUMPTION). Damit
|
||||
-- reicht die Rueckverfolgung vom Rohstoff ueber die Charge bis zur Kundin.
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RawMaterialKind" AS ENUM ('BEESWAX', 'PROPOLIS');
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "BatchMovementType" ADD VALUE 'CONSUMPTION';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "BatchMovement" ADD COLUMN "rawMaterialLotId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RawMaterialLot" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kind" "RawMaterialKind" NOT NULL,
|
||||
"lotNumber" TEXT NOT NULL,
|
||||
"date" TIMESTAMP(3) NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"apiaryId" TEXT,
|
||||
"harvestId" TEXT,
|
||||
"notes" TEXT,
|
||||
"userId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "RawMaterialLot_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProductionInput" (
|
||||
"id" TEXT NOT NULL,
|
||||
"productionBatchId" TEXT NOT NULL,
|
||||
"rawMaterialLotId" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ProductionInput_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RawMaterialLot_lotNumber_key" ON "RawMaterialLot"("lotNumber");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RawMaterialLot_kind_idx" ON "RawMaterialLot"("kind");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProductionInput_productionBatchId_idx" ON "ProductionInput"("productionBatchId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProductionInput_rawMaterialLotId_idx" ON "ProductionInput"("rawMaterialLotId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BatchMovement_rawMaterialLotId_idx" ON "BatchMovement"("rawMaterialLotId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_harvestId_fkey" FOREIGN KEY ("harvestId") REFERENCES "Harvest"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProductionInput" ADD CONSTRAINT "ProductionInput_productionBatchId_fkey" FOREIGN KEY ("productionBatchId") REFERENCES "ProductionBatch"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProductionInput" ADD CONSTRAINT "ProductionInput_rawMaterialLotId_fkey" FOREIGN KEY ("rawMaterialLotId") REFERENCES "RawMaterialLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_rawMaterialLotId_fkey" FOREIGN KEY ("rawMaterialLotId") REFERENCES "RawMaterialLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
|
||||
-- Eine Bewegung gehoert jetzt zu genau einer von drei Chargenarten.
|
||||
ALTER TABLE "BatchMovement" DROP CONSTRAINT "BatchMovement_exactly_one_batch";
|
||||
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_exactly_one_batch"
|
||||
CHECK (num_nonnulls("bottlingBatchId", "productionBatchId", "rawMaterialLotId") = 1);
|
||||
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_quantity_positive" CHECK ("quantity" > 0);
|
||||
ALTER TABLE "ProductionInput" ADD CONSTRAINT "ProductionInput_quantity_positive" CHECK ("quantity" > 0);
|
||||
|
||||
-- Permissions (catalog sync grants only brand-new rows to ADMIN): roles that
|
||||
-- may maintain production batches may maintain raw materials, readers read.
|
||||
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
|
||||
VALUES
|
||||
(gen_random_uuid()::text, 'raw-material:read', 'raw-material', 'read', 'Ansehen', 'Rohstoffe', 1630),
|
||||
(gen_random_uuid()::text, 'raw-material:create', 'raw-material', 'create', 'Anlegen', 'Rohstoffe', 1631),
|
||||
(gen_random_uuid()::text, 'raw-material:update', 'raw-material', 'update', 'Bearbeiten', 'Rohstoffe', 1632),
|
||||
(gen_random_uuid()::text, 'raw-material:delete', 'raw-material', 'delete', 'Löschen', 'Rohstoffe', 1633),
|
||||
(gen_random_uuid()::text, 'raw-material:withdraw', 'raw-material', 'withdraw', 'Entnehmen und Inventur', 'Rohstoffe', 1634)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||
SELECT DISTINCT rp."roleId", p.id, now()
|
||||
FROM "RolePermission" rp
|
||||
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'production-batch:create'
|
||||
JOIN "Permission" p ON p.key IN ('raw-material:create', 'raw-material:update', 'raw-material:delete', 'raw-material:withdraw')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||
SELECT DISTINCT rp."roleId", p.id, now()
|
||||
FROM "RolePermission" rp
|
||||
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'production-batch:read'
|
||||
JOIN "Permission" p ON p.key = 'raw-material:read'
|
||||
ON CONFLICT DO NOTHING;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AutomationTriggerType" ADD VALUE 'RAW_MATERIAL_LOT_NUMBER_FORMAT';
|
||||
ALTER TYPE "AutomationTriggerType" ADD VALUE 'PRODUCTION_BATCH_NUMBER_FORMAT';
|
||||
ALTER TYPE "AutomationTriggerType" ADD VALUE 'RAW_MATERIAL_LOW';
|
||||
@@ -0,0 +1,12 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "AutomationSequence" (
|
||||
"triggerType" "AutomationTriggerType" NOT NULL,
|
||||
"scope" TEXT NOT NULL,
|
||||
"value" INTEGER NOT NULL DEFAULT 0,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AutomationSequence_pkey" PRIMARY KEY ("triggerType","scope")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AutomationSequence" ADD CONSTRAINT "AutomationSequence_triggerType_fkey" FOREIGN KEY ("triggerType") REFERENCES "AutomationRule"("triggerType") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Outbox claims: one worker at a time writes an entry to M365, and a
|
||||
-- revision counter lets it notice an enqueue that arrived during the sync.
|
||||
-- AlterTable
|
||||
ALTER TABLE "CalendarSyncOutboxEntry" ADD COLUMN "claimedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "revision" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -80,6 +80,14 @@ model User {
|
||||
// src/auth/mfa/mfa-crypto.util.ts), nie im Klartext in der DB.
|
||||
mfaEnabled Boolean @default(false)
|
||||
mfaSecret String?
|
||||
// Zuletzt angenommener TOTP-Zeitschritt: derselbe Code (oder ein aelterer)
|
||||
// wird danach nie wieder akzeptiert (Replay-Schutz).
|
||||
mfaLastTimeStep Int?
|
||||
|
||||
// Fehlversuche (Passwort, MFA-Code, Bestaetigung) seit dem letzten Erfolg;
|
||||
// ab 5 ist das Konto bis authLockedUntil gesperrt.
|
||||
failedAuthAttempts Int @default(0)
|
||||
authLockedUntil DateTime?
|
||||
avatarPath String?
|
||||
|
||||
// Gesetzt bei jedem Passwortwechsel; JwtAuthGuard verwirft Access-Tokens,
|
||||
@@ -88,11 +96,17 @@ model User {
|
||||
// nach einer Passwortaenderung nicht bis zu 15 Minuten weiter funktioniert.
|
||||
passwordChangedAt DateTime?
|
||||
|
||||
// Unveraenderliche Identitaet beim Identity Provider ("<iss>|<oid>"),
|
||||
// gesetzt bei der ersten Microsoft-Anmeldung. Danach entscheidet nur sie,
|
||||
// nicht mehr der (unverifizierte) E-Mail-Claim, welches Konto sich anmeldet.
|
||||
ssoSubject String? @unique
|
||||
|
||||
refreshTokens RefreshToken[]
|
||||
|
||||
inspections Inspection[]
|
||||
varroaMeasurements VarroaMeasurement[]
|
||||
swarmControlChecks SwarmControlCheck[]
|
||||
winterChecks WinterCheck[]
|
||||
treatments Treatment[]
|
||||
feedings Feeding[]
|
||||
harvests Harvest[]
|
||||
@@ -106,15 +120,55 @@ model User {
|
||||
colonyMoves ColonyApiaryHistory[]
|
||||
auditLogs AuditLog[]
|
||||
bottlingBatches BottlingBatch[]
|
||||
productionBatches ProductionBatch[]
|
||||
batchMovements BatchMovement[]
|
||||
rawMaterialLots RawMaterialLot[]
|
||||
notifications Notification[]
|
||||
queenEvaluations QueenEvaluation[]
|
||||
webhookSubscriptions WebhookSubscription[]
|
||||
documents Document[]
|
||||
honeyBatchRelocations HoneyBatchRelocation[]
|
||||
apiKeys ApiKey[]
|
||||
|
||||
@@index([roleId])
|
||||
}
|
||||
|
||||
// Schlüssel für Maschinen-Zugriffe (z. B. Wabenhain-Shop-Sync) statt eines
|
||||
// Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash des
|
||||
// Schlüssels; der Klartext wird genau einmal bei der Erstellung angezeigt.
|
||||
// Ein Schlüssel ist kein Benutzer: er darf nur die Routen, deren
|
||||
// Berechtigung in `scopes` steht (siehe api-keys/api-key-scopes.ts).
|
||||
model ApiKey {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
// Anfang des Schlüssels ("ack_ab12cd34") - zum Wiedererkennen in der
|
||||
// Liste, ohne den Schlüssel selbst zu speichern.
|
||||
prefix String
|
||||
keyHash String @unique
|
||||
scopes String[]
|
||||
createdByUserId String?
|
||||
createdByUser User? @relation(fields: [createdByUserId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
lastUsedAt DateTime?
|
||||
// Absender-IP der letzten Nutzung - hilft beim Einrichten der Allowlist.
|
||||
lastUsedIp String?
|
||||
// Nur von diesen Adressen/Netzen (IP oder CIDR) gueltig; leer = ueberall.
|
||||
allowedIps String[] @default([])
|
||||
expiresAt DateTime?
|
||||
revokedAt DateTime?
|
||||
|
||||
@@index([revokedAt])
|
||||
}
|
||||
|
||||
// Einmal-Tokens (SSO-Abschluss, MFA-Zwischenschritt), die schon benutzt
|
||||
// wurden. Ein zweiter Versuch mit derselben jti wird abgelehnt.
|
||||
model ConsumedToken {
|
||||
jti String @id
|
||||
expiresAt DateTime
|
||||
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
@@ -123,6 +177,9 @@ model RefreshToken {
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
revokedAt DateTime?
|
||||
// Anmeldezeitpunkt der Sitzung, an jede rotierte Nachfolge-Token vererbt:
|
||||
// spaetestens SESSION_MAX_AGE danach ist neu anzumelden.
|
||||
sessionStartedAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
@@ -138,6 +195,7 @@ enum AuditAction {
|
||||
TOKEN_REFRESHED
|
||||
MFA_ENABLED
|
||||
MFA_DISABLED
|
||||
ACCOUNT_LOCKED
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
@@ -181,6 +239,8 @@ model Apiary {
|
||||
colonies Colony[]
|
||||
colonyMoves ColonyApiaryHistory[]
|
||||
harvests Harvest[]
|
||||
treatments Treatment[]
|
||||
rawMaterialLots RawMaterialLot[]
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
@@ -245,6 +305,7 @@ model Colony {
|
||||
inspections Inspection[]
|
||||
varroaMeasurements VarroaMeasurement[]
|
||||
swarmControlChecks SwarmControlCheck[]
|
||||
winterChecks WinterCheck[]
|
||||
treatments Treatment[]
|
||||
feedings Feeding[]
|
||||
harvests HarvestColony[]
|
||||
@@ -460,11 +521,17 @@ model Task {
|
||||
sourceInspection Inspection? @relation(fields: [sourceInspectionId], references: [id])
|
||||
sourceTemplateId String?
|
||||
sourceTemplate TaskTemplate? @relation(fields: [sourceTemplateId], references: [id])
|
||||
// Set when an automation rule created the task (not a person). Keeps an
|
||||
// automatic suggestion distinguishable from a planned task, and lets the
|
||||
// rule see that it already left an open task for the same object instead
|
||||
// of piling up a duplicate on every further trigger.
|
||||
sourceAutomationTrigger AutomationTriggerType?
|
||||
createdAt DateTime @default(now())
|
||||
completedAt DateTime?
|
||||
|
||||
@@index([status, dueDate])
|
||||
@@index([relatedType, relatedId])
|
||||
@@index([sourceAutomationTrigger, relatedType, relatedId, status])
|
||||
}
|
||||
|
||||
enum TaskTemplateRecurrenceType {
|
||||
@@ -564,6 +631,54 @@ model SwarmControlCheck {
|
||||
@@index([colonyId, date])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ein- und Auswinterung, Winterverluste
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum WinterCheckSeason {
|
||||
AUTUMN // Einwinterung
|
||||
SPRING // Auswinterung
|
||||
}
|
||||
|
||||
enum WinterOutcome {
|
||||
SURVIVED
|
||||
WEAK
|
||||
LOST
|
||||
}
|
||||
|
||||
// Verlustkategorien des COLOSS-Monitorings: totes/leeres Volk, unloesbares
|
||||
// Koeniginnenproblem, Naturereignis. Bewusst keine Diagnose (Varroa, Virus
|
||||
// ...): die gehoert, wenn ueberhaupt bekannt, in die Notiz.
|
||||
enum ColonyLossCause {
|
||||
DEAD_OR_EMPTY
|
||||
QUEEN_PROBLEM
|
||||
NATURAL_DISASTER
|
||||
}
|
||||
|
||||
// Eine Einwinterung und eine Auswinterung je Volk und Winter. winterYear ist
|
||||
// das Jahr, in dem der Winter beginnt (Winter 2026/27 = 2026).
|
||||
model WinterCheck {
|
||||
id String @id @default(cuid())
|
||||
colonyId String
|
||||
colony Colony @relation(fields: [colonyId], references: [id])
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
winterYear Int
|
||||
season WinterCheckSeason
|
||||
date DateTime
|
||||
occupiedSeams Int?
|
||||
feedStockKg Float?
|
||||
queenPresent Boolean?
|
||||
outcome WinterOutcome?
|
||||
lossCause ColonyLossCause?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([colonyId, winterYear, season])
|
||||
@@index([winterYear])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Behandlung / Tierarzneimittel (Abschnitt 4.11)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -584,6 +699,15 @@ model Treatment {
|
||||
reason String?
|
||||
batchNumber String?
|
||||
notes String?
|
||||
withdrawalPeriodDays Int?
|
||||
// Bestandsbuch (Art. 108 VO (EU) 2019/6): where the colony stood on the
|
||||
// day of the treatment - kept with the treatment because the colony may
|
||||
// be moved later - and the supplier / purchase proof / prescribing vet.
|
||||
apiaryId String?
|
||||
apiary Apiary? @relation(fields: [apiaryId], references: [id])
|
||||
supplier String?
|
||||
purchaseProof String?
|
||||
prescribingVet String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([colonyId, date])
|
||||
@@ -639,6 +763,7 @@ model Harvest {
|
||||
|
||||
colonies HarvestColony[]
|
||||
extractions ExtractionHarvest[]
|
||||
rawMaterialLots RawMaterialLot[]
|
||||
|
||||
@@index([apiaryId, date])
|
||||
}
|
||||
@@ -800,7 +925,9 @@ model HoneyBatchContainerAllocation {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([honeyBatchId, storageContainerId])
|
||||
// The name the migration created (an older Prisma shortened it). Mapped
|
||||
// explicitly so schema and database agree and no rename migration is due.
|
||||
@@unique([honeyBatchId, storageContainerId], map: "HoneyBatchContainerAllocation_honeyBatchId_storageContaine_key")
|
||||
@@index([storageContainerId])
|
||||
}
|
||||
|
||||
@@ -859,6 +986,22 @@ enum ProductStatus {
|
||||
DISCONTINUED
|
||||
}
|
||||
|
||||
// Wo der Bestand eines Artikels liegt: Honig je Abfuellung (BottlingBatch),
|
||||
// alles andere (Kerzen, Propolis-Produkte, ...) je Herstellungscharge
|
||||
// (ProductionBatch). Nicht umstellbar, sobald Chargen existieren.
|
||||
enum ProductKind {
|
||||
BOTTLED_HONEY
|
||||
MANUFACTURED
|
||||
}
|
||||
|
||||
// Vorgabe fuer die Kategorie des Produkts im Webshop (Wabenhain).
|
||||
enum ShopCategory {
|
||||
HONIG
|
||||
KERZEN
|
||||
PROPOLIS
|
||||
SONSTIGES
|
||||
}
|
||||
|
||||
model Product {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
@@ -868,10 +1011,13 @@ model Product {
|
||||
weight Float?
|
||||
salesUnit String?
|
||||
status ProductStatus @default(ACTIVE)
|
||||
kind ProductKind @default(BOTTLED_HONEY)
|
||||
shopCategory ShopCategory @default(HONIG)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
bottlingBatches BottlingBatch[]
|
||||
productionBatches ProductionBatch[]
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
@@ -898,6 +1044,10 @@ model LabelTemplate {
|
||||
marginLeftMm Float @default(5)
|
||||
gapXMm Float @default(2)
|
||||
gapYMm Float @default(2)
|
||||
// Kleine Schnittmarken im Blattrand, eine an jedem Ende jeder
|
||||
// Schnittlinie - zum Anlegen am Papierschneider. Aus bei vorgestanzten
|
||||
// Etikettenbögen, die nicht geschnitten werden.
|
||||
cropMarks Boolean @default(true)
|
||||
isDefault Boolean @default(false)
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
@@ -922,10 +1072,167 @@ model BottlingBatch {
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
movements BatchMovement[]
|
||||
|
||||
@@index([honeyBatchId])
|
||||
@@index([productId])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Herstellungschargen und Bestandsbewegungen (Bestandsabgleich mit dem Shop,
|
||||
// docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Gegenstueck zur Abfuellung fuer Artikel der Art MANUFACTURED (Kerzen,
|
||||
// Propolis-Tinkturen, Salben, ...). Spaeter Ziel eines Herstellungslaufs, der
|
||||
// Rohstoff-Chargen verbraucht.
|
||||
model ProductionBatch {
|
||||
id String @id @default(cuid())
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
lotNumber String @unique
|
||||
date DateTime
|
||||
// Hergestellte Stueckzahl - Ausgangsmenge fuer den Rest.
|
||||
quantity Int
|
||||
bestBeforeDate DateTime?
|
||||
notes String?
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
movements BatchMovement[]
|
||||
// Woraus die Charge hergestellt wurde (Rueckverfolgung Rohstoff -> Ware).
|
||||
inputs ProductionInput[]
|
||||
|
||||
@@index([productId])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rohstoffe (Wachs, Propolis) und ihr Verbrauch in der Herstellung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum RawMaterialKind {
|
||||
BEESWAX
|
||||
PROPOLIS
|
||||
}
|
||||
|
||||
// Eine Rohstoff-Charge, z. B. "Wachs 2026-01: 4200 g" aus dem Entdeckeln
|
||||
// oder "Propolis 2026-02: 180 g" vom Abkratzen der Gitter. Menge in Gramm;
|
||||
// der Rest folgt wie bei allen Chargen aus dem Bewegungsbuch.
|
||||
model RawMaterialLot {
|
||||
id String @id @default(cuid())
|
||||
kind RawMaterialKind
|
||||
lotNumber String @unique
|
||||
// Gewinnungsdatum
|
||||
date DateTime
|
||||
// Gewonnene Menge in Gramm - Ausgangsmenge fuer den Rest.
|
||||
quantity Int
|
||||
// Herkunft, optional: Stand und/oder Ernte, aus der der Rohstoff stammt.
|
||||
apiaryId String?
|
||||
apiary Apiary? @relation(fields: [apiaryId], references: [id])
|
||||
harvestId String?
|
||||
harvest Harvest? @relation(fields: [harvestId], references: [id])
|
||||
notes String?
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
movements BatchMovement[]
|
||||
usedIn ProductionInput[]
|
||||
|
||||
@@index([kind])
|
||||
}
|
||||
|
||||
// Verbrauch einer Rohstoff-Charge in einer Herstellungscharge. Die
|
||||
// Abbuchung selbst steht als CONSUMPTION im Bewegungsbuch des Rohstoffs.
|
||||
model ProductionInput {
|
||||
id String @id @default(cuid())
|
||||
productionBatchId String
|
||||
productionBatch ProductionBatch @relation(fields: [productionBatchId], references: [id], onDelete: Cascade)
|
||||
rawMaterialLotId String
|
||||
rawMaterialLot RawMaterialLot @relation(fields: [rawMaterialLotId], references: [id], onDelete: Restrict)
|
||||
// Verbrauchte Menge in Gramm.
|
||||
quantity Int
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([productionBatchId])
|
||||
@@index([rawMaterialLotId])
|
||||
}
|
||||
|
||||
enum BatchMovementType {
|
||||
SHOP_SALE
|
||||
SHOP_RETURN
|
||||
WITHDRAWAL
|
||||
CORRECTION
|
||||
// Rohstoff in einer Herstellungscharge verbraucht (minus) bzw. beim
|
||||
// Loeschen einer irrtuemlich angelegten Charge zurueckgegeben (plus).
|
||||
CONSUMPTION
|
||||
}
|
||||
|
||||
enum WithdrawalReason {
|
||||
HOFLADEN
|
||||
EIGENBEDARF
|
||||
BRUCH
|
||||
PROBE
|
||||
SONSTIGES
|
||||
}
|
||||
|
||||
// Bewegungsbuch: jede Bestandsaenderung einer Charge, nie geloescht (Fehler
|
||||
// werden gegengebucht). Rest = Ausgangsmenge + Summe aller quantity.
|
||||
// Genau eine der beiden Chargen-Spalten ist gesetzt (CHECK-Constraint in der
|
||||
// Migration).
|
||||
model BatchMovement {
|
||||
id String @id @default(cuid())
|
||||
bottlingBatchId String?
|
||||
bottlingBatch BottlingBatch? @relation(fields: [bottlingBatchId], references: [id], onDelete: Restrict)
|
||||
productionBatchId String?
|
||||
productionBatch ProductionBatch? @relation(fields: [productionBatchId], references: [id], onDelete: Restrict)
|
||||
rawMaterialLotId String?
|
||||
rawMaterialLot RawMaterialLot? @relation(fields: [rawMaterialLotId], references: [id], onDelete: Restrict)
|
||||
type BatchMovementType
|
||||
// Mit Vorzeichen: minus = raus, plus = rein.
|
||||
quantity Int
|
||||
reason WithdrawalReason?
|
||||
notes String?
|
||||
shopRequestId String?
|
||||
shopRequest ShopStockRequest? @relation(fields: [shopRequestId], references: [id], onDelete: Restrict)
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
apiKeyId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([bottlingBatchId])
|
||||
@@index([productionBatchId])
|
||||
@@index([rawMaterialLotId])
|
||||
@@index([shopRequestId])
|
||||
}
|
||||
|
||||
enum ShopStockRequestKind {
|
||||
SALE
|
||||
RETURN
|
||||
}
|
||||
|
||||
// Eine Buchungsanfrage des Shops. externalId (vom Shop vergeben) macht jede
|
||||
// Anfrage idempotent: dieselbe Anfrage zweimal bucht nur einmal.
|
||||
model ShopStockRequest {
|
||||
id String @id @default(cuid())
|
||||
externalId String @unique
|
||||
kind ShopStockRequestKind
|
||||
// Ziel: eine Abfuellung (Honig) ODER ein Artikel (MANUFACTURED, FIFO).
|
||||
bottlingBatchId String?
|
||||
productId String?
|
||||
quantity Int
|
||||
reversesExternalId String?
|
||||
apiKeyId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
movements BatchMovement[]
|
||||
|
||||
@@index([reversesExternalId])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inventar (Abschnitt 4.22) – v1.x Modul 24
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1039,6 +1346,11 @@ enum AutomationTriggerType {
|
||||
STOCK_ITEM_LOW
|
||||
HONEY_BATCH_NUMBER_FORMAT
|
||||
BOTTLING_LOT_NUMBER_FORMAT
|
||||
TREATMENT_FOLLOW_UP
|
||||
FEEDING_FOLLOW_UP
|
||||
RAW_MATERIAL_LOT_NUMBER_FORMAT
|
||||
PRODUCTION_BATCH_NUMBER_FORMAT
|
||||
RAW_MATERIAL_LOW
|
||||
}
|
||||
|
||||
model AutomationRule {
|
||||
@@ -1047,14 +1359,31 @@ model AutomationRule {
|
||||
active Boolean @default(true)
|
||||
config Json?
|
||||
actionTemplate String?
|
||||
// Running counter for the number-format rules (HONEY_BATCH_NUMBER_FORMAT /
|
||||
// BOTTLING_LOT_NUMBER_FORMAT) - unused by the threshold-style rules. Kept as
|
||||
// Running counter for the number-format rules (*_NUMBER_FORMAT) - unused
|
||||
// by the threshold-style rules. Kept as
|
||||
// a dedicated column (not inside `config`) so it can be incremented with a
|
||||
// single atomic `UPDATE ... SET sequenceValue = sequenceValue + 1`,
|
||||
// race-safe under concurrent batch/bottling creation.
|
||||
sequenceValue Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
sequences AutomationSequence[]
|
||||
}
|
||||
|
||||
// Separate counters of one number-format rule, one per scope - e.g. per raw
|
||||
// material kind when the format contains {ART}, so wax and propolis each
|
||||
// count WA-2026-0001, WA-2026-0002, ... and PR-2026-0001, ... on their own.
|
||||
// Without {ART} the rule keeps using AutomationRule.sequenceValue: separate
|
||||
// counters behind an identical prefix would produce duplicate numbers.
|
||||
model AutomationSequence {
|
||||
triggerType AutomationTriggerType
|
||||
rule AutomationRule @relation(fields: [triggerType], references: [triggerType], onDelete: Cascade)
|
||||
scope String
|
||||
value Int @default(0)
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@id([triggerType, scope])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1070,6 +1399,10 @@ model SiteSettings {
|
||||
instagramUrl String?
|
||||
tiktokUrl String?
|
||||
websiteUrl String?
|
||||
// Name and address of the bee keeper, printed on the Bestandsbuch. Never
|
||||
// part of any public page.
|
||||
holderName String?
|
||||
holderAddress String?
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -1135,6 +1468,37 @@ model CalendarSyncSettings {
|
||||
// erneutes Enqueue (z. B. nach einer Aufgaben-Änderung) aktualisiert das
|
||||
// bestehende Graph-Event über die gespeicherte graphEventId, statt ein
|
||||
// Duplikat anzulegen.
|
||||
// ---------------------------------------------------------------------------
|
||||
// Idempotenz – schützt Anlege-Anfragen vor doppelter Ausführung, wenn ein
|
||||
// Client dieselbe Anfrage wiederholt (Offline-Warteschlange nach verlorener
|
||||
// Antwort, Netzabbruch, erneutes Absenden). Der Client schickt pro fachlichem
|
||||
// Vorgang einen eindeutigen `Idempotency-Key`; eine Wiederholung bekommt die
|
||||
// gespeicherte erste Antwort statt eines zweiten Datensatzes. Siehe
|
||||
// src/common/idempotency/idempotency.interceptor.ts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum IdempotencyStatus {
|
||||
PENDING
|
||||
COMPLETED
|
||||
}
|
||||
|
||||
model IdempotencyRecord {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
key String
|
||||
// "POST /api/v1/inspections" - a key reused for a different endpoint is
|
||||
// rejected instead of replaying an unrelated response.
|
||||
scope String
|
||||
status IdempotencyStatus @default(PENDING)
|
||||
statusCode Int?
|
||||
responseBody Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([userId, key])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model CalendarSyncOutboxEntry {
|
||||
id String @id @default(cuid())
|
||||
sourceType CalendarSyncSourceType
|
||||
@@ -1143,6 +1507,14 @@ model CalendarSyncOutboxEntry {
|
||||
graphEventId String?
|
||||
attempts Int @default(0)
|
||||
lastError String?
|
||||
// Set while one worker (pod, cron tick, "Jetzt synchronisieren") writes
|
||||
// this entry to M365, so no second worker creates the same event. A claim
|
||||
// older than the stale timeout belongs to a crashed worker and is retaken.
|
||||
claimedAt DateTime?
|
||||
// Counts every enqueue: a worker only marks the entry SYNCED if nothing
|
||||
// was enqueued since it claimed it - an edit made during the sync is not
|
||||
// lost but synced on the next pass.
|
||||
revision Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -30,11 +30,172 @@ function printLoginBanner(email: string, password: string): void {
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
// Duplicated from src/task-templates/recurrence.ts rather than imported:
|
||||
// tsconfig.seed.json scopes this build to rootDir "./prisma" only (its own
|
||||
// isolated compile, separate from the main Nest build), so a cross-rootDir
|
||||
// import would fail to compile. Only the YEARLY case is needed here.
|
||||
function nextYearlyOccurrenceUtc(
|
||||
month: number,
|
||||
dayOfMonth: number,
|
||||
from: Date,
|
||||
): Date {
|
||||
const year = from.getUTCFullYear();
|
||||
const clampedDay = (y: number): number =>
|
||||
Math.min(dayOfMonth, new Date(Date.UTC(y, month, 0)).getUTCDate());
|
||||
const today = new Date(
|
||||
Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate()),
|
||||
);
|
||||
const candidate = new Date(Date.UTC(year, month - 1, clampedDay(year)));
|
||||
if (candidate.getTime() >= today.getTime()) {
|
||||
return candidate;
|
||||
}
|
||||
return new Date(Date.UTC(year + 1, month - 1, clampedDay(year + 1)));
|
||||
}
|
||||
|
||||
interface DefaultTaskTemplate {
|
||||
title: string;
|
||||
description: string;
|
||||
priority: 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
|
||||
month: number;
|
||||
dayOfMonth: number;
|
||||
}
|
||||
|
||||
// The standard German-beekeeping annual cycle, one flagship task per month -
|
||||
// gives a new deployment a ready-made starter calendar instead of a
|
||||
// completely empty task-template list. Admins can edit/deactivate any of
|
||||
// these afterward; this only ever runs once (see the count() guard below).
|
||||
const DEFAULT_TASK_TEMPLATES: DefaultTaskTemplate[] = [
|
||||
{
|
||||
title: 'Wintersitz kontrollieren',
|
||||
description:
|
||||
'Ruhige Kontrolle von außen: Flugloch frei, Gewicht per Anheben schätzen, auf Mäuse-/Spechtschäden achten. Beute möglichst nicht öffnen.',
|
||||
priority: 'NORMAL',
|
||||
month: 1,
|
||||
dayOfMonth: 15,
|
||||
},
|
||||
{
|
||||
title: 'Gemülldiagnose',
|
||||
description:
|
||||
'Windel auswerten: natürlicher Varroa-Totenfall, Bienensitz und Lage der Wintertraube anhand des Gemülls einschätzen.',
|
||||
priority: 'NORMAL',
|
||||
month: 2,
|
||||
dayOfMonth: 15,
|
||||
},
|
||||
{
|
||||
title: 'Frühjahrsdurchsicht',
|
||||
description:
|
||||
'Erste Innendurchsicht bei ca. 15°C und ruhigem Wetter: Brutnest, Futtervorrat, Volksstärke und Weiselrichtigkeit prüfen.',
|
||||
priority: 'HIGH',
|
||||
month: 3,
|
||||
dayOfMonth: 15,
|
||||
},
|
||||
{
|
||||
title: 'Volk erweitern',
|
||||
description:
|
||||
'Zargen/Rähmchen nach Volksstärke erweitern, Baurahmen einhängen, Platz für die Frühtracht schaffen.',
|
||||
priority: 'NORMAL',
|
||||
month: 4,
|
||||
dayOfMonth: 1,
|
||||
},
|
||||
{
|
||||
title: 'Schwarmkontrolle beginnen',
|
||||
description:
|
||||
'Wöchentlich auf Weiselzellen kontrollieren, bis zum Ende der Schwarmzeit fortführen. Maßnahmen bei Bedarf: Ableger bilden, Zellen brechen.',
|
||||
priority: 'HIGH',
|
||||
month: 5,
|
||||
dayOfMonth: 1,
|
||||
},
|
||||
{
|
||||
title: 'Honigernte vorbereiten',
|
||||
description:
|
||||
'Reifegrad der Frühtracht prüfen (Spritzprobe), Schleuderplan und Gerätereinigung vorbereiten.',
|
||||
priority: 'NORMAL',
|
||||
month: 6,
|
||||
dayOfMonth: 15,
|
||||
},
|
||||
{
|
||||
title: 'Varroa-Kontrolle nach der Ernte',
|
||||
description:
|
||||
'Milbenbefall messen (z. B. Puderzuckermethode oder natürlicher Totenfall) und Behandlung planen, sobald die Honigräume abgeräumt sind.',
|
||||
priority: 'HIGH',
|
||||
month: 7,
|
||||
dayOfMonth: 15,
|
||||
},
|
||||
{
|
||||
title: 'Einfütterung beginnen',
|
||||
description:
|
||||
'Wintervorrat auffüttern (Zielgewicht je nach Beutensystem), Futteraufnahme nach ein bis zwei Wochen kontrollieren.',
|
||||
priority: 'HIGH',
|
||||
month: 8,
|
||||
dayOfMonth: 1,
|
||||
},
|
||||
{
|
||||
title: 'Varroa-Restentmilbung / Winterfestigkeit prüfen',
|
||||
description:
|
||||
'Zweite Varroabehandlung durchführen, Volksstärke und Weiselrichtigkeit für den Winter beurteilen.',
|
||||
priority: 'HIGH',
|
||||
month: 9,
|
||||
dayOfMonth: 15,
|
||||
},
|
||||
{
|
||||
title: 'Fütterung abschließen, Mäusegitter anbringen',
|
||||
description:
|
||||
'Letzte Futtergabe, Fluglochkeile bzw. Mäusegitter montieren, Bienenstand winterfest machen.',
|
||||
priority: 'NORMAL',
|
||||
month: 10,
|
||||
dayOfMonth: 1,
|
||||
},
|
||||
{
|
||||
title: 'Winterbehandlung (Oxalsäure)',
|
||||
description:
|
||||
'Brutfreiheit abwarten und Träufel- oder Sprühbehandlung gegen Varroa durchführen.',
|
||||
priority: 'HIGH',
|
||||
month: 11,
|
||||
dayOfMonth: 15,
|
||||
},
|
||||
{
|
||||
title: 'Jahresrückblick und Gerätewartung',
|
||||
description:
|
||||
'Saison auswerten, Zargen und Rähmchen reinigen/reparieren, Material für die kommende Saison planen.',
|
||||
priority: 'LOW',
|
||||
month: 12,
|
||||
dayOfMonth: 15,
|
||||
},
|
||||
];
|
||||
|
||||
async function seedDefaultTaskTemplates(prisma: PrismaClient): Promise<void> {
|
||||
const existingCount = await prisma.taskTemplate.count();
|
||||
if (existingCount > 0) {
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
await prisma.taskTemplate.createMany({
|
||||
data: DEFAULT_TASK_TEMPLATES.map((template) => ({
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
priority: template.priority,
|
||||
recurrenceType: 'YEARLY',
|
||||
recurrenceMonth: template.month,
|
||||
recurrenceDayOfMonth: template.dayOfMonth,
|
||||
nextDueDate: nextYearlyOccurrenceUtc(
|
||||
template.month,
|
||||
template.dayOfMonth,
|
||||
now,
|
||||
),
|
||||
})),
|
||||
});
|
||||
console.log(
|
||||
`Seed: ${DEFAULT_TASK_TEMPLATES.length} Standard-Aufgabenvorlagen angelegt.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const prisma = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
||||
});
|
||||
|
||||
await seedDefaultTaskTemplates(prisma);
|
||||
|
||||
const email = process.env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL;
|
||||
const password = process.env.SEED_ADMIN_PASSWORD ?? generateRandomPassword();
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { PermissionKey } from '../auth/permissions/permission-catalog';
|
||||
|
||||
/**
|
||||
* Permissions an API key may carry. Deliberately a short allowlist, not the
|
||||
* whole catalog: a key is a long-lived secret stored in another system, so it
|
||||
* only ever gets what a machine integration needs. shop-sync:write lets the
|
||||
* shop book its own sales and returns - nothing else - and every booking is
|
||||
* idempotent and kept in the ledger, so a leaked key cannot erase history.
|
||||
* erp-inventory:read lets ERPNext read the inventory mirror (articles,
|
||||
* batches with remaining stock, stock movements, harvest totals) - read
|
||||
* only, no people, no notes. Removing an entry here also takes it away from
|
||||
* existing keys (see ApiKeysService.authenticate).
|
||||
*/
|
||||
export const API_KEY_SCOPES = [
|
||||
'shop-sync:read',
|
||||
'shop-sync:write',
|
||||
'erp-inventory:read',
|
||||
] as const satisfies readonly PermissionKey[];
|
||||
|
||||
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
|
||||
|
||||
export function isApiKeyScope(value: string): value is ApiKeyScope {
|
||||
return (API_KEY_SCOPES as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
||||
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
|
||||
import {
|
||||
ApiKeySummary,
|
||||
ApiKeysService,
|
||||
CreatedApiKey,
|
||||
} from './api-keys.service';
|
||||
|
||||
@ApiTags('api-keys')
|
||||
@ApiBearerAuth()
|
||||
@Controller('api-keys')
|
||||
export class ApiKeysController {
|
||||
constructor(private readonly apiKeys: ApiKeysService) {}
|
||||
|
||||
@RequirePermission('api-keys:manage')
|
||||
@Get()
|
||||
findAll(): Promise<ApiKeySummary[]> {
|
||||
return this.apiKeys.findAll();
|
||||
}
|
||||
|
||||
@RequirePermission('api-keys:manage')
|
||||
@Post()
|
||||
create(
|
||||
@Body() dto: CreateApiKeyDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
): Promise<CreatedApiKey> {
|
||||
return this.apiKeys.create(dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('api-keys:manage')
|
||||
@Patch(':id')
|
||||
updateAllowedIps(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateApiKeyDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
): Promise<ApiKeySummary> {
|
||||
return this.apiKeys.updateAllowedIps(id, dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('api-keys:manage')
|
||||
@Post(':id/revoke')
|
||||
revoke(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
): Promise<ApiKeySummary> {
|
||||
return this.apiKeys.revoke(id, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ApiKeysController } from './api-keys.controller';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
|
||||
// Global: the app-wide auth guards need ApiKeysService to recognise keys.
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [ApiKeysController],
|
||||
providers: [ApiKeysService],
|
||||
exports: [ApiKeysService],
|
||||
})
|
||||
export class ApiKeysModule {}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { ForbiddenException, HttpException, Logger } from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import {
|
||||
API_KEY_FAILURE_LIMIT,
|
||||
ApiKeysService,
|
||||
extractApiKeyToken,
|
||||
} from './api-keys.service';
|
||||
|
||||
// Synthetischer Schlüssel im echten Format - kein echter Zugang.
|
||||
const TOKEN = `ack_abcd1234_${'A'.repeat(43)}`;
|
||||
const OTHER_TOKEN = `ack_zzzz9999_${'B'.repeat(43)}`;
|
||||
|
||||
function storedKey(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'key-1',
|
||||
name: 'ERPNext',
|
||||
prefix: 'ack_abcd1234',
|
||||
keyHash: createHash('sha256').update(TOKEN).digest('hex'),
|
||||
scopes: ['erp-inventory:read'],
|
||||
createdByUserId: 'user-1',
|
||||
createdAt: new Date('2026-10-01T00:00:00.000Z'),
|
||||
lastUsedAt: null,
|
||||
lastUsedIp: null,
|
||||
allowedIps: [],
|
||||
expiresAt: null,
|
||||
revokedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ApiKeysService.authenticate', () => {
|
||||
let prisma: { apiKey: { findUnique: jest.Mock; update: jest.Mock } };
|
||||
let service: ApiKeysService;
|
||||
let warn: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
apiKey: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
service = new ApiKeysService(
|
||||
prisma as unknown as PrismaService,
|
||||
{ record: jest.fn() } as unknown as AuditLogService,
|
||||
{ getPermissions: jest.fn() } as unknown as PermissionResolverService,
|
||||
);
|
||||
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
function loggedText(): string {
|
||||
return JSON.stringify(warn.mock.calls);
|
||||
}
|
||||
|
||||
it('sucht den Schlüssel nur über seinen Hash', async () => {
|
||||
prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey());
|
||||
|
||||
const principal = await service.authenticate(TOKEN, '203.0.113.7');
|
||||
|
||||
expect(principal).toEqual({ id: 'key-1', scopes: ['erp-inventory:read'] });
|
||||
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
|
||||
where: { keyHash: createHash('sha256').update(TOKEN).digest('hex') },
|
||||
});
|
||||
});
|
||||
|
||||
it('lehnt einen fehlerhaft aufgebauten Schlüssel ohne Datenbankzugriff ab', async () => {
|
||||
await expect(service.authenticate('ack_kurz', '203.0.113.7')).resolves.toBe(
|
||||
null,
|
||||
);
|
||||
expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lehnt einen unbekannten Schlüssel ab', async () => {
|
||||
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('lehnt einen widerrufenen (gesperrten) Schlüssel ab', async () => {
|
||||
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||
storedKey({ revokedAt: new Date('2026-10-02T00:00:00.000Z') }),
|
||||
);
|
||||
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
|
||||
null,
|
||||
);
|
||||
expect(loggedText()).toContain('revoked');
|
||||
});
|
||||
|
||||
it('lehnt einen abgelaufenen Schlüssel ab', async () => {
|
||||
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||
storedKey({ expiresAt: new Date('2020-01-01T00:00:00.000Z') }),
|
||||
);
|
||||
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
|
||||
null,
|
||||
);
|
||||
expect(loggedText()).toContain('expired');
|
||||
});
|
||||
|
||||
it('schreibt nie den Schlüssel selbst ins Log, nur das Präfix', async () => {
|
||||
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||
storedKey({ revokedAt: new Date() }),
|
||||
);
|
||||
await service.authenticate(TOKEN, '203.0.113.7');
|
||||
await service.authenticate(OTHER_TOKEN, '203.0.113.7');
|
||||
|
||||
const text = loggedText();
|
||||
expect(text).not.toContain(TOKEN);
|
||||
expect(text).not.toContain(OTHER_TOKEN);
|
||||
expect(text).not.toContain('A'.repeat(43));
|
||||
expect(text).not.toContain('B'.repeat(43));
|
||||
expect(text).toContain('ack_abcd1234');
|
||||
});
|
||||
|
||||
it('gibt nur Rechte weiter, die für Schlüssel freigegeben sind', async () => {
|
||||
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||
storedKey({ scopes: ['erp-inventory:read', 'users:create'] }),
|
||||
);
|
||||
const principal = await service.authenticate(TOKEN, '203.0.113.7');
|
||||
expect(principal?.scopes).toEqual(['erp-inventory:read']);
|
||||
});
|
||||
|
||||
it('weist einen richtigen Schlüssel von einer nicht freigegebenen Adresse mit 403 ab', async () => {
|
||||
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||
storedKey({ allowedIps: ['198.51.100.0/24'] }),
|
||||
);
|
||||
await expect(
|
||||
service.authenticate(TOKEN, '203.0.113.7'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
async function failFrom(ip: string, times: number): Promise<void> {
|
||||
for (let i = 0; i < times; i++) {
|
||||
// Unbekannter, aber richtig aufgebauter Schlüssel.
|
||||
await expect(service.authenticate(OTHER_TOKEN, ip)).resolves.toBe(null);
|
||||
}
|
||||
}
|
||||
|
||||
it(`sperrt unbekannte Schlüssel einer Adresse nach ${API_KEY_FAILURE_LIMIT.maxFailures} Fehlversuchen mit 429`, async () => {
|
||||
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
|
||||
|
||||
const blocked = service.authenticate(OTHER_TOKEN, '203.0.113.7');
|
||||
await expect(blocked).rejects.toBeInstanceOf(HttpException);
|
||||
await expect(
|
||||
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
expect(loggedText()).toContain('203.0.113.7');
|
||||
|
||||
// Widerrufene Schlüssel sind ebenfalls ungültig und bleiben gesperrt.
|
||||
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||
storedKey({ revokedAt: new Date('2026-10-02T00:00:00.000Z') }),
|
||||
);
|
||||
await expect(
|
||||
service.authenticate(TOKEN, '203.0.113.7'),
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
|
||||
// Andere Adressen sind nicht betroffen.
|
||||
await expect(
|
||||
service.authenticate(OTHER_TOKEN, '198.51.100.1'),
|
||||
).resolves.toBe(null);
|
||||
});
|
||||
|
||||
it('weist einen gültigen Schlüssel nie wegen der Sperre ab - sonst könnte jeder ERPNext oder den Shop aussperren', async () => {
|
||||
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
|
||||
await expect(
|
||||
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey());
|
||||
|
||||
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toEqual({
|
||||
id: 'key-1',
|
||||
scopes: ['erp-inventory:read'],
|
||||
});
|
||||
});
|
||||
|
||||
it('zählt fehlerhaft aufgebaute Schlüssel nicht als Fehlversuch', async () => {
|
||||
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures * 2; i++) {
|
||||
await expect(service.authenticate('ack_x', '203.0.113.7')).resolves.toBe(
|
||||
null,
|
||||
);
|
||||
}
|
||||
// Weder gesperrt noch eine Datenbankabfrage.
|
||||
expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
|
||||
).resolves.toBe(null);
|
||||
});
|
||||
|
||||
it('zählt IPv6-Adressen aus demselben /64 gemeinsam (wie der Throttler)', async () => {
|
||||
// Wechselnde Adressen im eigenen /64 umgehen die Sperre nicht.
|
||||
for (let i = 1; i <= API_KEY_FAILURE_LIMIT.maxFailures; i++) {
|
||||
await service.authenticate(
|
||||
OTHER_TOKEN,
|
||||
`2001:db8:1:2::${i.toString(16)}`,
|
||||
);
|
||||
}
|
||||
await expect(
|
||||
service.authenticate(OTHER_TOKEN, '2001:db8:1:2::ffff'),
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
// Ein anderes /64 ist frei.
|
||||
await expect(
|
||||
service.authenticate(OTHER_TOKEN, '2001:db8:1:3::1'),
|
||||
).resolves.toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractApiKeyToken', () => {
|
||||
it('erkennt nur Bearer-Schlüssel im ApiCore-Format', () => {
|
||||
expect(extractApiKeyToken(`Bearer ${TOKEN}`)).toBe(TOKEN);
|
||||
expect(extractApiKeyToken('Bearer eyJhbGciOi.jwt')).toBeNull();
|
||||
expect(extractApiKeyToken(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { createHash, randomBytes, randomInt } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { ApiKey } from '../../generated/prisma';
|
||||
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
||||
import { ApiKeyScope, isApiKeyScope } from './api-key-scopes';
|
||||
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
|
||||
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { FailedAttemptLimiter } from './failed-attempt-limiter';
|
||||
import { rateLimitSubject } from '../common/throttler/rate-limit-subject';
|
||||
|
||||
function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
(entries ?? [])
|
||||
.map(normalizeAllowlistEntry)
|
||||
.filter((entry): entry is string => entry !== null),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// ack_<8 lowercase chars>_<32 random bytes, base64url>
|
||||
const TOKEN_PATTERN = /^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/;
|
||||
const PREFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
// lastUsedAt is informational; writing it on every request would turn each
|
||||
// read into a write for no benefit.
|
||||
const LAST_USED_RESOLUTION_MS = 5 * 60 * 1000;
|
||||
// "ack_" plus the 8-character prefix: identifies a key in logs without
|
||||
// revealing its secret part.
|
||||
const LOGGABLE_PREFIX_LENGTH = 12;
|
||||
|
||||
/**
|
||||
* Unknown, revoked or expired keys per client network before that network
|
||||
* gets 429 for further invalid keys for the rest of the window - the same
|
||||
* budget as the shop's ERP export. It slows down guessing and a
|
||||
* misconfigured client; a valid key is never refused by it. Otherwise
|
||||
* anyone able to send requests from (or forge) a shared address - the
|
||||
* cluster egress, a proxy - could lock ERPNext or the shop out, and a key
|
||||
* holds 256 random bits, so letting it through costs nothing.
|
||||
*/
|
||||
export const API_KEY_FAILURE_LIMIT = {
|
||||
maxFailures: 20,
|
||||
windowMs: 15 * 60 * 1000,
|
||||
maxTrackedSources: 10_000,
|
||||
} as const;
|
||||
|
||||
type RejectReason = 'unknown' | 'revoked' | 'expired';
|
||||
|
||||
/** What a request authenticated by an API key may do - it is not a user. */
|
||||
export interface ApiKeyPrincipal {
|
||||
id: string;
|
||||
scopes: ApiKeyScope[];
|
||||
}
|
||||
|
||||
/** An API key as shown in the admin UI - never the hash or the key itself. */
|
||||
export type ApiKeySummary = Omit<ApiKey, 'keyHash'>;
|
||||
|
||||
export interface CreatedApiKey {
|
||||
apiKey: ApiKeySummary;
|
||||
/** The only time the plain key exists outside the caller's system. */
|
||||
token: string;
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
function randomPrefix(): string {
|
||||
let prefix = '';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
prefix += PREFIX_ALPHABET[randomInt(PREFIX_ALPHABET.length)];
|
||||
}
|
||||
return `ack_${prefix}`;
|
||||
}
|
||||
|
||||
function toSummary(apiKey: ApiKey): ApiKeySummary {
|
||||
const { keyHash, ...summary } = apiKey;
|
||||
void keyHash;
|
||||
return summary;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeysService {
|
||||
private readonly logger = new Logger(ApiKeysService.name);
|
||||
private readonly failures = new FailedAttemptLimiter(API_KEY_FAILURE_LIMIT);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<ApiKeySummary[]> {
|
||||
const keys = await this.prisma.apiKey.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return keys.map(toSummary);
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateApiKeyDto,
|
||||
actingUserId: string,
|
||||
): Promise<CreatedApiKey> {
|
||||
// Nobody hands a machine more than they may do themselves.
|
||||
const callerPermissions =
|
||||
await this.permissionResolver.getPermissions(actingUserId);
|
||||
const missing = dto.scopes.filter((scope) => !callerPermissions.has(scope));
|
||||
if (missing.length > 0) {
|
||||
throw new ForbiddenException(
|
||||
`Diese Berechtigung haben Sie selbst nicht: ${missing.join(', ')}`,
|
||||
);
|
||||
}
|
||||
const prefix = randomPrefix();
|
||||
// 32 random bytes: a plain hash of it is safe to store without a slow
|
||||
// KDF - there is nothing guessable to brute-force.
|
||||
const token = `${prefix}_${randomBytes(32).toString('base64url')}`;
|
||||
const expiresAt =
|
||||
dto.expiresInDays !== undefined
|
||||
? new Date(Date.now() + dto.expiresInDays * 24 * 60 * 60 * 1000)
|
||||
: null;
|
||||
|
||||
const apiKey = await this.prisma.apiKey.create({
|
||||
data: {
|
||||
name: dto.name.trim(),
|
||||
prefix,
|
||||
keyHash: hashToken(token),
|
||||
scopes: [...new Set(dto.scopes)],
|
||||
allowedIps: normalizeAllowlist(dto.allowedIps),
|
||||
expiresAt,
|
||||
createdByUserId: actingUserId,
|
||||
},
|
||||
});
|
||||
const summary = toSummary(apiKey);
|
||||
await this.auditLog.record({
|
||||
entityType: 'ApiKey',
|
||||
entityId: apiKey.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: summary,
|
||||
});
|
||||
return { apiKey: summary, token };
|
||||
}
|
||||
|
||||
/** Changes where a key may be used from, without issuing a new one. */
|
||||
async updateAllowedIps(
|
||||
id: string,
|
||||
dto: UpdateApiKeyDto,
|
||||
actingUserId: string,
|
||||
): Promise<ApiKeySummary> {
|
||||
const before = await this.prisma.apiKey.findUnique({ where: { id } });
|
||||
if (!before) {
|
||||
throw new NotFoundException('API-Schlüssel nicht gefunden');
|
||||
}
|
||||
const updated = await this.prisma.apiKey.update({
|
||||
where: { id },
|
||||
data: { allowedIps: normalizeAllowlist(dto.allowedIps) },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'ApiKey',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: toSummary(before),
|
||||
afterData: toSummary(updated),
|
||||
});
|
||||
return toSummary(updated);
|
||||
}
|
||||
|
||||
async revoke(id: string, actingUserId: string): Promise<ApiKeySummary> {
|
||||
const before = await this.prisma.apiKey.findUnique({ where: { id } });
|
||||
if (!before) {
|
||||
throw new NotFoundException('API-Schlüssel nicht gefunden');
|
||||
}
|
||||
if (before.revokedAt) {
|
||||
return toSummary(before);
|
||||
}
|
||||
const revoked = await this.prisma.apiKey.update({
|
||||
where: { id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'ApiKey',
|
||||
entityId: id,
|
||||
action: 'DEACTIVATE',
|
||||
userId: actingUserId,
|
||||
beforeData: toSummary(before),
|
||||
afterData: toSummary(revoked),
|
||||
});
|
||||
return toSummary(revoked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a presented key to what it may do, or null for anything that
|
||||
* must not pass: malformed, unknown, revoked or expired.
|
||||
*
|
||||
* The key is found by the SHA-256 of the presented token through a unique
|
||||
* index - never by comparing secrets byte by byte, so response timing says
|
||||
* nothing about the stored key. Order matters: the lookup comes first, and
|
||||
* only an invalid key counts against (or is stopped by) the failure limit
|
||||
* of the client network. A malformed token is not a guess at a key and is
|
||||
* refused without lookup and without counting.
|
||||
*/
|
||||
async authenticate(
|
||||
token: string,
|
||||
clientIp?: string,
|
||||
): Promise<ApiKeyPrincipal | null> {
|
||||
if (!TOKEN_PATTERN.test(token)) {
|
||||
return null;
|
||||
}
|
||||
const apiKey = await this.prisma.apiKey.findUnique({
|
||||
where: { keyHash: hashToken(token) },
|
||||
});
|
||||
const now = new Date();
|
||||
const reason = rejectReason(apiKey, now);
|
||||
if (reason === null && apiKey) {
|
||||
return this.accept(apiKey, clientIp, now);
|
||||
}
|
||||
this.recordRejection(token, reason ?? 'unknown', clientIp);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Counts an invalid key; 429 once the client network is over the limit. */
|
||||
private recordRejection(
|
||||
token: string,
|
||||
reason: RejectReason,
|
||||
clientIp: string | undefined,
|
||||
): void {
|
||||
// Counted per client network like the throttler (IPv6 /64), so rotating
|
||||
// addresses inside one's own prefix does not reset the budget.
|
||||
const subject = clientIp ? rateLimitSubject(clientIp) : 'unknown';
|
||||
if (this.failures.isBlocked(subject)) {
|
||||
// Same body shape as the built-in 4xx exceptions (JSON object, not a
|
||||
// bare string), so clients can read `message` uniformly.
|
||||
throw new HttpException(
|
||||
{
|
||||
statusCode: HttpStatus.TOO_MANY_REQUESTS,
|
||||
error: 'Too Many Requests',
|
||||
message:
|
||||
'Zu viele Fehlversuche mit API-Schlüsseln - bitte später erneut versuchen',
|
||||
},
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
// Only the public prefix is ever logged, never the key itself.
|
||||
this.logger.warn(
|
||||
`API key rejected (${reason}): ${token.slice(0, LOGGABLE_PREFIX_LENGTH)} from ${clientIp ?? 'unknown'}`,
|
||||
);
|
||||
if (this.failures.recordFailure(subject)) {
|
||||
this.logger.warn(
|
||||
`Invalid API keys from ${subject} refused for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes after ${API_KEY_FAILURE_LIMIT.maxFailures} failures`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async accept(
|
||||
apiKey: ApiKey,
|
||||
clientIp: string | undefined,
|
||||
now: Date,
|
||||
): Promise<ApiKeyPrincipal> {
|
||||
if (!isIpAllowed(clientIp, apiKey.allowedIps)) {
|
||||
// The key itself is right: say so, so the admin finds the cause - a
|
||||
// thief learns nothing new, they already hold the key.
|
||||
this.logger.warn(
|
||||
`API key ${apiKey.prefix} used from ${clientIp ?? 'unknown'}, not in its allowlist`,
|
||||
);
|
||||
throw new ForbiddenException(
|
||||
'API-Schlüssel ist für diese IP-Adresse nicht freigegeben',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!apiKey.lastUsedAt ||
|
||||
now.getTime() - apiKey.lastUsedAt.getTime() > LAST_USED_RESOLUTION_MS ||
|
||||
apiKey.lastUsedIp !== (clientIp ?? null)
|
||||
) {
|
||||
await this.prisma.apiKey.update({
|
||||
where: { id: apiKey.id },
|
||||
data: { lastUsedAt: now, lastUsedIp: clientIp ?? null },
|
||||
});
|
||||
}
|
||||
// Intersect with today's allowlist: narrowing it later also narrows
|
||||
// keys issued before.
|
||||
return { id: apiKey.id, scopes: apiKey.scopes.filter(isApiKeyScope) };
|
||||
}
|
||||
}
|
||||
|
||||
function rejectReason(apiKey: ApiKey | null, now: Date): RejectReason | null {
|
||||
if (!apiKey) return 'unknown';
|
||||
if (apiKey.revokedAt) return 'revoked';
|
||||
if (apiKey.expiresAt && apiKey.expiresAt <= now) return 'expired';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The key from an `Authorization: Bearer ack_…` header, or null. */
|
||||
export function extractApiKeyToken(
|
||||
authorization: string | undefined,
|
||||
): string | null {
|
||||
if (!authorization?.startsWith('Bearer ack_')) {
|
||||
return null;
|
||||
}
|
||||
return authorization.slice('Bearer '.length).trim();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {
|
||||
ValidationArguments,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from 'class-validator';
|
||||
import { normalizeAllowlistEntry } from '../ip-allowlist';
|
||||
|
||||
@ValidatorConstraint({ name: 'allowedIp' })
|
||||
export class AllowedIpConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: unknown): boolean {
|
||||
return typeof value === 'string' && normalizeAllowlistEntry(value) !== null;
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
return `${String(args.value)} ist keine IP-Adresse und kein Netz (z. B. 203.0.113.7 oder 203.0.113.0/24)`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
Validate,
|
||||
} from 'class-validator';
|
||||
import { API_KEY_SCOPES } from '../api-key-scopes';
|
||||
import { MAX_ALLOWED_IPS } from '../ip-allowlist';
|
||||
import { AllowedIpConstraint } from './allowed-ips.validator';
|
||||
|
||||
export class CreateApiKeyDto {
|
||||
@ApiProperty({
|
||||
description: 'Wofür der Schlüssel ist, z. B. "Wabenhain-Shop"',
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ enum: API_KEY_SCOPES, isArray: true })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayUnique()
|
||||
@IsIn(API_KEY_SCOPES, { each: true })
|
||||
scopes!: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Gültigkeit in Tagen; ohne Angabe gilt der Schlüssel bis zum Widerruf',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(730)
|
||||
expiresInDays?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Erlaubte Absender (IP oder CIDR), z. B. die Adresse des Wabenhain-Servers',
|
||||
type: [String],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(MAX_ALLOWED_IPS)
|
||||
@IsString({ each: true })
|
||||
@Validate(AllowedIpConstraint, { each: true })
|
||||
allowedIps?: string[];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayMaxSize, IsArray, IsString, Validate } from 'class-validator';
|
||||
import { MAX_ALLOWED_IPS } from '../ip-allowlist';
|
||||
import { AllowedIpConstraint } from './allowed-ips.validator';
|
||||
|
||||
export class UpdateApiKeyDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Erlaubte Absender (IP oder CIDR); leere Liste = von überall nutzbar',
|
||||
type: [String],
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMaxSize(MAX_ALLOWED_IPS)
|
||||
@IsString({ each: true })
|
||||
@Validate(AllowedIpConstraint, { each: true })
|
||||
allowedIps!: string[];
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { FailedAttemptLimiter } from './failed-attempt-limiter';
|
||||
|
||||
describe('FailedAttemptLimiter', () => {
|
||||
let now: number;
|
||||
let limiter: FailedAttemptLimiter;
|
||||
|
||||
beforeEach(() => {
|
||||
now = 1_000_000;
|
||||
limiter = new FailedAttemptLimiter({
|
||||
maxFailures: 3,
|
||||
windowMs: 60_000,
|
||||
maxTrackedSources: 100,
|
||||
clock: () => now,
|
||||
});
|
||||
});
|
||||
|
||||
it('sperrt eine Quelle erst, wenn die Obergrenze erreicht ist', () => {
|
||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
||||
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
|
||||
|
||||
// Der Fehlversuch, mit dem die Grenze erreicht wird, meldet die Sperre.
|
||||
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
|
||||
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
|
||||
});
|
||||
|
||||
it('meldet die Sperre nur einmal, nicht bei jedem weiteren Versuch', () => {
|
||||
limiter.recordFailure('203.0.113.7');
|
||||
limiter.recordFailure('203.0.113.7');
|
||||
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
|
||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
||||
});
|
||||
|
||||
it('trennt Quellen voneinander', () => {
|
||||
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
|
||||
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
|
||||
expect(limiter.isBlocked('198.51.100.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('gibt die Quelle nach Ablauf des Fensters wieder frei', () => {
|
||||
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
|
||||
now += 60_001;
|
||||
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
|
||||
// Neues Fenster: der nächste Fehlversuch zählt wieder ab eins.
|
||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
||||
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
|
||||
});
|
||||
|
||||
it('vergisst alte Quellen, statt unbegrenzt Speicher zu belegen', () => {
|
||||
const small = new FailedAttemptLimiter({
|
||||
maxFailures: 1,
|
||||
windowMs: 60_000,
|
||||
maxTrackedSources: 2,
|
||||
clock: () => now,
|
||||
});
|
||||
small.recordFailure('a');
|
||||
small.recordFailure('b');
|
||||
now += 60_001;
|
||||
// Beim dritten Eintrag wird aufgeräumt: a und b sind abgelaufen.
|
||||
small.recordFailure('c');
|
||||
expect(small.trackedSources()).toBe(1);
|
||||
expect(small.isBlocked('c')).toBe(true);
|
||||
});
|
||||
|
||||
it('verdrängt bei voller Liste den ältesten Eintrag, auch wenn er noch läuft', () => {
|
||||
const small = new FailedAttemptLimiter({
|
||||
maxFailures: 5,
|
||||
windowMs: 60_000,
|
||||
maxTrackedSources: 2,
|
||||
clock: () => now,
|
||||
});
|
||||
small.recordFailure('a');
|
||||
small.recordFailure('b');
|
||||
small.recordFailure('c');
|
||||
expect(small.trackedSources()).toBe(2);
|
||||
});
|
||||
it('verdrängt bei voller Liste zuerst nicht gesperrte Quellen', () => {
|
||||
const small = new FailedAttemptLimiter({
|
||||
maxFailures: 2,
|
||||
windowMs: 60_000,
|
||||
maxTrackedSources: 2,
|
||||
clock: () => now,
|
||||
});
|
||||
small.recordFailure('angreifer');
|
||||
small.recordFailure('angreifer'); // gesperrt, ältester Eintrag
|
||||
small.recordFailure('harmlos'); // ein Fehlversuch, nicht gesperrt
|
||||
small.recordFailure('neu');
|
||||
|
||||
expect(small.trackedSources()).toBe(2);
|
||||
// Die Sperre bleibt, obwohl sie der älteste Eintrag ist.
|
||||
expect(small.isBlocked('angreifer')).toBe(true);
|
||||
// Verdrängt wurde der nicht gesperrte Eintrag.
|
||||
expect(small.recordFailure('harmlos')).toBe(false);
|
||||
});
|
||||
|
||||
it('verdrängt gesperrte Quellen erst, wenn alle gesperrt sind (älteste zuerst)', () => {
|
||||
const small = new FailedAttemptLimiter({
|
||||
maxFailures: 1,
|
||||
windowMs: 60_000,
|
||||
maxTrackedSources: 2,
|
||||
clock: () => now,
|
||||
});
|
||||
small.recordFailure('a');
|
||||
small.recordFailure('b');
|
||||
small.recordFailure('c');
|
||||
|
||||
expect(small.trackedSources()).toBe(2);
|
||||
expect(small.isBlocked('a')).toBe(false);
|
||||
expect(small.isBlocked('b')).toBe(true);
|
||||
expect(small.isBlocked('c')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
export interface FailedAttemptLimiterOptions {
|
||||
/** Ab so vielen Fehlversuchen im Fenster ist die Quelle gesperrt. */
|
||||
maxFailures: number;
|
||||
/** Länge des Fensters in Millisekunden, gezählt ab dem ersten Fehlversuch. */
|
||||
windowMs: number;
|
||||
/** Obergrenze gemerkter Quellen - schützt den Speicher bei verteilten Angriffen. */
|
||||
maxTrackedSources: number;
|
||||
clock?: () => number;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
failures: number;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zählt fehlgeschlagene Anmeldungen je Quelle (Client-IP) in einem festen
|
||||
* Zeitfenster und sperrt die Quelle, sobald die Obergrenze erreicht ist.
|
||||
*
|
||||
* Bewusst im Speicher des Prozesses, wie der globale Throttler: ApiCore läuft
|
||||
* mit einem Backend-Pod. Nach einem Neustart beginnt die Zählung von vorn -
|
||||
* das ist für eine Bremse gegen Durchprobieren ausreichend.
|
||||
*/
|
||||
export class FailedAttemptLimiter {
|
||||
private readonly windows = new Map<string, Window>();
|
||||
private readonly clock: () => number;
|
||||
|
||||
constructor(private readonly options: FailedAttemptLimiterOptions) {
|
||||
this.clock = options.clock ?? Date.now;
|
||||
}
|
||||
|
||||
isBlocked(source: string): boolean {
|
||||
const window = this.activeWindow(source);
|
||||
return window !== undefined && window.failures >= this.options.maxFailures;
|
||||
}
|
||||
|
||||
/** Zählt einen Fehlversuch; true genau dann, wenn er die Sperre auslöst. */
|
||||
recordFailure(source: string): boolean {
|
||||
const now = this.clock();
|
||||
const current = this.activeWindow(source);
|
||||
if (!current) {
|
||||
this.makeRoom(now);
|
||||
}
|
||||
const failures = (current?.failures ?? 0) + 1;
|
||||
this.windows.set(source, {
|
||||
failures,
|
||||
startedAt: current?.startedAt ?? now,
|
||||
});
|
||||
return failures === this.options.maxFailures;
|
||||
}
|
||||
|
||||
trackedSources(): number {
|
||||
return this.windows.size;
|
||||
}
|
||||
|
||||
private activeWindow(source: string): Window | undefined {
|
||||
const window = this.windows.get(source);
|
||||
if (window && this.clock() - window.startedAt > this.options.windowMs) {
|
||||
this.windows.delete(source);
|
||||
return undefined;
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
private makeRoom(now: number): void {
|
||||
if (this.windows.size < this.options.maxTrackedSources) return;
|
||||
for (const [source, window] of this.windows) {
|
||||
if (now - window.startedAt > this.options.windowMs) {
|
||||
this.windows.delete(source);
|
||||
}
|
||||
}
|
||||
// Immer noch voll: lieber eine Zählung verlieren als unbegrenzt wachsen.
|
||||
// Zuerst den ältesten nicht gesperrten Eintrag verdrängen - sonst könnte
|
||||
// ein Angreifer mit vielen Adressen seine eigene Sperre hinausschieben.
|
||||
// Nur wenn alle gesperrt sind, geht der älteste gesperrte (die Map behält
|
||||
// die Einfügereihenfolge).
|
||||
if (this.windows.size >= this.options.maxTrackedSources) {
|
||||
const victim =
|
||||
this.oldestWhere(
|
||||
(window) => window.failures < this.options.maxFailures,
|
||||
) ?? this.oldestWhere(() => true);
|
||||
if (victim !== undefined) this.windows.delete(victim);
|
||||
}
|
||||
}
|
||||
|
||||
private oldestWhere(
|
||||
matches: (window: Window) => boolean,
|
||||
): string | undefined {
|
||||
for (const [source, window] of this.windows) {
|
||||
if (matches(window)) return source;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
|
||||
|
||||
describe('ip-allowlist', () => {
|
||||
it('normalizes addresses and networks', () => {
|
||||
expect(normalizeAllowlistEntry(' 203.0.113.7 ')).toBe('203.0.113.7');
|
||||
expect(normalizeAllowlistEntry('203.0.113.0/24')).toBe('203.0.113.0/24');
|
||||
expect(normalizeAllowlistEntry('2001:db8::/32')).toBe('2001:db8::/32');
|
||||
expect(normalizeAllowlistEntry('::ffff:203.0.113.7')).toBe('203.0.113.7');
|
||||
});
|
||||
|
||||
it('rejects anything else', () => {
|
||||
for (const entry of [
|
||||
'',
|
||||
'example.com',
|
||||
'203.0.113.0/33',
|
||||
'1.2.3.4/x',
|
||||
'1.2.3.4/8/1',
|
||||
]) {
|
||||
expect(normalizeAllowlistEntry(entry)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('allows every address when the list is empty', () => {
|
||||
expect(isIpAllowed('198.51.100.1', [])).toBe(true);
|
||||
});
|
||||
|
||||
it('allows only listed addresses and networks', () => {
|
||||
const list = ['203.0.113.7', '198.51.100.0/24'];
|
||||
expect(isIpAllowed('203.0.113.7', list)).toBe(true);
|
||||
expect(isIpAllowed('::ffff:198.51.100.42', list)).toBe(true);
|
||||
expect(isIpAllowed('203.0.113.8', list)).toBe(false);
|
||||
expect(isIpAllowed(undefined, list)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as net from 'node:net';
|
||||
|
||||
/** Max entries per key - an allowlist is a short list of servers. */
|
||||
export const MAX_ALLOWED_IPS = 20;
|
||||
|
||||
function unmapIpv4(ip: string): string {
|
||||
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);
|
||||
return mapped ? mapped[1] : ip;
|
||||
}
|
||||
|
||||
/**
|
||||
* "203.0.113.7", "203.0.113.0/24", "2001:db8::/32" in canonical form, or
|
||||
* null if the entry is neither an address nor a network.
|
||||
*/
|
||||
export function normalizeAllowlistEntry(entry: string): string | null {
|
||||
const trimmed = entry.trim();
|
||||
const [address, prefix, ...rest] = trimmed.split('/');
|
||||
if (rest.length > 0 || !address) {
|
||||
return null;
|
||||
}
|
||||
const ip = unmapIpv4(address);
|
||||
const version = net.isIP(ip);
|
||||
if (version === 0) {
|
||||
return null;
|
||||
}
|
||||
if (prefix === undefined) {
|
||||
return ip;
|
||||
}
|
||||
if (!/^\d{1,3}$/.test(prefix)) {
|
||||
return null;
|
||||
}
|
||||
const bits = Number(prefix);
|
||||
if (bits > (version === 4 ? 32 : 128)) {
|
||||
return null;
|
||||
}
|
||||
return `${ip}/${bits}`;
|
||||
}
|
||||
|
||||
/** An empty list allows every address. */
|
||||
export function isIpAllowed(
|
||||
clientIp: string | undefined,
|
||||
allowedIps: readonly string[],
|
||||
): boolean {
|
||||
if (allowedIps.length === 0) {
|
||||
return true;
|
||||
}
|
||||
if (!clientIp) {
|
||||
return false;
|
||||
}
|
||||
const ip = unmapIpv4(clientIp);
|
||||
const version = net.isIP(ip);
|
||||
if (version === 0) {
|
||||
return false;
|
||||
}
|
||||
const list = new net.BlockList();
|
||||
for (const entry of allowedIps) {
|
||||
const [address, prefix] = entry.split('/');
|
||||
const family = net.isIP(address) === 6 ? 'ipv6' : 'ipv4';
|
||||
if (prefix === undefined) {
|
||||
list.addAddress(address, family);
|
||||
} else {
|
||||
list.addSubnet(address, Number(prefix), family);
|
||||
}
|
||||
}
|
||||
return list.check(ip, version === 6 ? 'ipv6' : 'ipv4');
|
||||
}
|
||||
@@ -12,6 +12,7 @@ describe('ApiariesService', () => {
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
colony: { count: jest.Mock };
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
@@ -30,6 +31,7 @@ describe('ApiariesService', () => {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
colony: { count: jest.fn().mockResolvedValue(0) },
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new ApiariesService(
|
||||
@@ -76,6 +78,30 @@ describe('ApiariesService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('deactivate counts every living colony, weak and queenless ones included', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
||||
prisma.apiary.update.mockResolvedValue(existingApiary);
|
||||
|
||||
await service.deactivate('apiary-1', 'user-1');
|
||||
|
||||
expect(prisma.colony.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
apiaryId: 'apiary-1',
|
||||
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('deactivate throws ConflictException when active colonies remain', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
||||
prisma.colony.count.mockResolvedValue(2);
|
||||
|
||||
await expect(
|
||||
service.deactivate('apiary-1', 'user-1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(prisma.apiary.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reactivate throws ConflictException when already active', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
||||
await expect(
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||
import { Apiary, ApiaryStatus } from '../../generated/prisma';
|
||||
import { CreateApiaryDto } from './dto/create-apiary.dto';
|
||||
import { UpdateApiaryDto } from './dto/update-apiary.dto';
|
||||
@@ -76,6 +77,19 @@ export class ApiariesService {
|
||||
|
||||
async deactivate(id: string, actingUserId: string): Promise<Apiary> {
|
||||
const before = await this.findById(id);
|
||||
// Colony.apiaryId is a plain FK, not enforced against apiary status -
|
||||
// without this guard a colony can silently keep pointing at an
|
||||
// inactive apiary (still shows ACTIVE, still countable, but its
|
||||
// "location" has vanished from the active apiary list). Move the
|
||||
// colonies elsewhere or dissolve them first instead.
|
||||
const activeColonyCount = await this.prisma.colony.count({
|
||||
where: { apiaryId: id, ...LIVING_COLONY_WHERE },
|
||||
});
|
||||
if (activeColonyCount > 0) {
|
||||
throw new ConflictException(
|
||||
`Der Bienenstand hat noch ${activeColonyCount} aktive(s) Volk/Völker - zuerst verlegen oder auflösen`,
|
||||
);
|
||||
}
|
||||
const apiary = await this.prisma.apiary.update({
|
||||
where: { id },
|
||||
data: { status: 'INACTIVE', deactivatedAt: new Date() },
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateApiaryDto } from './create-apiary.dto';
|
||||
|
||||
export class UpdateApiaryDto extends PartialType(CreateApiaryDto) {}
|
||||
export class UpdateApiaryDto extends PartialType(CreateApiaryDto, {
|
||||
skipNullProperties: false,
|
||||
}) {}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import {
|
||||
configureApp,
|
||||
describeTrustProxy,
|
||||
trustProxySetting,
|
||||
} from './app.config';
|
||||
|
||||
describe('trustProxySetting', () => {
|
||||
const original = process.env.TRUST_PROXY;
|
||||
afterEach(() => {
|
||||
if (original === undefined) delete process.env.TRUST_PROXY;
|
||||
else process.env.TRUST_PROXY = original;
|
||||
});
|
||||
|
||||
it('defaults to one hop when unset or blank', () => {
|
||||
delete process.env.TRUST_PROXY;
|
||||
expect(trustProxySetting()).toBe(1);
|
||||
process.env.TRUST_PROXY = ' ';
|
||||
expect(trustProxySetting()).toBe(1);
|
||||
});
|
||||
|
||||
it('parses a number as hop count and anything else as a list', () => {
|
||||
process.env.TRUST_PROXY = '2';
|
||||
expect(trustProxySetting()).toBe(2);
|
||||
process.env.TRUST_PROXY = 'loopback, 10.42.0.0/16';
|
||||
expect(trustProxySetting()).toBe('loopback, 10.42.0.0/16');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeTrustProxy', () => {
|
||||
it('reports the hop count mode and the default fallback', () => {
|
||||
expect(describeTrustProxy(1, false)).toBe(
|
||||
'TRUST_PROXY not set, using default of 1 trusted hop',
|
||||
);
|
||||
expect(describeTrustProxy(2, true)).toBe('TRUST_PROXY: 2 trusted hop(s)');
|
||||
});
|
||||
|
||||
it('reports the list mode with entry count but not the entries', () => {
|
||||
const text = describeTrustProxy(
|
||||
'loopback, 10.42.0.0/16, 2400:cb00::/32',
|
||||
true,
|
||||
);
|
||||
expect(text).toBe('TRUST_PROXY: list of 3 trusted proxy range(s)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('configureApp trust proxy logging', () => {
|
||||
it('logs the active mode once at startup', () => {
|
||||
const log = jest.spyOn(Logger.prototype, 'log').mockImplementation();
|
||||
const app = {
|
||||
set: jest.fn(),
|
||||
useBodyParser: jest.fn(),
|
||||
use: jest.fn(),
|
||||
useGlobalFilters: jest.fn(),
|
||||
enableCors: jest.fn(),
|
||||
setGlobalPrefix: jest.fn(),
|
||||
enableVersioning: jest.fn(),
|
||||
useGlobalPipes: jest.fn(),
|
||||
};
|
||||
configureApp(app as unknown as NestExpressApplication);
|
||||
expect(
|
||||
log.mock.calls.filter(([m]) => String(m).startsWith('TRUST_PROXY')),
|
||||
).toHaveLength(1);
|
||||
log.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('configureApp trust proxy warning', () => {
|
||||
const originalProxy = process.env.TRUST_PROXY;
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
afterEach(() => {
|
||||
if (originalProxy === undefined) delete process.env.TRUST_PROXY;
|
||||
else process.env.TRUST_PROXY = originalProxy;
|
||||
if (originalEnv === undefined) delete process.env.NODE_ENV;
|
||||
else process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
|
||||
function configure(): jest.SpyInstance {
|
||||
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation();
|
||||
const app = {
|
||||
set: jest.fn(),
|
||||
useBodyParser: jest.fn(),
|
||||
use: jest.fn(),
|
||||
useGlobalFilters: jest.fn(),
|
||||
enableCors: jest.fn(),
|
||||
setGlobalPrefix: jest.fn(),
|
||||
enableVersioning: jest.fn(),
|
||||
useGlobalPipes: jest.fn(),
|
||||
};
|
||||
configureApp(app as unknown as NestExpressApplication);
|
||||
return warn;
|
||||
}
|
||||
|
||||
function trustProxyWarnings(warn: jest.SpyInstance): unknown[][] {
|
||||
const calls = warn.mock.calls as unknown[][];
|
||||
return calls.filter(([m]) => String(m).includes('TRUST_PROXY'));
|
||||
}
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('warnt in Produktion, wenn TRUST_PROXY fehlt', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
delete process.env.TRUST_PROXY;
|
||||
expect(trustProxyWarnings(configure())).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('warnt nicht, wenn TRUST_PROXY gesetzt ist', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.TRUST_PROXY = 'loopback, 10.42.0.0/16';
|
||||
expect(trustProxyWarnings(configure())).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('warnt nicht in der Entwicklung', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
delete process.env.TRUST_PROXY;
|
||||
expect(trustProxyWarnings(configure())).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,96 @@
|
||||
import { RequestMethod, ValidationPipe, VersioningType } from '@nestjs/common';
|
||||
import {
|
||||
Logger,
|
||||
RequestMethod,
|
||||
ValidationPipe,
|
||||
VersioningType,
|
||||
} from '@nestjs/common';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import helmet from 'helmet';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||
import { requestLoggerMiddleware } from './common/middleware/request-logger.middleware';
|
||||
import { noStoreMiddleware } from './common/middleware/no-store.middleware';
|
||||
|
||||
/**
|
||||
* Which proxies to trust for X-Forwarded-For. It decides the client IP behind
|
||||
* every rate limit and API-key IP allowlist: trust too little and all users
|
||||
* share the proxy's IP, trust too much and a client can forge its own.
|
||||
*
|
||||
* Production runs client -> Cloudflare -> Traefik -> backend, so the hop
|
||||
* count is not stable; TRUST_PROXY is an Express list there ("loopback,
|
||||
* 10.42.0.0/16, <Cloudflare IPv4+IPv6 ranges>", see
|
||||
* deploy/k8s/backend/configmap.example.yaml). A plain number ("1") is a hop
|
||||
* count and stays the default when the variable is unset.
|
||||
*/
|
||||
export function trustProxySetting(): number | string {
|
||||
const raw = process.env.TRUST_PROXY?.trim();
|
||||
if (!raw) {
|
||||
return 1;
|
||||
}
|
||||
return /^\d+$/.test(raw) ? Number(raw) : raw;
|
||||
}
|
||||
|
||||
/** Startup log line for the active mode; never includes the addresses. */
|
||||
export function describeTrustProxy(
|
||||
setting: number | string,
|
||||
isExplicit: boolean,
|
||||
): string {
|
||||
if (typeof setting === 'number') {
|
||||
return isExplicit
|
||||
? `TRUST_PROXY: ${setting} trusted hop(s)`
|
||||
: `TRUST_PROXY not set, using default of ${setting} trusted hop`;
|
||||
}
|
||||
const entries = setting.split(',').filter((e) => e.trim()).length;
|
||||
return `TRUST_PROXY: list of ${entries} trusted proxy range(s)`;
|
||||
}
|
||||
|
||||
export function configureApp(app: NestExpressApplication): void {
|
||||
// Trust the single reverse-proxy hop (K8s ingress) so req.ip reflects the
|
||||
// Trust the configured proxies (Traefik, Cloudflare) so req.ip reflects the
|
||||
// real client IP from X-Forwarded-For — required for per-client throttling
|
||||
// to work correctly rather than rate-limiting all traffic together.
|
||||
app.set('trust proxy', 1);
|
||||
const trustProxy = trustProxySetting();
|
||||
app.set('trust proxy', trustProxy);
|
||||
const bootstrapLogger = new Logger('Bootstrap');
|
||||
const trustProxyExplicit = !!process.env.TRUST_PROXY?.trim();
|
||||
bootstrapLogger.log(describeTrustProxy(trustProxy, trustProxyExplicit));
|
||||
if (!trustProxyExplicit && process.env.NODE_ENV === 'production') {
|
||||
// Behind Cloudflare and Traefik one hop is wrong: req.ip becomes the
|
||||
// proxy, so rate limits, API-key failure limits and IP allowlists act
|
||||
// on a shared address instead of the client.
|
||||
bootstrapLogger.warn(
|
||||
'TRUST_PROXY is not set in production - client IPs for rate limits and API-key allowlists are likely the proxy address; set it as in deploy/k8s/backend/configmap.example.yaml',
|
||||
);
|
||||
}
|
||||
// No ETags: they let caches and proxies revalidate (and keep) personal
|
||||
// responses that are meant to be no-store.
|
||||
app.set('etag', false);
|
||||
// Default 100kb JSON body limit is too small for the avatar upload, which
|
||||
// sends the image as a base64 data: URL in the JSON body (not multipart -
|
||||
// see profile/dto/upload-avatar.dto.ts for why) - 5mb comfortably covers
|
||||
// the 2MB decoded size cap plus base64 overhead.
|
||||
app.useBodyParser('json', { limit: '5mb' });
|
||||
app.use(helmet());
|
||||
app.use(
|
||||
helmet({
|
||||
// This server only answers JSON and files. A strict policy means even
|
||||
// a response that is somehow rendered as a page can load nothing and
|
||||
// cannot be framed. The Swagger UI (opt-in) needs scripts, so it keeps
|
||||
// helmet's default page policy.
|
||||
contentSecurityPolicy:
|
||||
process.env.ENABLE_SWAGGER === 'true'
|
||||
? undefined
|
||||
: {
|
||||
useDefaults: false,
|
||||
directives: {
|
||||
defaultSrc: ["'none'"],
|
||||
frameAncestors: ["'none'"],
|
||||
baseUri: ["'none'"],
|
||||
formAction: ["'none'"],
|
||||
},
|
||||
},
|
||||
crossOriginResourcePolicy: { policy: 'same-site' },
|
||||
}),
|
||||
);
|
||||
app.use(noStoreMiddleware);
|
||||
app.use(cookieParser());
|
||||
app.use(requestLoggerMiddleware);
|
||||
app.useGlobalFilters(new AllExceptionsFilter());
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { ClientIpThrottlerGuard } from './common/throttler/client-ip-throttler.guard';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
@@ -27,6 +28,8 @@ import { ExportModule } from './export/export.module';
|
||||
import { QrModule } from './qr/qr.module';
|
||||
import { ProductsModule } from './products/products.module';
|
||||
import { BottlingModule } from './bottling/bottling.module';
|
||||
import { ProductionBatchesModule } from './production-batches/production-batches.module';
|
||||
import { RawMaterialsModule } from './raw-materials/raw-materials.module';
|
||||
import { EquipmentModule } from './equipment/equipment.module';
|
||||
import { CalendarModule } from './calendar/calendar.module';
|
||||
import { CalendarSyncModule } from './calendar-sync/calendar-sync.module';
|
||||
@@ -40,13 +43,19 @@ import { ImportModule } from './import/import.module';
|
||||
import { PublicModule } from './public/public.module';
|
||||
import { TaskTemplatesModule } from './task-templates/task-templates.module';
|
||||
import { SwarmControlModule } from './swarm-control/swarm-control.module';
|
||||
import { WinterChecksModule } from './winter-checks/winter-checks.module';
|
||||
import { WeatherModule } from './weather/weather.module';
|
||||
import { ProfileModule } from './profile/profile.module';
|
||||
import { SiteSettingsModule } from './site-settings/site-settings.module';
|
||||
import { BackupModule } from './backup/backup.module';
|
||||
import { ShopSyncModule } from './shop-sync/shop-sync.module';
|
||||
import { ErpExportModule } from './erp-export/erp-export.module';
|
||||
import { ApiKeysModule } from './api-keys/api-keys.module';
|
||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
||||
import { PermissionsModule } from './auth/permissions/permissions.module';
|
||||
import { IdempotencyInterceptor } from './common/idempotency/idempotency.interceptor';
|
||||
import { IdempotencyCleanupService } from './common/idempotency/idempotency-cleanup.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -78,6 +87,8 @@ import { PermissionsModule } from './auth/permissions/permissions.module';
|
||||
QrModule,
|
||||
ProductsModule,
|
||||
BottlingModule,
|
||||
ProductionBatchesModule,
|
||||
RawMaterialsModule,
|
||||
EquipmentModule,
|
||||
CalendarModule,
|
||||
CalendarSyncModule,
|
||||
@@ -91,16 +102,24 @@ import { PermissionsModule } from './auth/permissions/permissions.module';
|
||||
PublicModule,
|
||||
TaskTemplatesModule,
|
||||
SwarmControlModule,
|
||||
WinterChecksModule,
|
||||
WeatherModule,
|
||||
ProfileModule,
|
||||
SiteSettingsModule,
|
||||
BackupModule,
|
||||
ShopSyncModule,
|
||||
ErpExportModule,
|
||||
ApiKeysModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
// Throttle first: a request with a wrong token or API key must count
|
||||
// against the limit too, before any database lookup is spent on it.
|
||||
{ provide: APP_GUARD, useClass: ClientIpThrottlerGuard },
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
|
||||
IdempotencyCleanupService,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -82,6 +82,7 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle(SENSITIVE_ACTION_THROTTLE)
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async logout(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { UsersModule } from '../users/users.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { OneTimeTokenService } from './one-time-token.service';
|
||||
import { MfaModule } from './mfa/mfa.module';
|
||||
import { SsoModule } from './sso/sso.module';
|
||||
import { SsoController } from './sso/sso.controller';
|
||||
@@ -18,6 +19,6 @@ import { SsoController } from './sso/sso.controller';
|
||||
SsoModule,
|
||||
],
|
||||
controllers: [AuthController, SsoController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
providers: [AuthService, JwtStrategy, OneTimeTokenService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -5,6 +5,8 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { MfaService } from './mfa/mfa.service';
|
||||
import { AccountGuardService } from './mfa/account-guard.service';
|
||||
import { OneTimeTokenService } from './one-time-token.service';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let authService: AuthService;
|
||||
@@ -26,7 +28,13 @@ describe('AuthService', () => {
|
||||
let mfaService: {
|
||||
signChallengeToken: jest.Mock;
|
||||
verifyChallengeToken: jest.Mock;
|
||||
verifyCode: jest.Mock;
|
||||
};
|
||||
let oneTimeTokens: { consume: jest.Mock };
|
||||
let accountGuard: {
|
||||
isLocked: jest.Mock;
|
||||
recordFailure: jest.Mock;
|
||||
recordSuccess: jest.Mock;
|
||||
consumeTotp: jest.Mock;
|
||||
};
|
||||
|
||||
const activeUser = {
|
||||
@@ -60,7 +68,13 @@ describe('AuthService', () => {
|
||||
mfaService = {
|
||||
signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'),
|
||||
verifyChallengeToken: jest.fn(),
|
||||
verifyCode: jest.fn(),
|
||||
};
|
||||
oneTimeTokens = { consume: jest.fn().mockResolvedValue(undefined) };
|
||||
accountGuard = {
|
||||
isLocked: jest.fn().mockReturnValue(false),
|
||||
recordFailure: jest.fn().mockResolvedValue(undefined),
|
||||
recordSuccess: jest.fn().mockResolvedValue(undefined),
|
||||
consumeTotp: jest.fn(),
|
||||
};
|
||||
|
||||
authService = new AuthService(
|
||||
@@ -70,6 +84,8 @@ describe('AuthService', () => {
|
||||
auditLog as unknown as AuditLogService,
|
||||
permissionResolver as never,
|
||||
mfaService as unknown as MfaService,
|
||||
accountGuard as unknown as AccountGuardService,
|
||||
oneTimeTokens as unknown as OneTimeTokenService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -157,21 +173,40 @@ describe('AuthService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('records a LOGIN_FAILED audit entry keyed by email when the user is unknown', async () => {
|
||||
it('writes nothing attacker-chosen to the audit log for an unknown email', async () => {
|
||||
usersService.findByEmail.mockResolvedValue(null);
|
||||
await expect(
|
||||
authService.login('[email protected]', 'x'),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'LOGIN_FAILED',
|
||||
entityId: '[email protected]',
|
||||
}),
|
||||
);
|
||||
const [[recordArg]] = auditLog.record.mock.calls as [
|
||||
[{ userId?: string }],
|
||||
];
|
||||
expect(recordArg).not.toHaveProperty('userId');
|
||||
expect(auditLog.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('counts a wrong password against the account', async () => {
|
||||
usersService.findByEmail.mockResolvedValue(activeUser);
|
||||
await expect(
|
||||
authService.login(activeUser.email, 'wrong-password'),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(accountGuard.recordFailure).toHaveBeenCalledWith(activeUser.id);
|
||||
});
|
||||
|
||||
it('refuses a locked account even with the right password, with the same message', async () => {
|
||||
usersService.findByEmail.mockResolvedValue(activeUser);
|
||||
accountGuard.isLocked.mockReturnValue(true);
|
||||
|
||||
const lockedMessage = await authService
|
||||
.login(activeUser.email, 'correct-password')
|
||||
.then(() => 'no error')
|
||||
.catch((error: Error) => error.message);
|
||||
|
||||
accountGuard.isLocked.mockReturnValue(false);
|
||||
const wrongMessage = await authService
|
||||
.login(activeUser.email, 'wrong-password')
|
||||
.then(() => 'no error')
|
||||
.catch((error: Error) => error.message);
|
||||
|
||||
expect(lockedMessage).not.toBe('no error');
|
||||
expect(lockedMessage).toBe(wrongMessage);
|
||||
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,9 +218,12 @@ describe('AuthService', () => {
|
||||
};
|
||||
|
||||
it('issues tokens and records LOGIN when the code is valid', async () => {
|
||||
mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id });
|
||||
mfaService.verifyChallengeToken.mockReturnValue({
|
||||
userId: mfaUser.id,
|
||||
claims: { jti: 'jti-1', exp: 1_900_000_000 },
|
||||
});
|
||||
usersService.findById.mockResolvedValue(mfaUser);
|
||||
mfaService.verifyCode.mockResolvedValue(true);
|
||||
accountGuard.consumeTotp.mockResolvedValue(true);
|
||||
prisma.refreshToken.create.mockResolvedValue({});
|
||||
|
||||
const result = await authService.verifyMfaAndLogin(
|
||||
@@ -194,20 +232,20 @@ describe('AuthService', () => {
|
||||
);
|
||||
|
||||
expect(result.accessToken).toBe('signed.jwt.token');
|
||||
expect(mfaService.verifyCode).toHaveBeenCalledWith(
|
||||
mfaUser.mfaSecret,
|
||||
mfaUser.email,
|
||||
'123456',
|
||||
);
|
||||
expect(accountGuard.consumeTotp).toHaveBeenCalledWith(mfaUser, '123456');
|
||||
expect(accountGuard.recordSuccess).toHaveBeenCalledWith(mfaUser.id);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'LOGIN', userId: mfaUser.id }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws and records LOGIN_FAILED when the code is invalid', async () => {
|
||||
mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id });
|
||||
mfaService.verifyChallengeToken.mockReturnValue({
|
||||
userId: mfaUser.id,
|
||||
claims: { jti: 'jti-1', exp: 1_900_000_000 },
|
||||
});
|
||||
usersService.findById.mockResolvedValue(mfaUser);
|
||||
mfaService.verifyCode.mockResolvedValue(false);
|
||||
accountGuard.consumeTotp.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
authService.verifyMfaAndLogin('mfa-challenge-token', '000000'),
|
||||
@@ -295,7 +333,8 @@ describe('AuthService', () => {
|
||||
prisma.refreshToken.findUnique.mockResolvedValue({
|
||||
id: 'rt-1',
|
||||
userId: activeUser.id,
|
||||
revokedAt: new Date(),
|
||||
// Rotated out a minute ago - well past the parallel-tab window.
|
||||
revokedAt: new Date(Date.now() - 60_000),
|
||||
expiresAt: new Date(Date.now() + 1000),
|
||||
});
|
||||
prisma.refreshToken.updateMany.mockResolvedValue({ count: 2 });
|
||||
@@ -325,22 +364,78 @@ describe('AuthService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not revoke everything when a parallel tab just rotated the token', async () => {
|
||||
prisma.refreshToken.findUnique.mockResolvedValue({
|
||||
id: 'rt-1',
|
||||
userId: activeUser.id,
|
||||
revokedAt: new Date(Date.now() - 1000),
|
||||
expiresAt: new Date(Date.now() + 1000),
|
||||
});
|
||||
|
||||
await expect(authService.refresh('just-rotated')).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(prisma.refreshToken.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('gives only one of two concurrent refreshes a successor', async () => {
|
||||
prisma.refreshToken.findUnique.mockResolvedValue({
|
||||
id: 'rt-1',
|
||||
userId: activeUser.id,
|
||||
revokedAt: null,
|
||||
expiresAt: new Date(Date.now() + 1000),
|
||||
sessionStartedAt: new Date(),
|
||||
});
|
||||
usersService.findById.mockResolvedValue(activeUser);
|
||||
prisma.refreshToken.updateMany.mockResolvedValue({ count: 0 });
|
||||
|
||||
await expect(authService.refresh('raced-token')).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never extends a session past seven days from login', async () => {
|
||||
const startedSixDaysAgo = new Date(Date.now() - 6 * 24 * 3600 * 1000);
|
||||
prisma.refreshToken.findUnique.mockResolvedValue({
|
||||
id: 'rt-1',
|
||||
userId: activeUser.id,
|
||||
revokedAt: null,
|
||||
expiresAt: new Date(Date.now() + 1000),
|
||||
sessionStartedAt: startedSixDaysAgo,
|
||||
});
|
||||
usersService.findById.mockResolvedValue(activeUser);
|
||||
prisma.refreshToken.updateMany.mockResolvedValue({ count: 1 });
|
||||
prisma.refreshToken.create.mockResolvedValue({});
|
||||
|
||||
await authService.refresh('valid-token');
|
||||
|
||||
const [[createArg]] = prisma.refreshToken.create.mock.calls as [
|
||||
[{ data: { expiresAt: Date; sessionStartedAt: Date } }],
|
||||
];
|
||||
expect(createArg.data.sessionStartedAt).toEqual(startedSixDaysAgo);
|
||||
expect(createArg.data.expiresAt.getTime()).toBe(
|
||||
startedSixDaysAgo.getTime() + 7 * 24 * 3600 * 1000,
|
||||
);
|
||||
});
|
||||
|
||||
it('rotates the refresh token on success', async () => {
|
||||
prisma.refreshToken.findUnique.mockResolvedValue({
|
||||
id: 'rt-1',
|
||||
userId: activeUser.id,
|
||||
revokedAt: null,
|
||||
expiresAt: new Date(Date.now() + 1000),
|
||||
sessionStartedAt: new Date(),
|
||||
});
|
||||
usersService.findById.mockResolvedValue(activeUser);
|
||||
prisma.refreshToken.update.mockResolvedValue({});
|
||||
prisma.refreshToken.updateMany.mockResolvedValue({ count: 1 });
|
||||
prisma.refreshToken.create.mockResolvedValue({});
|
||||
|
||||
const result = await authService.refresh('valid-token');
|
||||
|
||||
expect(result.accessToken).toBe('signed.jwt.token');
|
||||
expect(prisma.refreshToken.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'rt-1' } }),
|
||||
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'rt-1', revokedAt: null } }),
|
||||
);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -7,7 +7,10 @@ import { UsersService } from '../users/users.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { PermissionResolverService } from './permissions/permission-resolver.service';
|
||||
import { MfaService } from './mfa/mfa.service';
|
||||
import { AccountGuardService } from './mfa/account-guard.service';
|
||||
import { OneTimeTokenService } from './one-time-token.service';
|
||||
import { parseDurationToMs } from './util/duration';
|
||||
import { TOKEN_AUDIENCE, jwtSignOptions } from './util/jwt-claims';
|
||||
|
||||
interface AuthTokens {
|
||||
accessToken: string;
|
||||
@@ -31,6 +34,25 @@ export interface CurrentUserProfile {
|
||||
|
||||
const REFRESH_TOKEN_BYTES = 40;
|
||||
|
||||
// Hard cap from the moment of login, however often the session is refreshed.
|
||||
const DEFAULT_SESSION_MAX_AGE = '7d';
|
||||
// Two tabs refreshing with the same token at the same moment is normal;
|
||||
// only a reuse after this window is treated as theft.
|
||||
const ROTATION_GRACE_MS = 10_000;
|
||||
|
||||
// Same answer for unknown email, wrong password, deactivated or locked
|
||||
// account - nothing in the response tells which one it was.
|
||||
const INVALID_CREDENTIALS =
|
||||
'E-Mail oder Passwort falsch. Nach mehreren Fehlversuchen ist die Anmeldung für 15 Minuten gesperrt.';
|
||||
|
||||
// Hash of a random value nobody knows, at the same cost as real password
|
||||
// hashes; compared against when there is no real hash, so every login
|
||||
// costs the same time.
|
||||
const DUMMY_PASSWORD_HASH = bcrypt.hashSync(
|
||||
crypto.randomBytes(32).toString('hex'),
|
||||
12,
|
||||
);
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
@@ -42,6 +64,8 @@ export class AuthService {
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
private readonly mfaService: MfaService,
|
||||
private readonly accountGuard: AccountGuardService,
|
||||
private readonly oneTimeTokens: OneTimeTokenService,
|
||||
) {}
|
||||
|
||||
async getCurrentUserProfile(userId: string): Promise<CurrentUserProfile> {
|
||||
@@ -72,56 +96,64 @@ export class AuthService {
|
||||
);
|
||||
return this.jwtService.sign(
|
||||
{ sub: user.id, email: user.email },
|
||||
{
|
||||
secret: process.env.JWT_ACCESS_SECRET,
|
||||
expiresIn: expiresInSeconds,
|
||||
},
|
||||
jwtSignOptions(TOKEN_AUDIENCE.access, expiresInSeconds),
|
||||
);
|
||||
}
|
||||
|
||||
private async issueRefreshToken(userId: string): Promise<string> {
|
||||
private async issueRefreshToken(
|
||||
userId: string,
|
||||
sessionStartedAt: Date = new Date(),
|
||||
): Promise<string> {
|
||||
const token = crypto.randomBytes(REFRESH_TOKEN_BYTES).toString('hex');
|
||||
const expiresAt = new Date(
|
||||
const slidingExpiry =
|
||||
Date.now() +
|
||||
parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'),
|
||||
);
|
||||
parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d');
|
||||
const absoluteExpiry =
|
||||
sessionStartedAt.getTime() +
|
||||
parseDurationToMs(process.env.SESSION_MAX_AGE ?? DEFAULT_SESSION_MAX_AGE);
|
||||
await this.prisma.refreshToken.create({
|
||||
data: { userId, tokenHash: this.hashRefreshToken(token), expiresAt },
|
||||
data: {
|
||||
userId,
|
||||
tokenHash: this.hashRefreshToken(token),
|
||||
expiresAt: new Date(Math.min(slidingExpiry, absoluteExpiry)),
|
||||
sessionStartedAt,
|
||||
},
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
async login(email: string, password: string): Promise<LoginResult> {
|
||||
const user = await this.usersService.findByEmail(email);
|
||||
// bcrypt runs on every path - unknown, deactivated, locked or real - so
|
||||
// the response time does not reveal which accounts exist.
|
||||
const passwordMatches = await bcrypt.compare(
|
||||
password,
|
||||
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
||||
);
|
||||
if (!user) {
|
||||
this.logger.warn(`Login failed: no user with email ${email}`);
|
||||
await this.auditLog.record({
|
||||
entityType: 'AuthSession',
|
||||
entityId: email,
|
||||
action: 'LOGIN_FAILED',
|
||||
});
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
this.logger.warn('Login failed: unknown email');
|
||||
throw new UnauthorizedException(INVALID_CREDENTIALS);
|
||||
}
|
||||
if (!user.isActive) {
|
||||
this.logger.warn(`Login failed: user ${user.id} is deactivated`);
|
||||
if (!user.isActive || this.accountGuard.isLocked(user)) {
|
||||
this.logger.warn(`Login refused: user ${user.id} inactive or locked`);
|
||||
await this.auditLog.record({
|
||||
entityType: 'AuthSession',
|
||||
entityId: user.id,
|
||||
action: 'LOGIN_FAILED',
|
||||
userId: user.id,
|
||||
});
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
throw new UnauthorizedException(INVALID_CREDENTIALS);
|
||||
}
|
||||
const passwordMatches = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!passwordMatches) {
|
||||
this.logger.warn(`Login failed: wrong password for user ${user.id}`);
|
||||
await this.accountGuard.recordFailure(user.id);
|
||||
await this.auditLog.record({
|
||||
entityType: 'AuthSession',
|
||||
entityId: user.id,
|
||||
action: 'LOGIN_FAILED',
|
||||
userId: user.id,
|
||||
});
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
throw new UnauthorizedException(INVALID_CREDENTIALS);
|
||||
}
|
||||
if (user.mfaEnabled) {
|
||||
return {
|
||||
@@ -135,6 +167,7 @@ export class AuthService {
|
||||
};
|
||||
const accessToken = this.signAccessToken(authenticatedUser);
|
||||
const refreshToken = await this.issueRefreshToken(user.id);
|
||||
await this.accountGuard.recordSuccess(user.id);
|
||||
await this.auditLog.record({
|
||||
entityType: 'AuthSession',
|
||||
entityId: user.id,
|
||||
@@ -177,20 +210,20 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async verifyMfaAndLogin(mfaToken: string, code: string): Promise<AuthTokens> {
|
||||
const { userId } = this.mfaService.verifyChallengeToken(mfaToken);
|
||||
const { userId, claims } = this.mfaService.verifyChallengeToken(mfaToken);
|
||||
const user = await this.usersService.findById(userId);
|
||||
if (!user.isActive || !user.mfaEnabled || !user.mfaSecret) {
|
||||
throw new UnauthorizedException('MFA nicht verfügbar');
|
||||
}
|
||||
const isValid = await this.mfaService.verifyCode(
|
||||
user.mfaSecret,
|
||||
user.email,
|
||||
code,
|
||||
);
|
||||
if (this.accountGuard.isLocked(user)) {
|
||||
throw new UnauthorizedException(INVALID_CREDENTIALS);
|
||||
}
|
||||
const isValid = await this.accountGuard.consumeTotp(user, code);
|
||||
if (!isValid) {
|
||||
this.logger.warn(
|
||||
`MFA verification failed: wrong code for user ${user.id}`,
|
||||
`MFA verification failed: wrong or reused code for user ${user.id}`,
|
||||
);
|
||||
await this.accountGuard.recordFailure(user.id);
|
||||
await this.auditLog.record({
|
||||
entityType: 'AuthSession',
|
||||
entityId: user.id,
|
||||
@@ -199,6 +232,9 @@ export class AuthService {
|
||||
});
|
||||
throw new UnauthorizedException('Code ungültig');
|
||||
}
|
||||
// A challenge that already opened a session cannot open another one.
|
||||
await this.oneTimeTokens.consume(claims);
|
||||
await this.accountGuard.recordSuccess(user.id);
|
||||
const authenticatedUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
@@ -222,6 +258,14 @@ export class AuthService {
|
||||
if (!stored) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
if (
|
||||
stored.revokedAt &&
|
||||
Date.now() - stored.revokedAt.getTime() < ROTATION_GRACE_MS
|
||||
) {
|
||||
// Lost a race with a parallel refresh (second tab): no new session,
|
||||
// but no alarm either - that tab simply uses the other's cookie.
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
if (stored.revokedAt) {
|
||||
// Reuse of an already-rotated-out refresh token is a strong signal
|
||||
// it was stolen (a legitimate client only ever presents the newest
|
||||
@@ -244,16 +288,25 @@ export class AuthService {
|
||||
if (!user.isActive) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
await this.prisma.refreshToken.update({
|
||||
where: { id: stored.id },
|
||||
// Atomic: of two requests presenting the same token, exactly one wins
|
||||
// and gets a successor - one stolen token can never fork into two live
|
||||
// sessions.
|
||||
const claimed = await this.prisma.refreshToken.updateMany({
|
||||
where: { id: stored.id, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
if (claimed.count !== 1) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
const authenticatedUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
};
|
||||
const accessToken = this.signAccessToken(authenticatedUser);
|
||||
const newRefreshToken = await this.issueRefreshToken(user.id);
|
||||
const newRefreshToken = await this.issueRefreshToken(
|
||||
user.id,
|
||||
stored.sessionStartedAt,
|
||||
);
|
||||
await this.auditLog.record({
|
||||
entityType: 'AuthSession',
|
||||
entityId: user.id,
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import { NormalizeEmail } from '../../common/validation/normalize-email';
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty()
|
||||
@NormalizeEmail()
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
// Long enough for any real password; bounds the bcrypt work an
|
||||
// anonymous request can cause.
|
||||
@MaxLength(256)
|
||||
password!: string;
|
||||
}
|
||||
@@ -2,16 +2,19 @@ import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { PermissionResolverService } from '../permissions/permission-resolver.service';
|
||||
import { ApiKeysService } from '../../api-keys/api-keys.service';
|
||||
|
||||
describe('JwtAuthGuard', () => {
|
||||
let guard: JwtAuthGuard;
|
||||
let reflector: { getAllAndOverride: jest.Mock };
|
||||
let permissionResolver: { isTokenValid: jest.Mock };
|
||||
let apiKeys: { authenticate: jest.Mock };
|
||||
|
||||
function contextWithUser(
|
||||
user: { id: string; tokenIssuedAt?: number } | undefined,
|
||||
authorization?: string,
|
||||
): ExecutionContext {
|
||||
const request = { user };
|
||||
const request = { user, headers: { authorization } };
|
||||
return {
|
||||
getHandler: () => ({}),
|
||||
getClass: () => ({}),
|
||||
@@ -22,9 +25,11 @@ describe('JwtAuthGuard', () => {
|
||||
beforeEach(() => {
|
||||
reflector = { getAllAndOverride: jest.fn().mockReturnValue(false) };
|
||||
permissionResolver = { isTokenValid: jest.fn() };
|
||||
apiKeys = { authenticate: jest.fn() };
|
||||
guard = new JwtAuthGuard(
|
||||
reflector as unknown as Reflector,
|
||||
permissionResolver as unknown as PermissionResolverService,
|
||||
apiKeys as unknown as ApiKeysService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -72,4 +77,27 @@ describe('JwtAuthGuard', () => {
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('authenticates an API key as a machine principal, not as a user', async () => {
|
||||
const principal = { id: 'key-1', scopes: ['shop-sync:read'] };
|
||||
apiKeys.authenticate.mockResolvedValue(principal);
|
||||
const context = contextWithUser(undefined, 'Bearer ack_abcdefgh_secret');
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
apiKey?: unknown;
|
||||
user?: unknown;
|
||||
}>();
|
||||
expect(request.apiKey).toBe(principal);
|
||||
expect(request.user).toBeUndefined();
|
||||
expect(permissionResolver.isTokenValid).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an unknown, revoked or expired API key', async () => {
|
||||
apiKeys.authenticate.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
guard.canActivate(contextWithUser(undefined, 'Bearer ack_abcdefgh_x')),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,17 @@ import { AuthGuard } from '@nestjs/passport';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
import { PermissionResolverService } from '../permissions/permission-resolver.service';
|
||||
import { AuthenticatedUser } from '../types/authenticated-user';
|
||||
import {
|
||||
ApiKeyPrincipal,
|
||||
ApiKeysService,
|
||||
extractApiKeyToken,
|
||||
} from '../../api-keys/api-keys.service';
|
||||
|
||||
interface RequestWithUser {
|
||||
headers: { authorization?: string };
|
||||
ip?: string;
|
||||
user?: AuthenticatedUser;
|
||||
apiKey?: ApiKeyPrincipal;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,6 +36,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
private readonly apiKeys: ApiKeysService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -40,11 +49,29 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||
|
||||
// An API key ("Bearer ack_…") authenticates a machine, not a user: no
|
||||
// request.user is set, so it can never pass a user-only route, and
|
||||
// PermissionsGuard checks its scopes instead of a role.
|
||||
const apiKeyToken = extractApiKeyToken(request.headers.authorization);
|
||||
if (apiKeyToken !== null) {
|
||||
// request.ip honours TRUST_PROXY (see app.config.ts).
|
||||
const principal = await this.apiKeys.authenticate(
|
||||
apiKeyToken,
|
||||
request.ip,
|
||||
);
|
||||
if (!principal) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
request.apiKey = principal;
|
||||
return true;
|
||||
}
|
||||
|
||||
const authenticated = (await super.canActivate(context)) as boolean;
|
||||
if (!authenticated) {
|
||||
return false;
|
||||
}
|
||||
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||
const user = request.user;
|
||||
if (
|
||||
!user ||
|
||||
|
||||
@@ -6,9 +6,11 @@ import { REQUIRE_PERMISSION_KEY } from '../decorators/require-permission.decorat
|
||||
import { PermissionKey } from '../permissions/permission-catalog';
|
||||
import { PermissionResolverService } from '../permissions/permission-resolver.service';
|
||||
import { AuthenticatedUser } from '../types/authenticated-user';
|
||||
import type { ApiKeyPrincipal } from '../../api-keys/api-keys.service';
|
||||
|
||||
interface RequestWithUser {
|
||||
user?: AuthenticatedUser;
|
||||
apiKey?: ApiKeyPrincipal;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,6 +49,15 @@ export class PermissionsGuard implements CanActivate {
|
||||
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||
const user = request.user;
|
||||
|
||||
// API keys pass only routes that name a permission the key carries -
|
||||
// never @AnyAuthenticatedUser routes, which are meant for people.
|
||||
if (request.apiKey) {
|
||||
const scopes: readonly string[] = request.apiKey.scopes;
|
||||
return Boolean(
|
||||
requiredPermissions?.some((permission) => scopes.includes(permission)),
|
||||
);
|
||||
}
|
||||
|
||||
if (requiredPermissions && requiredPermissions.length > 0) {
|
||||
if (!user) {
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AuditLogService } from '../../audit/audit-log.service';
|
||||
import { MfaService } from './mfa.service';
|
||||
|
||||
/** After this many failed attempts in a row the account is locked ... */
|
||||
export const MAX_FAILED_AUTH_ATTEMPTS = 5;
|
||||
/** ... for this long. */
|
||||
export const AUTH_LOCK_MS = 15 * 60 * 1000;
|
||||
|
||||
export interface GuardedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
mfaSecret: string | null;
|
||||
mfaLastTimeStep: number | null;
|
||||
authLockedUntil: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Account-level brute-force protection, on top of the per-IP throttle:
|
||||
* an attacker spreading guesses over many addresses still only gets five
|
||||
* tries per account per 15 minutes - for the password, the MFA code and
|
||||
* step-up confirmations alike. A TOTP code is accepted once: its time step
|
||||
* is recorded, and that step or any earlier one is refused afterwards.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AccountGuardService {
|
||||
private readonly logger = new Logger(AccountGuardService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mfaService: MfaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
) {}
|
||||
|
||||
isLocked(user: Pick<GuardedUser, 'authLockedUntil'>): boolean {
|
||||
return Boolean(user.authLockedUntil && user.authLockedUntil > new Date());
|
||||
}
|
||||
|
||||
async recordFailure(userId: string): Promise<void> {
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { failedAuthAttempts: { increment: 1 } },
|
||||
select: { failedAuthAttempts: true },
|
||||
});
|
||||
if (updated.failedAuthAttempts >= MAX_FAILED_AUTH_ATTEMPTS) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
authLockedUntil: new Date(Date.now() + AUTH_LOCK_MS),
|
||||
failedAuthAttempts: 0,
|
||||
},
|
||||
});
|
||||
this.logger.warn(`Account ${userId} locked after repeated failures`);
|
||||
await this.auditLog.record({
|
||||
entityType: 'AuthSession',
|
||||
entityId: userId,
|
||||
action: 'ACCOUNT_LOCKED',
|
||||
userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async recordSuccess(userId: string): Promise<void> {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { failedAuthAttempts: 0, authLockedUntil: null },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* True only for a valid code whose time step is newer than the last one
|
||||
* used - and only for one of two concurrent requests with the same code.
|
||||
*/
|
||||
async consumeTotp(user: GuardedUser, code: string): Promise<boolean> {
|
||||
if (!user.mfaSecret) {
|
||||
return false;
|
||||
}
|
||||
const timeStep = await this.mfaService.verifyCodeStep(
|
||||
user.mfaSecret,
|
||||
user.email,
|
||||
code,
|
||||
user.mfaLastTimeStep ?? undefined,
|
||||
);
|
||||
if (timeStep === null) {
|
||||
return false;
|
||||
}
|
||||
const claimed = await this.prisma.user.updateMany({
|
||||
where: {
|
||||
id: user.id,
|
||||
OR: [{ mfaLastTimeStep: null }, { mfaLastTimeStep: { lt: timeStep } }],
|
||||
},
|
||||
data: { mfaLastTimeStep: timeStep },
|
||||
});
|
||||
return claimed.count === 1;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { MfaService } from './mfa.service';
|
||||
import { AccountGuardService } from './account-guard.service';
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule.register({})],
|
||||
providers: [MfaService],
|
||||
exports: [MfaService],
|
||||
providers: [MfaService, AccountGuardService],
|
||||
exports: [MfaService, AccountGuardService],
|
||||
})
|
||||
export class MfaModule {}
|
||||
@@ -139,10 +139,13 @@ describe('MfaService', () => {
|
||||
jwtService.verify.mockReturnValue({
|
||||
sub: 'user-1',
|
||||
purpose: 'mfa-challenge',
|
||||
jti: 'jti-1',
|
||||
exp: 1_900_000_000,
|
||||
});
|
||||
|
||||
expect(mfaService.verifyChallengeToken('token')).toEqual({
|
||||
userId: 'user-1',
|
||||
claims: { jti: 'jti-1', exp: 1_900_000_000 },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,18 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as QRCode from 'qrcode';
|
||||
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
|
||||
import { buildTotpUri, generateTotpSecret, verifyTotpCode } from './totp.util';
|
||||
import {
|
||||
buildTotpUri,
|
||||
generateTotpSecret,
|
||||
verifyTotpCode,
|
||||
verifyTotpStep,
|
||||
} from './totp.util';
|
||||
import {
|
||||
OneTimeTokenClaims,
|
||||
TOKEN_AUDIENCE,
|
||||
jwtSignOptions,
|
||||
jwtVerifyOptions,
|
||||
} from '../util/jwt-claims';
|
||||
|
||||
const SETUP_TOKEN_TTL = '10m';
|
||||
const CHALLENGE_TOKEN_TTL = '5m';
|
||||
@@ -41,7 +52,7 @@ export class MfaService {
|
||||
});
|
||||
const setupToken = this.jwtService.sign(
|
||||
{ sub: userId, secret, purpose: SETUP_TOKEN_PURPOSE },
|
||||
{ secret: process.env.JWT_ACCESS_SECRET, expiresIn: SETUP_TOKEN_TTL },
|
||||
jwtSignOptions(TOKEN_AUDIENCE.mfaSetup, SETUP_TOKEN_TTL),
|
||||
);
|
||||
return { secret, qrCodeSvg, otpauthUri, setupToken };
|
||||
}
|
||||
@@ -55,9 +66,10 @@ export class MfaService {
|
||||
): Promise<{ encryptedSecret: string }> {
|
||||
let payload: SetupTokenPayload;
|
||||
try {
|
||||
payload = this.jwtService.verify<SetupTokenPayload>(setupToken, {
|
||||
secret: process.env.JWT_ACCESS_SECRET,
|
||||
});
|
||||
payload = this.jwtService.verify<SetupTokenPayload>(
|
||||
setupToken,
|
||||
jwtVerifyOptions(TOKEN_AUDIENCE.mfaSetup),
|
||||
);
|
||||
} catch {
|
||||
throw new UnauthorizedException(
|
||||
'Setup-Token ungültig oder abgelaufen, MFA-Einrichtung neu starten',
|
||||
@@ -76,16 +88,19 @@ export class MfaService {
|
||||
signChallengeToken(userId: string): string {
|
||||
return this.jwtService.sign(
|
||||
{ sub: userId, purpose: CHALLENGE_TOKEN_PURPOSE },
|
||||
{ secret: process.env.JWT_ACCESS_SECRET, expiresIn: CHALLENGE_TOKEN_TTL },
|
||||
jwtSignOptions(TOKEN_AUDIENCE.mfaChallenge, CHALLENGE_TOKEN_TTL),
|
||||
);
|
||||
}
|
||||
|
||||
verifyChallengeToken(challengeToken: string): { userId: string } {
|
||||
let payload: ChallengeTokenPayload;
|
||||
verifyChallengeToken(challengeToken: string): {
|
||||
userId: string;
|
||||
claims: OneTimeTokenClaims;
|
||||
} {
|
||||
let payload: ChallengeTokenPayload & OneTimeTokenClaims;
|
||||
try {
|
||||
payload = this.jwtService.verify<ChallengeTokenPayload>(challengeToken, {
|
||||
secret: process.env.JWT_ACCESS_SECRET,
|
||||
});
|
||||
payload = this.jwtService.verify<
|
||||
ChallengeTokenPayload & OneTimeTokenClaims
|
||||
>(challengeToken, jwtVerifyOptions(TOKEN_AUDIENCE.mfaChallenge));
|
||||
} catch {
|
||||
throw new UnauthorizedException(
|
||||
'Anmeldung abgelaufen, bitte erneut mit E-Mail und Passwort anmelden',
|
||||
@@ -94,7 +109,25 @@ export class MfaService {
|
||||
if (payload.purpose !== CHALLENGE_TOKEN_PURPOSE) {
|
||||
throw new UnauthorizedException('Ungültiger Token');
|
||||
}
|
||||
return { userId: payload.sub };
|
||||
return {
|
||||
userId: payload.sub,
|
||||
claims: { jti: payload.jti, exp: payload.exp },
|
||||
};
|
||||
}
|
||||
|
||||
/** See verifyTotpStep; use AccountGuardService.consumeTotp for logins. */
|
||||
async verifyCodeStep(
|
||||
encryptedSecret: string,
|
||||
email: string,
|
||||
code: string,
|
||||
afterTimeStep?: number,
|
||||
): Promise<number | null> {
|
||||
return verifyTotpStep(
|
||||
decryptMfaSecret(encryptedSecret),
|
||||
email,
|
||||
code,
|
||||
afterTimeStep,
|
||||
);
|
||||
}
|
||||
|
||||
async verifyCode(
|
||||
|
||||
@@ -20,6 +20,23 @@ export function buildTotpUri(secret: string, label: string): string {
|
||||
return createTotp(secret, label).toURI();
|
||||
}
|
||||
|
||||
/**
|
||||
* The time step a code belongs to, or null if it is wrong - or not newer
|
||||
* than afterTimeStep (a code that was already used, or an older one).
|
||||
*/
|
||||
export async function verifyTotpStep(
|
||||
secret: string,
|
||||
label: string,
|
||||
code: string,
|
||||
afterTimeStep?: number,
|
||||
): Promise<number | null> {
|
||||
const result = await createTotp(secret, label).verify(code, {
|
||||
epochTolerance: EPOCH_TOLERANCE_SECONDS,
|
||||
afterTimeStep,
|
||||
});
|
||||
return result.valid ? result.timeStep : null;
|
||||
}
|
||||
|
||||
export async function verifyTotpCode(
|
||||
secret: string,
|
||||
label: string,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { OneTimeTokenClaims } from './util/jwt-claims';
|
||||
|
||||
/**
|
||||
* Makes a short-lived helper token usable exactly once (SSO completion,
|
||||
* MFA challenge after a successful code): its id is recorded, and a
|
||||
* second presentation - a replayed request, a copied cookie - is refused.
|
||||
* Works across replicas because the record lives in the database.
|
||||
*/
|
||||
@Injectable()
|
||||
export class OneTimeTokenService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async consume(claims: OneTimeTokenClaims): Promise<void> {
|
||||
if (!claims.jti || !claims.exp) {
|
||||
throw new UnauthorizedException(
|
||||
'Anmeldung abgelaufen, bitte erneut anmelden',
|
||||
);
|
||||
}
|
||||
try {
|
||||
await this.prisma.consumedToken.create({
|
||||
data: { jti: claims.jti, expiresAt: new Date(claims.exp * 1000) },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002'
|
||||
) {
|
||||
throw new UnauthorizedException(
|
||||
'Diese Anmeldung wurde bereits abgeschlossen, bitte erneut anmelden',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { PERMISSION_CATALOG } from './permission-catalog';
|
||||
describe('PermissionCatalogSyncService', () => {
|
||||
let service: PermissionCatalogSyncService;
|
||||
let prisma: {
|
||||
permission: { findMany: jest.Mock; create: jest.Mock };
|
||||
permission: { findMany: jest.Mock; createMany: jest.Mock };
|
||||
role: { findMany: jest.Mock };
|
||||
rolePermission: { createMany: jest.Mock };
|
||||
};
|
||||
@@ -13,10 +13,10 @@ describe('PermissionCatalogSyncService', () => {
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
permission: {
|
||||
// 1st call: existing keys (set per test). 2nd call: ids of the
|
||||
// freshly inserted keys, derived from the `where.key.in` filter.
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn((args: { data: { key: string } }) =>
|
||||
Promise.resolve({ id: `perm-${args.data.key}`, ...args.data }),
|
||||
),
|
||||
createMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
role: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
rolePermission: { createMany: jest.fn().mockResolvedValue({ count: 0 }) },
|
||||
@@ -33,16 +33,16 @@ describe('PermissionCatalogSyncService', () => {
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(prisma.permission.create).not.toHaveBeenCalled();
|
||||
expect(prisma.permission.createMany).not.toHaveBeenCalled();
|
||||
expect(prisma.role.findMany).not.toHaveBeenCalled();
|
||||
expect(prisma.rolePermission.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('inserts missing catalog permissions and grants them to every system role', async () => {
|
||||
const [first, ...rest] = PERMISSION_CATALOG;
|
||||
prisma.permission.findMany.mockResolvedValue(
|
||||
rest.map((entry) => ({ key: entry.key })),
|
||||
);
|
||||
prisma.permission.findMany
|
||||
.mockResolvedValueOnce(rest.map((entry) => ({ key: entry.key })))
|
||||
.mockResolvedValueOnce([{ id: `perm-${first.key}` }]);
|
||||
prisma.role.findMany.mockResolvedValue([
|
||||
{ id: 'role-admin' },
|
||||
{ id: 'role-owner' },
|
||||
@@ -50,7 +50,10 @@ describe('PermissionCatalogSyncService', () => {
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(prisma.permission.create).toHaveBeenCalledWith({ data: first });
|
||||
expect(prisma.permission.createMany).toHaveBeenCalledWith({
|
||||
data: [first],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
expect(prisma.role.findMany).toHaveBeenCalledWith({
|
||||
where: { isSystem: true },
|
||||
select: { id: true },
|
||||
@@ -70,9 +73,26 @@ describe('PermissionCatalogSyncService', () => {
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(prisma.permission.create).toHaveBeenCalledTimes(
|
||||
PERMISSION_CATALOG.length,
|
||||
);
|
||||
expect(prisma.permission.createMany).toHaveBeenCalledWith({
|
||||
data: PERMISSION_CATALOG,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
expect(prisma.rolePermission.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tolerates another replica inserting the same permissions concurrently', async () => {
|
||||
// Both replicas saw the permission as missing; the other one won the
|
||||
// insert, so createMany skips it instead of throwing a unique error.
|
||||
const [first, ...rest] = PERMISSION_CATALOG;
|
||||
prisma.permission.findMany
|
||||
.mockResolvedValueOnce(rest.map((entry) => ({ key: entry.key })))
|
||||
.mockResolvedValueOnce([{ id: `perm-${first.key}` }]);
|
||||
prisma.role.findMany.mockResolvedValue([{ id: 'role-admin' }]);
|
||||
|
||||
await expect(service.onModuleInit()).resolves.toBeUndefined();
|
||||
expect(prisma.rolePermission.createMany).toHaveBeenCalledWith({
|
||||
data: [{ roleId: 'role-admin', permissionId: `perm-${first.key}` }],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -33,10 +33,18 @@ export class PermissionCatalogSyncService implements OnModuleInit {
|
||||
const missing = PERMISSION_CATALOG.filter(
|
||||
(entry) => !existingKeys.has(entry.key),
|
||||
);
|
||||
const createdPermissions = await Promise.all(
|
||||
missing.map((entry) => this.prisma.permission.create({ data: entry })),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
// skipDuplicates: several replicas boot at once against the same
|
||||
// database and all see the same "missing" set. Plain creates made
|
||||
// every replica but the first crash on a unique-key error.
|
||||
await this.prisma.permission.createMany({
|
||||
data: missing,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
const createdPermissions = await this.prisma.permission.findMany({
|
||||
where: { key: { in: missing.map((entry) => entry.key) } },
|
||||
select: { id: true },
|
||||
});
|
||||
this.logger.log(
|
||||
`Synced ${missing.length} new permission(s) from the code catalog: ${missing
|
||||
.map((entry) => entry.key)
|
||||
|
||||
@@ -111,6 +111,18 @@ export const PERMISSION_CATALOG = [
|
||||
],
|
||||
800,
|
||||
),
|
||||
// Seeded with role grants by the winter_checks migration (everyday records
|
||||
// like inspections, not an admin-only area).
|
||||
...group(
|
||||
'Überwinterung',
|
||||
'winter-checks',
|
||||
[
|
||||
['read', 'Ansehen'],
|
||||
['create', 'Anlegen'],
|
||||
['update', 'Bearbeiten'],
|
||||
],
|
||||
850,
|
||||
),
|
||||
...group(
|
||||
'Behandlungen',
|
||||
'treatments',
|
||||
@@ -191,9 +203,35 @@ export const PERMISSION_CATALOG = [
|
||||
['read', 'Ansehen'],
|
||||
['create', 'Anlegen'],
|
||||
['update', 'Bearbeiten'],
|
||||
['delete', 'Löschen'],
|
||||
['withdraw', 'Entnehmen und Inventur'],
|
||||
],
|
||||
1600,
|
||||
),
|
||||
...group(
|
||||
'Rohstoffe',
|
||||
'raw-material',
|
||||
[
|
||||
['read', 'Ansehen'],
|
||||
['create', 'Anlegen'],
|
||||
['update', 'Bearbeiten'],
|
||||
['delete', 'Löschen'],
|
||||
['withdraw', 'Entnehmen und Inventur'],
|
||||
],
|
||||
1630,
|
||||
),
|
||||
...group(
|
||||
'Herstellungschargen',
|
||||
'production-batch',
|
||||
[
|
||||
['read', 'Ansehen'],
|
||||
['create', 'Anlegen'],
|
||||
['update', 'Bearbeiten'],
|
||||
['delete', 'Löschen'],
|
||||
['withdraw', 'Entnehmen und Inventur'],
|
||||
],
|
||||
1650,
|
||||
),
|
||||
...group(
|
||||
'Etiketten',
|
||||
'label-templates',
|
||||
@@ -222,6 +260,7 @@ export const PERMISSION_CATALOG = [
|
||||
[
|
||||
['read', 'Ansehen'],
|
||||
['upload', 'Hochladen'],
|
||||
['update', 'Bearbeiten'],
|
||||
['delete', 'Löschen'],
|
||||
],
|
||||
1800,
|
||||
@@ -232,6 +271,7 @@ export const PERMISSION_CATALOG = [
|
||||
[
|
||||
['read', 'Ansehen'],
|
||||
['create', 'Anlegen'],
|
||||
['update', 'Bearbeiten'],
|
||||
['delete', 'Löschen'],
|
||||
],
|
||||
1900,
|
||||
@@ -303,6 +343,33 @@ export const PERMISSION_CATALOG = [
|
||||
],
|
||||
3200,
|
||||
),
|
||||
// Deliberately separate from harvests:read etc.: those endpoints return
|
||||
// internal notes and full records. The shop sync account needs only the
|
||||
// allowlisted feed, so it gets this one permission and nothing else.
|
||||
...group(
|
||||
'Shop-Synchronisation',
|
||||
'shop-sync',
|
||||
[
|
||||
['read', 'Freigegebene Daten für den Shop lesen'],
|
||||
['write', 'Verkäufe und Rückbuchungen des Shops buchen'],
|
||||
],
|
||||
3300,
|
||||
),
|
||||
...group(
|
||||
'API-Schlüssel',
|
||||
'api-keys',
|
||||
[['manage', 'Anlegen, ansehen und widerrufen']],
|
||||
3400,
|
||||
),
|
||||
// Like shop-sync: a narrow read-only allowlist for one machine consumer
|
||||
// (ERPNext's nightly inventory mirror), separate from products:read etc.,
|
||||
// which return descriptions and full records.
|
||||
...group(
|
||||
'ERP-Anbindung',
|
||||
'erp-inventory',
|
||||
[['read', 'Inventar-Spiegel für ERPNext lesen']],
|
||||
3500,
|
||||
),
|
||||
] as const satisfies readonly PermissionCatalogEntry[];
|
||||
|
||||
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
|
||||
@@ -165,15 +165,34 @@ describe('PermissionResolverService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('shares the cached entry with getPermissions instead of re-querying', async () => {
|
||||
it('reads fresh every time and refreshes the cache for getPermissions', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue(
|
||||
activeUserWithPermissions('colonies:read'),
|
||||
);
|
||||
|
||||
await resolver.getPermissions('user-1');
|
||||
await resolver.isTokenValid('user-1', nowSeconds);
|
||||
await resolver.getPermissions('user-1');
|
||||
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
|
||||
// One read for the first getPermissions, one fresh read for the
|
||||
// validity check - the second getPermissions uses that fresh entry.
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('sees a deactivation made elsewhere on the next request', async () => {
|
||||
prisma.user.findUnique.mockResolvedValueOnce(
|
||||
activeUserWithPermissions('colonies:read'),
|
||||
);
|
||||
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
prisma.user.findUnique.mockResolvedValueOnce({
|
||||
...activeUserWithPermissions('colonies:read'),
|
||||
isActive: false,
|
||||
});
|
||||
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -37,11 +37,18 @@ export class PermissionResolverService {
|
||||
return entry.permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Always read fresh (one primary-key lookup) and stored as the cache
|
||||
* entry the PermissionsGuard then uses: a deactivation, role change or
|
||||
* password change made on ANOTHER replica takes effect on the very next
|
||||
* request here, not after the cache window.
|
||||
*/
|
||||
async isTokenValid(
|
||||
userId: string,
|
||||
tokenIssuedAtSeconds: number,
|
||||
): Promise<boolean> {
|
||||
const entry = await this.getEntry(userId);
|
||||
const entry = await this.loadEntry(userId);
|
||||
this.cache.set(userId, entry);
|
||||
if (!entry.isActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MfaModule } from '../mfa/mfa.module';
|
||||
import { ReauthService } from './reauth.service';
|
||||
|
||||
@Module({
|
||||
imports: [MfaModule],
|
||||
providers: [ReauthService],
|
||||
exports: [ReauthService],
|
||||
})
|
||||
export class ReauthModule {}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AccountGuardService } from '../mfa/account-guard.service';
|
||||
|
||||
export interface ReauthProof {
|
||||
password?: string;
|
||||
mfaCode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step-up check for actions that are as powerful as the account itself
|
||||
* (restoring a backup overwrites every user): a stolen access token alone
|
||||
* must not be enough, so the caller proves they know the password - and,
|
||||
* with MFA on, holds the second factor - right now.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ReauthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly accountGuard: AccountGuardService,
|
||||
) {}
|
||||
|
||||
async verify(userId: string, proof: ReauthProof): Promise<void> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
passwordHash: true,
|
||||
mfaEnabled: true,
|
||||
mfaSecret: true,
|
||||
mfaLastTimeStep: true,
|
||||
authLockedUntil: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
if (!user || !user.isActive || this.accountGuard.isLocked(user)) {
|
||||
throw new UnauthorizedException(
|
||||
'Bestätigung derzeit nicht möglich. Nach mehreren Fehlversuchen ist sie für 15 Minuten gesperrt.',
|
||||
);
|
||||
}
|
||||
const passwordMatches =
|
||||
typeof proof.password === 'string' &&
|
||||
(await bcrypt.compare(proof.password, user.passwordHash));
|
||||
if (!passwordMatches) {
|
||||
await this.accountGuard.recordFailure(user.id);
|
||||
throw new UnauthorizedException(
|
||||
'Bitte zur Bestätigung Ihr aktuelles Passwort eingeben',
|
||||
);
|
||||
}
|
||||
if (user.mfaEnabled) {
|
||||
const codeMatches =
|
||||
typeof proof.mfaCode === 'string' &&
|
||||
(await this.accountGuard.consumeTotp(user, proof.mfaCode));
|
||||
if (!codeMatches) {
|
||||
await this.accountGuard.recordFailure(user.id);
|
||||
throw new UnauthorizedException(
|
||||
'Bitte zur Bestätigung den aktuellen Code aus Ihrer Authenticator-App eingeben',
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.accountGuard.recordSuccess(user.id);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { MfaRequiredResponse } from '../auth.controller';
|
||||
import { AccessTokenDto } from '../dto/access-token.dto';
|
||||
import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle';
|
||||
import { setRefreshTokenCookie } from '../util/refresh-token-cookie';
|
||||
import { OneTimeTokenService } from '../one-time-token.service';
|
||||
import { SsoService } from './sso.service';
|
||||
|
||||
const PKCE_COOKIE = 'oidcPkceVerifier';
|
||||
@@ -40,6 +41,7 @@ export class SsoController {
|
||||
constructor(
|
||||
private readonly ssoService: SsoService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly oneTimeTokens: OneTimeTokenService,
|
||||
) {}
|
||||
|
||||
private frontendLoginUrl(): string {
|
||||
@@ -82,7 +84,7 @@ export class SsoController {
|
||||
const rawCookie = (req.cookies as Record<string, string> | undefined)?.[
|
||||
PKCE_COOKIE
|
||||
];
|
||||
res.clearCookie(PKCE_COOKIE, { path: PKCE_COOKIE_PATH });
|
||||
res.clearCookie(PKCE_COOKIE, this.cookieOptions(PKCE_COOKIE_PATH, 0));
|
||||
|
||||
const pkce = this.parsePkceCookie(rawCookie);
|
||||
if (!pkce) {
|
||||
@@ -90,10 +92,10 @@ export class SsoController {
|
||||
return;
|
||||
}
|
||||
|
||||
const callbackUrl = new URL(
|
||||
req.originalUrl,
|
||||
`${req.protocol}://${req.get('host')}`,
|
||||
);
|
||||
// Built from the configured redirect URI, not from the Host header a
|
||||
// client sends; only the query (code, state, iss) comes from the request.
|
||||
const callbackUrl = new URL(process.env.OIDC_REDIRECT_URI as string);
|
||||
callbackUrl.search = new URL(req.originalUrl, 'http://placeholder').search;
|
||||
const result = await this.ssoService.handleCallback(
|
||||
callbackUrl,
|
||||
pkce.codeVerifier,
|
||||
@@ -126,13 +128,17 @@ export class SsoController {
|
||||
const ssoCode = (req.cookies as Record<string, string> | undefined)?.[
|
||||
SSO_CODE_COOKIE
|
||||
];
|
||||
res.clearCookie(SSO_CODE_COOKIE, { path: SSO_CODE_COOKIE_PATH });
|
||||
res.clearCookie(
|
||||
SSO_CODE_COOKIE,
|
||||
this.cookieOptions(SSO_CODE_COOKIE_PATH, 0),
|
||||
);
|
||||
if (!ssoCode) {
|
||||
throw new UnauthorizedException(
|
||||
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
|
||||
);
|
||||
}
|
||||
const { userId } = this.ssoService.verifySsoCode(ssoCode);
|
||||
const { userId, claims } = this.ssoService.verifySsoCode(ssoCode);
|
||||
await this.oneTimeTokens.consume(claims);
|
||||
const result = await this.authService.issueTokensForSsoUser(userId);
|
||||
if (result.mfaRequired) {
|
||||
return { mfaRequired: true, mfaToken: result.mfaToken };
|
||||
|
||||
@@ -18,9 +18,26 @@ jest.mock('openid-client', () => ({
|
||||
|
||||
const mockedClient = client as jest.Mocked<typeof client>;
|
||||
|
||||
const IDENTITY = {
|
||||
iss: 'https://login.microsoftonline.com/tenant/v2.0',
|
||||
sub: 'pairwise-sub',
|
||||
oid: 'object-id-1',
|
||||
};
|
||||
const SSO_SUBJECT = `${IDENTITY.iss}|${IDENTITY.oid}`;
|
||||
|
||||
function grantWithClaims(claims: Record<string, unknown>) {
|
||||
return {
|
||||
claims: () => claims,
|
||||
} as unknown as Awaited<ReturnType<typeof client.authorizationCodeGrant>>;
|
||||
}
|
||||
|
||||
describe('SsoService', () => {
|
||||
let ssoService: SsoService;
|
||||
let usersService: { findByEmail: jest.Mock };
|
||||
let usersService: {
|
||||
findByEmail: jest.Mock;
|
||||
findBySsoSubject: jest.Mock;
|
||||
linkSsoSubject: jest.Mock;
|
||||
};
|
||||
let jwtService: { sign: jest.Mock; verify: jest.Mock };
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
@@ -33,7 +50,11 @@ describe('SsoService', () => {
|
||||
process.env.OIDC_REDIRECT_URI =
|
||||
'https://api.example.com/api/v1/auth/sso/callback';
|
||||
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret';
|
||||
usersService = { findByEmail: jest.fn() };
|
||||
usersService = {
|
||||
findByEmail: jest.fn(),
|
||||
findBySsoSubject: jest.fn().mockResolvedValue(null),
|
||||
linkSsoSubject: jest.fn(),
|
||||
};
|
||||
jwtService = { sign: jest.fn(), verify: jest.fn() };
|
||||
ssoService = new SsoService(
|
||||
usersService as unknown as UsersService,
|
||||
@@ -50,6 +71,12 @@ describe('SsoService', () => {
|
||||
expect(ssoService.isConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a multi-tenant issuer', () => {
|
||||
process.env.OIDC_ISSUER_URL =
|
||||
'https://login.microsoftonline.com/common/v2.0';
|
||||
expect(ssoService.isConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when a variable is missing', () => {
|
||||
delete process.env.OIDC_CLIENT_SECRET;
|
||||
expect(ssoService.isConfigured()).toBe(false);
|
||||
@@ -116,7 +143,7 @@ describe('SsoService', () => {
|
||||
|
||||
it('returns no_email when the ID token has neither email nor preferred_username', async () => {
|
||||
mockedClient.authorizationCodeGrant.mockResolvedValue({
|
||||
claims: () => ({ sub: 'abc' }),
|
||||
claims: () => ({ ...IDENTITY }),
|
||||
} as unknown as Awaited<
|
||||
ReturnType<typeof client.authorizationCodeGrant>
|
||||
>);
|
||||
@@ -132,7 +159,7 @@ describe('SsoService', () => {
|
||||
|
||||
it('returns no_account when no active local user matches the email claim', async () => {
|
||||
mockedClient.authorizationCodeGrant.mockResolvedValue({
|
||||
claims: () => ({ email: '[email protected]' }),
|
||||
claims: () => ({ ...IDENTITY, email: '[email protected]' }),
|
||||
} as unknown as Awaited<
|
||||
ReturnType<typeof client.authorizationCodeGrant>
|
||||
>);
|
||||
@@ -149,7 +176,10 @@ describe('SsoService', () => {
|
||||
|
||||
it('signs a short-lived sso-complete token for an active matching user', async () => {
|
||||
mockedClient.authorizationCodeGrant.mockResolvedValue({
|
||||
claims: () => ({ preferred_username: '[email protected]' }),
|
||||
claims: () => ({
|
||||
...IDENTITY,
|
||||
preferred_username: '[email protected]',
|
||||
}),
|
||||
} as unknown as Awaited<
|
||||
ReturnType<typeof client.authorizationCodeGrant>
|
||||
>);
|
||||
@@ -175,6 +205,56 @@ describe('SsoService', () => {
|
||||
expect.objectContaining({ sub: 'user-1', purpose: 'sso-complete' }),
|
||||
expect.objectContaining({ expiresIn: '60s' }),
|
||||
);
|
||||
expect(usersService.linkSsoSubject).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
SSO_SUBJECT,
|
||||
);
|
||||
});
|
||||
|
||||
it('signs in a linked account by its identity, whatever the email claim says', async () => {
|
||||
mockedClient.authorizationCodeGrant.mockResolvedValue(
|
||||
grantWithClaims({ ...IDENTITY, email: '[email protected]' }),
|
||||
);
|
||||
usersService.findBySsoSubject.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
isActive: true,
|
||||
});
|
||||
jwtService.sign.mockReturnValue('signed-sso-code');
|
||||
|
||||
const result = await ssoService.handleCallback(
|
||||
new URL('https://api.example.com/callback?code=x'),
|
||||
'verifier',
|
||||
'random-state',
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: 'success', ssoCode: 'signed-sso-code' });
|
||||
expect(usersService.findBySsoSubject).toHaveBeenCalledWith(SSO_SUBJECT);
|
||||
expect(usersService.findByEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a matching email when the account is linked to another identity', async () => {
|
||||
mockedClient.authorizationCodeGrant.mockResolvedValue(
|
||||
grantWithClaims({
|
||||
...IDENTITY,
|
||||
oid: 'attacker-object-id',
|
||||
email: '[email protected]',
|
||||
}),
|
||||
);
|
||||
usersService.findByEmail.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
isActive: true,
|
||||
ssoSubject: SSO_SUBJECT,
|
||||
});
|
||||
|
||||
const result = await ssoService.handleCallback(
|
||||
new URL('https://api.example.com/callback?code=x'),
|
||||
'verifier',
|
||||
'random-state',
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: 'error', reason: 'no_account' });
|
||||
expect(jwtService.sign).not.toHaveBeenCalled();
|
||||
expect(usersService.linkSsoSubject).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,8 +263,13 @@ describe('SsoService', () => {
|
||||
jwtService.verify.mockReturnValue({
|
||||
sub: 'user-1',
|
||||
purpose: 'sso-complete',
|
||||
jti: 'jti-1',
|
||||
exp: 1_900_000_000,
|
||||
});
|
||||
expect(ssoService.verifySsoCode('token')).toEqual({
|
||||
userId: 'user-1',
|
||||
claims: { jti: 'jti-1', exp: 1_900_000_000 },
|
||||
});
|
||||
expect(ssoService.verifySsoCode('token')).toEqual({ userId: 'user-1' });
|
||||
});
|
||||
|
||||
it('throws UnauthorizedException when verification fails', () => {
|
||||
|
||||
@@ -7,6 +7,12 @@ import {
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as client from 'openid-client';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
import {
|
||||
OneTimeTokenClaims,
|
||||
TOKEN_AUDIENCE,
|
||||
jwtSignOptions,
|
||||
jwtVerifyOptions,
|
||||
} from '../util/jwt-claims';
|
||||
|
||||
const SSO_COMPLETE_TOKEN_TTL = '60s';
|
||||
const SSO_COMPLETE_TOKEN_PURPOSE = 'sso-complete';
|
||||
@@ -16,6 +22,21 @@ interface SsoCompleteTokenPayload {
|
||||
purpose: typeof SSO_COMPLETE_TOKEN_PURPOSE;
|
||||
}
|
||||
|
||||
// Entra's shared endpoints accept users of ANY tenant - with them, anyone
|
||||
// able to create an account in some tenant could present a matching email.
|
||||
const MULTI_TENANT_SEGMENTS = new Set(['common', 'organizations', 'consumers']);
|
||||
|
||||
function isSingleTenantIssuer(issuerUrl: string): boolean {
|
||||
try {
|
||||
const segments = new URL(issuerUrl).pathname.split('/').filter(Boolean);
|
||||
return !segments.some((segment) =>
|
||||
MULTI_TENANT_SEGMENTS.has(segment.toLowerCase()),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type SsoCallbackResult =
|
||||
| { status: 'success'; ssoCode: string }
|
||||
| { status: 'error'; reason: 'exchange_failed' | 'no_email' | 'no_account' };
|
||||
@@ -31,12 +52,22 @@ export class SsoService {
|
||||
) {}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return Boolean(
|
||||
process.env.OIDC_ISSUER_URL &&
|
||||
process.env.OIDC_CLIENT_ID &&
|
||||
process.env.OIDC_CLIENT_SECRET &&
|
||||
process.env.OIDC_REDIRECT_URI,
|
||||
const issuer = process.env.OIDC_ISSUER_URL;
|
||||
if (
|
||||
!issuer ||
|
||||
!process.env.OIDC_CLIENT_ID ||
|
||||
!process.env.OIDC_CLIENT_SECRET ||
|
||||
!process.env.OIDC_REDIRECT_URI
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!isSingleTenantIssuer(issuer)) {
|
||||
this.logger.error(
|
||||
'SSO disabled: OIDC_ISSUER_URL must name one tenant (not common/organizations/consumers)',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async getConfig(): Promise<client.Configuration> {
|
||||
@@ -93,6 +124,27 @@ export class SsoService {
|
||||
return { status: 'error', reason: 'exchange_failed' };
|
||||
}
|
||||
const claims = tokens.claims();
|
||||
// The account is bound to the IdP's immutable identity (issuer + object
|
||||
// id), not to the email claim: Entra does not verify email or
|
||||
// preferred_username, so a mutable claim alone must never pick the
|
||||
// account.
|
||||
const objectId =
|
||||
(claims?.oid as string | undefined) ?? claims?.sub ?? undefined;
|
||||
const ssoSubject =
|
||||
claims?.iss && objectId ? `${claims.iss}|${objectId}` : null;
|
||||
if (!ssoSubject) {
|
||||
this.logger.warn('SSO callback succeeded but ID token had no subject');
|
||||
return { status: 'error', reason: 'no_account' };
|
||||
}
|
||||
const linked = await this.usersService.findBySsoSubject(ssoSubject);
|
||||
if (linked) {
|
||||
if (!linked.isActive) {
|
||||
this.logger.warn(`SSO login denied: user ${linked.id} is deactivated`);
|
||||
return { status: 'error', reason: 'no_account' };
|
||||
}
|
||||
return { status: 'success', ssoCode: this.signSsoCode(linked.id) };
|
||||
}
|
||||
|
||||
const email =
|
||||
(claims?.email as string | undefined) ??
|
||||
(claims?.preferred_username as string | undefined);
|
||||
@@ -104,27 +156,38 @@ export class SsoService {
|
||||
}
|
||||
const user = await this.usersService.findByEmail(email);
|
||||
if (!user || !user.isActive) {
|
||||
this.logger.warn('SSO login denied: no active local account for email');
|
||||
return { status: 'error', reason: 'no_account' };
|
||||
}
|
||||
if (user.ssoSubject) {
|
||||
// Already bound to a different IdP identity: someone else is
|
||||
// presenting this email.
|
||||
this.logger.warn(
|
||||
`SSO login denied: no active local account for ${email}`,
|
||||
`SSO login denied: user ${user.id} is linked to another identity`,
|
||||
);
|
||||
return { status: 'error', reason: 'no_account' };
|
||||
}
|
||||
const ssoCode = this.jwtService.sign(
|
||||
{ sub: user.id, purpose: SSO_COMPLETE_TOKEN_PURPOSE },
|
||||
{
|
||||
secret: process.env.JWT_ACCESS_SECRET,
|
||||
expiresIn: SSO_COMPLETE_TOKEN_TTL,
|
||||
},
|
||||
);
|
||||
return { status: 'success', ssoCode };
|
||||
// First sign-in: bind the account to this identity for good.
|
||||
await this.usersService.linkSsoSubject(user.id, ssoSubject);
|
||||
return { status: 'success', ssoCode: this.signSsoCode(user.id) };
|
||||
}
|
||||
|
||||
verifySsoCode(ssoCode: string): { userId: string } {
|
||||
let payload: SsoCompleteTokenPayload;
|
||||
private signSsoCode(userId: string): string {
|
||||
return this.jwtService.sign(
|
||||
{ sub: userId, purpose: SSO_COMPLETE_TOKEN_PURPOSE },
|
||||
jwtSignOptions(TOKEN_AUDIENCE.ssoComplete, SSO_COMPLETE_TOKEN_TTL),
|
||||
);
|
||||
}
|
||||
|
||||
verifySsoCode(ssoCode: string): {
|
||||
userId: string;
|
||||
claims: OneTimeTokenClaims;
|
||||
} {
|
||||
let payload: SsoCompleteTokenPayload & OneTimeTokenClaims;
|
||||
try {
|
||||
payload = this.jwtService.verify<SsoCompleteTokenPayload>(ssoCode, {
|
||||
secret: process.env.JWT_ACCESS_SECRET,
|
||||
});
|
||||
payload = this.jwtService.verify<
|
||||
SsoCompleteTokenPayload & OneTimeTokenClaims
|
||||
>(ssoCode, jwtVerifyOptions(TOKEN_AUDIENCE.ssoComplete));
|
||||
} catch {
|
||||
throw new UnauthorizedException(
|
||||
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
|
||||
@@ -133,6 +196,9 @@ export class SsoService {
|
||||
if (payload.purpose !== SSO_COMPLETE_TOKEN_PURPOSE) {
|
||||
throw new UnauthorizedException('Ungültiger Token');
|
||||
}
|
||||
return { userId: payload.sub };
|
||||
return {
|
||||
userId: payload.sub,
|
||||
claims: { jti: payload.jti, exp: payload.exp },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { AuthenticatedUser } from '../types/authenticated-user';
|
||||
import { JWT_ALGORITHM, JWT_ISSUER, TOKEN_AUDIENCE } from '../util/jwt-claims';
|
||||
|
||||
interface AccessTokenPayload {
|
||||
sub: string;
|
||||
@@ -10,6 +11,8 @@ interface AccessTokenPayload {
|
||||
// reject tokens issued before the user's last password change (see
|
||||
// PermissionResolverService.isTokenValid).
|
||||
iat: number;
|
||||
// Set only on the helper tokens (MFA setup/challenge, SSO completion).
|
||||
purpose?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -19,10 +22,19 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_ACCESS_SECRET as string,
|
||||
// Only access tokens: the MFA challenge token (issued after the
|
||||
// password step, before the second factor) shares the secret and must
|
||||
// never open a session on its own.
|
||||
algorithms: [JWT_ALGORITHM],
|
||||
issuer: JWT_ISSUER,
|
||||
audience: TOKEN_AUDIENCE.access,
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: AccessTokenPayload): AuthenticatedUser {
|
||||
if (payload.purpose !== undefined || typeof payload.sub !== 'string') {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Every JWT ApiCore issues is signed with JWT_ACCESS_SECRET, so the token's
|
||||
* audience - not the secret - is what tells an access token apart from the
|
||||
* short-lived helper tokens (MFA setup, MFA challenge, SSO completion).
|
||||
* Without a pinned audience, the MFA challenge token handed out after the
|
||||
* password step would pass the access-token strategy and skip the second
|
||||
* factor entirely.
|
||||
*
|
||||
* Algorithm and issuer are pinned as well, so a token is only ever accepted
|
||||
* in the exact shape ApiCore itself produces.
|
||||
*/
|
||||
export const JWT_ISSUER = 'apicore';
|
||||
export const JWT_ALGORITHM = 'HS256';
|
||||
|
||||
export const TOKEN_AUDIENCE = {
|
||||
access: 'apicore:access',
|
||||
mfaSetup: 'apicore:mfa-setup',
|
||||
mfaChallenge: 'apicore:mfa-challenge',
|
||||
ssoComplete: 'apicore:sso-complete',
|
||||
} as const;
|
||||
|
||||
export type TokenAudience =
|
||||
(typeof TOKEN_AUDIENCE)[keyof typeof TOKEN_AUDIENCE];
|
||||
|
||||
export function jwtSignOptions(
|
||||
audience: TokenAudience,
|
||||
expiresIn: JwtSignOptions['expiresIn'],
|
||||
): JwtSignOptions {
|
||||
return {
|
||||
secret: process.env.JWT_ACCESS_SECRET,
|
||||
algorithm: JWT_ALGORITHM,
|
||||
issuer: JWT_ISSUER,
|
||||
audience,
|
||||
expiresIn,
|
||||
// Unique id, so a one-time token can be marked as used.
|
||||
jwtid: randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
/** What a verified one-time token carries for OneTimeTokenService. */
|
||||
export interface OneTimeTokenClaims {
|
||||
jti?: string;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
export function jwtVerifyOptions(audience: TokenAudience): JwtVerifyOptions {
|
||||
return {
|
||||
secret: process.env.JWT_ACCESS_SECRET,
|
||||
algorithms: [JWT_ALGORITHM],
|
||||
issuer: JWT_ISSUER,
|
||||
audience,
|
||||
};
|
||||
}
|
||||
@@ -5,24 +5,32 @@ import { parseDurationToMs } from './duration';
|
||||
export const REFRESH_TOKEN_COOKIE = 'refreshToken';
|
||||
export const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth';
|
||||
|
||||
export function setRefreshTokenCookie(
|
||||
res: Response,
|
||||
refreshToken: string,
|
||||
): void {
|
||||
res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, {
|
||||
function cookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
// Explicit opt-out for local HTTP dev only; every real deployment must
|
||||
// set COOKIE_SECURE=true so the refresh-token cookie is never sent
|
||||
// over plaintext HTTP.
|
||||
secure: process.env.COOKIE_SECURE !== 'false',
|
||||
sameSite: 'lax',
|
||||
sameSite: 'lax' as const,
|
||||
path: REFRESH_TOKEN_COOKIE_PATH,
|
||||
};
|
||||
}
|
||||
|
||||
export function setRefreshTokenCookie(
|
||||
res: Response,
|
||||
refreshToken: string,
|
||||
): void {
|
||||
res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, {
|
||||
...cookieOptions(),
|
||||
maxAge: parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'),
|
||||
});
|
||||
}
|
||||
|
||||
// Same attributes as when set - some browsers keep a cookie that is
|
||||
// cleared with different ones.
|
||||
export function clearRefreshTokenCookie(res: Response): void {
|
||||
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: REFRESH_TOKEN_COOKIE_PATH });
|
||||
res.clearCookie(REFRESH_TOKEN_COOKIE, cookieOptions());
|
||||
}
|
||||
|
||||
export function extractRefreshToken(req: Request): string {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { Logger, NotFoundException } from '@nestjs/common';
|
||||
import { AutomationService } from './automation.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { AutomationTriggerType, Prisma } from '../../generated/prisma';
|
||||
|
||||
describe('AutomationService', () => {
|
||||
let service: AutomationService;
|
||||
@@ -14,7 +15,10 @@ describe('AutomationService', () => {
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
task: { create: jest.Mock };
|
||||
automationSequence: { upsert: jest.Mock };
|
||||
task: { create: jest.Mock; findFirst: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
$queryRaw: jest.Mock;
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let notifications: { record: jest.Mock };
|
||||
@@ -50,7 +54,14 @@ describe('AutomationService', () => {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
task: { create: jest.fn().mockResolvedValue({ id: 'task-1' }) },
|
||||
automationSequence: { upsert: jest.fn() },
|
||||
task: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'task-1' }),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
// Interactive transactions run against the same mocks.
|
||||
$transaction: jest.fn((work: (tx: unknown) => unknown) => work(prisma)),
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
notifications = { record: jest.fn() };
|
||||
@@ -79,10 +90,128 @@ describe('AutomationService', () => {
|
||||
|
||||
const result = await service.findAll();
|
||||
|
||||
expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(8);
|
||||
expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(
|
||||
Object.values(AutomationTriggerType).length,
|
||||
);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
// Rule evaluation runs after the triggering record is committed. A failure
|
||||
// here must not turn that request into an error - the client would retry
|
||||
// and store the inspection or treatment a second time.
|
||||
describe('post-commit evaluation', () => {
|
||||
let errorSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
prisma.automationRule.upsert.mockRejectedValue(new Error('db down'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
const colony = { id: 'colony-1', healthStatus: 'GOOD' };
|
||||
const evaluations: [string, string, (s: AutomationService) => unknown][] = [
|
||||
[
|
||||
'evaluateInspection',
|
||||
'Inspection inspection-1',
|
||||
(s) =>
|
||||
s.evaluateInspection({
|
||||
id: 'inspection-1',
|
||||
colonyId: 'colony-1',
|
||||
} as never),
|
||||
],
|
||||
[
|
||||
'evaluateVarroaMeasurement',
|
||||
'VarroaMeasurement measurement-1',
|
||||
(s) =>
|
||||
s.evaluateVarroaMeasurement({
|
||||
id: 'measurement-1',
|
||||
colonyId: 'colony-1',
|
||||
} as never),
|
||||
],
|
||||
[
|
||||
'evaluateSwarmControlCheck',
|
||||
'SwarmControlCheck check-1',
|
||||
(s) =>
|
||||
s.evaluateSwarmControlCheck({
|
||||
id: 'check-1',
|
||||
colonyId: 'colony-1',
|
||||
} as never),
|
||||
],
|
||||
[
|
||||
'evaluateColonyHealth',
|
||||
'Colony colony-1',
|
||||
(s) =>
|
||||
s.evaluateColonyHealth(
|
||||
colony as never,
|
||||
{ ...colony, healthStatus: 'CRITICAL' } as never,
|
||||
),
|
||||
],
|
||||
[
|
||||
'evaluateStockItem',
|
||||
'StockItem item-1',
|
||||
(s) =>
|
||||
s.evaluateStockItem({
|
||||
id: 'item-1',
|
||||
quantity: 1,
|
||||
minThreshold: 5,
|
||||
} as never),
|
||||
],
|
||||
[
|
||||
'evaluateRawMaterialLow',
|
||||
'RawMaterialKind BEESWAX',
|
||||
(s) => s.evaluateRawMaterialLow('BEESWAX', 100),
|
||||
],
|
||||
[
|
||||
'evaluateTreatment',
|
||||
'Treatment treatment-1',
|
||||
(s) =>
|
||||
s.evaluateTreatment({
|
||||
id: 'treatment-1',
|
||||
colonyId: 'colony-1',
|
||||
date: new Date('2026-08-01'),
|
||||
endDate: null,
|
||||
product: 'Ameisensäure',
|
||||
} as never),
|
||||
],
|
||||
[
|
||||
'evaluateFeeding',
|
||||
'Feeding feeding-1',
|
||||
(s) =>
|
||||
s.evaluateFeeding({
|
||||
id: 'feeding-1',
|
||||
colonyId: 'colony-1',
|
||||
date: new Date('2026-09-01'),
|
||||
amount: 5,
|
||||
unit: 'l',
|
||||
} as never),
|
||||
],
|
||||
];
|
||||
|
||||
it.each(evaluations)(
|
||||
'%s never throws and logs the failure with the record',
|
||||
async (_name, subject, evaluate) => {
|
||||
await expect(evaluate(service)).resolves.toBeUndefined();
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
const [message] = errorSpy.mock.calls[0] as [string];
|
||||
expect(message).toContain(subject);
|
||||
expect(message).toContain('db down');
|
||||
},
|
||||
);
|
||||
|
||||
it('still throws from generateNumber, which runs before the write', async () => {
|
||||
await expect(
|
||||
service.generateNumber('HONEY_BATCH_NUMBER_FORMAT'),
|
||||
).rejects.toThrow('db down');
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateInspection', () => {
|
||||
it('creates a task and notification when queen not seen and no eggs present', async () => {
|
||||
prisma.automationRule.findMany.mockResolvedValue([queenlessRule]);
|
||||
@@ -154,6 +283,7 @@ describe('AutomationService', () => {
|
||||
await service.evaluateVarroaMeasurement({
|
||||
id: 'measurement-1',
|
||||
colonyId: 'colony-1',
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
calculatedValue: 12,
|
||||
miteCount: null,
|
||||
} as never);
|
||||
@@ -167,6 +297,7 @@ describe('AutomationService', () => {
|
||||
await service.evaluateVarroaMeasurement({
|
||||
id: 'measurement-2',
|
||||
colonyId: 'colony-1',
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
calculatedValue: 5,
|
||||
miteCount: null,
|
||||
} as never);
|
||||
@@ -174,17 +305,90 @@ describe('AutomationService', () => {
|
||||
expect(prisma.task.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to miteCount when calculatedValue is null', async () => {
|
||||
it('ignores a raw board count without a per-day rate', async () => {
|
||||
// 15 mites over an unknown number of days says nothing about the
|
||||
// per-day threshold - comparing them would be a unit error.
|
||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
|
||||
await service.evaluateVarroaMeasurement({
|
||||
id: 'measurement-3',
|
||||
colonyId: 'colony-1',
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
calculatedValue: null,
|
||||
miteCount: 15,
|
||||
} as never);
|
||||
|
||||
expect(prisma.task.create).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.task.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores sample-based methods, whose values use another unit', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
|
||||
await service.evaluateVarroaMeasurement({
|
||||
id: 'measurement-4',
|
||||
colonyId: 'colony-1',
|
||||
method: 'WASHING_METHOD',
|
||||
calculatedValue: 30,
|
||||
miteCount: 30,
|
||||
} as never);
|
||||
|
||||
expect(prisma.task.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds no second task while one for the same colony is still open', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
prisma.task.findFirst.mockResolvedValue({ id: 'open-task' });
|
||||
|
||||
await service.evaluateVarroaMeasurement({
|
||||
id: 'measurement-5',
|
||||
colonyId: 'colony-1',
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
calculatedValue: 25,
|
||||
miteCount: 25,
|
||||
} as never);
|
||||
|
||||
expect(prisma.task.create).not.toHaveBeenCalled();
|
||||
expect(notifications.record).not.toHaveBeenCalled();
|
||||
expect(prisma.task.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
sourceAutomationTrigger: 'VARROA_THRESHOLD',
|
||||
relatedType: 'COLONY',
|
||||
relatedId: 'colony-1',
|
||||
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('checks for an open task and creates one under a lock on the rule row', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
|
||||
await service.evaluateVarroaMeasurement({
|
||||
id: 'measurement-6',
|
||||
colonyId: 'colony-1',
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
calculatedValue: 25,
|
||||
miteCount: 25,
|
||||
} as never);
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
const [sql, ...values] = prisma.$queryRaw.mock.calls[0] as [
|
||||
TemplateStringsArray,
|
||||
...unknown[],
|
||||
];
|
||||
expect(sql.join('?')).toMatch(
|
||||
/SELECT id FROM "AutomationRule" WHERE id = \? FOR UPDATE/,
|
||||
);
|
||||
expect(values).toEqual(['rule-3']);
|
||||
// Lock first, then check, then create - all in that one transaction.
|
||||
const [lockOrder] = prisma.$queryRaw.mock.invocationCallOrder;
|
||||
const [checkOrder] = prisma.task.findFirst.mock.invocationCallOrder;
|
||||
const [createOrder] = prisma.task.create.mock.invocationCallOrder;
|
||||
expect(lockOrder).toBeLessThan(checkOrder);
|
||||
expect(checkOrder).toBeLessThan(createOrder);
|
||||
// Webhook and warning follow the commit, outside the lock.
|
||||
const [dispatchOrder] = webhooks.dispatch.mock.invocationCallOrder;
|
||||
expect(dispatchOrder).toBeGreaterThan(createOrder);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -288,6 +492,60 @@ describe('AutomationService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateRawMaterialLow', () => {
|
||||
const rawRule = {
|
||||
id: 'rule-7',
|
||||
triggerType: 'RAW_MATERIAL_LOW',
|
||||
active: true,
|
||||
config: { minGrams: { BEESWAX: 1000 } },
|
||||
actionTemplate: 'Rohstoff unter Mindestmenge',
|
||||
};
|
||||
|
||||
it('creates one task per kind when the kind drops below its minimum', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(rawRule);
|
||||
|
||||
await service.evaluateRawMaterialLow('BEESWAX', 400);
|
||||
|
||||
const [createArgs] = prisma.task.create.mock.calls[0] as [unknown];
|
||||
expect(createArgs).toMatchObject({
|
||||
data: {
|
||||
relatedType: 'GENERAL',
|
||||
relatedId: 'BEESWAX',
|
||||
sourceAutomationTrigger: 'RAW_MATERIAL_LOW',
|
||||
description:
|
||||
'Bienenwachs: noch 400 g auf Lager, Mindestmenge 1000 g.',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not trigger at or above the minimum', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(rawRule);
|
||||
|
||||
await service.evaluateRawMaterialLow('BEESWAX', 1000);
|
||||
|
||||
expect(prisma.task.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not trigger for a kind without a minimum', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(rawRule);
|
||||
|
||||
await service.evaluateRawMaterialLow('PROPOLIS', 0);
|
||||
|
||||
expect(prisma.task.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not trigger when the rule is inactive', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue({
|
||||
...rawRule,
|
||||
active: false,
|
||||
});
|
||||
|
||||
await service.evaluateRawMaterialLow('BEESWAX', 0);
|
||||
|
||||
expect(prisma.task.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('records an audit log entry with before/after data', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
@@ -306,6 +564,34 @@ describe('AutomationService', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('clears the config on null so the rule falls back to its defaults', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
prisma.automationRule.update.mockResolvedValue({
|
||||
...varroaRule,
|
||||
config: null,
|
||||
});
|
||||
|
||||
await service.update('rule-3', { config: null as never }, 'user-1');
|
||||
|
||||
const [[updateArg]] = prisma.automationRule.update.mock.calls as [
|
||||
[{ data: { config?: unknown } }],
|
||||
];
|
||||
// A Json column is cleared with DbNull - a plain null fails the write.
|
||||
expect(updateArg.data.config).toBe(Prisma.DbNull);
|
||||
});
|
||||
|
||||
it('leaves the config untouched when it is omitted', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
prisma.automationRule.update.mockResolvedValue(varroaRule);
|
||||
|
||||
await service.update('rule-3', { active: true }, 'user-1');
|
||||
|
||||
const [[updateArg]] = prisma.automationRule.update.mock.calls as [
|
||||
[{ data: { config?: unknown } }],
|
||||
];
|
||||
expect(updateArg.data.config).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateNumber', () => {
|
||||
@@ -357,6 +643,73 @@ describe('AutomationService', () => {
|
||||
});
|
||||
expect(result).toBe(`WH-${new Date().getFullYear()}-0007`);
|
||||
});
|
||||
|
||||
const rawFormatRule = {
|
||||
id: 'rule-8',
|
||||
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
|
||||
active: true,
|
||||
config: { format: '{ART}-{YYYY}-{SEQ:4}' },
|
||||
sequenceValue: 0,
|
||||
};
|
||||
|
||||
it('counts per scope when the format tells the scopes apart', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(rawFormatRule);
|
||||
prisma.automationSequence.upsert.mockResolvedValue({ value: 3 });
|
||||
|
||||
const result = await service.generateNumber(
|
||||
'RAW_MATERIAL_LOT_NUMBER_FORMAT',
|
||||
{ scope: 'PROPOLIS', vars: { ART: 'PR' } },
|
||||
);
|
||||
|
||||
expect(prisma.automationSequence.upsert).toHaveBeenCalledWith({
|
||||
where: {
|
||||
triggerType_scope: {
|
||||
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
|
||||
scope: 'PROPOLIS',
|
||||
},
|
||||
},
|
||||
create: {
|
||||
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
|
||||
scope: 'PROPOLIS',
|
||||
value: 1,
|
||||
},
|
||||
update: { value: { increment: 1 } },
|
||||
select: { value: true },
|
||||
});
|
||||
expect(prisma.automationRule.update).not.toHaveBeenCalled();
|
||||
expect(result).toBe(`PR-${new Date().getFullYear()}-0003`);
|
||||
});
|
||||
|
||||
it('keeps one shared counter when the format has no {ART}', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue({
|
||||
...rawFormatRule,
|
||||
config: { format: 'RS-{YYYY}-{SEQ:4}' },
|
||||
});
|
||||
prisma.automationRule.update.mockResolvedValue({ sequenceValue: 5 });
|
||||
|
||||
const result = await service.generateNumber(
|
||||
'RAW_MATERIAL_LOT_NUMBER_FORMAT',
|
||||
{ scope: 'BEESWAX', vars: { ART: 'WA' } },
|
||||
);
|
||||
|
||||
expect(prisma.automationSequence.upsert).not.toHaveBeenCalled();
|
||||
expect(result).toBe(`RS-${new Date().getFullYear()}-0005`);
|
||||
});
|
||||
|
||||
it('retries once when two first uses of a scope collide', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(rawFormatRule);
|
||||
prisma.automationSequence.upsert
|
||||
.mockRejectedValueOnce({ code: 'P2002' })
|
||||
.mockResolvedValueOnce({ value: 2 });
|
||||
|
||||
const result = await service.generateNumber(
|
||||
'RAW_MATERIAL_LOT_NUMBER_FORMAT',
|
||||
{ scope: 'BEESWAX', vars: { ART: 'WA' } },
|
||||
);
|
||||
|
||||
expect(prisma.automationSequence.upsert).toHaveBeenCalledTimes(2);
|
||||
expect(result).toBe(`WA-${new Date().getFullYear()}-0002`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetSequence', () => {
|
||||
@@ -377,7 +730,7 @@ describe('AutomationService', () => {
|
||||
|
||||
expect(prisma.automationRule.update).toHaveBeenCalledWith({
|
||||
where: { id: 'rule-4' },
|
||||
data: { sequenceValue: 0 },
|
||||
data: { sequenceValue: 0, sequences: { deleteMany: {} } },
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { runAfterCommit } from '../common/after-commit';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
@@ -8,18 +9,54 @@ import {
|
||||
AutomationTriggerType,
|
||||
Colony,
|
||||
Inspection,
|
||||
Feeding,
|
||||
Prisma,
|
||||
RawMaterialKind,
|
||||
StockItem,
|
||||
SwarmControlCheck,
|
||||
TaskRelatedType,
|
||||
Treatment,
|
||||
VarroaMeasurement,
|
||||
} from '../../generated/prisma';
|
||||
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
|
||||
import { formatGeneratedNumber } from './number-format';
|
||||
|
||||
/** The rules that fill in a batch/lot number instead of creating tasks. */
|
||||
export type NumberFormatTrigger =
|
||||
| 'HONEY_BATCH_NUMBER_FORMAT'
|
||||
| 'BOTTLING_LOT_NUMBER_FORMAT'
|
||||
| 'RAW_MATERIAL_LOT_NUMBER_FORMAT'
|
||||
| 'PRODUCTION_BATCH_NUMBER_FORMAT';
|
||||
|
||||
const RAW_MATERIAL_KIND_LABELS: Record<RawMaterialKind, string> = {
|
||||
BEESWAX: 'Bienenwachs',
|
||||
PROPOLIS: 'Propolis',
|
||||
};
|
||||
|
||||
/** What {ART} turns into in the raw material number format. */
|
||||
export const RAW_MATERIAL_KIND_CODES: Record<RawMaterialKind, string> = {
|
||||
BEESWAX: 'WA',
|
||||
PROPOLIS: 'PR',
|
||||
};
|
||||
|
||||
export type AutomationRuleWithSequences = AutomationRule & {
|
||||
sequences: { scope: string; value: number }[];
|
||||
};
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
error.code === 'P2002'
|
||||
);
|
||||
}
|
||||
|
||||
interface RuleDefaults {
|
||||
config: Record<string, unknown>;
|
||||
actionTemplate: string;
|
||||
/** Rules without a sensible built-in setting start switched off. */
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
|
||||
@@ -60,10 +97,76 @@ const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
|
||||
config: { format: 'WH-{YYYY}-{SEQ:4}' },
|
||||
actionTemplate: '',
|
||||
},
|
||||
// {ART} (WA/PR) gives wax and propolis a counter each.
|
||||
RAW_MATERIAL_LOT_NUMBER_FORMAT: {
|
||||
config: { format: '{ART}-{YYYY}-{SEQ:4}' },
|
||||
actionTemplate: '',
|
||||
},
|
||||
PRODUCTION_BATCH_NUMBER_FORMAT: {
|
||||
config: { format: 'HS-{YYYY}-{SEQ:4}' },
|
||||
actionTemplate: '',
|
||||
},
|
||||
// The raw-material counterpart of STOCK_ITEM_LOW. Raw material lots carry
|
||||
// no minimum of their own, so the minimum per kind (config.minGrams) lives
|
||||
// here - and without one there is nothing to compare, hence off by default.
|
||||
RAW_MATERIAL_LOW: {
|
||||
config: {},
|
||||
actionTemplate: 'Rohstoff unter Mindestmenge: Nachschub einplanen.',
|
||||
active: false,
|
||||
},
|
||||
// Follow-up checks: how many days after a treatment or feeding a check
|
||||
// makes sense depends on the product and the season, so there is no
|
||||
// built-in interval - the beekeeper switches the rule on and sets
|
||||
// config.dueInDays.
|
||||
TREATMENT_FOLLOW_UP: {
|
||||
config: {},
|
||||
actionTemplate: 'Behandlungserfolg kontrollieren',
|
||||
active: false,
|
||||
},
|
||||
FEEDING_FOLLOW_UP: {
|
||||
config: {},
|
||||
actionTemplate: 'Futterabnahme und Futtervorrat kontrollieren',
|
||||
active: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Optional per-rule follow-up interval (config.dueInDays). There is no
|
||||
* built-in default: how soon a queenless colony or a high mite fall needs a
|
||||
* look depends on season and practice, so the rule stays date-less unless the
|
||||
* beekeeper configures one.
|
||||
*/
|
||||
function formatDay(date: Date): string {
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
function dueDateFromConfig(
|
||||
dueInDays: unknown,
|
||||
baseDate: Date = new Date(),
|
||||
): Date | undefined {
|
||||
if (
|
||||
typeof dueInDays !== 'number' ||
|
||||
!Number.isInteger(dueInDays) ||
|
||||
dueInDays < 0 ||
|
||||
dueInDays > 365
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const due = new Date(baseDate);
|
||||
due.setUTCHours(0, 0, 0, 0);
|
||||
due.setUTCDate(due.getUTCDate() + dueInDays);
|
||||
return due;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AutomationService {
|
||||
private readonly logger = new Logger(AutomationService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
@@ -81,6 +184,7 @@ export class AutomationService {
|
||||
update: {},
|
||||
create: {
|
||||
triggerType,
|
||||
active: defaults.active ?? true,
|
||||
config: defaults.config as Prisma.InputJsonValue,
|
||||
actionTemplate: defaults.actionTemplate,
|
||||
},
|
||||
@@ -88,10 +192,16 @@ export class AutomationService {
|
||||
}
|
||||
}
|
||||
|
||||
async findAll(): Promise<AutomationRule[]> {
|
||||
async findAll(): Promise<AutomationRuleWithSequences[]> {
|
||||
await this.ensureDefaults();
|
||||
return this.prisma.automationRule.findMany({
|
||||
orderBy: { triggerType: 'asc' },
|
||||
include: {
|
||||
sequences: {
|
||||
select: { scope: true, value: true },
|
||||
orderBy: { scope: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -115,7 +225,12 @@ export class AutomationService {
|
||||
where: { id },
|
||||
data: {
|
||||
...dto,
|
||||
config: dto.config as Prisma.InputJsonValue | undefined,
|
||||
// null resets the rule to its defaults; a Json column is cleared
|
||||
// with DbNull, a plain null would fail the write.
|
||||
config:
|
||||
dto.config === null
|
||||
? Prisma.DbNull
|
||||
: (dto.config as Prisma.InputJsonValue | undefined),
|
||||
},
|
||||
});
|
||||
await this.auditLog.record({
|
||||
@@ -129,7 +244,25 @@ export class AutomationService {
|
||||
return rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every evaluate*() runs after the triggering record is committed and is
|
||||
* called by the domain services without a guard - so it never throws (see
|
||||
* runAfterCommit). A failed evaluation is logged, the request succeeds.
|
||||
*/
|
||||
private evaluateAfterCommit(
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
evaluate: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
return runAfterCommit(
|
||||
this.logger,
|
||||
{ action: 'Automation rule evaluation', entityType, entityId },
|
||||
evaluate,
|
||||
);
|
||||
}
|
||||
|
||||
async evaluateInspection(inspection: Inspection): Promise<void> {
|
||||
await this.evaluateAfterCommit('Inspection', inspection.id, async () => {
|
||||
await this.ensureDefaults();
|
||||
const rules = await this.prisma.automationRule.findMany({
|
||||
where: {
|
||||
@@ -162,11 +295,16 @@ export class AutomationService {
|
||||
inspection.id,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async evaluateVarroaMeasurement(
|
||||
measurement: VarroaMeasurement,
|
||||
): Promise<void> {
|
||||
await this.evaluateAfterCommit(
|
||||
'VarroaMeasurement',
|
||||
measurement.id,
|
||||
async () => {
|
||||
await this.ensureDefaults();
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
where: { triggerType: 'VARROA_THRESHOLD' },
|
||||
@@ -174,15 +312,26 @@ export class AutomationService {
|
||||
if (!rule || !rule.active) {
|
||||
return;
|
||||
}
|
||||
// The threshold is a natural mite fall rate (mites per day). Only
|
||||
// that method yields a value in this unit - a mite count from a
|
||||
// sugar roll or wash is per bee sample and would be compared against
|
||||
// the wrong scale, as would a raw board count without the number of
|
||||
// days it covered.
|
||||
if (measurement.method !== 'NATURAL_MITE_FALL') {
|
||||
return;
|
||||
}
|
||||
const config = rule.config as { thresholdMitesPerDay?: number } | null;
|
||||
const threshold = config?.thresholdMitesPerDay ?? 10;
|
||||
const value = measurement.calculatedValue ?? measurement.miteCount;
|
||||
const value = measurement.calculatedValue;
|
||||
if (value !== null && value >= threshold) {
|
||||
await this.triggerAction(rule, 'COLONY', measurement.colonyId);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async evaluateSwarmControlCheck(check: SwarmControlCheck): Promise<void> {
|
||||
await this.evaluateAfterCommit('SwarmControlCheck', check.id, async () => {
|
||||
await this.ensureDefaults();
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
where: { triggerType: 'SWARM_MOOD_HIGH' },
|
||||
@@ -193,10 +342,12 @@ export class AutomationService {
|
||||
if (check.swarmMood === 'HIGH') {
|
||||
await this.triggerAction(rule, 'COLONY', check.colonyId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Fires only on the transition into CRITICAL, not on every edit while already critical. */
|
||||
async evaluateColonyHealth(before: Colony, after: Colony): Promise<void> {
|
||||
await this.evaluateAfterCommit('Colony', after.id, async () => {
|
||||
await this.ensureDefaults();
|
||||
if (
|
||||
after.healthStatus !== 'CRITICAL' ||
|
||||
@@ -211,9 +362,11 @@ export class AutomationService {
|
||||
return;
|
||||
}
|
||||
await this.triggerAction(rule, 'COLONY', after.id);
|
||||
});
|
||||
}
|
||||
|
||||
async evaluateStockItem(item: StockItem): Promise<void> {
|
||||
await this.evaluateAfterCommit('StockItem', item.id, async () => {
|
||||
await this.ensureDefaults();
|
||||
if (item.minThreshold === null || item.quantity >= item.minThreshold) {
|
||||
return;
|
||||
@@ -225,12 +378,93 @@ export class AutomationService {
|
||||
return;
|
||||
}
|
||||
await this.triggerAction(rule, 'STORAGE', item.id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* After raw material left its lots (consumption, withdrawal, inventory):
|
||||
* compares what is left of the kind across all lots with config.minGrams.
|
||||
* The task is about the kind, not one lot - a new lot of the same kind is
|
||||
* the remedy - so it hangs on GENERAL with the kind as relatedId, which
|
||||
* keeps one open task per kind.
|
||||
*/
|
||||
async evaluateRawMaterialLow(
|
||||
kind: RawMaterialKind,
|
||||
remainingGrams: number,
|
||||
): Promise<void> {
|
||||
await this.evaluateAfterCommit('RawMaterialKind', kind, async () => {
|
||||
await this.ensureDefaults();
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
where: { triggerType: 'RAW_MATERIAL_LOW' },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
return;
|
||||
}
|
||||
const config = rule.config as {
|
||||
minGrams?: Partial<Record<RawMaterialKind, unknown>>;
|
||||
} | null;
|
||||
const minGrams = config?.minGrams?.[kind];
|
||||
if (typeof minGrams !== 'number' || remainingGrams >= minGrams) {
|
||||
return;
|
||||
}
|
||||
await this.triggerAction(rule, 'GENERAL', kind, undefined, {
|
||||
description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async evaluateTreatment(treatment: Treatment): Promise<void> {
|
||||
await this.evaluateAfterCommit('Treatment', treatment.id, () =>
|
||||
this.evaluateFollowUp(
|
||||
'TREATMENT_FOLLOW_UP',
|
||||
treatment.colonyId,
|
||||
treatment.endDate ?? treatment.date,
|
||||
`Nach Behandlung mit ${treatment.product} am ${formatDay(treatment.endDate ?? treatment.date)}.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async evaluateFeeding(feeding: Feeding): Promise<void> {
|
||||
await this.evaluateAfterCommit('Feeding', feeding.id, () =>
|
||||
this.evaluateFollowUp(
|
||||
'FEEDING_FOLLOW_UP',
|
||||
feeding.colonyId,
|
||||
feeding.date,
|
||||
`Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** A check counted from the measure's own date, not from today. */
|
||||
private async evaluateFollowUp(
|
||||
triggerType: 'TREATMENT_FOLLOW_UP' | 'FEEDING_FOLLOW_UP',
|
||||
colonyId: string,
|
||||
measureDate: Date,
|
||||
description: string,
|
||||
): Promise<void> {
|
||||
await this.ensureDefaults();
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
where: { triggerType },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
return;
|
||||
}
|
||||
await this.triggerAction(rule, 'COLONY', colonyId, undefined, {
|
||||
baseDate: measureDate,
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
// Returns null (never throws) when the rule is inactive or has no format
|
||||
// configured, so the caller can fall back to requiring manual entry.
|
||||
//
|
||||
// `scope` asks for a counter of its own (e.g. per raw material kind). It
|
||||
// only applies when the format contains a placeholder from `vars` that
|
||||
// tells the scopes apart - otherwise two scopes would hand out the same
|
||||
// number, so the rule falls back to its one shared counter.
|
||||
async generateNumber(
|
||||
triggerType: 'HONEY_BATCH_NUMBER_FORMAT' | 'BOTTLING_LOT_NUMBER_FORMAT',
|
||||
triggerType: NumberFormatTrigger,
|
||||
options: { scope?: string; vars?: Record<string, string> } = {},
|
||||
): Promise<string | null> {
|
||||
await this.ensureDefaults();
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
@@ -244,14 +478,51 @@ export class AutomationService {
|
||||
if (!format) {
|
||||
return null;
|
||||
}
|
||||
const vars = options.vars ?? {};
|
||||
const scoped =
|
||||
options.scope !== undefined &&
|
||||
Object.keys(vars).some((key) => format.includes(`{${key}}`));
|
||||
const sequence = scoped
|
||||
? await this.nextScopedSequence(triggerType, options.scope!)
|
||||
: await this.nextSharedSequence(triggerType);
|
||||
return formatGeneratedNumber(format, sequence, new Date(), vars);
|
||||
}
|
||||
|
||||
// Atomic `SET sequenceValue = sequenceValue + 1` - safe under concurrent
|
||||
// batch/bottling creation, no explicit transaction/lock needed.
|
||||
private async nextSharedSequence(
|
||||
triggerType: NumberFormatTrigger,
|
||||
): Promise<number> {
|
||||
const updated = await this.prisma.automationRule.update({
|
||||
where: { triggerType },
|
||||
data: { sequenceValue: { increment: 1 } },
|
||||
select: { sequenceValue: true },
|
||||
});
|
||||
return formatGeneratedNumber(format, updated.sequenceValue, new Date());
|
||||
return updated.sequenceValue;
|
||||
}
|
||||
|
||||
// Same atomic increment on the scope's own row, created on first use. Two
|
||||
// first uses at once can both try the insert; the loser gets P2002 and
|
||||
// simply increments the row the winner created.
|
||||
private async nextScopedSequence(
|
||||
triggerType: NumberFormatTrigger,
|
||||
scope: string,
|
||||
): Promise<number> {
|
||||
const upsert = () =>
|
||||
this.prisma.automationSequence.upsert({
|
||||
where: { triggerType_scope: { triggerType, scope } },
|
||||
create: { triggerType, scope, value: 1 },
|
||||
update: { value: { increment: 1 } },
|
||||
select: { value: true },
|
||||
});
|
||||
try {
|
||||
return (await upsert()).value;
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
return (await upsert()).value;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async resetSequence(
|
||||
@@ -259,9 +530,10 @@ export class AutomationService {
|
||||
actingUserId: string,
|
||||
): Promise<AutomationRule> {
|
||||
const before = await this.findById(id);
|
||||
// Resets the shared counter and every per-scope counter of the rule.
|
||||
const rule = await this.prisma.automationRule.update({
|
||||
where: { id },
|
||||
data: { sequenceValue: 0 },
|
||||
data: { sequenceValue: 0, sequences: { deleteMany: {} } },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'AutomationRule',
|
||||
@@ -274,22 +546,59 @@ export class AutomationService {
|
||||
return rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves a suggestion task (never a record of work done) plus a warning.
|
||||
*
|
||||
* At most one open task per rule and object: while the reorder task for a
|
||||
* stock item or the check task for a colony is still open, further triggers
|
||||
* (the next removal below minimum, a second high varroa count) add nothing.
|
||||
* Repeating the same alarm would only bury the one task that matters.
|
||||
*
|
||||
* Check and create run in one transaction under a lock on the rule's row:
|
||||
* two evaluations at the same moment (parallel requests, several pods)
|
||||
* would otherwise both see "no open task" and both create one. The lock
|
||||
* is per rule and held for two short statements; webhook, warning and
|
||||
* audit entry follow after the commit.
|
||||
*/
|
||||
private async triggerAction(
|
||||
rule: AutomationRule,
|
||||
relatedType: TaskRelatedType,
|
||||
relatedId: string,
|
||||
sourceInspectionId?: string,
|
||||
options: { baseDate?: Date; description?: string } = {},
|
||||
): Promise<void> {
|
||||
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
|
||||
const task = await this.prisma.task.create({
|
||||
const config = rule.config as { dueInDays?: unknown } | null;
|
||||
const task = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`SELECT id FROM "AutomationRule" WHERE id = ${rule.id} FOR UPDATE`;
|
||||
const alreadyOpen = await tx.task.findFirst({
|
||||
where: {
|
||||
sourceAutomationTrigger: rule.triggerType,
|
||||
relatedType,
|
||||
relatedId,
|
||||
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (alreadyOpen) {
|
||||
return null;
|
||||
}
|
||||
return tx.task.create({
|
||||
data: {
|
||||
title,
|
||||
priority: 'HIGH',
|
||||
relatedType,
|
||||
relatedId,
|
||||
sourceInspectionId,
|
||||
sourceAutomationTrigger: rule.triggerType,
|
||||
description: options.description,
|
||||
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate),
|
||||
},
|
||||
});
|
||||
});
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
await this.webhooks.dispatch('task.created', task);
|
||||
await this.notifications.record({
|
||||
priority: 'WARNUNG',
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
export class UpdateAutomationRuleDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
|
||||
|
||||
@@ -28,4 +28,11 @@ describe('formatGeneratedNumber', () => {
|
||||
|
||||
expect(result).toBe('FIXED-NUMBER');
|
||||
});
|
||||
|
||||
it('clamps a pathologically large {SEQ:N} width instead of allocating it', () => {
|
||||
const result = formatGeneratedNumber('L-{SEQ:999999999}', 7, date);
|
||||
|
||||
expect(result).toBe(`L-${'0'.repeat(19)}7`);
|
||||
expect(result.length).toBe(22);
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,41 @@
|
||||
// Far beyond any realistic business use (sequence numbers are a handful of
|
||||
// digits) but small enough that padStart can never be tricked into
|
||||
// allocating a pathologically large string - {SEQ:999999999} in a
|
||||
// carelessly typed format would otherwise try to build a ~1GB string on
|
||||
// every single batch/bottling creation from then on.
|
||||
const MAX_SEQUENCE_PAD_WIDTH = 20;
|
||||
|
||||
// Placeholders: {YYYY} 4-digit year, {YY} 2-digit year, {MM} month (01-12),
|
||||
// {DD} day (01-31), {SEQ} running sequence unpadded, {SEQ:N} sequence
|
||||
// zero-padded to N digits. Example matching the lastenheft's own sample
|
||||
// ("WH-2026-0001"): format "WH-{YYYY}-{SEQ:4}".
|
||||
// `vars` fills rule-specific placeholders, e.g. { ART: 'WA' } for {ART} in
|
||||
// the raw material format; a placeholder without a value stays as typed.
|
||||
export function formatGeneratedNumber(
|
||||
format: string,
|
||||
sequence: number,
|
||||
date: Date,
|
||||
vars: Record<string, string> = {},
|
||||
): string {
|
||||
const yyyy = String(date.getFullYear());
|
||||
const yy = yyyy.slice(-2);
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
|
||||
return format
|
||||
const withVars = Object.entries(vars).reduce(
|
||||
(result, [key, value]) => result.split(`{${key}}`).join(value),
|
||||
format,
|
||||
);
|
||||
return withVars
|
||||
.replace(/\{YYYY\}/g, yyyy)
|
||||
.replace(/\{YY\}/g, yy)
|
||||
.replace(/\{MM\}/g, mm)
|
||||
.replace(/\{DD\}/g, dd)
|
||||
.replace(/\{SEQ:(\d+)\}/g, (_match, width: string) =>
|
||||
String(sequence).padStart(Number(width), '0'),
|
||||
String(sequence).padStart(
|
||||
Math.min(Number(width), MAX_SEQUENCE_PAD_WIDTH),
|
||||
'0',
|
||||
),
|
||||
)
|
||||
.replace(/\{SEQ\}/g, String(sequence));
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { isSealedBackup, openBackup, sealBackup } from './backup-crypto';
|
||||
|
||||
describe('backup-crypto', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
const bundle = {
|
||||
version: 2,
|
||||
exportedAt: '2026-10-01T09:00:00.000Z',
|
||||
data: { user: [{ id: 'u1', passwordHash: '$2a$12$secret' }] },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.JWT_ACCESS_SECRET = 'x'.repeat(40);
|
||||
delete process.env.BACKUP_ENCRYPTION_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('round-trips a bundle and hides its contents', () => {
|
||||
const sealed = sealBackup(bundle, bundle.exportedAt);
|
||||
|
||||
expect(isSealedBackup(sealed)).toBe(true);
|
||||
expect(JSON.stringify(sealed)).not.toContain('passwordHash');
|
||||
expect(JSON.stringify(sealed)).not.toContain('$2a$12$secret');
|
||||
expect(openBackup(sealed)).toEqual(bundle);
|
||||
});
|
||||
|
||||
it('rejects a file whose ciphertext was changed', () => {
|
||||
const sealed = sealBackup(bundle, bundle.exportedAt);
|
||||
const bytes = Buffer.from(sealed.ciphertext, 'base64');
|
||||
bytes[0] ^= 0xff;
|
||||
|
||||
expect(() =>
|
||||
openBackup({ ...sealed, ciphertext: bytes.toString('base64') }),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a file whose header was changed', () => {
|
||||
const sealed = sealBackup(bundle, bundle.exportedAt);
|
||||
|
||||
expect(() =>
|
||||
openBackup({ ...sealed, exportedAt: '2020-01-01T00:00:00.000Z' }),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a file sealed with another key', () => {
|
||||
const sealed = sealBackup(bundle, bundle.exportedAt);
|
||||
process.env.BACKUP_ENCRYPTION_KEY = 'y'.repeat(40);
|
||||
|
||||
expect(() => openBackup(sealed)).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('uses BACKUP_ENCRYPTION_KEY independently of the JWT secret', () => {
|
||||
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
|
||||
const sealed = sealBackup(bundle, bundle.exportedAt);
|
||||
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
|
||||
|
||||
expect(openBackup(sealed)).toEqual(bundle);
|
||||
});
|
||||
|
||||
it('refuses a too short BACKUP_ENCRYPTION_KEY', () => {
|
||||
process.env.BACKUP_ENCRYPTION_KEY = 'short';
|
||||
expect(() => sealBackup(bundle, bundle.exportedAt)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as zlib from 'node:zlib';
|
||||
|
||||
/**
|
||||
* A backup holds everything - password hashes, encrypted MFA secrets,
|
||||
* webhook signing secrets - and an import writes straight into the users
|
||||
* table. So the file is sealed: AES-256-GCM over the gzipped bundle, which
|
||||
* keeps it unreadable outside ApiCore AND makes any change to it (or a
|
||||
* hand-made file) fail authentication before a single row is touched.
|
||||
*
|
||||
* Key: BACKUP_ENCRYPTION_KEY when set (needed to restore into another
|
||||
* deployment), otherwise derived from JWT_ACCESS_SECRET - so a deployment
|
||||
* can always restore its own backups. Either way the AES key comes from
|
||||
* HKDF with a backup-specific label, never the raw secret.
|
||||
*/
|
||||
export const SEALED_BACKUP_FORMAT = 'apicore-backup';
|
||||
export const SEALED_BACKUP_VERSION = 3;
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_BYTES = 12;
|
||||
const MIN_KEY_LENGTH = 32;
|
||||
// Decompressed size cap: a small file must not unpack into gigabytes.
|
||||
const MAX_PLAINTEXT_BYTES = 512 * 1024 * 1024;
|
||||
|
||||
export interface SealedBackup {
|
||||
format: typeof SEALED_BACKUP_FORMAT;
|
||||
version: typeof SEALED_BACKUP_VERSION;
|
||||
algorithm: 'A256GCM+gzip';
|
||||
/** Tells which key a file needs, without revealing the key. */
|
||||
keyId: string;
|
||||
exportedAt: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
ciphertext: string;
|
||||
}
|
||||
|
||||
function rootSecret(): { secret: string; source: 'backup' | 'jwt' } {
|
||||
const backupKey = process.env.BACKUP_ENCRYPTION_KEY;
|
||||
if (backupKey) {
|
||||
if (backupKey.length < MIN_KEY_LENGTH) {
|
||||
throw new Error(
|
||||
`BACKUP_ENCRYPTION_KEY must be at least ${MIN_KEY_LENGTH} characters`,
|
||||
);
|
||||
}
|
||||
return { secret: backupKey, source: 'backup' };
|
||||
}
|
||||
const jwtSecret = process.env.JWT_ACCESS_SECRET;
|
||||
if (!jwtSecret) {
|
||||
throw new Error('JWT_ACCESS_SECRET is not set');
|
||||
}
|
||||
return { secret: jwtSecret, source: 'jwt' };
|
||||
}
|
||||
|
||||
function deriveKey(): { key: Buffer; keyId: string } {
|
||||
const { secret, source } = rootSecret();
|
||||
const key = Buffer.from(
|
||||
crypto.hkdfSync(
|
||||
'sha256',
|
||||
secret,
|
||||
'apicore-backup',
|
||||
`apicore-backup-v${SEALED_BACKUP_VERSION}:${source}`,
|
||||
32,
|
||||
),
|
||||
);
|
||||
const keyId = crypto
|
||||
.createHash('sha256')
|
||||
.update(key)
|
||||
.digest('hex')
|
||||
.slice(0, 12);
|
||||
return { key, keyId };
|
||||
}
|
||||
|
||||
function associatedData(
|
||||
header: Pick<SealedBackup, 'format' | 'version' | 'keyId' | 'exportedAt'>,
|
||||
): Buffer {
|
||||
return Buffer.from(
|
||||
`${header.format}|${header.version}|${header.keyId}|${header.exportedAt}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function sealBackup(bundle: unknown, exportedAt: string): SealedBackup {
|
||||
const { key, keyId } = deriveKey();
|
||||
const iv = crypto.randomBytes(IV_BYTES);
|
||||
const header = {
|
||||
format: SEALED_BACKUP_FORMAT,
|
||||
version: SEALED_BACKUP_VERSION,
|
||||
keyId,
|
||||
exportedAt,
|
||||
} as const;
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
cipher.setAAD(associatedData(header));
|
||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(bundle)));
|
||||
const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
|
||||
return {
|
||||
...header,
|
||||
algorithm: 'A256GCM+gzip',
|
||||
iv: iv.toString('base64'),
|
||||
tag: cipher.getAuthTag().toString('base64'),
|
||||
ciphertext: ciphertext.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
export function isSealedBackup(value: unknown): value is SealedBackup {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
return (
|
||||
candidate.format === SEALED_BACKUP_FORMAT &&
|
||||
typeof candidate.ciphertext === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/** The bundle inside a sealed backup; throws if the file was not made with this key or was changed. */
|
||||
export function openBackup(sealed: SealedBackup): unknown {
|
||||
if (sealed.version !== SEALED_BACKUP_VERSION) {
|
||||
throw new BadRequestException(
|
||||
`Backup-Version ${String(sealed.version)} wird nicht unterstützt`,
|
||||
);
|
||||
}
|
||||
const { key, keyId } = deriveKey();
|
||||
if (sealed.keyId !== keyId) {
|
||||
throw new BadRequestException(
|
||||
'Backup wurde mit einem anderen Schlüssel erstellt (BACKUP_ENCRYPTION_KEY prüfen)',
|
||||
);
|
||||
}
|
||||
let compressed: Buffer;
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv(
|
||||
ALGORITHM,
|
||||
key,
|
||||
Buffer.from(sealed.iv, 'base64'),
|
||||
);
|
||||
decipher.setAAD(associatedData(sealed));
|
||||
decipher.setAuthTag(Buffer.from(sealed.tag, 'base64'));
|
||||
compressed = Buffer.concat([
|
||||
decipher.update(Buffer.from(sealed.ciphertext, 'base64')),
|
||||
decipher.final(),
|
||||
]);
|
||||
} catch {
|
||||
throw new BadRequestException(
|
||||
'Backup-Datei ist beschädigt oder wurde verändert',
|
||||
);
|
||||
}
|
||||
let json: string;
|
||||
try {
|
||||
json = zlib
|
||||
.gunzipSync(compressed, { maxOutputLength: MAX_PLAINTEXT_BYTES })
|
||||
.toString('utf-8');
|
||||
} catch {
|
||||
throw new BadRequestException('Backup-Datei ist zu groß oder beschädigt');
|
||||
}
|
||||
return JSON.parse(json) as unknown;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
DEFAULT_BACKUP_MAX_IMPORT_BYTES,
|
||||
backupMaxImportBytes,
|
||||
exceedsBackupImportLimit,
|
||||
} from './backup-import-limit';
|
||||
|
||||
describe('backup-import-limit', () => {
|
||||
const original = process.env.BACKUP_MAX_IMPORT_BYTES;
|
||||
let warn: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warn.mockRestore();
|
||||
if (original === undefined) {
|
||||
delete process.env.BACKUP_MAX_IMPORT_BYTES;
|
||||
} else {
|
||||
process.env.BACKUP_MAX_IMPORT_BYTES = original;
|
||||
}
|
||||
});
|
||||
|
||||
it('allows 50 MB when BACKUP_MAX_IMPORT_BYTES is not set', () => {
|
||||
delete process.env.BACKUP_MAX_IMPORT_BYTES;
|
||||
|
||||
expect(DEFAULT_BACKUP_MAX_IMPORT_BYTES).toBe(50 * 1024 * 1024);
|
||||
expect(backupMaxImportBytes()).toBe(DEFAULT_BACKUP_MAX_IMPORT_BYTES);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('takes a positive byte count from BACKUP_MAX_IMPORT_BYTES', () => {
|
||||
process.env.BACKUP_MAX_IMPORT_BYTES = '104857600';
|
||||
|
||||
expect(backupMaxImportBytes()).toBe(104857600);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['abc', '0', '-5', '1.5', '50MB'])(
|
||||
'falls back to the default with a logged warning for "%s"',
|
||||
(value) => {
|
||||
process.env.BACKUP_MAX_IMPORT_BYTES = value;
|
||||
|
||||
expect(backupMaxImportBytes()).toBe(DEFAULT_BACKUP_MAX_IMPORT_BYTES);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('BACKUP_MAX_IMPORT_BYTES'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('caps a value beyond 256 MB, since the whole upload is held in memory', () => {
|
||||
process.env.BACKUP_MAX_IMPORT_BYTES = String(8 * 1024 * 1024 * 1024);
|
||||
|
||||
expect(backupMaxImportBytes()).toBe(256 * 1024 * 1024);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('warns only once for the same invalid value', () => {
|
||||
process.env.BACKUP_MAX_IMPORT_BYTES = 'not-a-number';
|
||||
|
||||
backupMaxImportBytes();
|
||||
backupMaxImportBytes();
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('flags only a file larger than the limit', () => {
|
||||
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
|
||||
|
||||
expect(exceedsBackupImportLimit(1000)).toBe(false);
|
||||
expect(exceedsBackupImportLimit(1001)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Largest backup file POST /backup/import accepts - and the size every
|
||||
* export is checked against, so a backup that could no longer be restored
|
||||
* is noticed when it is written, not on the day it is needed. A full bundle
|
||||
* is far larger than the 5mb CSV import cap elsewhere (dozens of tables,
|
||||
* years of records, document contents as base64), hence its own limit.
|
||||
*
|
||||
* BACKUP_MAX_IMPORT_BYTES overrides the default. Like the other BACKUP_*
|
||||
* variables, an unusable value falls back instead of stopping the server,
|
||||
* here with a logged warning.
|
||||
*/
|
||||
export const DEFAULT_BACKUP_MAX_IMPORT_BYTES = 50 * 1024 * 1024;
|
||||
// The whole upload is held in memory, and parsed and decoded from there
|
||||
// (a string near V8's ~512 MB limit, a 512Mi pod) - no setting goes beyond.
|
||||
const BACKUP_MAX_IMPORT_CEILING_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
const logger = new Logger('BackupImportLimit');
|
||||
// Parsed once per distinct env value, so an invalid one warns only once.
|
||||
let resolved: { raw: string | undefined; bytes: number } | undefined;
|
||||
|
||||
function parseLimit(raw: string | undefined): number {
|
||||
if (raw === undefined || raw.trim() === '') {
|
||||
return DEFAULT_BACKUP_MAX_IMPORT_BYTES;
|
||||
}
|
||||
const bytes = Number(raw);
|
||||
if (!Number.isInteger(bytes) || bytes <= 0) {
|
||||
logger.warn(
|
||||
`BACKUP_MAX_IMPORT_BYTES="${raw}" ist keine positive ganze Zahl (Bytes) - es gilt der Standard von ${DEFAULT_BACKUP_MAX_IMPORT_BYTES} Bytes`,
|
||||
);
|
||||
return DEFAULT_BACKUP_MAX_IMPORT_BYTES;
|
||||
}
|
||||
if (bytes > BACKUP_MAX_IMPORT_CEILING_BYTES) {
|
||||
logger.warn(
|
||||
`BACKUP_MAX_IMPORT_BYTES=${bytes} liegt über der Obergrenze - es gelten ${BACKUP_MAX_IMPORT_CEILING_BYTES} Bytes`,
|
||||
);
|
||||
return BACKUP_MAX_IMPORT_CEILING_BYTES;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function backupMaxImportBytes(): number {
|
||||
const raw = process.env.BACKUP_MAX_IMPORT_BYTES;
|
||||
if (!resolved || resolved.raw !== raw) {
|
||||
resolved = { raw, bytes: parseLimit(raw) };
|
||||
}
|
||||
return resolved.bytes;
|
||||
}
|
||||
|
||||
/** True when a backup file of this size would be refused on import. */
|
||||
export function exceedsBackupImportLimit(sizeBytes: number): boolean {
|
||||
return sizeBytes > backupMaxImportBytes();
|
||||
}
|
||||
|
||||
/** A byte count as "52.4 MB" for log lines. */
|
||||
export function formatMegabytes(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
assertBackupCron,
|
||||
assertGuiBackupPath,
|
||||
resolveGuiBackupPath,
|
||||
} from './backup-schedule-rules';
|
||||
|
||||
describe('backup-schedule-rules', () => {
|
||||
const originalRoot = process.env.BACKUP_ROOT;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.BACKUP_ROOT = path.resolve('/data/backups');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env.BACKUP_ROOT = originalRoot;
|
||||
});
|
||||
|
||||
it('keeps a GUI path under the backup root', () => {
|
||||
expect(resolveGuiBackupPath('nightly')).toBe(
|
||||
path.resolve('/data/backups/nightly'),
|
||||
);
|
||||
expect(() => assertGuiBackupPath('nightly')).not.toThrow();
|
||||
});
|
||||
|
||||
it('refuses a GUI path that leaves the backup root', () => {
|
||||
expect(() => assertGuiBackupPath('../../app/public')).toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(() => assertGuiBackupPath('/etc')).toThrow(BadRequestException);
|
||||
expect(() => assertGuiBackupPath('/data/backups-evil')).toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a daily schedule', () => {
|
||||
expect(() => assertBackupCron('0 3 * * *')).not.toThrow();
|
||||
});
|
||||
|
||||
it('refuses a schedule that runs more than hourly or has seconds', () => {
|
||||
expect(() => assertBackupCron('* * * * *')).toThrow(BadRequestException);
|
||||
expect(() => assertBackupCron('*/10 * * * *')).toThrow(BadRequestException);
|
||||
expect(() => assertBackupCron('* * * * * *')).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('refuses an invalid expression', () => {
|
||||
expect(() => assertBackupCron('every day')).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { CronTime, validateCronExpression } from 'cron';
|
||||
import * as path from 'node:path';
|
||||
|
||||
/**
|
||||
* Limits on what the admin UI may configure for automatic backups. The env
|
||||
* variables (set by the operator) stay unrestricted; a GUI value is data a
|
||||
* logged-in user typed, so it may neither write a full dump anywhere on the
|
||||
* server's file system nor run an export every second.
|
||||
*/
|
||||
const MIN_INTERVAL_MS = 60 * 60 * 1000;
|
||||
const INTERVAL_SAMPLES = 48;
|
||||
|
||||
/** Root every GUI-configured backup directory must stay under. */
|
||||
export function backupRoot(): string {
|
||||
return path.resolve(process.env.BACKUP_ROOT ?? './backups');
|
||||
}
|
||||
|
||||
/** The GUI path resolved under backupRoot(), or null if it would leave it. */
|
||||
export function resolveGuiBackupPath(value: string): string | null {
|
||||
const root = backupRoot();
|
||||
const resolved = path.resolve(root, value);
|
||||
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function assertGuiBackupPath(value: string): void {
|
||||
if (resolveGuiBackupPath(value) === null) {
|
||||
throw new BadRequestException(
|
||||
`Backup-Pfad muss innerhalb von ${backupRoot()} liegen`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** A standard five-field cron expression that fires at most once an hour. */
|
||||
export function assertBackupCron(expression: string): void {
|
||||
const fields = expression.trim().split(/\s+/);
|
||||
if (fields.length !== 5 || !validateCronExpression(expression).valid) {
|
||||
throw new BadRequestException(
|
||||
'Zeitplan muss ein Cron-Ausdruck mit fünf Feldern sein, z. B. "0 3 * * *"',
|
||||
);
|
||||
}
|
||||
const runs = new CronTime(expression)
|
||||
.sendAt(INTERVAL_SAMPLES)
|
||||
.map((run) => run.toMillis());
|
||||
for (let i = 1; i < runs.length; i++) {
|
||||
if (runs[i] - runs[i - 1] < MIN_INTERVAL_MS) {
|
||||
throw new BadRequestException(
|
||||
'Zeitplan darf höchstens einmal pro Stunde sichern',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { CronJob } from 'cron';
|
||||
import * as path from 'node:path';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { BackupSchedulerService } from './backup-scheduler.service';
|
||||
import { BackupService } from './backup.service';
|
||||
import { BackupSettingsService } from './backup-settings.service';
|
||||
|
||||
jest.mock('node:fs/promises', () => ({
|
||||
mkdir: jest.fn(),
|
||||
writeFile: jest.fn(),
|
||||
readdir: jest.fn(),
|
||||
stat: jest.fn(),
|
||||
unlink: jest.fn(),
|
||||
}));
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
|
||||
describe('BackupSchedulerService', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
const directory = path.resolve('/data/backups');
|
||||
let service: BackupSchedulerService;
|
||||
let backupService: { exportSealed: jest.Mock };
|
||||
let error: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.BACKUP_STORAGE_PATH = directory;
|
||||
delete process.env.BACKUP_MAX_IMPORT_BYTES;
|
||||
delete process.env.BACKUP_RETENTION_COUNT;
|
||||
delete process.env.BACKUP_CRON_SCHEDULE;
|
||||
error = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
|
||||
|
||||
backupService = {
|
||||
exportSealed: jest.fn().mockResolvedValue({
|
||||
format: 'apicore-backup',
|
||||
exportedAt: '2026-10-12T03:00:00.000Z',
|
||||
ciphertext: 'x'.repeat(2000),
|
||||
}),
|
||||
};
|
||||
const settings = {
|
||||
get: jest.fn().mockResolvedValue({
|
||||
storagePath: null,
|
||||
cronSchedule: null,
|
||||
retentionCount: null,
|
||||
}),
|
||||
};
|
||||
service = new BackupSchedulerService(
|
||||
backupService as unknown as BackupService,
|
||||
settings as unknown as BackupSettingsService,
|
||||
{} as SchedulerRegistry,
|
||||
);
|
||||
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||
(fs.readdir as jest.Mock).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
error.mockRestore();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
function onDisk(files: { name: string; size: number; mtime: string }[]) {
|
||||
(fs.readdir as jest.Mock).mockResolvedValue(files.map((f) => f.name));
|
||||
(fs.stat as jest.Mock).mockImplementation((filePath: string) => {
|
||||
const file = files.find((f) => filePath.endsWith(f.name));
|
||||
return Promise.resolve({
|
||||
size: file?.size,
|
||||
mtime: new Date(file!.mtime),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('reports the import limit and the size of the newest backup', async () => {
|
||||
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
|
||||
onDisk([
|
||||
{
|
||||
name: 'apicore-backup-old.json',
|
||||
size: 5000,
|
||||
mtime: '2026-10-10T03:00:00.000Z',
|
||||
},
|
||||
{
|
||||
name: 'apicore-backup-new.json',
|
||||
size: 900,
|
||||
mtime: '2026-10-11T03:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
const status = await service.getStatus();
|
||||
|
||||
expect(status).toEqual(
|
||||
expect.objectContaining({
|
||||
importLimitBytes: 1000,
|
||||
lastBackupSizeBytes: 900,
|
||||
lastBackupExceedsImportLimit: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('warns when the newest backup could no longer be imported', async () => {
|
||||
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
|
||||
onDisk([
|
||||
{
|
||||
name: 'apicore-backup-new.json',
|
||||
size: 1001,
|
||||
mtime: '2026-10-11T03:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
const status = await service.getStatus();
|
||||
|
||||
expect(status.lastBackupSizeBytes).toBe(1001);
|
||||
expect(status.lastBackupExceedsImportLimit).toBe(true);
|
||||
});
|
||||
|
||||
it('still reports the import limit while no schedule is configured', async () => {
|
||||
delete process.env.BACKUP_STORAGE_PATH;
|
||||
|
||||
const status = await service.getStatus();
|
||||
|
||||
expect(status).toEqual(
|
||||
expect.objectContaining({
|
||||
enabled: false,
|
||||
importLimitBytes: 50 * 1024 * 1024,
|
||||
lastBackupSizeBytes: null,
|
||||
lastBackupExceedsImportLimit: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runNow', () => {
|
||||
it('keeps a backup larger than the import limit but logs an error', async () => {
|
||||
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
|
||||
|
||||
const result = await service.runNow();
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||
expect(result.fileName).toMatch(/^apicore-backup-.*\.json$/);
|
||||
expect(error).toHaveBeenCalledTimes(1);
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Import-Limit'),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs nothing for a backup within the import limit', async () => {
|
||||
await service.runNow();
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// The export reads every table through Prisma; a Prisma message quotes
|
||||
// query values, so a failed scheduled run logs name, code and model only.
|
||||
describe('scheduled run', () => {
|
||||
it('logs a failed scheduled backup without the Prisma message', async () => {
|
||||
const registry = {
|
||||
doesExist: jest.fn().mockReturnValue(false),
|
||||
addCronJob: jest.fn(),
|
||||
deleteCronJob: jest.fn(),
|
||||
};
|
||||
const scheduled = new BackupSchedulerService(
|
||||
backupService as unknown as BackupService,
|
||||
{
|
||||
get: jest.fn().mockResolvedValue({
|
||||
storagePath: null,
|
||||
cronSchedule: null,
|
||||
retentionCount: null,
|
||||
}),
|
||||
} as unknown as BackupSettingsService,
|
||||
registry as unknown as SchedulerRegistry,
|
||||
);
|
||||
backupService.exportSealed.mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
|
||||
code: 'P2010',
|
||||
clientVersion: '7.9.1',
|
||||
meta: { modelName: 'Task' },
|
||||
}),
|
||||
);
|
||||
await scheduled.reload();
|
||||
const [[, job]] = registry.addCronJob.mock.calls as [[string, CronJob]];
|
||||
await job.stop();
|
||||
|
||||
await job.fireOnTick();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const failure = error.mock.calls.find(([message]) =>
|
||||
String(message).startsWith('Scheduled backup failed'),
|
||||
) as [string, unknown];
|
||||
expect(failure[0]).toContain('P2010');
|
||||
expect(failure[0]).not.toContain('Mustermann');
|
||||
expect(failure[1]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,16 @@ import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { BackupService } from './backup.service';
|
||||
import { BackupSettingsService } from './backup-settings.service';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import {
|
||||
assertBackupCron,
|
||||
resolveGuiBackupPath,
|
||||
} from './backup-schedule-rules';
|
||||
import {
|
||||
backupMaxImportBytes,
|
||||
exceedsBackupImportLimit,
|
||||
formatMegabytes,
|
||||
} from './backup-import-limit';
|
||||
|
||||
const SCHEDULED_JOB_NAME = 'automatic-backup';
|
||||
const DEFAULT_CRON_SCHEDULE = '0 3 * * *';
|
||||
@@ -24,6 +34,13 @@ export interface BackupScheduleStatus {
|
||||
cronSchedule: string | null;
|
||||
retentionCount: number | null;
|
||||
lastRunAt: string | null;
|
||||
// Largest file POST /backup/import accepts (BACKUP_MAX_IMPORT_BYTES).
|
||||
importLimitBytes: number;
|
||||
// Size of the newest backup file - taken from the file itself, so the
|
||||
// warning survives restarts and is the same on every replica.
|
||||
lastBackupSizeBytes: number | null;
|
||||
// True when that file could not be restored without a raised limit.
|
||||
lastBackupExceedsImportLimit: boolean;
|
||||
// Which fields are currently pinned by an env var (and therefore not
|
||||
// editable via the GUI form) - env always wins over the GUI-stored value.
|
||||
envOverrides: {
|
||||
@@ -68,7 +85,13 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
const gui = await this.backupSettingsService.get();
|
||||
|
||||
return {
|
||||
path: hasEnvPath ? (envPath as string) : (gui.storagePath ?? null),
|
||||
// A GUI path counts only inside BACKUP_ROOT (a value stored before
|
||||
// that rule existed and pointing elsewhere disables the schedule).
|
||||
path: hasEnvPath
|
||||
? (envPath as string)
|
||||
: gui.storagePath
|
||||
? resolveGuiBackupPath(gui.storagePath)
|
||||
: null,
|
||||
cronSchedule: hasEnvCron
|
||||
? (envCron as string)
|
||||
: (gui.cronSchedule ?? DEFAULT_CRON_SCHEDULE),
|
||||
@@ -88,11 +111,22 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
// admin's schedule/path/retention change takes effect immediately without
|
||||
// a pod restart.
|
||||
async reload(): Promise<void> {
|
||||
const settings = await this.effectiveSettings();
|
||||
if (!settings.envOverrides.cronSchedule) {
|
||||
try {
|
||||
assertBackupCron(settings.cronSchedule);
|
||||
} catch {
|
||||
// Keep whatever is running instead of silently ending backups.
|
||||
this.logger.error(
|
||||
`Ungültiger Backup-Zeitplan "${settings.cronSchedule}" - bisheriger Zeitplan bleibt aktiv`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.schedulerRegistry.doesExist('cron', SCHEDULED_JOB_NAME)) {
|
||||
this.schedulerRegistry.deleteCronJob(SCHEDULED_JOB_NAME);
|
||||
}
|
||||
|
||||
const settings = await this.effectiveSettings();
|
||||
if (!settings.path) {
|
||||
this.logger.log(
|
||||
'Kein Backup-Pfad konfiguriert (weder Env noch GUI) - automatische Backups deaktiviert',
|
||||
@@ -104,9 +138,11 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
const job = CronJob.from({
|
||||
cronTime: settings.cronSchedule,
|
||||
onTick: () => {
|
||||
this.runScheduled(directory).catch((error) =>
|
||||
this.logger.error('Scheduled backup failed', error),
|
||||
);
|
||||
// Prisma errors by name/code/model only (see loggableError).
|
||||
this.runScheduled(directory).catch((error: unknown) => {
|
||||
const { message, stack } = loggableError(error);
|
||||
this.logger.error(`Scheduled backup failed: ${message}`, stack);
|
||||
});
|
||||
},
|
||||
start: true,
|
||||
});
|
||||
@@ -118,6 +154,7 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
|
||||
async getStatus(): Promise<BackupScheduleStatus> {
|
||||
const settings = await this.effectiveSettings();
|
||||
const importLimitBytes = backupMaxImportBytes();
|
||||
if (!settings.path) {
|
||||
return {
|
||||
enabled: false,
|
||||
@@ -125,12 +162,16 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
cronSchedule: null,
|
||||
retentionCount: null,
|
||||
lastRunAt: null,
|
||||
importLimitBytes,
|
||||
lastBackupSizeBytes: null,
|
||||
lastBackupExceedsImportLimit: false,
|
||||
envOverrides: settings.envOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
const files = await this.listBackupFiles(settings.path);
|
||||
const lastRunAt = files[0]?.mtime.toISOString() ?? null;
|
||||
const lastBackupSizeBytes = files[0]?.size ?? null;
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
@@ -138,13 +179,18 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
cronSchedule: settings.cronSchedule,
|
||||
retentionCount: settings.retentionCount,
|
||||
lastRunAt,
|
||||
importLimitBytes,
|
||||
lastBackupSizeBytes,
|
||||
lastBackupExceedsImportLimit:
|
||||
lastBackupSizeBytes !== null &&
|
||||
exceedsBackupImportLimit(lastBackupSizeBytes),
|
||||
envOverrides: settings.envOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Manual trigger (e.g. "Jetzt sichern" button) - always runs, no
|
||||
// duplicate-tick guard, since a human explicitly asked for it.
|
||||
async runNow(): Promise<{ filePath: string }> {
|
||||
async runNow(): Promise<{ fileName: string }> {
|
||||
const settings = await this.effectiveSettings();
|
||||
if (!settings.path) {
|
||||
throw new Error(
|
||||
@@ -153,7 +199,8 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
}
|
||||
const filePath = await this.writeBackup(settings.path);
|
||||
await this.pruneOldBackups(settings.path, settings.retentionCount);
|
||||
return { filePath };
|
||||
// Only the name: the server's directory layout stays on the server.
|
||||
return { fileName: path.basename(filePath) };
|
||||
}
|
||||
|
||||
private async runScheduled(directory: string): Promise<void> {
|
||||
@@ -176,14 +223,24 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
}
|
||||
|
||||
private async writeBackup(directory: string): Promise<string> {
|
||||
const bundle = await this.backupService.exportAll();
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
const timestamp = bundle.exportedAt.replace(/[:.]/g, '-');
|
||||
const sealed = await this.backupService.exportSealed();
|
||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const timestamp = sealed.exportedAt.replace(/[:.]/g, '-');
|
||||
const filePath = path.join(
|
||||
directory,
|
||||
`${BACKUP_FILE_PREFIX}${timestamp}${BACKUP_FILE_SUFFIX}`,
|
||||
);
|
||||
await fs.writeFile(filePath, JSON.stringify(bundle, null, 2));
|
||||
const content = JSON.stringify(sealed);
|
||||
// Owner-only: the file holds every account's credentials (encrypted).
|
||||
await fs.writeFile(filePath, content, { mode: 0o600 });
|
||||
const sizeBytes = Buffer.byteLength(content);
|
||||
if (exceedsBackupImportLimit(sizeBytes)) {
|
||||
// Kept anyway (a raised BACKUP_MAX_IMPORT_BYTES restores it); the
|
||||
// status reads the size from the file and warns in the UI.
|
||||
this.logger.error(
|
||||
`Backup ${path.basename(filePath)} ist ${formatMegabytes(sizeBytes)} groß und überschreitet das Import-Limit von ${formatMegabytes(backupMaxImportBytes())} - eine Wiederherstellung würde scheitern (BACKUP_MAX_IMPORT_BYTES erhöhen)`,
|
||||
);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
@@ -208,7 +265,7 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
|
||||
private async listBackupFiles(
|
||||
directory: string,
|
||||
): Promise<{ filePath: string; mtime: Date }[]> {
|
||||
): Promise<{ filePath: string; mtime: Date; size: number }[]> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(directory);
|
||||
@@ -226,7 +283,7 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
backupFiles.map(async (name) => {
|
||||
const filePath = path.join(directory, name);
|
||||
const stat = await fs.stat(filePath);
|
||||
return { filePath, mtime: stat.mtime };
|
||||
return { filePath, mtime: stat.mtime, size: stat.size };
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { BackupSettings } from '../../generated/prisma';
|
||||
import { UpdateBackupSettingsDto } from './dto/update-backup-settings.dto';
|
||||
import { assertBackupCron, assertGuiBackupPath } from './backup-schedule-rules';
|
||||
|
||||
const SINGLETON_ID = 'singleton';
|
||||
|
||||
@@ -31,6 +32,12 @@ export class BackupSettingsService {
|
||||
dto: UpdateBackupSettingsDto,
|
||||
actingUserId: string,
|
||||
): Promise<BackupSettings> {
|
||||
if (dto.storagePath) {
|
||||
assertGuiBackupPath(dto.storagePath);
|
||||
}
|
||||
if (dto.cronSchedule) {
|
||||
assertBackupCron(dto.cronSchedule);
|
||||
}
|
||||
const before = await this.get();
|
||||
const settings = await this.prisma.backupSettings.update({
|
||||
where: { id: SINGLETON_ID },
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { BackupController } from './backup.controller';
|
||||
import { BackupService } from './backup.service';
|
||||
import { BackupSchedulerService } from './backup-scheduler.service';
|
||||
import { BackupSettingsService } from './backup-settings.service';
|
||||
import { ReauthService } from '../auth/reauth/reauth.service';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
|
||||
describe('BackupController', () => {
|
||||
const originalLimit = process.env.BACKUP_MAX_IMPORT_BYTES;
|
||||
let controller: BackupController;
|
||||
let backupService: { importFile: jest.Mock; exportSealed: jest.Mock };
|
||||
let scheduler: { reload: jest.Mock };
|
||||
let reauth: { verify: jest.Mock };
|
||||
const actor = { id: 'admin-1' } as AuthenticatedUser;
|
||||
const file = {
|
||||
buffer: Buffer.from(JSON.stringify({ format: 'apicore-backup' })),
|
||||
} as Express.Multer.File;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.BACKUP_MAX_IMPORT_BYTES;
|
||||
backupService = {
|
||||
importFile: jest.fn().mockResolvedValue({ imported: {}, skipped: [] }),
|
||||
exportSealed: jest.fn().mockResolvedValue({
|
||||
format: 'apicore-backup',
|
||||
exportedAt: '2026-10-12T03:00:00.000Z',
|
||||
ciphertext: 'x'.repeat(2000),
|
||||
}),
|
||||
};
|
||||
scheduler = { reload: jest.fn().mockResolvedValue(undefined) };
|
||||
reauth = { verify: jest.fn().mockResolvedValue(undefined) };
|
||||
controller = new BackupController(
|
||||
backupService as unknown as BackupService,
|
||||
scheduler as unknown as BackupSchedulerService,
|
||||
{} as BackupSettingsService,
|
||||
reauth as unknown as ReauthService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalLimit === undefined) {
|
||||
delete process.env.BACKUP_MAX_IMPORT_BYTES;
|
||||
} else {
|
||||
process.env.BACKUP_MAX_IMPORT_BYTES = originalLimit;
|
||||
}
|
||||
});
|
||||
|
||||
it('re-registers the backup schedule after an import, since it may restore other backup settings', async () => {
|
||||
await controller.import(file, { password: 'secret' }, actor);
|
||||
|
||||
expect(backupService.importFile).toHaveBeenCalledTimes(1);
|
||||
expect(scheduler.reload).toHaveBeenCalledTimes(1);
|
||||
expect(scheduler.reload.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
backupService.importFile.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the schedule alone when the import fails', async () => {
|
||||
backupService.importFile.mockRejectedValue(new Error('broken file'));
|
||||
|
||||
await expect(
|
||||
controller.import(file, { password: 'secret' }, actor),
|
||||
).rejects.toThrow('broken file');
|
||||
expect(scheduler.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('export', () => {
|
||||
let warn: jest.SpyInstance;
|
||||
let res: { setHeader: jest.Mock; send: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||
res = { setHeader: jest.fn(), send: jest.fn() };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('still delivers a backup larger than the import limit, but logs a warning', async () => {
|
||||
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
|
||||
|
||||
await controller.export(res as unknown as Response);
|
||||
|
||||
expect(res.send).toHaveBeenCalledTimes(1);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Import-Limit'),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs nothing for a backup within the import limit', async () => {
|
||||
await controller.export(res as unknown as Response);
|
||||
|
||||
expect(res.send).toHaveBeenCalledTimes(1);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Logger,
|
||||
Patch,
|
||||
Post,
|
||||
Res,
|
||||
@@ -16,28 +17,32 @@ import { RequirePermission } from '../auth/decorators/require-permission.decorat
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { BackupSettings } from '../../generated/prisma';
|
||||
import { BackupBundle, BackupService } from './backup.service';
|
||||
import { BackupService } from './backup.service';
|
||||
import { ReauthService } from '../auth/reauth/reauth.service';
|
||||
import { BackupImportProofDto } from './dto/backup-import-proof.dto';
|
||||
import {
|
||||
BackupScheduleStatus,
|
||||
BackupSchedulerService,
|
||||
} from './backup-scheduler.service';
|
||||
import { BackupSettingsService } from './backup-settings.service';
|
||||
import { UpdateBackupSettingsDto } from './dto/update-backup-settings.dto';
|
||||
|
||||
// A full backup bundle can be much larger than the 5mb CSV import cap
|
||||
// elsewhere (dozens of tables, potentially years of records, plus embedded
|
||||
// label-template images) - 50mb comfortably covers a real dataset without
|
||||
// raising the app-wide JSON body limit for every other route.
|
||||
const MAX_BACKUP_FILE_SIZE_BYTES = 50 * 1024 * 1024;
|
||||
import {
|
||||
backupMaxImportBytes,
|
||||
exceedsBackupImportLimit,
|
||||
formatMegabytes,
|
||||
} from './backup-import-limit';
|
||||
|
||||
@ApiTags('backup')
|
||||
@ApiBearerAuth()
|
||||
@Controller('backup')
|
||||
export class BackupController {
|
||||
private readonly logger = new Logger(BackupController.name);
|
||||
|
||||
constructor(
|
||||
private readonly backupService: BackupService,
|
||||
private readonly backupSchedulerService: BackupSchedulerService,
|
||||
private readonly backupSettingsService: BackupSettingsService,
|
||||
private readonly reauth: ReauthService,
|
||||
) {}
|
||||
|
||||
@RequirePermission('backup:export')
|
||||
@@ -48,7 +53,7 @@ export class BackupController {
|
||||
|
||||
@RequirePermission('backup:export')
|
||||
@Post('schedule/run-now')
|
||||
runScheduleNow(): Promise<{ filePath: string }> {
|
||||
runScheduleNow(): Promise<{ fileName: string }> {
|
||||
return this.backupSchedulerService.runNow();
|
||||
}
|
||||
|
||||
@@ -72,19 +77,30 @@ export class BackupController {
|
||||
@RequirePermission('backup:export')
|
||||
@Get('export')
|
||||
async export(@Res() res: Response): Promise<void> {
|
||||
const bundle = await this.backupService.exportAll();
|
||||
const sealed = await this.backupService.exportSealed();
|
||||
const body = JSON.stringify(sealed);
|
||||
const sizeBytes = Buffer.byteLength(body);
|
||||
if (exceedsBackupImportLimit(sizeBytes)) {
|
||||
// Still delivered: losing the data is worse than needing a raised
|
||||
// BACKUP_MAX_IMPORT_BYTES to restore it. The UI compares the file
|
||||
// against the limit from GET /backup/schedule and warns there.
|
||||
this.logger.warn(
|
||||
`Exportiertes Backup ist ${formatMegabytes(sizeBytes)} groß und überschreitet das Import-Limit von ${formatMegabytes(backupMaxImportBytes())} - eine Wiederherstellung würde scheitern`,
|
||||
);
|
||||
}
|
||||
const filename = `apicore-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(JSON.stringify(bundle, null, 2));
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.send(body);
|
||||
}
|
||||
|
||||
@RequirePermission('backup:import')
|
||||
@Post('import')
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(
|
||||
// Size limit from MulterModule in BackupModule (BACKUP_MAX_IMPORT_BYTES).
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: MAX_BACKUP_FILE_SIZE_BYTES },
|
||||
fileFilter: (_req, file, callback) => {
|
||||
if (!file.originalname.toLowerCase().endsWith('.json')) {
|
||||
callback(
|
||||
@@ -99,17 +115,23 @@ export class BackupController {
|
||||
)
|
||||
async import(
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body() proof: BackupImportProofDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
// Restoring overwrites every account: prove it is really you, now.
|
||||
await this.reauth.verify(actor.id, proof);
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file uploaded');
|
||||
}
|
||||
let bundle: BackupBundle;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
bundle = JSON.parse(file.buffer.toString('utf-8')) as BackupBundle;
|
||||
parsed = JSON.parse(file.buffer.toString('utf-8'));
|
||||
} catch {
|
||||
throw new BadRequestException('Datei ist kein gültiges JSON');
|
||||
}
|
||||
return this.backupService.importAll(bundle, actor.id);
|
||||
const summary = await this.backupService.importFile(parsed, actor.id);
|
||||
// The backup may have restored the schedule's settings too.
|
||||
await this.backupSchedulerService.reload();
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { ReauthModule } from '../auth/reauth/reauth.module';
|
||||
import { BackupController } from './backup.controller';
|
||||
import { BackupService } from './backup.service';
|
||||
import { BackupSchedulerService } from './backup-scheduler.service';
|
||||
import { BackupSettingsService } from './backup-settings.service';
|
||||
import { backupMaxImportBytes } from './backup-import-limit';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ReauthModule,
|
||||
// Upload limit of POST /backup/import. A factory rather than a constant
|
||||
// in the decorator: it runs once the app starts, after ConfigModule has
|
||||
// loaded .env, so the limit is the same one exports are checked against.
|
||||
MulterModule.registerAsync({
|
||||
useFactory: () => ({ limits: { fileSize: backupMaxImportBytes() } }),
|
||||
}),
|
||||
],
|
||||
controllers: [BackupController],
|
||||
providers: [BackupService, BackupSchedulerService, BackupSettingsService],
|
||||
})
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { BackupService } from './backup.service';
|
||||
import { BadRequestException, Logger } from '@nestjs/common';
|
||||
import * as path from 'node:path';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import {
|
||||
BackupService,
|
||||
LEGACY_UNSEALED_TABLES,
|
||||
MODELS_EXCLUDED_FROM_BACKUP,
|
||||
} from './backup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
|
||||
jest.mock('node:fs/promises', () => ({
|
||||
readFile: jest.fn(),
|
||||
writeFile: jest.fn(),
|
||||
mkdir: jest.fn(),
|
||||
unlink: jest.fn(),
|
||||
}));
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
@@ -24,6 +32,7 @@ const BUSINESS_ENTITY_KEYS = [
|
||||
'task',
|
||||
'varroaMeasurement',
|
||||
'swarmControlCheck',
|
||||
'winterCheck',
|
||||
'treatment',
|
||||
'feeding',
|
||||
'harvest',
|
||||
@@ -34,15 +43,25 @@ const BUSINESS_ENTITY_KEYS = [
|
||||
'honeyBatch',
|
||||
'honeyMeasurement',
|
||||
'honeyBatchRelocation',
|
||||
'honeyBatchContainerAllocation',
|
||||
'stockItem',
|
||||
'stockMovement',
|
||||
'product',
|
||||
'labelTemplate',
|
||||
'bottlingBatch',
|
||||
'rawMaterialLot',
|
||||
'productionBatch',
|
||||
'productionInput',
|
||||
'shopStockRequest',
|
||||
'batchMovement',
|
||||
'equipment',
|
||||
'webhookSubscription',
|
||||
'automationRule',
|
||||
'automationSequence',
|
||||
'siteSettings',
|
||||
'backupSettings',
|
||||
'calendarSyncSettings',
|
||||
'calendarSyncOutboxEntry',
|
||||
] as const;
|
||||
|
||||
const ALL_ENTITY_KEYS = [
|
||||
@@ -55,75 +74,80 @@ const ALL_ENTITY_KEYS = [
|
||||
'auditLog',
|
||||
] as const;
|
||||
|
||||
// Prisma delegate name of a model: `productionBatch` for ProductionBatch.
|
||||
function modelKey(model: string): string {
|
||||
return model.charAt(0).toLowerCase() + model.slice(1);
|
||||
}
|
||||
|
||||
// Every model the schema has - so a table added later is mocked too and the
|
||||
// coverage guard below reports it instead of crashing on a missing mock.
|
||||
const MODEL_KEYS = Object.values(Prisma.ModelName).map(modelKey);
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
describe('BackupService', () => {
|
||||
let service: BackupService;
|
||||
let prisma: Record<string, { findMany: jest.Mock }> & {
|
||||
user: { findMany: jest.Mock };
|
||||
rolePermission: { findMany: jest.Mock };
|
||||
document: { findMany: jest.Mock };
|
||||
document: { findMany: jest.Mock; count: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let tx: Record<string, { upsert: jest.Mock; update?: jest.Mock }> & {
|
||||
permission: { findUnique: jest.Mock };
|
||||
user: { upsert: jest.Mock; findMany: jest.Mock };
|
||||
queen: { upsert: jest.Mock; update: jest.Mock; findMany: jest.Mock };
|
||||
document: { upsert: jest.Mock; findUnique: jest.Mock };
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let permissionResolver: { invalidateAll: jest.Mock };
|
||||
const TRUSTED = { trusted: true };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
tx = {} as never;
|
||||
for (const key of [
|
||||
...BUSINESS_ENTITY_KEYS,
|
||||
'role',
|
||||
'user',
|
||||
'rolePermission',
|
||||
'document',
|
||||
'notification',
|
||||
'auditLog',
|
||||
]) {
|
||||
for (const key of MODEL_KEYS) {
|
||||
tx[key] = {
|
||||
upsert: jest.fn().mockResolvedValue({ id: `${key}-upserted` }),
|
||||
};
|
||||
}
|
||||
tx.queen.update = jest.fn().mockResolvedValue({});
|
||||
tx.queen.findMany = jest.fn().mockResolvedValue([]);
|
||||
tx.document.findUnique = jest.fn().mockResolvedValue(null);
|
||||
tx.permission = {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'permission-1' }),
|
||||
};
|
||||
tx.user.findMany = jest.fn().mockResolvedValue([{ id: 'known-user' }]);
|
||||
(tx.role as unknown as { findMany: jest.Mock }).findMany = jest
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
|
||||
prisma = {
|
||||
user: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
rolePermission: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
document: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
||||
callback(tx),
|
||||
),
|
||||
} as never;
|
||||
for (const key of [
|
||||
'role',
|
||||
...BUSINESS_ENTITY_KEYS,
|
||||
'notification',
|
||||
'auditLog',
|
||||
]) {
|
||||
for (const key of MODEL_KEYS) {
|
||||
prisma[key] = { findMany: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
prisma.document.count = jest.fn().mockResolvedValue(0);
|
||||
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
permissionResolver = { invalidateAll: jest.fn() };
|
||||
service = new BackupService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
permissionResolver as unknown as PermissionResolverService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('exportAll', () => {
|
||||
it('returns a version 2 bundle with every entity key, including role/user/document', async () => {
|
||||
it('returns a version 3 bundle with every entity key, including role/user/document', async () => {
|
||||
const bundle = await service.exportAll();
|
||||
|
||||
expect(bundle.version).toBe(2);
|
||||
expect(bundle.version).toBe(3);
|
||||
expect(typeof bundle.exportedAt).toBe('string');
|
||||
for (const key of ALL_ENTITY_KEYS) {
|
||||
expect(bundle.data).toHaveProperty(key);
|
||||
@@ -197,7 +221,7 @@ describe('BackupService', () => {
|
||||
describe('importAll', () => {
|
||||
it('rejects a bundle with no data object', async () => {
|
||||
await expect(
|
||||
service.importAll({ version: 2 } as never, 'admin-1'),
|
||||
service.importAll({ version: 2 } as never, 'admin-1', TRUSTED),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -210,6 +234,7 @@ describe('BackupService', () => {
|
||||
data: { role: [{ id: 'source-id', key: 'ADMIN', name: 'Admin' }] },
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.role.upsert.mock.calls as [
|
||||
@@ -232,6 +257,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.user.upsert.mock.calls as [
|
||||
@@ -251,6 +277,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
@@ -274,6 +301,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
expect(tx.rolePermission.upsert).toHaveBeenCalledTimes(1);
|
||||
@@ -292,6 +320,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
expect(tx.queen.upsert).toHaveBeenCalledTimes(2);
|
||||
@@ -325,6 +354,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.inspection.upsert.mock.calls as [
|
||||
@@ -351,6 +381,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.inspection.upsert.mock.calls as [
|
||||
@@ -371,6 +402,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.stockMovement.upsert.mock.calls as [
|
||||
@@ -395,6 +427,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.automationRule.upsert.mock.calls as [
|
||||
@@ -421,14 +454,14 @@ describe('BackupService', () => {
|
||||
document: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
entityType: 'Colony',
|
||||
entityType: 'COLONY',
|
||||
mimeType: 'application/pdf',
|
||||
uploadedByUserId: 'known-user',
|
||||
contentBase64: Buffer.from('hello').toString('base64'),
|
||||
contentBase64: Buffer.from('%PDF-1.4 hello').toString('base64'),
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
entityType: 'Colony',
|
||||
entityType: 'COLONY',
|
||||
mimeType: 'application/pdf',
|
||||
uploadedByUserId: null,
|
||||
contentBase64: null,
|
||||
@@ -437,6 +470,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||
@@ -456,6 +490,7 @@ describe('BackupService', () => {
|
||||
data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] },
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
@@ -463,9 +498,673 @@ describe('BackupService', () => {
|
||||
entityType: 'Backup',
|
||||
action: 'CREATE',
|
||||
userId: 'admin-1',
|
||||
afterData: expect.objectContaining({ apiary: 1 }) as unknown,
|
||||
afterData: expect.objectContaining({
|
||||
imported: expect.objectContaining({ apiary: 1 }) as unknown,
|
||||
}) as unknown,
|
||||
}),
|
||||
);
|
||||
expect(permissionResolver.invalidateAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a document whose entityType would leave the storage root', async () => {
|
||||
await expect(
|
||||
service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
document: [
|
||||
{
|
||||
id: 'doc-evil',
|
||||
entityType: '../../app/dist',
|
||||
mimeType: 'application/pdf',
|
||||
contentBase64: Buffer.from('%PDF-1.4').toString('base64'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(fs.mkdir).not.toHaveBeenCalled();
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a document whose bytes do not match its declared type', async () => {
|
||||
await expect(
|
||||
service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
document: [
|
||||
{
|
||||
id: 'doc-html',
|
||||
entityType: 'COLONY',
|
||||
mimeType: 'application/pdf',
|
||||
contentBase64: Buffer.from('<script>').toString('base64'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never rewrites audit entries that already exist', async () => {
|
||||
await service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: { auditLog: [{ id: 'audit-1', action: 'LOGIN' }] },
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
const [[call]] = tx.auditLog.upsert.mock.calls as [
|
||||
[{ update: Record<string, unknown> }],
|
||||
];
|
||||
expect(call.update).toEqual({});
|
||||
});
|
||||
|
||||
it('takes no users, roles, webhooks or audit entries from an unsealed file', async () => {
|
||||
const summary = await service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
role: [{ key: 'ADMIN', name: 'Admin' }],
|
||||
user: [
|
||||
{
|
||||
id: 'admin-victim',
|
||||
email: '[email protected]',
|
||||
passwordHash: 'attacker-hash',
|
||||
roleKey: 'ADMIN',
|
||||
},
|
||||
],
|
||||
webhookSubscription: [{ id: 'hook-1', url: 'https://evil' }],
|
||||
auditLog: [{ id: 'audit-1' }],
|
||||
apiary: [{ id: 'apiary-1', name: 'Stand Wald' }],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
{ trusted: false },
|
||||
);
|
||||
|
||||
expect(tx.role.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.user.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.webhookSubscription.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.apiary.upsert).toHaveBeenCalledTimes(1);
|
||||
expect(summary.skipped).toEqual(
|
||||
expect.arrayContaining(['role', 'user', 'webhookSubscription']),
|
||||
);
|
||||
});
|
||||
|
||||
it('restores production, raw material and stock ledger rows by id, remapping their optional userId', async () => {
|
||||
await service.importAll(
|
||||
{
|
||||
version: 3,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
rawMaterialLot: [{ id: 'lot-1', userId: 'ghost-user' }],
|
||||
productionBatch: [
|
||||
{ id: 'batch-1', productId: 'product-1', userId: 'known-user' },
|
||||
],
|
||||
productionInput: [
|
||||
{
|
||||
id: 'input-1',
|
||||
productionBatchId: 'batch-1',
|
||||
rawMaterialLotId: 'lot-1',
|
||||
},
|
||||
],
|
||||
shopStockRequest: [{ id: 'request-1', externalId: 'order-1' }],
|
||||
batchMovement: [
|
||||
{
|
||||
id: 'movement-1',
|
||||
productionBatchId: 'batch-1',
|
||||
shopRequestId: 'request-1',
|
||||
userId: null,
|
||||
},
|
||||
],
|
||||
honeyBatchContainerAllocation: [
|
||||
{
|
||||
id: 'allocation-1',
|
||||
honeyBatchId: 'honey-1',
|
||||
storageContainerId: 'container-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const firstCall = (key: string) =>
|
||||
(
|
||||
tx[key].upsert.mock.calls as [
|
||||
{
|
||||
where: Record<string, unknown>;
|
||||
create: Record<string, unknown>;
|
||||
},
|
||||
][]
|
||||
)[0][0];
|
||||
expect(firstCall('rawMaterialLot').where).toEqual({ id: 'lot-1' });
|
||||
expect(firstCall('rawMaterialLot').create.userId).toBe('admin-1');
|
||||
expect(firstCall('productionBatch').create.userId).toBe('known-user');
|
||||
expect(firstCall('batchMovement').create.userId).toBeNull();
|
||||
expect(firstCall('productionInput').where).toEqual({ id: 'input-1' });
|
||||
expect(firstCall('shopStockRequest').where).toEqual({ id: 'request-1' });
|
||||
expect(firstCall('honeyBatchContainerAllocation').where).toEqual({
|
||||
id: 'allocation-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('writes every parent row before the rows that reference it', async () => {
|
||||
const one = (id: string) => [{ id }];
|
||||
await service.importAll(
|
||||
{
|
||||
version: 3,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
apiary: one('apiary-1'),
|
||||
harvest: one('harvest-1'),
|
||||
storageContainer: one('container-1'),
|
||||
honeyBatch: one('honey-1'),
|
||||
honeyBatchContainerAllocation: one('allocation-1'),
|
||||
product: one('product-1'),
|
||||
bottlingBatch: one('bottling-1'),
|
||||
rawMaterialLot: one('lot-1'),
|
||||
productionBatch: one('batch-1'),
|
||||
productionInput: one('input-1'),
|
||||
shopStockRequest: one('request-1'),
|
||||
batchMovement: one('movement-1'),
|
||||
automationRule: [{ triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT' }],
|
||||
automationSequence: [
|
||||
{
|
||||
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
|
||||
scope: 'BEESWAX',
|
||||
value: 7,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const order = (key: string) => tx[key].upsert.mock.invocationCallOrder[0];
|
||||
const dependencies: [parent: string, child: string][] = [
|
||||
['storageContainer', 'honeyBatchContainerAllocation'],
|
||||
['honeyBatch', 'honeyBatchContainerAllocation'],
|
||||
['apiary', 'rawMaterialLot'],
|
||||
['harvest', 'rawMaterialLot'],
|
||||
['product', 'productionBatch'],
|
||||
['productionBatch', 'productionInput'],
|
||||
['rawMaterialLot', 'productionInput'],
|
||||
['bottlingBatch', 'batchMovement'],
|
||||
['productionBatch', 'batchMovement'],
|
||||
['rawMaterialLot', 'batchMovement'],
|
||||
['shopStockRequest', 'batchMovement'],
|
||||
['automationRule', 'automationSequence'],
|
||||
];
|
||||
for (const [parent, child] of dependencies) {
|
||||
expect({ parent, child, before: order(parent) < order(child) }).toEqual(
|
||||
{ parent, child, before: true },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('upserts automationSequence by triggerType and scope', async () => {
|
||||
await service.importAll(
|
||||
{
|
||||
version: 3,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
automationSequence: [
|
||||
{
|
||||
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
|
||||
scope: 'BEESWAX',
|
||||
value: 7,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.automationSequence.upsert.mock.calls as [
|
||||
[{ where: unknown; create: Record<string, unknown> }],
|
||||
];
|
||||
expect(call.where).toEqual({
|
||||
triggerType_scope: {
|
||||
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
|
||||
scope: 'BEESWAX',
|
||||
},
|
||||
});
|
||||
expect(call.create.value).toBe(7);
|
||||
});
|
||||
|
||||
it('upserts a calendar outbox entry by its source and keeps the target row id', async () => {
|
||||
await service.importAll(
|
||||
{
|
||||
version: 3,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
calendarSyncOutboxEntry: [
|
||||
{
|
||||
id: 'entry-source-id',
|
||||
sourceType: 'TASK',
|
||||
sourceId: 'task-1',
|
||||
graphEventId: 'graph-event-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.calendarSyncOutboxEntry.upsert.mock.calls as [
|
||||
[{ where: unknown; update: Record<string, unknown> }],
|
||||
];
|
||||
expect(call.where).toEqual({
|
||||
sourceType_sourceId: { sourceType: 'TASK', sourceId: 'task-1' },
|
||||
});
|
||||
expect(call.update).not.toHaveProperty('id');
|
||||
expect(call.update.graphEventId).toBe('graph-event-1');
|
||||
});
|
||||
|
||||
it('upserts the backup and calendar settings singletons by id without rewriting it', async () => {
|
||||
await service.importAll(
|
||||
{
|
||||
version: 3,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
backupSettings: [{ id: 'singleton', retentionCount: 30 }],
|
||||
calendarSyncSettings: [{ id: 'singleton', enabled: true }],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
for (const key of ['backupSettings', 'calendarSyncSettings']) {
|
||||
const [[call]] = tx[key].upsert.mock.calls as [
|
||||
[{ where: unknown; update: Record<string, unknown> }],
|
||||
];
|
||||
expect(call.where).toEqual({ id: 'singleton' });
|
||||
expect(call.update).not.toHaveProperty('id');
|
||||
}
|
||||
});
|
||||
|
||||
it('takes no calendar sync or backup schedule from an unsealed file', async () => {
|
||||
const summary = await service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
backupSettings: [{ id: 'singleton', retentionCount: 1 }],
|
||||
calendarSyncSettings: [{ id: 'singleton', groupId: 'evil' }],
|
||||
calendarSyncOutboxEntry: [
|
||||
{ id: 'entry-1', sourceType: 'TASK', sourceId: 'task-1' },
|
||||
],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
{ trusted: false },
|
||||
);
|
||||
|
||||
expect(tx.backupSettings.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.calendarSyncSettings.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
|
||||
expect(summary.skipped).toEqual(
|
||||
expect.arrayContaining([
|
||||
'backupSettings',
|
||||
'calendarSyncSettings',
|
||||
'calendarSyncOutboxEntry',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('restores an older bundle that lacks the newer tables as if they were empty', async () => {
|
||||
const summary = await service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] },
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
expect(summary.imported).toEqual(
|
||||
expect.objectContaining({
|
||||
apiary: 1,
|
||||
productionBatch: 0,
|
||||
rawMaterialLot: 0,
|
||||
batchMovement: 0,
|
||||
automationSequence: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// A file without the seal cannot be proven to come from ApiCore: it may
|
||||
// only add plain master and field records, nothing else.
|
||||
describe('unsealed file', () => {
|
||||
const UNSEALED = { trusted: false };
|
||||
const SEALED_ONLY_SAMPLE: Record<string, Row[]> = {
|
||||
automationRule: [{ triggerType: 'HONEY_BATCH_NUMBER_FORMAT' }],
|
||||
automationSequence: [
|
||||
{ triggerType: 'HONEY_BATCH_NUMBER_FORMAT', scope: 'x', value: 0 },
|
||||
],
|
||||
stockItem: [{ id: 'stock-1' }],
|
||||
stockMovement: [{ id: 'movement-1' }],
|
||||
batchMovement: [{ id: 'ledger-1' }],
|
||||
shopStockRequest: [{ id: 'request-1', externalId: 'order-1' }],
|
||||
rawMaterialLot: [{ id: 'lot-1' }],
|
||||
productionBatch: [{ id: 'batch-1' }],
|
||||
productionInput: [{ id: 'input-1' }],
|
||||
honeyBatchContainerAllocation: [{ id: 'allocation-1' }],
|
||||
siteSettings: [{ id: 'singleton', holderName: 'Fake' }],
|
||||
document: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
entityType: 'COLONY',
|
||||
mimeType: 'application/pdf',
|
||||
contentBase64: Buffer.from('%PDF-1.4').toString('base64'),
|
||||
},
|
||||
],
|
||||
notification: [{ id: 'note-1', message: 'Bitte hier anmelden' }],
|
||||
};
|
||||
|
||||
it('takes no counters, ledgers, shop requests, settings, documents or notifications from it', async () => {
|
||||
const summary = await service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
...SEALED_ONLY_SAMPLE,
|
||||
apiary: [{ id: 'apiary-1', name: 'Stand Wald' }],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
UNSEALED,
|
||||
);
|
||||
|
||||
for (const key of Object.keys(SEALED_ONLY_SAMPLE)) {
|
||||
expect({ key, calls: tx[key].upsert.mock.calls.length }).toEqual({
|
||||
key,
|
||||
calls: 0,
|
||||
});
|
||||
}
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
expect(tx.apiary.upsert).toHaveBeenCalledTimes(1);
|
||||
expect([...summary.skipped].sort()).toEqual(
|
||||
Object.keys(SEALED_ONLY_SAMPLE).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('allows only plain master and field records - no table added later', () => {
|
||||
expect([...LEGACY_UNSEALED_TABLES].sort()).toEqual(
|
||||
[
|
||||
'apiary',
|
||||
'queen',
|
||||
'colony',
|
||||
'colonyApiaryHistory',
|
||||
'colonyLineage',
|
||||
'queenEvaluation',
|
||||
'queenColonyHistory',
|
||||
'inspection',
|
||||
'taskTemplate',
|
||||
'task',
|
||||
'varroaMeasurement',
|
||||
'swarmControlCheck',
|
||||
'winterCheck',
|
||||
'treatment',
|
||||
'feeding',
|
||||
'harvest',
|
||||
'harvestColony',
|
||||
'extraction',
|
||||
'extractionHarvest',
|
||||
'storageContainer',
|
||||
'honeyBatch',
|
||||
'honeyMeasurement',
|
||||
'honeyBatchRelocation',
|
||||
'product',
|
||||
'labelTemplate',
|
||||
'bottlingBatch',
|
||||
'equipment',
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('adds the records it carries but never rewrites ones that already exist', async () => {
|
||||
const data = Object.fromEntries(
|
||||
LEGACY_UNSEALED_TABLES.map((key) => [
|
||||
key,
|
||||
[{ id: `${key}-1`, notes: 'aus der Datei' }],
|
||||
]),
|
||||
);
|
||||
|
||||
await service.importAll(
|
||||
{ version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data },
|
||||
'admin-1',
|
||||
UNSEALED,
|
||||
);
|
||||
|
||||
for (const key of LEGACY_UNSEALED_TABLES) {
|
||||
const [[call]] = tx[key].upsert.mock.calls as [
|
||||
[{ create: Row; update: Row }],
|
||||
];
|
||||
expect({ key, update: call.update }).toEqual({ key, update: {} });
|
||||
expect(call.create.notes).toBe('aus der Datei');
|
||||
}
|
||||
});
|
||||
|
||||
it('does not re-link the mother line of a queen that was already there', async () => {
|
||||
tx.queen.findMany.mockResolvedValue([{ id: 'queen-existing' }]);
|
||||
|
||||
await service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
queen: [
|
||||
{ id: 'queen-existing', motherQueenId: 'queen-mother' },
|
||||
{ id: 'queen-new', motherQueenId: 'queen-mother' },
|
||||
{ id: 'queen-mother', motherQueenId: null },
|
||||
],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
UNSEALED,
|
||||
);
|
||||
|
||||
expect(tx.queen.update).toHaveBeenCalledTimes(1);
|
||||
expect(tx.queen.update).toHaveBeenCalledWith({
|
||||
where: { id: 'queen-new' },
|
||||
data: { motherQueenId: 'queen-mother' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the import as unsealed and lists no unknown sections as skipped', async () => {
|
||||
const summary = await service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: { notATable: [{ id: 'x' }], apiary: [{ id: 'apiary-1' }] },
|
||||
},
|
||||
'admin-1',
|
||||
UNSEALED,
|
||||
);
|
||||
|
||||
expect(summary.sealed).toBe(false);
|
||||
expect(summary.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it('still restores everything, overwriting existing rows, from a sealed file', async () => {
|
||||
const summary = await service.importAll(
|
||||
{
|
||||
version: 3,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
apiary: [{ id: 'apiary-1', name: 'Stand Wald' }],
|
||||
stockMovement: [{ id: 'movement-1', userId: null }],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
expect(summary.sealed).toBe(true);
|
||||
expect(summary.skipped).toEqual([]);
|
||||
const [[call]] = tx.apiary.upsert.mock.calls as [[{ update: Row }]];
|
||||
expect(call.update).toEqual({ id: 'apiary-1', name: 'Stand Wald' });
|
||||
expect(tx.stockMovement.upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Restoring a document writes its content to a new file; the file the row
|
||||
// pointed at before must not be left behind on disk.
|
||||
describe('replaced document files', () => {
|
||||
const storageRoot = path.resolve(
|
||||
process.env.DOCUMENTS_STORAGE_PATH ?? './uploads',
|
||||
);
|
||||
const oldFile = path.join(storageRoot, 'colony', 'old.pdf');
|
||||
const bundle = {
|
||||
version: 3,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
document: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
entityType: 'COLONY',
|
||||
mimeType: 'application/pdf',
|
||||
contentBase64: Buffer.from('%PDF-1.4 new').toString('base64'),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
let warn: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||
(fs.unlink as jest.Mock).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('deletes the file a restored row replaced, only after the import committed', async () => {
|
||||
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
|
||||
|
||||
await service.importAll(bundle, 'admin-1', TRUSTED);
|
||||
|
||||
expect(fs.unlink).toHaveBeenCalledWith(oldFile);
|
||||
expect(
|
||||
(fs.unlink as jest.Mock).mock.invocationCallOrder[0],
|
||||
).toBeGreaterThan(tx.document.upsert.mock.invocationCallOrder[0]);
|
||||
expect(prisma.document.count).toHaveBeenCalledWith({
|
||||
where: { storagePath: oldFile },
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes nothing when the import fails', async () => {
|
||||
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
|
||||
tx.notification.upsert.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(
|
||||
service.importAll(
|
||||
{
|
||||
...bundle,
|
||||
data: { ...bundle.data, notification: [{ id: 'note-1' }] },
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toThrow('db down');
|
||||
expect(fs.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a replaced file outside the documents root and logs it', async () => {
|
||||
tx.document.findUnique.mockResolvedValue({ storagePath: '/etc/passwd' });
|
||||
|
||||
await service.importAll(bundle, 'admin-1', TRUSTED);
|
||||
|
||||
expect(fs.unlink).not.toHaveBeenCalled();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps a replaced file that another document row still uses', async () => {
|
||||
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
|
||||
prisma.document.count.mockResolvedValue(1);
|
||||
|
||||
await service.importAll(bundle, 'admin-1', TRUSTED);
|
||||
|
||||
expect(fs.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('completes the import when a replaced file cannot be deleted, and logs it', async () => {
|
||||
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
|
||||
(fs.unlink as jest.Mock).mockRejectedValue(new Error('EBUSY'));
|
||||
|
||||
await expect(
|
||||
service.importAll(bundle, 'admin-1', TRUSTED),
|
||||
).resolves.toEqual(expect.objectContaining({ sealed: true }));
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('deletes nothing for a document that is new to this database', async () => {
|
||||
await service.importAll(bundle, 'admin-1', TRUSTED);
|
||||
|
||||
expect(fs.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// Drift guard: a model added to the schema has to be backed up or listed
|
||||
// in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
|
||||
describe('model coverage', () => {
|
||||
it('backs up every Prisma model or names why it is left out', async () => {
|
||||
const bundle = await service.exportAll();
|
||||
|
||||
const uncovered = Object.values(Prisma.ModelName).filter(
|
||||
(model) =>
|
||||
!(modelKey(model) in bundle.data) &&
|
||||
!(model in MODELS_EXCLUDED_FROM_BACKUP),
|
||||
);
|
||||
|
||||
expect(uncovered).toEqual([]);
|
||||
});
|
||||
|
||||
it('gives every excluded model a reason and never exports it anyway', async () => {
|
||||
const bundle = await service.exportAll();
|
||||
|
||||
for (const [model, reason] of Object.entries(
|
||||
MODELS_EXCLUDED_FROM_BACKUP,
|
||||
)) {
|
||||
expect(bundle.data).not.toHaveProperty(modelKey(model));
|
||||
expect(reason?.trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('restores every table it exports', async () => {
|
||||
const bundle = await service.exportAll();
|
||||
|
||||
const summary = await service.importAll(bundle, 'admin-1', TRUSTED);
|
||||
|
||||
expect(Object.keys(summary.imported).sort()).toEqual(
|
||||
Object.keys(bundle.data).sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,27 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { ALLOWED_DOCUMENT_MIME_TYPES } from '../documents/document-mime-types';
|
||||
import {
|
||||
ALLOWED_DOCUMENT_MIME_TYPES,
|
||||
isAllowedDocumentMimeType,
|
||||
} from '../documents/document-mime-types';
|
||||
import { matchesFileSignature } from '../common/file-signature';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { DocumentEntityType, Prisma } from '../../generated/prisma';
|
||||
import {
|
||||
SealedBackup,
|
||||
isSealedBackup,
|
||||
openBackup,
|
||||
sealBackup,
|
||||
} from './backup-crypto';
|
||||
|
||||
const BACKUP_VERSION = 2;
|
||||
// 3: production, raw material, stock ledger, number sequence, calendar sync
|
||||
// and backup schedule tables. Older bundles simply lack those sections and
|
||||
// restore them as empty.
|
||||
const BACKUP_VERSION = 3;
|
||||
const IMPORT_TRANSACTION_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
export interface BackupBundle {
|
||||
@@ -17,15 +32,108 @@ export interface BackupBundle {
|
||||
|
||||
export interface BackupImportSummary {
|
||||
imported: Record<string, number>;
|
||||
/**
|
||||
* Tables the file carried but an unsealed backup may not write - every
|
||||
* table outside LEGACY_UNSEALED_TABLES.
|
||||
*/
|
||||
skipped: string[];
|
||||
/** False for an old, unsealed file: restricted and add-only. */
|
||||
sealed: boolean;
|
||||
}
|
||||
|
||||
// Excluded on purpose:
|
||||
// - Permission: code-derived, re-synced from the catalog on every boot -
|
||||
// including it would just fight that sync. RolePermission references
|
||||
// permissions by resource+action (not id) instead, resolved against
|
||||
// whatever the target database's own sync already produced.
|
||||
// - RefreshToken: live session tokens, meaningless once restored (expired,
|
||||
// or a stale session that should never be revivable from a backup file).
|
||||
/**
|
||||
* All an unsealed file may write. Its contents cannot be proven to come from
|
||||
* ApiCore (it may be hand-built), so it is limited to the plain beekeeping
|
||||
* master and field records of the old unsealed (v2) format - records an
|
||||
* admin could just as well type in again - and it may only add missing
|
||||
* rows, never rewrite existing ones.
|
||||
*
|
||||
* Sealed-only, and why (any table not listed here is sealed-only too, so
|
||||
* tables added later are safe by default):
|
||||
* - role, user, rolePermission: grant access.
|
||||
* - webhookSubscription, calendarSyncSettings, calendarSyncOutboxEntry:
|
||||
* send data out, or update/delete events in an M365 calendar.
|
||||
* - backupSettings: retention 1 would prune every older backup.
|
||||
* - auditLog: history must not be rewritten.
|
||||
* - automationRule, automationSequence: hold the lot-number counters (a
|
||||
* reset hands out duplicate batch numbers) and the rule thresholds.
|
||||
* - stockItem, stockMovement, batchMovement: running stock and the stock
|
||||
* ledgers - never forged, only booked.
|
||||
* - shopStockRequest: the shop's idempotency keys - a planted one would
|
||||
* swallow a real sale.
|
||||
* - rawMaterialLot, productionBatch, productionInput,
|
||||
* honeyBatchContainerAllocation: newer than the unsealed format, so a
|
||||
* genuine old file never carries them.
|
||||
* - siteSettings: keeper name/address printed on the Bestandsbuch and the
|
||||
* public links.
|
||||
* - document: writes files to the server's disk.
|
||||
* - notification: messages shown to users as coming from ApiCore.
|
||||
*/
|
||||
export const LEGACY_UNSEALED_TABLES = [
|
||||
'apiary',
|
||||
'queen',
|
||||
'colony',
|
||||
'colonyApiaryHistory',
|
||||
'colonyLineage',
|
||||
'queenEvaluation',
|
||||
'queenColonyHistory',
|
||||
'inspection',
|
||||
'taskTemplate',
|
||||
'task',
|
||||
'varroaMeasurement',
|
||||
'swarmControlCheck',
|
||||
'winterCheck',
|
||||
'treatment',
|
||||
'feeding',
|
||||
'harvest',
|
||||
'harvestColony',
|
||||
'extraction',
|
||||
'extractionHarvest',
|
||||
'storageContainer',
|
||||
'honeyBatch',
|
||||
'honeyMeasurement',
|
||||
'honeyBatchRelocation',
|
||||
'product',
|
||||
'labelTemplate',
|
||||
'bottlingBatch',
|
||||
'equipment',
|
||||
] as const;
|
||||
|
||||
// Bundle sections that name a real table - a file's other keys are ignored
|
||||
// for every file and so never reported as skipped.
|
||||
const MODEL_SECTIONS = new Set<string>(
|
||||
Object.values(Prisma.ModelName).map(
|
||||
(model) => model.charAt(0).toLowerCase() + model.slice(1),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Models deliberately left out of the backup, each with its reason. Every
|
||||
* other Prisma model is a section of the bundle, named like its client
|
||||
* delegate (`productionBatch`). The model coverage test in
|
||||
* backup.service.spec.ts fails for a model that is neither, so a new table
|
||||
* cannot silently drop out of the backup.
|
||||
*/
|
||||
export const MODELS_EXCLUDED_FROM_BACKUP: Readonly<
|
||||
Partial<Record<Prisma.ModelName, string>>
|
||||
> = {
|
||||
Permission:
|
||||
'Code-derived catalog, re-synced from the code on every boot. RolePermission rows reference permissions by resource+action instead, resolved against the target database.',
|
||||
RefreshToken:
|
||||
'Live session tokens - a session must never be revivable from a backup file.',
|
||||
ConsumedToken:
|
||||
'Replay guard for one-time tokens that expire within minutes, so always stale by the time a backup is restored.',
|
||||
ApiKey:
|
||||
'Machine credentials stay out on purpose (ARCHITECTURE.md assumption 14): a new key is issued after a restore. BatchMovement/ShopStockRequest keep apiKeyId only as a plain historical reference.',
|
||||
IdempotencyRecord:
|
||||
'Volatile cache of first responses to retried create requests (ARCHITECTURE.md assumption 11) - a restored entry could only replay a stale response.',
|
||||
};
|
||||
|
||||
const DOCUMENT_ENTITY_TYPES = new Set<string>(
|
||||
Object.values(DocumentEntityType),
|
||||
);
|
||||
|
||||
// Excluded on purpose: see MODELS_EXCLUDED_FROM_BACKUP.
|
||||
//
|
||||
// Role/User/RolePermission ARE included (v2) - Role is matched by its `key`
|
||||
// (not id, since a fresh deployment's seeded system roles get a new
|
||||
@@ -52,11 +160,38 @@ function documentsStorageRoot(): string {
|
||||
|
||||
@Injectable()
|
||||
export class BackupService {
|
||||
private readonly logger = new Logger(BackupService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
) {}
|
||||
|
||||
/** The export as it leaves the server: encrypted and tamper-evident. */
|
||||
async exportSealed(): Promise<SealedBackup> {
|
||||
const bundle = await this.exportAll();
|
||||
return sealBackup(bundle, bundle.exportedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores an uploaded file. A sealed backup is restored completely; an
|
||||
* old unsealed one only add-only and limited to LEGACY_UNSEALED_TABLES.
|
||||
*/
|
||||
async importFile(
|
||||
parsed: unknown,
|
||||
actingUserId: string,
|
||||
): Promise<BackupImportSummary> {
|
||||
if (isSealedBackup(parsed)) {
|
||||
return this.importAll(openBackup(parsed) as BackupBundle, actingUserId, {
|
||||
trusted: true,
|
||||
});
|
||||
}
|
||||
return this.importAll(parsed as BackupBundle, actingUserId, {
|
||||
trusted: false,
|
||||
});
|
||||
}
|
||||
|
||||
async exportAll(): Promise<BackupBundle> {
|
||||
const data: Record<string, unknown[]> = {};
|
||||
|
||||
@@ -93,6 +228,7 @@ export class BackupService {
|
||||
data.task = await this.prisma.task.findMany();
|
||||
data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany();
|
||||
data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany();
|
||||
data.winterCheck = await this.prisma.winterCheck.findMany();
|
||||
data.treatment = await this.prisma.treatment.findMany();
|
||||
data.feeding = await this.prisma.feeding.findMany();
|
||||
data.harvest = await this.prisma.harvest.findMany();
|
||||
@@ -104,15 +240,28 @@ export class BackupService {
|
||||
data.honeyMeasurement = await this.prisma.honeyMeasurement.findMany();
|
||||
data.honeyBatchRelocation =
|
||||
await this.prisma.honeyBatchRelocation.findMany();
|
||||
data.honeyBatchContainerAllocation =
|
||||
await this.prisma.honeyBatchContainerAllocation.findMany();
|
||||
data.stockItem = await this.prisma.stockItem.findMany();
|
||||
data.stockMovement = await this.prisma.stockMovement.findMany();
|
||||
data.product = await this.prisma.product.findMany();
|
||||
data.labelTemplate = await this.prisma.labelTemplate.findMany();
|
||||
data.bottlingBatch = await this.prisma.bottlingBatch.findMany();
|
||||
data.rawMaterialLot = await this.prisma.rawMaterialLot.findMany();
|
||||
data.productionBatch = await this.prisma.productionBatch.findMany();
|
||||
data.productionInput = await this.prisma.productionInput.findMany();
|
||||
data.shopStockRequest = await this.prisma.shopStockRequest.findMany();
|
||||
data.batchMovement = await this.prisma.batchMovement.findMany();
|
||||
data.equipment = await this.prisma.equipment.findMany();
|
||||
data.webhookSubscription = await this.prisma.webhookSubscription.findMany();
|
||||
data.automationRule = await this.prisma.automationRule.findMany();
|
||||
data.automationSequence = await this.prisma.automationSequence.findMany();
|
||||
data.siteSettings = await this.prisma.siteSettings.findMany();
|
||||
data.backupSettings = await this.prisma.backupSettings.findMany();
|
||||
data.calendarSyncSettings =
|
||||
await this.prisma.calendarSyncSettings.findMany();
|
||||
data.calendarSyncOutboxEntry =
|
||||
await this.prisma.calendarSyncOutboxEntry.findMany();
|
||||
|
||||
const documents = await this.prisma.document.findMany();
|
||||
data.document = await Promise.all(
|
||||
@@ -144,6 +293,7 @@ export class BackupService {
|
||||
async importAll(
|
||||
bundle: BackupBundle,
|
||||
actingUserId: string,
|
||||
{ trusted }: { trusted: boolean },
|
||||
): Promise<BackupImportSummary> {
|
||||
if (
|
||||
typeof bundle !== 'object' ||
|
||||
@@ -154,16 +304,45 @@ export class BackupService {
|
||||
throw new BadRequestException('Invalid backup file');
|
||||
}
|
||||
|
||||
const isAllowedUnsealed = (key: string): boolean =>
|
||||
(LEGACY_UNSEALED_TABLES as readonly string[]).includes(key);
|
||||
const skipped = trusted
|
||||
? []
|
||||
: Object.keys(bundle.data).filter((key) => {
|
||||
const value = bundle.data[key];
|
||||
return (
|
||||
MODEL_SECTIONS.has(key) &&
|
||||
!isAllowedUnsealed(key) &&
|
||||
Array.isArray(value) &&
|
||||
value.length > 0
|
||||
);
|
||||
});
|
||||
const rowsOf = (key: string): Row[] => {
|
||||
if (!trusted && !isAllowedUnsealed(key)) {
|
||||
return [];
|
||||
}
|
||||
const value = bundle.data[key];
|
||||
return Array.isArray(value) ? (value as Row[]) : [];
|
||||
};
|
||||
// An unsealed file may add the rows it carries, never rewrite or roll
|
||||
// back ones that already exist here.
|
||||
const updateWith = (data: Row): Row => (trusted ? data : {});
|
||||
// Document files the restored rows no longer point at - deleted only
|
||||
// once the transaction has committed.
|
||||
const replacedDocumentFiles: string[] = [];
|
||||
|
||||
const imported: Record<string, number> = {};
|
||||
|
||||
await this.prisma.$transaction(
|
||||
async (tx) => {
|
||||
const roleKeyToId = new Map<string, string>();
|
||||
// Roles already in this database resolve too - an unsealed file
|
||||
// brings none of its own.
|
||||
for (const role of await tx.role.findMany({
|
||||
select: { id: true, key: true },
|
||||
})) {
|
||||
roleKeyToId.set(role.key, role.id);
|
||||
}
|
||||
for (const row of rowsOf('role')) {
|
||||
const withoutId: Row = { ...row };
|
||||
delete withoutId.id;
|
||||
@@ -247,11 +426,26 @@ export class BackupService {
|
||||
await tx.apiary.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.apiary = rowsOf('apiary').length;
|
||||
|
||||
// Queens already here before this import keep their mother line when
|
||||
// the file is unsealed (add-only, see updateWith).
|
||||
const preexistingQueenIds = new Set<string>(
|
||||
trusted
|
||||
? []
|
||||
: (
|
||||
await tx.queen.findMany({
|
||||
where: {
|
||||
id: { in: rowsOf('queen').map((row) => row.id as string) },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
).map((queen) => queen.id),
|
||||
);
|
||||
|
||||
// Inserted without motherQueenId first (a daughter queen can appear
|
||||
// before her mother in export order) - patched in a second pass
|
||||
// below once every queen row exists.
|
||||
@@ -260,7 +454,7 @@ export class BackupService {
|
||||
await tx.queen.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: withoutMother as never,
|
||||
update: withoutMother as never,
|
||||
update: updateWith(withoutMother) as never,
|
||||
});
|
||||
}
|
||||
imported.queen = rowsOf('queen').length;
|
||||
@@ -269,13 +463,13 @@ export class BackupService {
|
||||
await tx.colony.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.colony = rowsOf('colony').length;
|
||||
|
||||
for (const row of rowsOf('queen')) {
|
||||
if (row.motherQueenId) {
|
||||
if (row.motherQueenId && !preexistingQueenIds.has(row.id as string)) {
|
||||
await tx.queen.update({
|
||||
where: { id: row.id as string },
|
||||
data: { motherQueenId: row.motherQueenId as string },
|
||||
@@ -292,12 +486,12 @@ export class BackupService {
|
||||
asString(row, 'movedByUserId'),
|
||||
),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
movedByUserId: resolveOptionalUserId(
|
||||
asString(row, 'movedByUserId'),
|
||||
),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.colonyApiaryHistory = rowsOf('colonyApiaryHistory').length;
|
||||
@@ -306,7 +500,7 @@ export class BackupService {
|
||||
await tx.colonyLineage.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.colonyLineage = rowsOf('colonyLineage').length;
|
||||
@@ -318,10 +512,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.queenEvaluation = rowsOf('queenEvaluation').length;
|
||||
@@ -330,7 +524,7 @@ export class BackupService {
|
||||
await tx.queenColonyHistory.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.queenColonyHistory = rowsOf('queenColonyHistory').length;
|
||||
@@ -342,10 +536,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.inspection = rowsOf('inspection').length;
|
||||
@@ -362,7 +556,7 @@ export class BackupService {
|
||||
asString(row, 'createdByUserId'),
|
||||
),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
assignedUserId: resolveOptionalUserId(
|
||||
asString(row, 'assignedUserId'),
|
||||
@@ -370,7 +564,7 @@ export class BackupService {
|
||||
createdByUserId: resolveOptionalUserId(
|
||||
asString(row, 'createdByUserId'),
|
||||
),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.taskTemplate = rowsOf('taskTemplate').length;
|
||||
@@ -387,7 +581,7 @@ export class BackupService {
|
||||
asString(row, 'createdByUserId'),
|
||||
),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
assignedUserId: resolveOptionalUserId(
|
||||
asString(row, 'assignedUserId'),
|
||||
@@ -395,7 +589,7 @@ export class BackupService {
|
||||
createdByUserId: resolveOptionalUserId(
|
||||
asString(row, 'createdByUserId'),
|
||||
),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.task = rowsOf('task').length;
|
||||
@@ -407,10 +601,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.varroaMeasurement = rowsOf('varroaMeasurement').length;
|
||||
@@ -422,14 +616,29 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.swarmControlCheck = rowsOf('swarmControlCheck').length;
|
||||
|
||||
for (const row of rowsOf('winterCheck')) {
|
||||
await tx.winterCheck.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.winterCheck = rowsOf('winterCheck').length;
|
||||
|
||||
for (const row of rowsOf('treatment')) {
|
||||
await tx.treatment.upsert({
|
||||
where: { id: row.id as string },
|
||||
@@ -437,10 +646,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.treatment = rowsOf('treatment').length;
|
||||
@@ -452,10 +661,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.feeding = rowsOf('feeding').length;
|
||||
@@ -467,10 +676,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.harvest = rowsOf('harvest').length;
|
||||
@@ -479,7 +688,7 @@ export class BackupService {
|
||||
await tx.harvestColony.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.harvestColony = rowsOf('harvestColony').length;
|
||||
@@ -491,10 +700,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.extraction = rowsOf('extraction').length;
|
||||
@@ -503,7 +712,7 @@ export class BackupService {
|
||||
await tx.extractionHarvest.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.extractionHarvest = rowsOf('extractionHarvest').length;
|
||||
@@ -512,7 +721,7 @@ export class BackupService {
|
||||
await tx.storageContainer.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.storageContainer = rowsOf('storageContainer').length;
|
||||
@@ -521,7 +730,7 @@ export class BackupService {
|
||||
await tx.honeyBatch.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.honeyBatch = rowsOf('honeyBatch').length;
|
||||
@@ -533,10 +742,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.honeyMeasurement = rowsOf('honeyMeasurement').length;
|
||||
@@ -548,14 +757,25 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.honeyBatchRelocation = rowsOf('honeyBatchRelocation').length;
|
||||
|
||||
for (const row of rowsOf('honeyBatchContainerAllocation')) {
|
||||
await tx.honeyBatchContainerAllocation.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
});
|
||||
}
|
||||
imported.honeyBatchContainerAllocation = rowsOf(
|
||||
'honeyBatchContainerAllocation',
|
||||
).length;
|
||||
|
||||
for (const row of rowsOf('stockItem')) {
|
||||
await tx.stockItem.upsert({
|
||||
where: { id: row.id as string },
|
||||
@@ -584,7 +804,7 @@ export class BackupService {
|
||||
await tx.product.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.product = rowsOf('product').length;
|
||||
@@ -593,13 +813,28 @@ export class BackupService {
|
||||
await tx.labelTemplate.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.labelTemplate = rowsOf('labelTemplate').length;
|
||||
|
||||
for (const row of rowsOf('bottlingBatch')) {
|
||||
await tx.bottlingBatch.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.bottlingBatch = rowsOf('bottlingBatch').length;
|
||||
|
||||
for (const row of rowsOf('rawMaterialLot')) {
|
||||
await tx.rawMaterialLot.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: {
|
||||
...row,
|
||||
@@ -611,13 +846,62 @@ export class BackupService {
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
imported.bottlingBatch = rowsOf('bottlingBatch').length;
|
||||
imported.rawMaterialLot = rowsOf('rawMaterialLot').length;
|
||||
|
||||
for (const row of rowsOf('productionBatch')) {
|
||||
await tx.productionBatch.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
imported.productionBatch = rowsOf('productionBatch').length;
|
||||
|
||||
for (const row of rowsOf('productionInput')) {
|
||||
await tx.productionInput.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
});
|
||||
}
|
||||
imported.productionInput = rowsOf('productionInput').length;
|
||||
|
||||
for (const row of rowsOf('shopStockRequest')) {
|
||||
await tx.shopStockRequest.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
});
|
||||
}
|
||||
imported.shopStockRequest = rowsOf('shopStockRequest').length;
|
||||
|
||||
// After every kind of batch and the shop requests it may reference.
|
||||
for (const row of rowsOf('batchMovement')) {
|
||||
await tx.batchMovement.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
imported.batchMovement = rowsOf('batchMovement').length;
|
||||
|
||||
for (const row of rowsOf('equipment')) {
|
||||
await tx.equipment.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.equipment = rowsOf('equipment').length;
|
||||
@@ -656,6 +940,23 @@ export class BackupService {
|
||||
}
|
||||
imported.automationRule = rowsOf('automationRule').length;
|
||||
|
||||
// Keyed by triggerType+scope, its primary key (the table has no id).
|
||||
// Restoring the counters keeps the next lot number from colliding
|
||||
// with a restored one.
|
||||
for (const row of rowsOf('automationSequence')) {
|
||||
await tx.automationSequence.upsert({
|
||||
where: {
|
||||
triggerType_scope: {
|
||||
triggerType: row.triggerType as never,
|
||||
scope: row.scope as string,
|
||||
},
|
||||
},
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
});
|
||||
}
|
||||
imported.automationSequence = rowsOf('automationSequence').length;
|
||||
|
||||
for (const row of rowsOf('siteSettings')) {
|
||||
const { id, ...withoutId } = row;
|
||||
await tx.siteSettings.upsert({
|
||||
@@ -666,6 +967,48 @@ export class BackupService {
|
||||
}
|
||||
imported.siteSettings = rowsOf('siteSettings').length;
|
||||
|
||||
for (const row of rowsOf('backupSettings')) {
|
||||
const { id, ...withoutId } = row;
|
||||
await tx.backupSettings.upsert({
|
||||
where: { id: (id as string) ?? 'singleton' },
|
||||
create: row as never,
|
||||
update: withoutId as never,
|
||||
});
|
||||
}
|
||||
imported.backupSettings = rowsOf('backupSettings').length;
|
||||
|
||||
for (const row of rowsOf('calendarSyncSettings')) {
|
||||
const { id, ...withoutId } = row;
|
||||
await tx.calendarSyncSettings.upsert({
|
||||
where: { id: (id as string) ?? 'singleton' },
|
||||
create: row as never,
|
||||
update: withoutId as never,
|
||||
});
|
||||
}
|
||||
imported.calendarSyncSettings = rowsOf('calendarSyncSettings').length;
|
||||
|
||||
// Keyed by sourceType+sourceId, like CalendarSyncService.enqueue -
|
||||
// the target database may already track the same source under
|
||||
// another id. graphEventId comes along, so a restored entry keeps
|
||||
// updating its existing M365 event instead of creating a duplicate.
|
||||
for (const row of rowsOf('calendarSyncOutboxEntry')) {
|
||||
const withoutId: Row = { ...row };
|
||||
delete withoutId.id;
|
||||
await tx.calendarSyncOutboxEntry.upsert({
|
||||
where: {
|
||||
sourceType_sourceId: {
|
||||
sourceType: row.sourceType as never,
|
||||
sourceId: row.sourceId as string,
|
||||
},
|
||||
},
|
||||
create: row as never,
|
||||
update: withoutId as never,
|
||||
});
|
||||
}
|
||||
imported.calendarSyncOutboxEntry = rowsOf(
|
||||
'calendarSyncOutboxEntry',
|
||||
).length;
|
||||
|
||||
// Writes the file to disk as part of this loop, before the
|
||||
// surrounding transaction is known to commit - if a later entity in
|
||||
// this same import fails and the transaction rolls back, a file
|
||||
@@ -677,19 +1020,43 @@ export class BackupService {
|
||||
if (!contentBase64) {
|
||||
continue;
|
||||
}
|
||||
const buffer = Buffer.from(contentBase64, 'base64');
|
||||
const extension =
|
||||
ALLOWED_DOCUMENT_MIME_TYPES[row.mimeType as string] ?? '';
|
||||
const directory = path.join(
|
||||
documentsStorageRoot(),
|
||||
(row.entityType as string).toLowerCase(),
|
||||
const entityType = asString(row, 'entityType');
|
||||
const mimeType = asString(row, 'mimeType');
|
||||
if (
|
||||
!entityType ||
|
||||
!DOCUMENT_ENTITY_TYPES.has(entityType) ||
|
||||
!mimeType ||
|
||||
!isAllowedDocumentMimeType(mimeType)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Dokument ${String(row.id)} hat einen unzulässigen Typ`,
|
||||
);
|
||||
}
|
||||
const buffer = Buffer.from(contentBase64, 'base64');
|
||||
if (!matchesFileSignature(buffer, mimeType)) {
|
||||
throw new BadRequestException(
|
||||
`Dokument ${String(row.id)}: Inhalt passt nicht zum Dateityp`,
|
||||
);
|
||||
}
|
||||
const extension = ALLOWED_DOCUMENT_MIME_TYPES[mimeType];
|
||||
const storageRoot = path.resolve(documentsStorageRoot());
|
||||
const directory = path.join(storageRoot, entityType.toLowerCase());
|
||||
if (!directory.startsWith(storageRoot + path.sep)) {
|
||||
throw new BadRequestException('Unzulässiger Speicherpfad');
|
||||
}
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
const storagePath = path.join(
|
||||
directory,
|
||||
`${crypto.randomUUID()}${extension}`,
|
||||
);
|
||||
await fs.writeFile(storagePath, buffer);
|
||||
const previous = await tx.document.findUnique({
|
||||
where: { id: row.id as string },
|
||||
select: { storagePath: true },
|
||||
});
|
||||
if (previous) {
|
||||
replacedDocumentFiles.push(previous.storagePath);
|
||||
}
|
||||
const withoutContent: Row = { ...row };
|
||||
delete withoutContent.contentBase64;
|
||||
const data = {
|
||||
@@ -723,6 +1090,7 @@ export class BackupService {
|
||||
}
|
||||
imported.notification = rowsOf('notification').length;
|
||||
|
||||
// Append-only: entries already here are never rewritten by a file.
|
||||
for (const row of rowsOf('auditLog')) {
|
||||
await tx.auditLog.upsert({
|
||||
where: { id: row.id as string },
|
||||
@@ -730,10 +1098,7 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
imported.auditLog = rowsOf('auditLog').length;
|
||||
@@ -741,14 +1106,50 @@ export class BackupService {
|
||||
{ timeout: IMPORT_TRANSACTION_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
// Users, roles and grants may have changed underneath every cache.
|
||||
this.permissionResolver.invalidateAll();
|
||||
|
||||
await this.auditLog.record({
|
||||
entityType: 'Backup',
|
||||
entityId: 'import',
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: imported,
|
||||
afterData: { imported, skipped, sealed: trusted },
|
||||
});
|
||||
|
||||
return { imported };
|
||||
await this.removeReplacedDocumentFiles(replacedDocumentFiles);
|
||||
|
||||
return { imported, skipped, sealed: trusted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the files restored document rows no longer point at - only now
|
||||
* that the import has committed, only inside the documents root and only
|
||||
* while no row still uses them. Best effort: a file left behind wastes
|
||||
* space, it never breaks a document.
|
||||
*/
|
||||
private async removeReplacedDocumentFiles(
|
||||
storagePaths: string[],
|
||||
): Promise<void> {
|
||||
const storageRoot = path.resolve(documentsStorageRoot());
|
||||
for (const storagePath of storagePaths) {
|
||||
const resolved = path.resolve(storagePath);
|
||||
if (!resolved.startsWith(storageRoot + path.sep)) {
|
||||
this.logger.warn(
|
||||
`Ersetzte Dokumentdatei liegt außerhalb von ${storageRoot} und bleibt erhalten: ${resolved}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if ((await this.prisma.document.count({ where: { storagePath } })) > 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await fs.unlink(resolved);
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn(
|
||||
`Ersetzte Dokumentdatei ${resolved} konnte nicht gelöscht werden: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
/** Step-up proof sent with a backup upload (multipart fields). */
|
||||
export class BackupImportProofDto {
|
||||
@ApiPropertyOptional({ description: 'Aktuelles Passwort' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
password?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Aktueller TOTP-Code, falls MFA aktiv' })
|
||||
@IsOptional()
|
||||
@Matches(/^\d{6}$/)
|
||||
mfaCode?: string;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
@@ -14,12 +15,19 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
|
||||
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
|
||||
import { BottlingService } from './bottling.service';
|
||||
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||
import { StockService } from '../stock/stock.service';
|
||||
import { WithdrawStockDto } from '../stock/dto/withdraw-stock.dto';
|
||||
import { CorrectStockDto } from '../stock/dto/correct-stock.dto';
|
||||
|
||||
@ApiTags('bottling')
|
||||
@ApiBearerAuth()
|
||||
@Controller('bottling-batches')
|
||||
export class BottlingController {
|
||||
constructor(private readonly bottlingService: BottlingService) {}
|
||||
constructor(
|
||||
private readonly bottlingService: BottlingService,
|
||||
private readonly stock: StockService,
|
||||
) {}
|
||||
|
||||
@RequirePermission('bottling:read')
|
||||
@Get()
|
||||
@@ -33,7 +41,39 @@ export class BottlingController {
|
||||
return this.bottlingService.findById(id);
|
||||
}
|
||||
|
||||
@RequirePermission('bottling:read')
|
||||
@Get(':id/stock')
|
||||
async stockOf(@Param('id') id: string) {
|
||||
await this.bottlingService.findById(id);
|
||||
return this.stock.stockOf({ kind: 'bottling', id });
|
||||
}
|
||||
|
||||
@RequirePermission('bottling:withdraw')
|
||||
@Idempotent()
|
||||
@Post(':id/withdrawals')
|
||||
async withdraw(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: WithdrawStockDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
await this.bottlingService.findById(id);
|
||||
return this.stock.withdraw({ kind: 'bottling', id }, dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('bottling:withdraw')
|
||||
@Idempotent()
|
||||
@Post(':id/corrections')
|
||||
async correct(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CorrectStockDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
await this.bottlingService.findById(id);
|
||||
return this.stock.correct({ kind: 'bottling', id }, dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('bottling:create')
|
||||
@Idempotent()
|
||||
@Post()
|
||||
create(
|
||||
@Body() dto: CreateBottlingBatchDto,
|
||||
@@ -51,4 +91,10 @@ export class BottlingController {
|
||||
) {
|
||||
return this.bottlingService.update(id, dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('bottling:delete')
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
||||
return this.bottlingService.remove(id, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AutomationModule } from '../automation/automation.module';
|
||||
import { StockModule } from '../stock/stock.module';
|
||||
import { BottlingController } from './bottling.controller';
|
||||
import { BottlingService } from './bottling.service';
|
||||
|
||||
@Module({
|
||||
imports: [AutomationModule],
|
||||
imports: [AutomationModule, StockModule],
|
||||
controllers: [BottlingController],
|
||||
providers: [BottlingService],
|
||||
})
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { BottlingService } from './bottling.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
import { StockService } from '../stock/stock.service';
|
||||
|
||||
type AmountChange = number | { increment?: number; decrement?: number };
|
||||
|
||||
function applyAmountChange(
|
||||
current: number | null,
|
||||
change: AmountChange | undefined,
|
||||
): number | null {
|
||||
if (change === undefined) return current;
|
||||
if (typeof change === 'number') return change;
|
||||
if (current === null) return null;
|
||||
return current + (change.increment ?? 0) - (change.decrement ?? 0);
|
||||
}
|
||||
|
||||
describe('BottlingService', () => {
|
||||
let service: BottlingService;
|
||||
@@ -20,14 +37,77 @@ describe('BottlingService', () => {
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let tx: {
|
||||
bottlingBatch: { create: jest.Mock };
|
||||
honeyBatch: { update: jest.Mock };
|
||||
bottlingBatch: { create: jest.Mock; update: jest.Mock; delete: jest.Mock };
|
||||
honeyBatch: {
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
updateMany: jest.Mock;
|
||||
};
|
||||
};
|
||||
let webhooks: { dispatch: jest.Mock };
|
||||
let stock: {
|
||||
remainingByBottling: jest.Mock;
|
||||
notify: jest.Mock;
|
||||
hasMovements: jest.Mock;
|
||||
};
|
||||
// Honey batch rows as the transaction sees them - the tests assert the
|
||||
// resulting amounts instead of the exact sequence of UPDATE statements.
|
||||
let honeyBatches: Map<string, { amount: number | null; status?: string }>;
|
||||
|
||||
beforeEach(() => {
|
||||
honeyBatches = new Map();
|
||||
tx = {
|
||||
bottlingBatch: { create: jest.fn() },
|
||||
honeyBatch: { update: jest.fn() },
|
||||
bottlingBatch: {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
honeyBatch: {
|
||||
findUnique: jest.fn(({ where }: { where: { id: string } }) => {
|
||||
const row = honeyBatches.get(where.id);
|
||||
return Promise.resolve(row ? { id: where.id, ...row } : null);
|
||||
}),
|
||||
update: jest.fn(
|
||||
({
|
||||
where,
|
||||
data,
|
||||
}: {
|
||||
where: { id: string };
|
||||
data: { amount?: AmountChange; status?: string };
|
||||
}) => {
|
||||
const row = honeyBatches.get(where.id) ?? { amount: null };
|
||||
honeyBatches.set(where.id, {
|
||||
amount: applyAmountChange(row.amount, data.amount),
|
||||
status: data.status ?? row.status,
|
||||
});
|
||||
return Promise.resolve({});
|
||||
},
|
||||
),
|
||||
updateMany: jest.fn(
|
||||
({
|
||||
where,
|
||||
data,
|
||||
}: {
|
||||
where: { id: string; amount?: { gte?: number } };
|
||||
data: { amount: AmountChange; status?: string };
|
||||
}) => {
|
||||
const row = honeyBatches.get(where.id);
|
||||
const minimum = where.amount?.gte;
|
||||
if (
|
||||
!row ||
|
||||
row.amount === null ||
|
||||
(minimum !== undefined && row.amount < minimum)
|
||||
) {
|
||||
return Promise.resolve({ count: 0 });
|
||||
}
|
||||
honeyBatches.set(where.id, {
|
||||
amount: applyAmountChange(row.amount, data.amount),
|
||||
status: data.status ?? row.status,
|
||||
});
|
||||
return Promise.resolve({ count: 1 });
|
||||
},
|
||||
),
|
||||
},
|
||||
};
|
||||
prisma = {
|
||||
honeyBatch: { findUnique: jest.fn(), update: jest.fn() },
|
||||
@@ -43,13 +123,19 @@ describe('BottlingService', () => {
|
||||
),
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||
webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||
const automation = { generateNumber: jest.fn().mockResolvedValue(null) };
|
||||
stock = {
|
||||
remainingByBottling: jest.fn().mockResolvedValue(new Map()),
|
||||
notify: jest.fn().mockResolvedValue(undefined),
|
||||
hasMovements: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
service = new BottlingService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
webhooks as unknown as WebhooksService,
|
||||
automation as unknown as AutomationService,
|
||||
stock as unknown as StockService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -96,6 +182,7 @@ describe('BottlingService', () => {
|
||||
id: 'h1',
|
||||
amount: 25,
|
||||
});
|
||||
honeyBatches.set('h1', { amount: 25 });
|
||||
tx.bottlingBatch.create.mockResolvedValueOnce({
|
||||
id: 'b1',
|
||||
totalAmount: 25,
|
||||
@@ -118,10 +205,7 @@ describe('BottlingService', () => {
|
||||
);
|
||||
|
||||
expect(result).toEqual({ id: 'b1', totalAmount: 25 });
|
||||
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
|
||||
where: { id: 'h1' },
|
||||
data: { amount: 0, status: 'BOTTLED' },
|
||||
});
|
||||
expect(honeyBatches.get('h1')).toEqual({ amount: 0, status: 'BOTTLED' });
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'BottlingBatch',
|
||||
@@ -130,24 +214,63 @@ describe('BottlingService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a bottling when the stored amount shrank after the first check', async () => {
|
||||
// The pre-check saw 25 kg, but a concurrent bottling already took most
|
||||
// of it - the conditional booking inside the transaction must catch that.
|
||||
prisma.honeyBatch.findUnique.mockResolvedValueOnce({
|
||||
id: 'h1',
|
||||
amount: 25,
|
||||
});
|
||||
honeyBatches.set('h1', { amount: 3 });
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
{
|
||||
honeyBatchId: 'h1',
|
||||
date: '2026-01-01',
|
||||
jarSize: 500,
|
||||
jarCount: 20,
|
||||
bestBeforeDate: '2027-01-01',
|
||||
lotNumber: 'L-9',
|
||||
},
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(tx.bottlingBatch.create).not.toHaveBeenCalled();
|
||||
expect(honeyBatches.get('h1')?.amount).toBe(3);
|
||||
});
|
||||
|
||||
it('update writes an audit log entry with before and after data', async () => {
|
||||
const before = { id: 'b1', lotNumber: 'L-1' };
|
||||
const updated = { id: 'b1', lotNumber: 'L-2' };
|
||||
const before = {
|
||||
id: 'b1',
|
||||
lotNumber: 'L-1',
|
||||
honeyBatchId: 'h1',
|
||||
honeyBatch: { id: 'h1', amount: 10 },
|
||||
jarSize: 500,
|
||||
jarCount: 10,
|
||||
totalAmount: 5,
|
||||
};
|
||||
const updated = { ...before, lotNumber: 'L-2' };
|
||||
prisma.bottlingBatch.findUnique
|
||||
.mockResolvedValueOnce(before)
|
||||
.mockResolvedValueOnce(updated);
|
||||
prisma.bottlingBatch.update.mockResolvedValue(updated);
|
||||
|
||||
const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
|
||||
|
||||
expect(result).toEqual(updated);
|
||||
// Without movements the remaining stock equals the jar count.
|
||||
expect(result).toEqual({ ...updated, remainingCount: 10 });
|
||||
expect(stock.notify).toHaveBeenCalledWith({
|
||||
bottlingBatchIds: ['b1'],
|
||||
productIds: [],
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'BottlingBatch',
|
||||
entityId: 'b1',
|
||||
action: 'UPDATE',
|
||||
beforeData: before,
|
||||
afterData: updated,
|
||||
// The audit entry keeps the stock at that moment as well.
|
||||
beforeData: { ...before, remainingCount: 10 },
|
||||
afterData: { ...updated, remainingCount: 10 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -160,4 +283,169 @@ describe('BottlingService', () => {
|
||||
service.update('b1', { productId: 'missing' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('clears the product assignment when productId is null', async () => {
|
||||
const before = {
|
||||
id: 'b1',
|
||||
honeyBatchId: 'h1',
|
||||
honeyBatch: { id: 'h1', amount: 10 },
|
||||
jarSize: 500,
|
||||
jarCount: 10,
|
||||
totalAmount: 5,
|
||||
productId: 'p1',
|
||||
};
|
||||
prisma.bottlingBatch.findUnique
|
||||
.mockResolvedValueOnce(before)
|
||||
.mockResolvedValueOnce({ ...before, productId: null });
|
||||
|
||||
await service.update('b1', { productId: null }, 'user-1');
|
||||
|
||||
expect(prisma.product.findUnique).not.toHaveBeenCalled();
|
||||
const [updateArgs] = tx.bottlingBatch.update.mock.calls as [
|
||||
[{ where: { id: string }; data: { productId: string | null } }],
|
||||
];
|
||||
expect(updateArgs[0].where).toEqual({ id: 'b1' });
|
||||
expect(updateArgs[0].data.productId).toBeNull();
|
||||
});
|
||||
|
||||
it('releases the previous amount before re-reserving a corrected jar count', async () => {
|
||||
const before = {
|
||||
id: 'b1',
|
||||
honeyBatchId: 'h1',
|
||||
honeyBatch: { id: 'h1', amount: 0 },
|
||||
jarSize: 500,
|
||||
jarCount: 20,
|
||||
totalAmount: 10,
|
||||
};
|
||||
prisma.bottlingBatch.findUnique
|
||||
.mockResolvedValueOnce(before)
|
||||
.mockResolvedValueOnce({ ...before, jarCount: 10, totalAmount: 5 });
|
||||
honeyBatches.set('h1', { amount: 0 });
|
||||
|
||||
await service.update('b1', { jarCount: 10 }, 'user-1');
|
||||
|
||||
const [updateArgs] = tx.bottlingBatch.update.mock.calls as [
|
||||
[{ data: { jarCount: number; totalAmount: number } }],
|
||||
];
|
||||
expect(updateArgs[0].data.jarCount).toBe(10);
|
||||
expect(updateArgs[0].data.totalAmount).toBe(5);
|
||||
expect(honeyBatches.get('h1')?.amount).toBe(5);
|
||||
});
|
||||
|
||||
it('rejects a corrected amount that exceeds the honey batch capacity', async () => {
|
||||
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
|
||||
id: 'b1',
|
||||
honeyBatchId: 'h1',
|
||||
honeyBatch: { id: 'h1', amount: 0 },
|
||||
jarSize: 500,
|
||||
jarCount: 10,
|
||||
totalAmount: 5,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.update('b1', { jarCount: 40 }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('moves the amount between honey batches when the honey batch changes', async () => {
|
||||
const before = {
|
||||
id: 'b1',
|
||||
honeyBatchId: 'h1',
|
||||
honeyBatch: { id: 'h1', amount: 2 },
|
||||
jarSize: 500,
|
||||
jarCount: 10,
|
||||
totalAmount: 5,
|
||||
};
|
||||
prisma.bottlingBatch.findUnique
|
||||
.mockResolvedValueOnce(before)
|
||||
.mockResolvedValueOnce({ ...before, honeyBatchId: 'h2' });
|
||||
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h2', amount: 8 });
|
||||
honeyBatches.set('h1', { amount: 2 });
|
||||
honeyBatches.set('h2', { amount: 8 });
|
||||
|
||||
await service.update('b1', { honeyBatchId: 'h2' }, 'user-1');
|
||||
|
||||
expect(honeyBatches.get('h1')?.amount).toBe(7);
|
||||
expect(honeyBatches.get('h2')).toEqual({ amount: 3, status: 'BOTTLED' });
|
||||
});
|
||||
|
||||
it('remove gives the amount back and records a DELETE audit entry', async () => {
|
||||
const before = {
|
||||
id: 'b1',
|
||||
honeyBatchId: 'h1',
|
||||
honeyBatch: { id: 'h1', amount: 2 },
|
||||
totalAmount: 5,
|
||||
};
|
||||
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(before);
|
||||
honeyBatches.set('h1', { amount: 2 });
|
||||
|
||||
const result = await service.remove('b1', 'user-1');
|
||||
|
||||
expect(result).toEqual({ id: 'b1' });
|
||||
expect(tx.bottlingBatch.delete).toHaveBeenCalledWith({
|
||||
where: { id: 'b1' },
|
||||
});
|
||||
expect(honeyBatches.get('h1')?.amount).toBe(7);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'BottlingBatch',
|
||||
entityId: 'b1',
|
||||
action: 'DELETE',
|
||||
beforeData: before,
|
||||
}),
|
||||
);
|
||||
expect(webhooks.dispatch).toHaveBeenCalledWith(
|
||||
'batch.updated',
|
||||
before.honeyBatch,
|
||||
);
|
||||
});
|
||||
|
||||
it('remove refuses a bottling whose jars were already sold or withdrawn', async () => {
|
||||
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
|
||||
id: 'b1',
|
||||
honeyBatchId: 'h1',
|
||||
honeyBatch: { id: 'h1', amount: 2 },
|
||||
totalAmount: 5,
|
||||
});
|
||||
stock.hasMovements.mockResolvedValueOnce(true);
|
||||
|
||||
await expect(service.remove('b1', 'user-1')).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(tx.bottlingBatch.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to bottle onto an article that keeps stock in production batches', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValueOnce({
|
||||
id: 'h1',
|
||||
amount: 10,
|
||||
});
|
||||
prisma.product.findUnique.mockResolvedValueOnce({
|
||||
id: 'candle',
|
||||
kind: 'MANUFACTURED',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
{
|
||||
honeyBatchId: 'h1',
|
||||
productId: 'candle',
|
||||
date: '2026-07-01',
|
||||
jarSize: 500,
|
||||
jarCount: 2,
|
||||
bestBeforeDate: '2028-07-01',
|
||||
lotNumber: 'L1',
|
||||
},
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('remove throws NotFoundException for an unknown bottling batch', async () => {
|
||||
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(service.remove('missing', 'user-1')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
@@ -7,7 +8,8 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
import { BottlingBatch } from '../../generated/prisma';
|
||||
import { StockService } from '../stock/stock.service';
|
||||
import { BottlingBatch, Prisma } from '../../generated/prisma';
|
||||
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
|
||||
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
|
||||
|
||||
@@ -20,6 +22,83 @@ export interface BottlingBatchFilter {
|
||||
honeyBatchId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Amounts are kept in kilograms with gram precision. Rounding on every write
|
||||
* keeps a honey batch's remaining amount from drifting when a bottling batch
|
||||
* is corrected or removed and its amount is given back.
|
||||
*/
|
||||
function roundKg(amount: number): number {
|
||||
return Math.round(amount * 1000) / 1000;
|
||||
}
|
||||
|
||||
const INSUFFICIENT_AMOUNT_MESSAGE =
|
||||
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge';
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
|
||||
/**
|
||||
* Deducts `amount` kg from a honey batch in one conditional UPDATE, so the
|
||||
* database - not a value read earlier - decides whether enough is left.
|
||||
* A batch without a recorded amount (null) is not tracked and stays null.
|
||||
*/
|
||||
async function takeFromHoneyBatch(
|
||||
tx: Tx,
|
||||
honeyBatchId: string,
|
||||
amount: number,
|
||||
): Promise<void> {
|
||||
const current = await tx.honeyBatch.findUnique({
|
||||
where: { id: honeyBatchId },
|
||||
select: { amount: true },
|
||||
});
|
||||
if (!current) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
if (current.amount === null) {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: honeyBatchId },
|
||||
data: { status: 'BOTTLED' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Tolerance of half a gram absorbs float noise from earlier writes.
|
||||
const booked = await tx.honeyBatch.updateMany({
|
||||
where: { id: honeyBatchId, amount: { gte: amount - 0.0005 } },
|
||||
data: { amount: { decrement: amount }, status: 'BOTTLED' },
|
||||
});
|
||||
if (booked.count === 0) {
|
||||
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
|
||||
}
|
||||
await normalizeAmount(tx, honeyBatchId);
|
||||
}
|
||||
|
||||
async function giveBackToHoneyBatch(
|
||||
tx: Tx,
|
||||
honeyBatchId: string,
|
||||
amount: number,
|
||||
): Promise<void> {
|
||||
await tx.honeyBatch.updateMany({
|
||||
where: { id: honeyBatchId, amount: { not: null } },
|
||||
data: { amount: { increment: amount } },
|
||||
});
|
||||
await normalizeAmount(tx, honeyBatchId);
|
||||
}
|
||||
|
||||
/** Keeps gram precision and clamps float noise below zero to exactly 0. */
|
||||
async function normalizeAmount(tx: Tx, honeyBatchId: string): Promise<void> {
|
||||
const row = await tx.honeyBatch.findUnique({
|
||||
where: { id: honeyBatchId },
|
||||
select: { amount: true },
|
||||
});
|
||||
if (row?.amount == null) return;
|
||||
const normalized = Math.max(0, roundKg(row.amount));
|
||||
if (normalized !== row.amount) {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: honeyBatchId },
|
||||
data: { amount: normalized },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BottlingService {
|
||||
constructor(
|
||||
@@ -27,14 +106,22 @@ export class BottlingService {
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly webhooks: WebhooksService,
|
||||
private readonly automation: AutomationService,
|
||||
private readonly stock: StockService,
|
||||
) {}
|
||||
|
||||
async findAll(filter: BottlingBatchFilter = {}) {
|
||||
return this.prisma.bottlingBatch.findMany({
|
||||
const batches = await this.prisma.bottlingBatch.findMany({
|
||||
where: { honeyBatchId: filter.honeyBatchId },
|
||||
include: bottlingBatchInclude,
|
||||
orderBy: { date: 'desc' },
|
||||
});
|
||||
const remaining = await this.stock.remainingByBottling(
|
||||
batches.map((b) => b.id),
|
||||
);
|
||||
return batches.map((b) => ({
|
||||
...b,
|
||||
remainingCount: remaining.get(b.id) ?? b.jarCount,
|
||||
}));
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
@@ -45,7 +132,26 @@ export class BottlingService {
|
||||
if (!bottlingBatch) {
|
||||
throw new NotFoundException('Bottling batch not found');
|
||||
}
|
||||
return bottlingBatch;
|
||||
const remaining = await this.stock.remainingByBottling([id]);
|
||||
return {
|
||||
...bottlingBatch,
|
||||
remainingCount: remaining.get(id) ?? bottlingBatch.jarCount,
|
||||
};
|
||||
}
|
||||
|
||||
/** Honey is bottled; other articles keep their stock in production batches. */
|
||||
private async assertBottledProduct(productId: string): Promise<void> {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id: productId },
|
||||
});
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
if (product.kind !== 'BOTTLED_HONEY') {
|
||||
throw new BadRequestException(
|
||||
'Abfüllungen nur für Honig-Artikel - hergestellte Waren bekommen Herstellungschargen',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async create(dto: CreateBottlingBatchDto, actingUserId: string) {
|
||||
@@ -56,19 +162,12 @@ export class BottlingService {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
if (dto.productId) {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id: dto.productId },
|
||||
});
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
await this.assertBottledProduct(dto.productId);
|
||||
}
|
||||
|
||||
const totalAmount = (dto.jarSize * dto.jarCount) / 1000;
|
||||
const totalAmount = roundKg((dto.jarSize * dto.jarCount) / 1000);
|
||||
if (honeyBatch.amount !== null && totalAmount > honeyBatch.amount) {
|
||||
throw new BadRequestException(
|
||||
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge',
|
||||
);
|
||||
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
|
||||
}
|
||||
|
||||
const lotNumber =
|
||||
@@ -82,7 +181,12 @@ export class BottlingService {
|
||||
|
||||
const bottlingBatch: BottlingBatch = await this.prisma.$transaction(
|
||||
async (tx) => {
|
||||
const created = await tx.bottlingBatch.create({
|
||||
// Booked against the stored amount inside the transaction, never
|
||||
// against the value read above: two bottlings submitted at the same
|
||||
// time (double tap, two devices) must not both pass the check and
|
||||
// overwrite each other's deduction.
|
||||
await takeFromHoneyBatch(tx, dto.honeyBatchId, totalAmount);
|
||||
return tx.bottlingBatch.create({
|
||||
data: {
|
||||
honeyBatchId: dto.honeyBatchId,
|
||||
productId: dto.productId,
|
||||
@@ -95,17 +199,6 @@ export class BottlingService {
|
||||
userId: actingUserId,
|
||||
},
|
||||
});
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: dto.honeyBatchId },
|
||||
data: {
|
||||
amount:
|
||||
honeyBatch.amount !== null
|
||||
? honeyBatch.amount - totalAmount
|
||||
: null,
|
||||
status: 'BOTTLED',
|
||||
},
|
||||
});
|
||||
return created;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -119,29 +212,87 @@ export class BottlingService {
|
||||
|
||||
const created = await this.findById(bottlingBatch.id);
|
||||
await this.webhooks.dispatch('batch.updated', created.honeyBatch);
|
||||
await this.stock.notify({
|
||||
bottlingBatchIds: [created.id],
|
||||
productIds: [],
|
||||
});
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Corrects any field of an existing bottling batch. Changing the honey
|
||||
* batch, the jar size or the jar count also rebalances the remaining amount
|
||||
* of the affected honey batch(es): the previously bottled amount is given
|
||||
* back before the corrected amount is taken again, so a correction never
|
||||
* double-charges the source batch.
|
||||
*/
|
||||
async update(id: string, dto: UpdateBottlingBatchDto, actingUserId: string) {
|
||||
const before = await this.findById(id);
|
||||
|
||||
if (dto.productId) {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id: dto.productId },
|
||||
await this.assertBottledProduct(dto.productId);
|
||||
}
|
||||
|
||||
const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId;
|
||||
const movedToOtherHoneyBatch = honeyBatchId !== before.honeyBatchId;
|
||||
const jarSize = dto.jarSize ?? before.jarSize;
|
||||
const jarCount = dto.jarCount ?? before.jarCount;
|
||||
const totalAmount = roundKg((jarSize * jarCount) / 1000);
|
||||
|
||||
const targetHoneyBatch = movedToOtherHoneyBatch
|
||||
? await this.prisma.honeyBatch.findUnique({ where: { id: honeyBatchId } })
|
||||
: before.honeyBatch;
|
||||
if (!targetHoneyBatch) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
|
||||
// Early, friendly check against the amounts read above. The booking
|
||||
// itself happens atomically inside the transaction below.
|
||||
const availableOnTarget =
|
||||
targetHoneyBatch.amount === null
|
||||
? null
|
||||
: roundKg(
|
||||
targetHoneyBatch.amount +
|
||||
(movedToOtherHoneyBatch ? 0 : before.totalAmount),
|
||||
);
|
||||
if (availableOnTarget !== null && totalAmount > availableOnTarget) {
|
||||
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
|
||||
}
|
||||
|
||||
// A duplicate lotNumber surfaces as Prisma P2002 and is turned into a
|
||||
// 409 with a field-specific message by AllExceptionsFilter.
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const amountChanged =
|
||||
movedToOtherHoneyBatch || totalAmount !== before.totalAmount;
|
||||
if (amountChanged) {
|
||||
// Give the previous amount back first, then take the corrected one -
|
||||
// a correction never double-charges the source batch.
|
||||
await giveBackToHoneyBatch(tx, before.honeyBatchId, before.totalAmount);
|
||||
await takeFromHoneyBatch(tx, honeyBatchId, totalAmount);
|
||||
}
|
||||
if (movedToOtherHoneyBatch) {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: honeyBatchId },
|
||||
data: { status: 'BOTTLED' },
|
||||
});
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
}
|
||||
await this.prisma.bottlingBatch.update({
|
||||
await tx.bottlingBatch.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...dto,
|
||||
honeyBatchId,
|
||||
productId: dto.productId === undefined ? undefined : dto.productId,
|
||||
date: dto.date ? new Date(dto.date) : undefined,
|
||||
jarSize,
|
||||
jarCount,
|
||||
totalAmount,
|
||||
bestBeforeDate: dto.bestBeforeDate
|
||||
? new Date(dto.bestBeforeDate)
|
||||
: undefined,
|
||||
lotNumber: dto.lotNumber,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const bottlingBatch = await this.findById(id);
|
||||
await this.auditLog.record({
|
||||
entityType: 'BottlingBatch',
|
||||
@@ -151,6 +302,46 @@ export class BottlingService {
|
||||
beforeData: before,
|
||||
afterData: bottlingBatch,
|
||||
});
|
||||
await this.webhooks.dispatch('batch.updated', bottlingBatch.honeyBatch);
|
||||
// A corrected jar count changes the remaining stock as well.
|
||||
await this.stock.notify({ bottlingBatchIds: [id], productIds: [] });
|
||||
return bottlingBatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a bottling batch that was entered by mistake and gives its amount
|
||||
* back to the honey batch. The full record is kept in the audit log
|
||||
* (action DELETE, beforeData), so the history survives the row.
|
||||
*
|
||||
* The honey batch's status is deliberately left untouched: the status it
|
||||
* held before being bottled is not recoverable here, and guessing it would
|
||||
* write a fact that never happened.
|
||||
*/
|
||||
async remove(id: string, actingUserId: string) {
|
||||
const before = await this.findById(id);
|
||||
// Once jars were sold or withdrawn, the bottling is history: deleting it
|
||||
// would erase where they came from. Wrong stock is fixed by an inventory
|
||||
// correction instead.
|
||||
if (await this.stock.hasMovements({ kind: 'bottling', id })) {
|
||||
throw new ConflictException(
|
||||
'Abfüllung hat bereits Bestandsbewegungen und kann nicht gelöscht werden - Bestand über "Inventur korrigieren" anpassen',
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.bottlingBatch.delete({ where: { id } });
|
||||
await giveBackToHoneyBatch(tx, before.honeyBatchId, before.totalAmount);
|
||||
});
|
||||
|
||||
await this.auditLog.record({
|
||||
entityType: 'BottlingBatch',
|
||||
entityId: id,
|
||||
action: 'DELETE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
});
|
||||
await this.webhooks.dispatch('batch.updated', before.honeyBatch);
|
||||
await this.stock.notify({ bottlingBatchIds: [id], productIds: [] });
|
||||
return { id };
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,64 @@
|
||||
import { PartialType, PickType } from '@nestjs/swagger';
|
||||
import { CreateBottlingBatchDto } from './create-bottling-batch.dto';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
export class UpdateBottlingBatchDto extends PartialType(
|
||||
PickType(CreateBottlingBatchDto, [
|
||||
'productId',
|
||||
'date',
|
||||
'bestBeforeDate',
|
||||
'lotNumber',
|
||||
] as const),
|
||||
) {}
|
||||
/**
|
||||
* Every field of a bottling batch stays correctable after the fact - a
|
||||
* mistyped jar count or a batch bottled against the wrong honey batch is a
|
||||
* data-entry error, not a fact of production. The service rebalances the
|
||||
* affected honey batch amounts for any change to honeyBatchId/jarSize/
|
||||
* jarCount, so the traceability chain stays consistent.
|
||||
*
|
||||
* `productId: null` explicitly clears the product assignment; omitting the
|
||||
* field leaves it untouched.
|
||||
*/
|
||||
export class UpdateBottlingBatchDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsString()
|
||||
honeyBatchId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: 'null entfernt die Produktzuordnung',
|
||||
})
|
||||
@IsOptional()
|
||||
@ValidateIf((_object, value) => value !== null)
|
||||
@IsString()
|
||||
productId?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsDateString()
|
||||
date?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Glasgröße in Gramm' })
|
||||
@IsOptionalNotNull()
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
jarSize?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
jarCount?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsDateString()
|
||||
bestBeforeDate?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsString()
|
||||
lotNumber?: string;
|
||||
}
|
||||
@@ -115,6 +115,23 @@ describe('CalendarSyncSettingsService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// PATCH null rule: null on the nullable groupId clears it, like "".
|
||||
it('clears groupId when given null', async () => {
|
||||
prisma.calendarSyncSettings.findUnique.mockResolvedValue(existing);
|
||||
prisma.calendarSyncSettings.update.mockResolvedValue({
|
||||
...existing,
|
||||
groupId: null,
|
||||
});
|
||||
|
||||
await service.update({ groupId: null }, 'user-1');
|
||||
|
||||
expect(prisma.calendarSyncSettings.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ groupId: null }) as unknown,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves fields untouched when omitted from the dto', async () => {
|
||||
prisma.calendarSyncSettings.findUnique.mockResolvedValue(existing);
|
||||
prisma.calendarSyncSettings.update.mockResolvedValue(existing);
|
||||
|
||||
@@ -6,7 +6,8 @@ import { UpdateCalendarSyncSettingsDto } from './dto/update-calendar-sync-settin
|
||||
|
||||
const SINGLETON_ID = 'singleton';
|
||||
|
||||
function emptyToNull(value: string | undefined): string | null {
|
||||
// "" (the GUI emptying the field) and null both clear the stored value.
|
||||
function emptyToNull(value: string | null | undefined): string | null {
|
||||
return value === '' ? null : (value ?? null);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Body, Controller, Get, Patch, Post } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
@@ -37,10 +45,13 @@ export class CalendarSyncController {
|
||||
return this.calendarSyncService.getStatus();
|
||||
}
|
||||
|
||||
// Starts a pass and answers at once (202) - the pass itself can take
|
||||
// minutes. The returned status says running: true; GET status follows it.
|
||||
@RequirePermission('calendar:configure')
|
||||
@Post('run-now')
|
||||
async runNow() {
|
||||
await this.calendarSyncService.runNow();
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
runNow() {
|
||||
this.calendarSyncService.startRun();
|
||||
return this.calendarSyncService.getStatus();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { CalendarSyncService } from './calendar-sync.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
|
||||
import { GraphClientService } from './graph-client.service';
|
||||
import { GraphClientService, GraphRequestError } from './graph-client.service';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
|
||||
describe('CalendarSyncService', () => {
|
||||
let service: CalendarSyncService;
|
||||
@@ -10,6 +12,7 @@ describe('CalendarSyncService', () => {
|
||||
upsert: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
updateMany: jest.Mock;
|
||||
findMany: jest.Mock;
|
||||
count: jest.Mock;
|
||||
};
|
||||
@@ -41,13 +44,47 @@ describe('CalendarSyncService', () => {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const pendingEntry = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'entry-1',
|
||||
sourceType: 'TASK',
|
||||
sourceId: 'task-1',
|
||||
status: 'PENDING',
|
||||
graphEventId: null,
|
||||
attempts: 0,
|
||||
revision: 3,
|
||||
claimedAt: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** Derived from the outbox entry, shaped like a GUID. */
|
||||
const TRANSACTION_ID = expect.stringMatching(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
) as string;
|
||||
|
||||
/** Free, or held by a worker that gave up long ago (cut-off time). */
|
||||
const CLAIMABLE = {
|
||||
OR: [{ claimedAt: null }, { claimedAt: { lt: expect.any(Date) as Date } }],
|
||||
};
|
||||
|
||||
type UpdateManyArgs = {
|
||||
where: Record<string, unknown>;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
const updateManyCalls = () =>
|
||||
prisma.calendarSyncOutboxEntry.updateMany.mock.calls.map(
|
||||
([args]) => args as UpdateManyArgs,
|
||||
);
|
||||
/** The claim taken by this pass - every later write is conditional on it. */
|
||||
const claimedAt = () => updateManyCalls()[0].data.claimedAt as Date;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
calendarSyncOutboxEntry: {
|
||||
upsert: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
},
|
||||
task: { findUnique: jest.fn() },
|
||||
@@ -94,7 +131,7 @@ describe('CalendarSyncService', () => {
|
||||
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('upserts a PENDING row, resetting attempts and lastError', async () => {
|
||||
it('upserts a PENDING row, resetting attempts and lastError and counting the revision up', async () => {
|
||||
await service.enqueue('TASK', 'task-1');
|
||||
|
||||
expect(prisma.calendarSyncOutboxEntry.upsert).toHaveBeenCalledWith({
|
||||
@@ -102,11 +139,58 @@ describe('CalendarSyncService', () => {
|
||||
sourceType_sourceId: { sourceType: 'TASK', sourceId: 'task-1' },
|
||||
},
|
||||
create: { sourceType: 'TASK', sourceId: 'task-1' },
|
||||
update: { status: 'PENDING', attempts: 0, lastError: null },
|
||||
update: {
|
||||
status: 'PENDING',
|
||||
attempts: 0,
|
||||
lastError: null,
|
||||
revision: { increment: 1 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// enqueue()/cancel() run after the source record is committed: a failure
|
||||
// must not fail that request (the client would retry and store it twice).
|
||||
describe('post-commit calls', () => {
|
||||
let errorSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('enqueue never throws and logs the failure with the record', async () => {
|
||||
settingsService.get.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(
|
||||
service.enqueue('INSPECTION', 'inspection-1'),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
const [message] = errorSpy.mock.calls[0] as [string];
|
||||
expect(message).toContain('INSPECTION inspection-1');
|
||||
expect(message).toContain('db down');
|
||||
});
|
||||
|
||||
it('cancel never throws and logs the failure with the record', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findUnique.mockRejectedValue(
|
||||
new Error('db down'),
|
||||
);
|
||||
|
||||
await expect(service.cancel('TASK', 'task-1')).resolves.toBeUndefined();
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
const [message] = errorSpy.mock.calls[0] as [string];
|
||||
expect(message).toContain('TASK task-1');
|
||||
expect(message).toContain('db down');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('does nothing when no outbox row exists', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue(null);
|
||||
@@ -151,6 +235,25 @@ describe('CalendarSyncService', () => {
|
||||
});
|
||||
|
||||
describe('processOutbox', () => {
|
||||
const dueTask = {
|
||||
id: 'task-1',
|
||||
title: 'Beute prüfen',
|
||||
dueDate: new Date('2026-03-07'),
|
||||
};
|
||||
|
||||
/** The outcome write: only lands if the row is unchanged since the claim. */
|
||||
const expectSettled = (data: Record<string, unknown>) => {
|
||||
expect(updateManyCalls()[1]).toEqual({
|
||||
where: {
|
||||
id: 'entry-1',
|
||||
status: 'PENDING',
|
||||
revision: 3,
|
||||
claimedAt: claimedAt(),
|
||||
},
|
||||
data: { ...data, claimedAt: null },
|
||||
});
|
||||
};
|
||||
|
||||
it('does nothing when sync is disabled, unconfigured, or no groupId', async () => {
|
||||
settingsService.get.mockResolvedValue({
|
||||
...enabledSettings,
|
||||
@@ -160,76 +263,170 @@ describe('CalendarSyncService', () => {
|
||||
expect(prisma.calendarSyncOutboxEntry.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a Graph event for a new TASK entry and marks it SYNCED', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'entry-1',
|
||||
sourceType: 'TASK',
|
||||
sourceId: 'task-1',
|
||||
graphEventId: null,
|
||||
attempts: 0,
|
||||
},
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue({
|
||||
id: 'task-1',
|
||||
title: 'Beute prüfen',
|
||||
dueDate: new Date('2026-03-07'),
|
||||
it('only picks PENDING rows that nobody holds, or whose holder gave up long ago', async () => {
|
||||
await service.processOutbox();
|
||||
const after = Date.now();
|
||||
|
||||
const [[query]] = prisma.calendarSyncOutboxEntry.findMany.mock.calls as [
|
||||
[{ where: Record<string, unknown> }],
|
||||
];
|
||||
expect(query.where).toEqual({
|
||||
status: 'PENDING',
|
||||
...CLAIMABLE,
|
||||
});
|
||||
const staleBefore = (
|
||||
query.where.OR as [unknown, { claimedAt: { lt: Date } }]
|
||||
)[1].claimedAt.lt;
|
||||
// A claim counts as abandoned only after several minutes - far longer
|
||||
// than one entry can take with bounded Graph requests.
|
||||
expect(after - staleBefore.getTime()).toBeGreaterThanOrEqual(
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
|
||||
it('claims a row with a conditional update before working on it', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
pendingEntry(),
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue(dueTask);
|
||||
graphClient.createEvent.mockResolvedValue('graph-event-1');
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(graphClient.createEvent).toHaveBeenCalledWith('group-1', {
|
||||
title: 'Beute prüfen',
|
||||
date: new Date('2026-03-07'),
|
||||
});
|
||||
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
|
||||
where: { id: 'entry-1' },
|
||||
data: {
|
||||
status: 'SYNCED',
|
||||
graphEventId: 'graph-event-1',
|
||||
lastError: null,
|
||||
expect(updateManyCalls()[0]).toEqual({
|
||||
where: {
|
||||
id: 'entry-1',
|
||||
status: 'PENDING',
|
||||
revision: 3,
|
||||
...CLAIMABLE,
|
||||
},
|
||||
data: { claimedAt: expect.any(Date) as Date },
|
||||
});
|
||||
const [claimOrder] =
|
||||
prisma.calendarSyncOutboxEntry.updateMany.mock.invocationCallOrder;
|
||||
const [createOrder] = graphClient.createEvent.mock.invocationCallOrder;
|
||||
expect(claimOrder).toBeLessThan(createOrder);
|
||||
});
|
||||
|
||||
it('marks the entry SKIPPED when the Task no longer has a dueDate', async () => {
|
||||
it('leaves a row alone when another pod or run claimed it first', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'entry-1',
|
||||
sourceType: 'TASK',
|
||||
sourceId: 'task-1',
|
||||
graphEventId: null,
|
||||
attempts: 0,
|
||||
},
|
||||
pendingEntry(),
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue({
|
||||
id: 'task-1',
|
||||
title: 'Beute prüfen',
|
||||
dueDate: null,
|
||||
prisma.calendarSyncOutboxEntry.updateMany.mockResolvedValueOnce({
|
||||
count: 0,
|
||||
});
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(prisma.task.findUnique).not.toHaveBeenCalled();
|
||||
expect(graphClient.createEvent).not.toHaveBeenCalled();
|
||||
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
|
||||
where: { id: 'entry-1' },
|
||||
data: { status: 'SKIPPED' },
|
||||
expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a Graph event for a new TASK entry and marks it SYNCED', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
pendingEntry(),
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue(dueTask);
|
||||
graphClient.createEvent.mockResolvedValue('graph-event-1');
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(graphClient.createEvent).toHaveBeenCalledWith(
|
||||
'group-1',
|
||||
{ title: 'Beute prüfen', date: new Date('2026-03-07') },
|
||||
TRANSACTION_ID,
|
||||
);
|
||||
expectSettled({
|
||||
status: 'SYNCED',
|
||||
graphEventId: 'graph-event-1',
|
||||
lastError: null,
|
||||
});
|
||||
expect(prisma.calendarSyncOutboxEntry.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the new event id but stays PENDING when the source changed during the sync', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
pendingEntry(),
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue(dueTask);
|
||||
graphClient.createEvent.mockResolvedValue('graph-event-1');
|
||||
prisma.calendarSyncOutboxEntry.updateMany
|
||||
.mockResolvedValueOnce({ count: 1 }) // claim
|
||||
.mockResolvedValueOnce({ count: 0 }); // re-enqueued meanwhile
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
// Not SYNCED: the newer state still has to reach M365, and the next
|
||||
// pass must update this event rather than create a second one.
|
||||
expect(updateManyCalls()[2]).toEqual({
|
||||
where: { id: 'entry-1', status: 'PENDING', claimedAt: claimedAt() },
|
||||
data: { graphEventId: 'graph-event-1', claimedAt: null },
|
||||
});
|
||||
expect(graphClient.deleteEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes the event again when the source left the calendar during the sync', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
pendingEntry(),
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue(dueTask);
|
||||
graphClient.createEvent.mockResolvedValue('graph-event-1');
|
||||
prisma.calendarSyncOutboxEntry.updateMany
|
||||
.mockResolvedValueOnce({ count: 1 }) // claim
|
||||
.mockResolvedValueOnce({ count: 0 }) // not unchanged
|
||||
.mockResolvedValueOnce({ count: 0 }) // not PENDING: cancelled
|
||||
.mockResolvedValueOnce({ count: 1 }); // claim released
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(updateManyCalls()[3]).toEqual({
|
||||
where: { id: 'entry-1', claimedAt: claimedAt() },
|
||||
data: { claimedAt: null },
|
||||
});
|
||||
expect(graphClient.deleteEvent).toHaveBeenCalledWith(
|
||||
'group-1',
|
||||
'graph-event-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('marks the entry SKIPPED when the Task no longer has a dueDate', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
pendingEntry(),
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue({ ...dueTask, dueDate: null });
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(graphClient.createEvent).not.toHaveBeenCalled();
|
||||
expectSettled({ status: 'SKIPPED' });
|
||||
});
|
||||
|
||||
it('only lets go of the claim when the row changed before SKIPPED could be written', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
pendingEntry(),
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue({ ...dueTask, dueDate: null });
|
||||
prisma.calendarSyncOutboxEntry.updateMany
|
||||
.mockResolvedValueOnce({ count: 1 })
|
||||
.mockResolvedValueOnce({ count: 0 });
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(updateManyCalls()[2]).toEqual({
|
||||
where: { id: 'entry-1', claimedAt: claimedAt() },
|
||||
data: { claimedAt: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('updates an existing Graph event when graphEventId is already set', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'entry-1',
|
||||
sourceType: 'TASK',
|
||||
sourceId: 'task-1',
|
||||
graphEventId: 'graph-event-1',
|
||||
attempts: 0,
|
||||
},
|
||||
pendingEntry({ graphEventId: 'graph-event-1' }),
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue({
|
||||
id: 'task-1',
|
||||
...dueTask,
|
||||
title: 'Beute prüfen (geändert)',
|
||||
dueDate: new Date('2026-03-10'),
|
||||
});
|
||||
@@ -243,96 +440,108 @@ describe('CalendarSyncService', () => {
|
||||
{ title: 'Beute prüfen (geändert)', date: new Date('2026-03-10') },
|
||||
);
|
||||
expect(graphClient.createEvent).not.toHaveBeenCalled();
|
||||
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
|
||||
where: { id: 'entry-1' },
|
||||
data: {
|
||||
expectSettled({
|
||||
status: 'SYNCED',
|
||||
graphEventId: 'graph-event-1',
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to creating a fresh event when updating a stale graphEventId 404s', async () => {
|
||||
it.each([404, 410])(
|
||||
'falls back to creating a fresh event when the stored event is gone (%i)',
|
||||
async (status) => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'entry-1',
|
||||
sourceType: 'TASK',
|
||||
sourceId: 'task-1',
|
||||
graphEventId: 'deleted-on-m365',
|
||||
attempts: 0,
|
||||
},
|
||||
pendingEntry({ graphEventId: 'deleted-on-m365' }),
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue({
|
||||
id: 'task-1',
|
||||
title: 'Beute prüfen',
|
||||
dueDate: new Date('2026-03-07'),
|
||||
});
|
||||
graphClient.updateEvent.mockRejectedValue(new Error('404 Not Found'));
|
||||
prisma.task.findUnique.mockResolvedValue(dueTask);
|
||||
graphClient.updateEvent.mockRejectedValue(
|
||||
new GraphRequestError('updateEvent', status, 'gone'),
|
||||
);
|
||||
graphClient.createEvent.mockResolvedValue('graph-event-new');
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(graphClient.createEvent).toHaveBeenCalled();
|
||||
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
|
||||
where: { id: 'entry-1' },
|
||||
data: {
|
||||
expectSettled({
|
||||
status: 'SYNCED',
|
||||
graphEventId: 'graph-event-new',
|
||||
lastError: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['a Graph server error', new GraphRequestError('updateEvent', 503, '')],
|
||||
['a throttled request', new GraphRequestError('updateEvent', 429, '')],
|
||||
['a network failure', new Error('fetch failed')],
|
||||
])(
|
||||
'does not create a second event after %s, but retries the update later',
|
||||
async (_case, failure) => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
pendingEntry({ graphEventId: 'graph-event-1', attempts: 1 }),
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue(dueTask);
|
||||
graphClient.updateEvent.mockRejectedValue(failure);
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(graphClient.createEvent).not.toHaveBeenCalled();
|
||||
expectSettled({
|
||||
status: 'PENDING',
|
||||
attempts: 2,
|
||||
lastError: failure.message,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('retries (stays PENDING) on failure below the max attempt count', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'entry-1',
|
||||
sourceType: 'TASK',
|
||||
sourceId: 'task-1',
|
||||
graphEventId: null,
|
||||
attempts: 1,
|
||||
},
|
||||
pendingEntry({ attempts: 1 }),
|
||||
]);
|
||||
prisma.task.findUnique.mockRejectedValue(new Error('db error'));
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
|
||||
where: { id: 'entry-1' },
|
||||
data: { status: 'PENDING', attempts: 2, lastError: 'db error' },
|
||||
expectSettled({ status: 'PENDING', attempts: 2, lastError: 'db error' });
|
||||
});
|
||||
|
||||
// lastError is stored and shown to admins - a Prisma message would carry
|
||||
// query values (titles, names) into it.
|
||||
it('stores a Prisma failure in lastError by name, code and model only', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
pendingEntry({ attempts: 1 }),
|
||||
]);
|
||||
prisma.task.findUnique.mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
|
||||
code: 'P2024',
|
||||
clientVersion: '7.9.1',
|
||||
meta: { modelName: 'Task' },
|
||||
}),
|
||||
);
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expectSettled({
|
||||
status: 'PENDING',
|
||||
attempts: 2,
|
||||
lastError: 'PrismaClientKnownRequestError P2024 (Task)',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks FAILED once the max attempt count is reached', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'entry-1',
|
||||
sourceType: 'TASK',
|
||||
sourceId: 'task-1',
|
||||
graphEventId: null,
|
||||
attempts: 4,
|
||||
},
|
||||
pendingEntry({ attempts: 4 }),
|
||||
]);
|
||||
prisma.task.findUnique.mockRejectedValue(new Error('db error'));
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
|
||||
where: { id: 'entry-1' },
|
||||
data: { status: 'FAILED', attempts: 5, lastError: 'db error' },
|
||||
});
|
||||
expectSettled({ status: 'FAILED', attempts: 5, lastError: 'db error' });
|
||||
});
|
||||
|
||||
it('builds titles per source type matching the read-only calendar view', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'e1',
|
||||
sourceType: 'INSPECTION',
|
||||
sourceId: 'i1',
|
||||
graphEventId: null,
|
||||
attempts: 0,
|
||||
},
|
||||
pendingEntry({ sourceType: 'INSPECTION', sourceId: 'i1' }),
|
||||
]);
|
||||
prisma.inspection.findUnique.mockResolvedValue({
|
||||
id: 'i1',
|
||||
@@ -343,11 +552,225 @@ describe('CalendarSyncService', () => {
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(graphClient.createEvent).toHaveBeenCalledWith('group-1', {
|
||||
title: 'Durchsicht V-001',
|
||||
date: new Date('2026-03-07'),
|
||||
expect(graphClient.createEvent).toHaveBeenCalledWith(
|
||||
'group-1',
|
||||
{ title: 'Durchsicht V-001', date: new Date('2026-03-07') },
|
||||
TRANSACTION_ID,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// A create that timed out may still have reached Graph. The retry - by
|
||||
// this or another pod - sends the same transactionId, so Graph can drop
|
||||
// it instead of adding a second event.
|
||||
describe('transactionId on create', () => {
|
||||
/** Runs one pass over the entry and returns the transactionId sent. */
|
||||
const transactionIdFor = async (entry: Record<string, unknown>) => {
|
||||
graphClient.createEvent.mockClear();
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
pendingEntry(entry),
|
||||
]);
|
||||
await service.processOutbox();
|
||||
const [[, , transactionId]] = graphClient.createEvent.mock.calls as [
|
||||
[string, unknown, string],
|
||||
];
|
||||
return transactionId;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
prisma.task.findUnique.mockResolvedValue({
|
||||
id: 'task-1',
|
||||
title: 'Beute prüfen',
|
||||
dueDate: new Date('2026-03-07'),
|
||||
});
|
||||
graphClient.createEvent.mockResolvedValue('graph-event-1');
|
||||
});
|
||||
|
||||
it('is the same for every retry of the same create', async () => {
|
||||
graphClient.createEvent.mockRejectedValueOnce(new Error('timeout'));
|
||||
const first = await transactionIdFor({ attempts: 0 });
|
||||
const retry = await transactionIdFor({ attempts: 1 });
|
||||
|
||||
expect(first).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
);
|
||||
expect(retry).toBe(first);
|
||||
});
|
||||
|
||||
it('differs per outbox entry', async () => {
|
||||
const one = await transactionIdFor({ id: 'entry-1' });
|
||||
const other = await transactionIdFor({ id: 'entry-2' });
|
||||
|
||||
expect(other).not.toBe(one);
|
||||
});
|
||||
|
||||
// A cancelled source deleted its event; enqueued again it needs a new
|
||||
// one, which an old transactionId might be matched against.
|
||||
it('is new once the entry was enqueued again', async () => {
|
||||
const before = await transactionIdFor({ revision: 3 });
|
||||
const after = await transactionIdFor({ revision: 4 });
|
||||
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
|
||||
it('is new when replacing an event that was deleted in M365', async () => {
|
||||
const initial = await transactionIdFor({ graphEventId: null });
|
||||
graphClient.updateEvent.mockRejectedValue(
|
||||
new GraphRequestError('updateEvent', 404, 'gone'),
|
||||
);
|
||||
const replacement = await transactionIdFor({
|
||||
graphEventId: 'deleted-on-m365',
|
||||
});
|
||||
|
||||
expect(replacement).not.toBe(initial);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runNow', () => {
|
||||
it('gives FAILED entries a fresh set of attempts before processing', async () => {
|
||||
await service.runNow();
|
||||
|
||||
expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledWith({
|
||||
where: { status: 'FAILED' },
|
||||
data: { status: 'PENDING', attempts: 0, lastError: null },
|
||||
});
|
||||
const [resetOrder] =
|
||||
prisma.calendarSyncOutboxEntry.updateMany.mock.invocationCallOrder;
|
||||
const [readOrder] =
|
||||
prisma.calendarSyncOutboxEntry.findMany.mock.invocationCallOrder;
|
||||
expect(resetOrder).toBeLessThan(readOrder);
|
||||
});
|
||||
|
||||
it('leaves FAILED entries alone on a regular cron tick', async () => {
|
||||
await service.processOutbox();
|
||||
|
||||
expect(
|
||||
prisma.calendarSyncOutboxEntry.updateMany,
|
||||
).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { status: 'FAILED' } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// "Jetzt synchronisieren" must not hold the HTTP request for a whole pass
|
||||
// (up to 20 entries with several Graph calls each): it only starts it.
|
||||
describe('startRun', () => {
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
};
|
||||
/** Lets the pass hang on reading the outbox until released. */
|
||||
const holdOutbox = () => {
|
||||
let release: () => void = () => undefined;
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
release = () => resolve([]);
|
||||
}),
|
||||
);
|
||||
return () => release();
|
||||
};
|
||||
|
||||
it('returns while the pass is still running and reports it as running', async () => {
|
||||
const release = holdOutbox();
|
||||
|
||||
service.startRun();
|
||||
await flush();
|
||||
|
||||
expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledWith({
|
||||
where: { status: 'FAILED' },
|
||||
data: { status: 'PENDING', attempts: 0, lastError: null },
|
||||
});
|
||||
expect((await service.getStatus()).running).toBe(true);
|
||||
|
||||
release();
|
||||
await flush();
|
||||
|
||||
expect((await service.getStatus()).running).toBe(false);
|
||||
});
|
||||
|
||||
it('starts only one pass per pod at a time', async () => {
|
||||
const release = holdOutbox();
|
||||
|
||||
service.startRun();
|
||||
service.startRun();
|
||||
await flush();
|
||||
|
||||
expect(prisma.calendarSyncOutboxEntry.findMany).toHaveBeenCalledTimes(1);
|
||||
release();
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('logs a failed pass without query values and clears the indicator', async () => {
|
||||
const errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
|
||||
code: 'P2010',
|
||||
clientVersion: '7.9.1',
|
||||
meta: { modelName: 'CalendarSyncOutboxEntry' },
|
||||
}),
|
||||
);
|
||||
|
||||
service.startRun();
|
||||
await flush();
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
|
||||
expect(message).toContain('P2010');
|
||||
expect(message).not.toContain('Mustermann');
|
||||
expect(stack).toBeUndefined();
|
||||
expect((await service.getStatus()).running).toBe(false);
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('lets shutdown wait for a running pass', async () => {
|
||||
const release = holdOutbox();
|
||||
service.startRun();
|
||||
let destroyed = false;
|
||||
|
||||
const shutdown = service.onModuleDestroy().then(() => {
|
||||
destroyed = true;
|
||||
});
|
||||
await flush();
|
||||
expect(destroyed).toBe(false);
|
||||
|
||||
release();
|
||||
await shutdown;
|
||||
expect(destroyed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// The cron tick's own errors would otherwise reach the scheduler's logger
|
||||
// with the full Prisma message and stack.
|
||||
describe('scheduledRun (cron tick)', () => {
|
||||
it('runs the regular pass', async () => {
|
||||
await service.scheduledRun();
|
||||
|
||||
expect(prisma.calendarSyncOutboxEntry.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('never throws and logs a failed tick without the Prisma message', async () => {
|
||||
const errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
|
||||
code: 'P2010',
|
||||
clientVersion: '7.9.1',
|
||||
meta: { modelName: 'CalendarSyncOutboxEntry' },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(service.scheduledRun()).resolves.toBeUndefined();
|
||||
|
||||
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
|
||||
expect(message).toContain('P2010');
|
||||
expect(message).not.toContain('Mustermann');
|
||||
expect(stack).toBeUndefined();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
@@ -355,7 +778,8 @@ describe('CalendarSyncService', () => {
|
||||
prisma.calendarSyncOutboxEntry.count
|
||||
.mockResolvedValueOnce(2) // PENDING
|
||||
.mockResolvedValueOnce(1) // FAILED
|
||||
.mockResolvedValueOnce(5); // SYNCED
|
||||
.mockResolvedValueOnce(5) // SYNCED
|
||||
.mockResolvedValueOnce(0); // held by a worker right now
|
||||
|
||||
const status = await service.getStatus();
|
||||
|
||||
@@ -366,6 +790,24 @@ describe('CalendarSyncService', () => {
|
||||
pendingCount: 2,
|
||||
failedCount: 1,
|
||||
syncedCount: 5,
|
||||
running: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Another pod (its cron tick or its "Jetzt synchronisieren") may be the
|
||||
// one working - its live claims show that across replicas.
|
||||
it('reports running while any worker holds an entry', async () => {
|
||||
prisma.calendarSyncOutboxEntry.count
|
||||
.mockResolvedValueOnce(0)
|
||||
.mockResolvedValueOnce(0)
|
||||
.mockResolvedValueOnce(0)
|
||||
.mockResolvedValueOnce(1);
|
||||
|
||||
const status = await service.getStatus();
|
||||
|
||||
expect(status.running).toBe(true);
|
||||
expect(prisma.calendarSyncOutboxEntry.count).toHaveBeenLastCalledWith({
|
||||
where: { claimedAt: { gte: expect.any(Date) as Date } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { runAfterCommit } from '../common/after-commit';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
|
||||
import {
|
||||
GraphClientService,
|
||||
GraphCalendarEventInput,
|
||||
GraphRequestError,
|
||||
} from './graph-client.service';
|
||||
import {
|
||||
CalendarSyncOutboxEntry,
|
||||
CalendarSyncSettings,
|
||||
CalendarSyncSourceType,
|
||||
Prisma,
|
||||
} from '../../generated/prisma';
|
||||
|
||||
// Bounded per tick so a large backlog can't turn one cron run into a
|
||||
@@ -19,6 +24,47 @@ const BATCH_SIZE = 20;
|
||||
// (status becomes FAILED) - "Jetzt synchronisieren" or a fresh enqueue
|
||||
// (e.g. editing the source record) resets it back to PENDING.
|
||||
const MAX_ATTEMPTS = 5;
|
||||
// A claim this old belongs to a worker that died mid-entry (pod restart);
|
||||
// the entry is taken over. Far above the longest a live worker can hold one
|
||||
// entry (three Graph requests, each bounded by GRAPH_REQUEST_TIMEOUT_MS).
|
||||
const STALE_CLAIM_MS = 5 * 60 * 1000;
|
||||
// Graph answers these when the stored event no longer exists (deleted on
|
||||
// the M365 side) - only then is a fresh event the right fix.
|
||||
const EVENT_GONE_STATUSES = [404, 410];
|
||||
|
||||
/** An outbox row this worker holds; every write back is conditional on it. */
|
||||
type ClaimedEntry = CalendarSyncOutboxEntry & { claimedAt: Date };
|
||||
|
||||
/**
|
||||
* Graph's transactionId for creating this entry's event, GUID-shaped.
|
||||
* The same for every retry of one create - after a timeout, or by another
|
||||
* pod after a stale claim - so Graph can drop the repeat. A new one for
|
||||
* every deliberate create: per entry, per enqueue (revision) and per
|
||||
* replaced event, so a fresh create is never matched against an event that
|
||||
* was cancelled or deleted in M365 before.
|
||||
*/
|
||||
function eventTransactionId(entry: CalendarSyncOutboxEntry): string {
|
||||
const hex = createHash('sha256')
|
||||
.update(`${entry.id}:${entry.revision}:${entry.graphEventId ?? ''}`)
|
||||
.digest('hex');
|
||||
return [
|
||||
hex.slice(0, 8),
|
||||
hex.slice(8, 12),
|
||||
hex.slice(12, 16),
|
||||
hex.slice(16, 20),
|
||||
hex.slice(20, 32),
|
||||
].join('-');
|
||||
}
|
||||
|
||||
/** Free, or held by a worker that gave up long ago. */
|
||||
function claimableNow(): Prisma.CalendarSyncOutboxEntryWhereInput {
|
||||
return {
|
||||
OR: [
|
||||
{ claimedAt: null },
|
||||
{ claimedAt: { lt: new Date(Date.now() - STALE_CLAIM_MS) } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export interface CalendarSyncStatusReport {
|
||||
configured: boolean;
|
||||
@@ -27,6 +73,8 @@ export interface CalendarSyncStatusReport {
|
||||
pendingCount: number;
|
||||
failedCount: number;
|
||||
syncedCount: number;
|
||||
/** A pass is under way - on this pod, or any pod holding an entry. */
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
@@ -34,8 +82,10 @@ function getErrorMessage(error: unknown): string {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CalendarSyncService {
|
||||
export class CalendarSyncService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(CalendarSyncService.name);
|
||||
// The pass started by "Jetzt synchronisieren" on this pod, if one runs.
|
||||
private activeRun: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@@ -58,15 +108,37 @@ export class CalendarSyncService {
|
||||
return flagBySourceType[sourceType];
|
||||
}
|
||||
|
||||
// enqueue()/cancel() follow a committed write in the domain services and
|
||||
// are called without a guard - so they never throw (see runAfterCommit).
|
||||
private afterCommit(
|
||||
action: string,
|
||||
sourceType: CalendarSyncSourceType,
|
||||
sourceId: string,
|
||||
work: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
return runAfterCommit(
|
||||
this.logger,
|
||||
{ action, entityType: sourceType, entityId: sourceId },
|
||||
work,
|
||||
);
|
||||
}
|
||||
|
||||
// Called from the ~6 domain services right after a create/update that
|
||||
// should appear on the M365 calendar. Upserts rather than always
|
||||
// inserting: re-enqueuing an already-synced row (e.g. a Task's due date
|
||||
// changed) resets it to PENDING while keeping its graphEventId, so the
|
||||
// worker updates the existing Graph event instead of creating a duplicate.
|
||||
// The revision counts up so a worker syncing the row right now notices the
|
||||
// newer state instead of marking it SYNCED.
|
||||
async enqueue(
|
||||
sourceType: CalendarSyncSourceType,
|
||||
sourceId: string,
|
||||
): Promise<void> {
|
||||
await this.afterCommit(
|
||||
'Calendar sync enqueue',
|
||||
sourceType,
|
||||
sourceId,
|
||||
async () => {
|
||||
const settings = await this.settingsService.get();
|
||||
if (!settings.enabled) {
|
||||
return;
|
||||
@@ -77,8 +149,15 @@ export class CalendarSyncService {
|
||||
await this.prisma.calendarSyncOutboxEntry.upsert({
|
||||
where: { sourceType_sourceId: { sourceType, sourceId } },
|
||||
create: { sourceType, sourceId },
|
||||
update: { status: 'PENDING', attempts: 0, lastError: null },
|
||||
update: {
|
||||
status: 'PENDING',
|
||||
attempts: 0,
|
||||
lastError: null,
|
||||
revision: { increment: 1 },
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Called when a source record no longer belongs on the calendar (e.g. a
|
||||
@@ -89,6 +168,11 @@ export class CalendarSyncService {
|
||||
sourceType: CalendarSyncSourceType,
|
||||
sourceId: string,
|
||||
): Promise<void> {
|
||||
await this.afterCommit(
|
||||
'Calendar sync cancel',
|
||||
sourceType,
|
||||
sourceId,
|
||||
async () => {
|
||||
const entry = await this.prisma.calendarSyncOutboxEntry.findUnique({
|
||||
where: { sourceType_sourceId: { sourceType, sourceId } },
|
||||
});
|
||||
@@ -96,31 +180,48 @@ export class CalendarSyncService {
|
||||
return;
|
||||
}
|
||||
if (entry.graphEventId) {
|
||||
const settings = await this.settingsService.get();
|
||||
if (
|
||||
settings.enabled &&
|
||||
settings.groupId &&
|
||||
this.graphClient.isConfigured()
|
||||
) {
|
||||
try {
|
||||
await this.graphClient.deleteEvent(
|
||||
settings.groupId,
|
||||
entry.graphEventId,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn(
|
||||
`Could not delete Graph event ${entry.graphEventId}: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.deleteGraphEvent(entry.graphEventId);
|
||||
}
|
||||
await this.prisma.calendarSyncOutboxEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { status: 'SKIPPED', graphEventId: null },
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Best-effort removal from M365: logged, never thrown.
|
||||
private async deleteGraphEvent(graphEventId: string): Promise<void> {
|
||||
const settings = await this.settingsService.get();
|
||||
if (
|
||||
!settings.enabled ||
|
||||
!settings.groupId ||
|
||||
!this.graphClient.isConfigured()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.graphClient.deleteEvent(settings.groupId, graphEventId);
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn(
|
||||
`Could not delete Graph event ${graphEventId}: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The cron tick. An error is logged here, Prisma errors by name/code/model
|
||||
// only (see loggableError) - left uncaught, the scheduler would log the
|
||||
// full Prisma message and stack.
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async scheduledRun(): Promise<void> {
|
||||
try {
|
||||
await this.processOutbox();
|
||||
} catch (error: unknown) {
|
||||
const { message, stack } = loggableError(error);
|
||||
this.logger.error(`Calendar sync tick failed: ${message}`, stack);
|
||||
}
|
||||
}
|
||||
|
||||
async processOutbox(): Promise<void> {
|
||||
const settings = await this.settingsService.get();
|
||||
if (
|
||||
@@ -131,22 +232,80 @@ export class CalendarSyncService {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = await this.prisma.calendarSyncOutboxEntry.findMany({
|
||||
where: { status: 'PENDING' },
|
||||
// Several workers may run at once - one cron tick per pod, plus "Jetzt
|
||||
// synchronisieren". Each entry is claimed right before it is written,
|
||||
// so exactly one of them talks to Graph about it.
|
||||
const candidates = await this.prisma.calendarSyncOutboxEntry.findMany({
|
||||
where: { status: 'PENDING', ...claimableNow() },
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
take: BATCH_SIZE,
|
||||
});
|
||||
for (const entry of entries) {
|
||||
await this.processEntry(entry, settings.groupId);
|
||||
for (const candidate of candidates) {
|
||||
const claimed = await this.claim(candidate);
|
||||
if (claimed) {
|
||||
await this.processEntry(claimed, settings.groupId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "Jetzt synchronisieren" also gives FAILED entries a fresh set of
|
||||
// attempts - besides editing the source record the documented way out of
|
||||
// FAILED. A FAILED row is never claimed (settle() clears the claim with
|
||||
// it), so this cannot pull a row from under a running worker. The regular
|
||||
// cron tick leaves FAILED alone.
|
||||
async runNow(): Promise<void> {
|
||||
await this.prisma.calendarSyncOutboxEntry.updateMany({
|
||||
where: { status: 'FAILED' },
|
||||
data: { status: 'PENDING', attempts: 0, lastError: null },
|
||||
});
|
||||
await this.processOutbox();
|
||||
}
|
||||
|
||||
private async processEntry(
|
||||
// What the API's "Jetzt synchronisieren" calls: starts runNow() in the
|
||||
// background and returns at once - a pass over up to BATCH_SIZE entries
|
||||
// with several Graph calls each must not hold the HTTP request. One such
|
||||
// pass per pod at a time; passes on other pods (and the cron tick) stay
|
||||
// safe through the per-entry claims. getStatus().running follows it.
|
||||
startRun(): void {
|
||||
if (!this.activeRun) {
|
||||
this.activeRun = this.runNow()
|
||||
.catch((error: unknown) => {
|
||||
const { message, stack } = loggableError(error);
|
||||
this.logger.error(`Calendar sync run failed: ${message}`, stack);
|
||||
})
|
||||
.finally(() => {
|
||||
this.activeRun = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A pass cut off mid-entry would only be retaken after STALE_CLAIM_MS -
|
||||
// shutdown lets it finish (each Graph request is bounded by its timeout).
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.activeRun;
|
||||
}
|
||||
|
||||
// Compare-and-set on the row as it was read: the update only matches while
|
||||
// the row is still PENDING, unchanged and unclaimed, so of two workers
|
||||
// reading the same candidate exactly one gets it (the other gets count 0).
|
||||
private async claim(
|
||||
entry: CalendarSyncOutboxEntry,
|
||||
): Promise<ClaimedEntry | null> {
|
||||
const claimedAt = new Date();
|
||||
const claim = await this.prisma.calendarSyncOutboxEntry.updateMany({
|
||||
where: {
|
||||
id: entry.id,
|
||||
status: 'PENDING',
|
||||
revision: entry.revision,
|
||||
...claimableNow(),
|
||||
},
|
||||
data: { claimedAt },
|
||||
});
|
||||
return claim.count === 1 ? { ...entry, claimedAt } : null;
|
||||
}
|
||||
|
||||
private async processEntry(
|
||||
entry: ClaimedEntry,
|
||||
groupId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
@@ -155,47 +314,132 @@ export class CalendarSyncService {
|
||||
entry.sourceId,
|
||||
);
|
||||
if (!input) {
|
||||
await this.prisma.calendarSyncOutboxEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { status: 'SKIPPED' },
|
||||
});
|
||||
await this.settle(entry, { status: 'SKIPPED' });
|
||||
return;
|
||||
}
|
||||
|
||||
const graphEventId = entry.graphEventId
|
||||
? await this.updateExisting(groupId, entry.graphEventId, input)
|
||||
: await this.graphClient.createEvent(groupId, input);
|
||||
? await this.updateExisting(groupId, entry, entry.graphEventId, input)
|
||||
: await this.graphClient.createEvent(
|
||||
groupId,
|
||||
input,
|
||||
eventTransactionId(entry),
|
||||
);
|
||||
|
||||
await this.prisma.calendarSyncOutboxEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { status: 'SYNCED', graphEventId, lastError: null },
|
||||
});
|
||||
await this.recordSynced(entry, graphEventId);
|
||||
} catch (error: unknown) {
|
||||
const attempts = entry.attempts + 1;
|
||||
await this.prisma.calendarSyncOutboxEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: {
|
||||
await this.settle(entry, {
|
||||
status: attempts >= MAX_ATTEMPTS ? 'FAILED' : 'PENDING',
|
||||
attempts,
|
||||
lastError: getErrorMessage(error),
|
||||
},
|
||||
// Stored and shown to admins: Prisma errors by name/code/model only.
|
||||
lastError: loggableError(error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** The row exactly as claimed: still ours, PENDING, nothing enqueued since. */
|
||||
private unchangedSinceClaim(
|
||||
entry: ClaimedEntry,
|
||||
): Prisma.CalendarSyncOutboxEntryWhereInput {
|
||||
return {
|
||||
id: entry.id,
|
||||
status: 'PENDING',
|
||||
revision: entry.revision,
|
||||
claimedAt: entry.claimedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Writes this pass's outcome only if the row is still as claimed. If it
|
||||
// was enqueued or cancelled meanwhile, the outcome is stale: the claim is
|
||||
// just released, and the next pass works from the row's current state.
|
||||
private async settle(
|
||||
entry: ClaimedEntry,
|
||||
outcome: Prisma.CalendarSyncOutboxEntryUpdateManyMutationInput,
|
||||
): Promise<void> {
|
||||
const settled = await this.prisma.calendarSyncOutboxEntry.updateMany({
|
||||
where: this.unchangedSinceClaim(entry),
|
||||
data: { ...outcome, claimedAt: null },
|
||||
});
|
||||
if (settled.count === 0) {
|
||||
await this.release(entry);
|
||||
}
|
||||
}
|
||||
|
||||
private async release(entry: ClaimedEntry): Promise<number> {
|
||||
const released = await this.prisma.calendarSyncOutboxEntry.updateMany({
|
||||
where: { id: entry.id, claimedAt: entry.claimedAt },
|
||||
data: { claimedAt: null },
|
||||
});
|
||||
return released.count;
|
||||
}
|
||||
|
||||
// The event now exists in M365. Unless the row changed meanwhile it is
|
||||
// SYNCED. Otherwise the event id must still be kept - a lost id means a
|
||||
// duplicate event on the next pass.
|
||||
private async recordSynced(
|
||||
entry: ClaimedEntry,
|
||||
graphEventId: string,
|
||||
): Promise<void> {
|
||||
const synced = await this.prisma.calendarSyncOutboxEntry.updateMany({
|
||||
where: this.unchangedSinceClaim(entry),
|
||||
data: {
|
||||
status: 'SYNCED',
|
||||
graphEventId,
|
||||
lastError: null,
|
||||
claimedAt: null,
|
||||
},
|
||||
});
|
||||
if (synced.count === 1) {
|
||||
return;
|
||||
}
|
||||
// Re-enqueued during the sync (the source was edited): stays PENDING,
|
||||
// and the next pass updates this event with the newer state.
|
||||
const kept = await this.prisma.calendarSyncOutboxEntry.updateMany({
|
||||
where: { id: entry.id, status: 'PENDING', claimedAt: entry.claimedAt },
|
||||
data: { graphEventId, claimedAt: null },
|
||||
});
|
||||
if (kept.count === 1) {
|
||||
return;
|
||||
}
|
||||
// Cancelled during the sync (the source left the calendar): the event
|
||||
// just written must not stay behind.
|
||||
if ((await this.release(entry)) === 1) {
|
||||
await this.deleteGraphEvent(graphEventId);
|
||||
return;
|
||||
}
|
||||
this.logger.warn(
|
||||
`Calendar sync claim on ${entry.sourceType} ${entry.sourceId} expired before Graph event ${graphEventId} was recorded`,
|
||||
);
|
||||
}
|
||||
|
||||
// PATCH on an existing event id that was deleted on the M365 side (e.g. an
|
||||
// admin removed it manually) would 404 forever - fall back to creating a
|
||||
// fresh event instead of leaving the row permanently FAILED.
|
||||
// fresh event instead of leaving the row permanently FAILED. Only then:
|
||||
// after any other failure (5xx, throttling, timeout) the event may well
|
||||
// still exist, and a second one would be a duplicate - that attempt fails
|
||||
// and is retried as an update.
|
||||
private async updateExisting(
|
||||
groupId: string,
|
||||
entry: ClaimedEntry,
|
||||
graphEventId: string,
|
||||
input: GraphCalendarEventInput,
|
||||
): Promise<string> {
|
||||
try {
|
||||
await this.graphClient.updateEvent(groupId, graphEventId, input);
|
||||
return graphEventId;
|
||||
} catch {
|
||||
return this.graphClient.createEvent(groupId, input);
|
||||
} catch (error: unknown) {
|
||||
const eventGone =
|
||||
error instanceof GraphRequestError &&
|
||||
EVENT_GONE_STATUSES.includes(error.status);
|
||||
if (!eventGone) {
|
||||
throw error;
|
||||
}
|
||||
return this.graphClient.createEvent(
|
||||
groupId,
|
||||
input,
|
||||
eventTransactionId(entry),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +523,8 @@ export class CalendarSyncService {
|
||||
|
||||
async getStatus(): Promise<CalendarSyncStatusReport> {
|
||||
const settings = await this.settingsService.get();
|
||||
const [pendingCount, failedCount, syncedCount] = await Promise.all([
|
||||
const [pendingCount, failedCount, syncedCount, heldCount] =
|
||||
await Promise.all([
|
||||
this.prisma.calendarSyncOutboxEntry.count({
|
||||
where: { status: 'PENDING' },
|
||||
}),
|
||||
@@ -289,6 +534,10 @@ export class CalendarSyncService {
|
||||
this.prisma.calendarSyncOutboxEntry.count({
|
||||
where: { status: 'SYNCED' },
|
||||
}),
|
||||
// Live claims: some worker is writing an entry right now.
|
||||
this.prisma.calendarSyncOutboxEntry.count({
|
||||
where: { claimedAt: { gte: new Date(Date.now() - STALE_CLAIM_MS) } },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
configured: this.graphClient.isConfigured(),
|
||||
@@ -297,6 +546,7 @@ export class CalendarSyncService {
|
||||
pendingCount,
|
||||
failedCount,
|
||||
syncedCount,
|
||||
running: this.activeRun !== null || heldCount > 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { UpdateCalendarSyncSettingsDto } from './update-calendar-sync-settings.dto';
|
||||
|
||||
async function errorsFor(groupId: unknown): Promise<string[]> {
|
||||
const dto = plainToInstance(UpdateCalendarSyncSettingsDto, { groupId });
|
||||
const errors = await validate(dto);
|
||||
return errors.flatMap((error) => Object.values(error.constraints ?? {}));
|
||||
}
|
||||
|
||||
// The group id goes into the Graph URL path - only an Entra object id
|
||||
// (a GUID) is accepted, or an empty string to clear the value.
|
||||
describe('UpdateCalendarSyncSettingsDto groupId', () => {
|
||||
it.each([
|
||||
'3fa85f64-5717-4562-b3fc-2c963f66afa6',
|
||||
'3FA85F64-5717-4562-B3FC-2C963F66AFA6',
|
||||
'',
|
||||
])('accepts %p', async (groupId) => {
|
||||
expect(await errorsFor(groupId)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'meine-gruppe',
|
||||
'3fa85f64-5717-4562-b3fc-2c963f66afa6/events',
|
||||
'../users/[email protected]',
|
||||
'3fa85f64571745 62b3fc2c963f66afa6',
|
||||
' 3fa85f64-5717-4562-b3fc-2c963f66afa6',
|
||||
])('rejects %p with a German message', async (groupId) => {
|
||||
expect(await errorsFor(groupId)).toEqual([
|
||||
'Gruppen-ID muss die Objekt-ID der Microsoft-365-Gruppe sein (Format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,48 +1,69 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
const GROUP_ID_PATTERN =
|
||||
/^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?$/i;
|
||||
|
||||
// PATCH null rule: an omitted field stays unchanged. The switches are NOT
|
||||
// NULL columns, so null on them is a 400. groupId is nullable: null clears
|
||||
// it, and so does "" - the GUI's way of emptying the field.
|
||||
export class UpdateCalendarSyncSettingsDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
description:
|
||||
'Objekt-ID der Microsoft-365-Gruppe. Leerer String löscht den GUI-Wert.',
|
||||
'Objekt-ID der Microsoft-365-Gruppe. Leerer String oder null löscht den GUI-Wert.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
groupId?: string;
|
||||
// An Entra object id (GUID) or "" - the value goes into the Graph URL
|
||||
// path, so nothing else may get through.
|
||||
@Matches(GROUP_ID_PATTERN, {
|
||||
message:
|
||||
'Gruppen-ID muss die Objekt-ID der Microsoft-365-Gruppe sein (Format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)',
|
||||
})
|
||||
groupId?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
syncTask?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
syncInspection?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
syncTreatment?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
syncFeeding?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
syncHarvest?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
syncQueenAssignment?: boolean;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GraphClientService } from './graph-client.service';
|
||||
import { GraphClientService, GraphRequestError } from './graph-client.service';
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return {
|
||||
@@ -53,10 +53,11 @@ describe('GraphClientService', () => {
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse(201, { id: 'graph-event-1' }));
|
||||
|
||||
const result = await service.createEvent('group-1', {
|
||||
title: 'Durchsicht V-001',
|
||||
date: new Date(Date.UTC(2026, 2, 7)),
|
||||
});
|
||||
const result = await service.createEvent(
|
||||
'group-1',
|
||||
{ title: 'Durchsicht V-001', date: new Date(Date.UTC(2026, 2, 7)) },
|
||||
'tx-durchsicht',
|
||||
);
|
||||
|
||||
expect(result).toBe('graph-event-1');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
@@ -82,8 +83,11 @@ describe('GraphClientService', () => {
|
||||
isAllDay: boolean;
|
||||
start: { dateTime: string };
|
||||
end: { dateTime: string };
|
||||
transactionId: string;
|
||||
};
|
||||
expect(body.subject).toBe('Durchsicht V-001');
|
||||
// Lets Graph drop a repeated create after a timed-out first attempt.
|
||||
expect(body.transactionId).toBe('tx-durchsicht');
|
||||
expect(body.isAllDay).toBe(true);
|
||||
expect(body.start.dateTime).toBe('2026-03-07T00:00:00.0000000');
|
||||
expect(body.end.dateTime).toBe('2026-03-08T00:00:00.0000000');
|
||||
@@ -97,14 +101,16 @@ describe('GraphClientService', () => {
|
||||
.mockResolvedValueOnce(jsonResponse(201, { id: 'event-a' }))
|
||||
.mockResolvedValueOnce(jsonResponse(201, { id: 'event-b' }));
|
||||
|
||||
await service.createEvent('group-1', {
|
||||
title: 'A',
|
||||
date: new Date(Date.UTC(2026, 2, 7)),
|
||||
});
|
||||
await service.createEvent('group-1', {
|
||||
title: 'B',
|
||||
date: new Date(Date.UTC(2026, 2, 8)),
|
||||
});
|
||||
await service.createEvent(
|
||||
'group-1',
|
||||
{ title: 'A', date: new Date(Date.UTC(2026, 2, 7)) },
|
||||
'tx-a',
|
||||
);
|
||||
await service.createEvent(
|
||||
'group-1',
|
||||
{ title: 'B', date: new Date(Date.UTC(2026, 2, 8)) },
|
||||
'tx-b',
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
@@ -117,14 +123,99 @@ describe('GraphClientService', () => {
|
||||
.mockResolvedValueOnce(jsonResponse(403, { error: 'Forbidden' }));
|
||||
|
||||
await expect(
|
||||
service.createEvent('group-1', {
|
||||
title: 'A',
|
||||
date: new Date(Date.UTC(2026, 2, 7)),
|
||||
}),
|
||||
service.createEvent(
|
||||
'group-1',
|
||||
{ title: 'A', date: new Date(Date.UTC(2026, 2, 7)) },
|
||||
'tx-a',
|
||||
),
|
||||
).rejects.toThrow(/createEvent fehlgeschlagen \(403\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateEvent', () => {
|
||||
it('never sends a transactionId, which Graph does not let an update change', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse(200, {}));
|
||||
|
||||
await service.updateEvent('group-1', 'event-1', {
|
||||
title: 'A',
|
||||
date: new Date(Date.UTC(2026, 2, 7)),
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[1] as [string, { body: string }];
|
||||
expect(JSON.parse(init.body)).not.toHaveProperty('transactionId');
|
||||
});
|
||||
|
||||
it('rejects with a GraphRequestError carrying the HTTP status', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse(404, { error: 'NotFound' }));
|
||||
|
||||
const update = service.updateEvent('group-1', 'event-1', {
|
||||
title: 'A',
|
||||
date: new Date(Date.UTC(2026, 2, 7)),
|
||||
});
|
||||
|
||||
await expect(update).rejects.toBeInstanceOf(GraphRequestError);
|
||||
await expect(update).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
// The outbox claim expires after a fixed time; a Graph call hanging longer
|
||||
// would let a second pod take over the same row while this one still works.
|
||||
it('bounds every request to Microsoft with a timeout', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse(201, { id: 'event-1' }))
|
||||
.mockResolvedValueOnce(jsonResponse(200, {}))
|
||||
.mockResolvedValueOnce(jsonResponse(204, {}));
|
||||
const input = { title: 'A', date: new Date(Date.UTC(2026, 2, 7)) };
|
||||
|
||||
await service.createEvent('group-1', input, 'tx-1');
|
||||
await service.updateEvent('group-1', 'event-1', input);
|
||||
await service.deleteEvent('group-1', 'event-1');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
for (const [, init] of fetchMock.mock.calls as [string, RequestInit][]) {
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
}
|
||||
});
|
||||
|
||||
// Ids end up in the URL path: a value with "/", "?" or "#" must not be
|
||||
// able to point the request at another Graph resource.
|
||||
it('encodes group and event ids in every request path', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse(201, { id: 'event-1' }))
|
||||
.mockResolvedValueOnce(jsonResponse(200, {}))
|
||||
.mockResolvedValueOnce(jsonResponse(204, {}));
|
||||
const input = { title: 'A', date: new Date(Date.UTC(2026, 2, 7)) };
|
||||
const groupId = '../users/x?$top=1#';
|
||||
const eventId = 'AAMk/abc+def=';
|
||||
|
||||
await service.createEvent(groupId, input, 'tx-1');
|
||||
await service.updateEvent(groupId, eventId, input);
|
||||
await service.deleteEvent(groupId, eventId);
|
||||
|
||||
const urls = (fetchMock.mock.calls as [string][]).map(([url]) => url);
|
||||
const group = '..%2Fusers%2Fx%3F%24top%3D1%23';
|
||||
const event = 'AAMk%2Fabc%2Bdef%3D';
|
||||
expect(urls.slice(1)).toEqual([
|
||||
`https://graph.microsoft.com/v1.0/groups/${group}/events`,
|
||||
`https://graph.microsoft.com/v1.0/groups/${group}/events/${event}`,
|
||||
`https://graph.microsoft.com/v1.0/groups/${group}/events/${event}`,
|
||||
]);
|
||||
});
|
||||
|
||||
describe('deleteEvent', () => {
|
||||
it('treats a 404 as success (idempotent delete)', async () => {
|
||||
fetchMock
|
||||
|
||||
Loaded 100 of 678 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user