diff --git a/.env.example b/.env.example index 364170f..38df94b 100644 --- a/.env.example +++ b/.env.example @@ -59,8 +59,15 @@ SEED_ADMIN_PASSWORD= # INTERN (Standard) oder DIENSTLEISTUNG. SERVICE wird als Alias akzeptiert. SEED_MODE=INTERN +# Name der eigenen Organisation. Im Modus INTERN taucht er nirgends in der +# Oberflaeche auf, wird aber beim Wechsel zu DIENSTLEISTUNG zum ersten Kunden. +SEED_ORG_NAME=Eigene Organisation +# 2 bis 6 Grossbuchstaben oder Ziffern. Im Modus INTERN unsichtbar; im Modus +# DIENSTLEISTUNG steht das Kuerzel auf Etiketten und in der Hostname-Regel. +SEED_ORG_KUERZEL=EIGEN + # ---------- Tests ---------- # Datenbank fuer Vitest. Ohne diese Variable ueberspringen sich die Datenbanktests. -# Passend zu docker-compose.dev.yml (Port 5433): -TEST_DATABASE_URL=postgresql://laufzettel:laufzettel@localhost:5433/laufzettel_test?schema=public +# Passend zu docker-compose.dev.yml (Port 5434, umstellbar ueber DEV_DB_PORT): +TEST_DATABASE_URL=postgresql://laufzettel:laufzettel@localhost:5434/laufzettel_test?schema=public diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f70a62..f413000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,20 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). - `pnpm docs:screenshots` erzeugt Screenshots reproduzierbar und prüft dabei gleich, dass am Handy nicht horizontal gescrollt wird (#005) - Issues, Labels und Meilensteine für alle vier Phasen im Gitea-Tracker (#001–#019) +- Vollständiges Datenmodell in deutscher Benennung: 20 Tabellen, 9 Aufzählungen, erste + Migration, idempotenter Seed für Betriebsmodus, eigene Organisation und Administrator (#006) +- Erzeugung der `tagCode`s für die QR-Aufkleber, mit Vergabebuch, aus dem nie gelöscht wird — + ein Code kommt auch nach dem Löschen eines Geräts nicht zurück (#006) +- Verschlüsselung von Geheimnissen mit AES-256-GCM aus `APP_SECRET`, für das LDAP-Bind-Passwort + und später die Zugangsdaten der Connectoren (#006) ### Sicherheit - Der Compose-Stand bricht ab, wenn `APP_SECRET`, `POSTGRES_PASSWORD` oder `APP_URL` fehlen, statt mit Standardwerten zu starten (#005) +- Die Mandantengrenze steht als zusammengesetzter Fremdschlüssel in der Datenbank: ein Lauf kann + strukturell kein Gerät, keine Person und keine Abteilung eines fremden Kunden wählen (#006) +- Ein Bind-Passwort ohne Versionspräfix wird von der Datenbank abgewiesen, ein versehentlich im + Klartext geschriebenes fällt damit sofort auf (#006) +- Ein Lauf nimmt seine Schrittergebnisse und Kommentare beim Löschen nicht mehr mit, und ein + Protokolleintrag kann seinen Urheber nicht mehr verlieren (#006) diff --git a/CLAUDE.md b/CLAUDE.md index 975887e..0ad9e0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,11 +33,13 @@ Der Auftrag nennt englische Modelle. Verbindlich gilt: | Device | `Geraet` | StepResult | `Schrittergebnis` | | DeviceTemplateDefault | `GeraetetypVorlage` | Comment | `Kommentar` | | — (ergänzt) | `AbgleichSperre` | AuditLog | `Protokoll` | +| — (ergänzt) | `BenutzerKunde` | — (ergänzt) | `TagCodeVergabe` | Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER}` · `Betriebsmodus {INTERN DIENSTLEISTUNG}` · `LaufStatus {OFFEN IN_ARBEIT ABGESCHLOSSEN ARCHIVIERT}` · `SchrittZustand {OFFEN ERLEDIGT NICHT_ZUTREFFEND FEHLGESCHLAGEN}` · `SchrittTyp {HAKEN TEXT ZAHL AUSWAHL DATUM BEFEHL}` (später `AKTION`) · +`VariablenTyp {TEXT ZAHL AUSWAHL DATUM}` · `GeraeteTyp {PC NOTEBOOK SERVER FIREWALL SWITCH DRUCKER RAUM SONSTIGES}` · `VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}`. @@ -76,7 +78,14 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen. - **`{{generiertes_passwort}}`:** wird je Anzeige neu erzeugt und **nie** gespeichert — nicht in `Schrittergebnis`, `Kommentar`, `Protokoll` oder der Druckansicht. - **Erweiterbarkeit:** Kein Codepfad nimmt an, dass ein Schritt nur von einem Menschen erledigt - wird. `SchrittTyp` bekommt später `AKTION`, `konfig` und `wert` sind Json. + wird. `SchrittTyp` bekommt später `AKTION`, `konfig` und `wert` sind Json, und + `Lauf.erstelltVonId` wie `Schrittergebnis.aktualisiertVonId` dürfen leer sein — ein Systemlauf + ist darstellbar. +- **Regeln stehen in der Datenbank, nicht nur im Service.** Zwölf CHECK-Bedingungen, zwei + partielle Eindeutigkeitsindizes und vier zusammengesetzte Fremdschlüssel halten fest, was auch + dann gelten muss, wenn jemand an der Service-Schicht vorbei schreibt. Die Mandantengrenze ist + einer davon: ein Lauf kann strukturell kein Gerät, keine Person und keine Abteilung eines + fremden Kunden wählen. Der Service prüft dasselbe noch einmal, nur mit besserer Meldung. ## Werkzeuge und Versionen diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index b0bb68d..4cc5e8c 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,4 +1,6 @@ -# Nur fuer die Entwicklung: Postgres auf Port 5433, damit ein lokales 5432 frei bleibt. +# Nur fuer die Entwicklung: PostgreSQL auf einem eigenen Port, damit ein lokales +# 5432 und die Datenbanken anderer Projekte frei bleiben. Den Port bei Bedarf +# ueber DEV_DB_PORT umstellen. # Diese Datei niemals im Betrieb verwenden – dort ist die Datenbank absichtlich zu. name: laufzettel-dev @@ -12,7 +14,7 @@ services: POSTGRES_PASSWORD: laufzettel POSTGRES_DB: laufzettel ports: - - "127.0.0.1:5433:5432" + - "127.0.0.1:${DEV_DB_PORT:-5434}:5432" volumes: - datenbank-dev:/var/lib/postgresql/data healthcheck: diff --git a/docs/architektur.md b/docs/architektur.md index b3f89d2..ac35e95 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -80,6 +80,37 @@ Schritte**. Damit der Schritt dorthin keine Migration braucht: - kein Codepfad nimmt an, dass ein Schritt nur von einem Menschen erledigt wird - das Krypto-Modul für das LDAP-Bind-Passwort ist schon der Platz für Connector-Geheimnisse +## 5a. Was in der Datenbank steht und warum + +Die Service-Schicht ist der richtige Ort für Regeln mit verständlichen Meldungen. Sie ist aber +nicht der einzige Weg, auf dem Daten entstehen: Migrationen, `psql`, Skripte und ab Phase 4 die +Connectoren schreiben an ihr vorbei. Was stillschweigend falsch werden könnte, steht deshalb +zusätzlich in der Datenbank: + +| Regel | Form | +|---|---| +| Höchstens eine eigene Organisation | partieller Eindeutigkeitsindex | +| Höchstens ein Entwurf je Vorlage | partieller Eindeutigkeitsindex | +| Kundenkürzel, `tagCode`-Form, verschlüsseltes Bind-Passwort | CHECK auf die Form | +| Registriertes Gerät braucht Hostname oder Bezeichnung | CHECK, greift erst ab `registriertAm` | +| Mitarbeiter aus dem Verzeichnis **oder** Freitextname | CHECK | +| Status und Zeitstempel laufen nicht auseinander | vier CHECKs | +| `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` brauchen eine Notiz | CHECK | +| Einstellung ist eine einzelne Zeile | CHECK auf die feste Kennung | +| **Mandantengrenze** | vier zusammengesetzte Fremdschlüssel | + +Die Mandantengrenze verdient die Erklärung: `Lauf` verweist mit `("geraetId", "kundeId")` auf +`Geraet("id", "kundeId")`, analog für Person und Abteilung, und `Geraet` ebenso auf den Besitzer. +Damit ist ein Lauf, der ein Gerät eines fremden Kunden zieht, nicht mehr bloß ein Fehler im +Service, sondern strukturell unmöglich. `MATCH SIMPLE` sorgt dafür, dass ein Lauf ohne Gerät +weiterhin erlaubt ist. + +Zwei Dinge sind bewusst **nicht** kaskadierend: ein `Lauf` nimmt seine Schrittergebnisse und +Kommentare nicht mit, und ein `Protokoll`-Eintrag verliert seinen Urheber nicht. Beides stünde +gegen den Zweck eines Archivs. Der `tagCode` ist zusätzlich in `TagCodeVergabe` festgehalten, +aus dem nie gelöscht wird — sonst würde ein Code nach dem Löschen eines Geräts wieder frei und +ein alter Aufkleber zeigte auf ein fremdes Objekt. + ## 6. Betrieb: zwei Wege, eine Regel Die Datenbank ist auf beiden Wegen von außen nicht erreichbar: diff --git a/docs/devlog.md b/docs/devlog.md index 1fa016d..db1cd51 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -8,11 +8,12 @@ als Unterpunkte mit Aufgabe und Dauer. | Issue | Titel | Tatsächlich | |---|---|---| | #005 | M0 – Gerüst, Prozess, Design-Token | 1 h 25 min | -| | **Summe** | **1 h 25 min** | +| #006 | M1 – Datenmodell | 1 h 10 min | +| | **Summe** | **2 h 35 min** | | Meilenstein | Tatsächlich | Status | |---|---|---| -| v0.1.0 | 1 h 25 min | in Arbeit | +| v0.1.0 | 2 h 35 min | in Arbeit | --- @@ -104,3 +105,63 @@ als Unterpunkte mit Aufgabe und Dauer. - #006 Datenmodell: das vollständige Schema in deutscher Benennung, `tagCode`-Generator, Krypto-Helfer, erste Migration, idempotenter Seed-Rumpf. + +--- + +## 2026-09-25 · Sitzung 2 · 00:05–01:15 + +**Issue:** #006 (M1) +**Version:** 0.1.0 + +### Erledigt + +- Vollständiges Schema in deutscher Benennung: 20 Tabellen, 9 Aufzählungen, eine Migration +- `src/lib/tagcode.ts` und `src/lib/geheimnisse.ts` samt Tests, parallel von einem Sub-Agenten + gebaut, während ich das Schema schrieb (Dauer dort: rund 10 min) +- Idempotenter Seed für Betriebsmodus, eigene Organisation und Administrator — zweimal + hintereinander gefahren, keine Zeile geändert +- Unabhängiger Review-Agent gegen die Akzeptanzkriterien; alle Blocker und alle neun „sollte + behoben werden"-Punkte abgearbeitet +- **106 Tests** grün (vorher 11), davon 42 gegen die echten Datenbankregeln + +### Entscheidungen + +- **Regeln gehören zusätzlich in die Datenbank.** Zwölf CHECK-Bedingungen, zwei partielle + Eindeutigkeitsindizes und vier zusammengesetzte Fremdschlüssel. Begründung: die Service-Schicht + ist nicht der einzige Weg, auf dem Daten entstehen — Migrationen, `psql`, Skripte und ab + Phase 4 die Connectoren schreiben an ihr vorbei. +- **Die Mandantengrenze wird strukturell erzwungen.** `Lauf` verweist mit + `("geraetId", "kundeId")` auf `Geraet("id", "kundeId")`, analog für Person und Abteilung. + `docs/architektur.md` nennt die Grenze eine Sicherheitsgrenze — dann soll sie nicht allein an + `kundeErmitteln()` hängen. `MATCH SIMPLE` lässt einen Lauf ohne Gerät weiter zu. +- **Kein Cascade dort, wo das Archiv hängt.** Ein `DELETE` auf einen Lauf hätte Schrittergebnisse + und Kommentare stillschweigend mitgenommen, ein Protokolleintrag hätte seinen Urheber verlieren + können. Beides jetzt `Restrict`. +- **`TagCodeVergabe` als Vergabebuch.** Die Eindeutigkeit allein aus `Geraet.tagCode` zu ziehen + reicht nicht: verschwindet die Zeile, wäre der Code wieder frei und ein alter Aufkleber im + Regal zeigte auf ein fremdes Objekt. +- **Widerspruch im Auftrag beim tagCode-Alphabet.** Das wörtliche Alphabet enthält ein `L`, + die Klammer daneben verbietet es. Aufgelöst zugunsten des wörtlichen Alphabets: die Klammer + benennt Verwechslungsgruppen, keine Ausschlussliste — aus `{1, I, L}` sind `1` und `I` weg, + dadurch ist `L` eindeutig. Das Akzeptanzkriterium in #006 ist richtiggestellt worden, statt + einen grünen Test darüber zu setzen. +- **Ein Systemlauf muss darstellbar sein.** `Lauf.erstelltVonId` ist optional, wie schon + `Schrittergebnis.aktualisiertVonId`. Wiederkehrende Läufe und der Start per Webhook (Phase 4) + haben keinen Menschen dahinter. + +### Probleme + +- **`prisma migrate reset --force` zeigte den Bestätigungsdialog trotzdem** und ließ einen + Schema-Engine-Prozess zurück, der die Advisory-Sperre hielt; der nächste `migrate dev` lief + daraufhin in einen Timeout. Verlässlicher: `DROP SCHEMA public CASCADE` per psql, dann + `migrate dev --create-only < /dev/null`. +- **Port 5433 ist von ProxLedger belegt.** Die Entwicklungsdatenbank liegt jetzt auf 5434, + umstellbar über `DEV_DB_PORT`. +- **Eine Regex-Ersetzung hat `quelle.length` auch aus der Längenprüfung gerissen** und den + Krypto-Helfer unübersetzbar gemacht. Die Testsuite hat es sofort gezeigt — ein Argument mehr + dafür, nach jeder Sammeländerung wirklich alle Schranken laufen zu lassen. + +### Nächster Schritt + +- #007 Auth, Rollen, Benutzer: `openid-client` mit Discovery, `jose`-Sitzungscookie, lokaler + Login mit argon2, `RECHTE`-Tabelle, Login-Seite mit Rücksprung auf die ursprüngliche URL. diff --git a/docs/issues/006-datenmodell.md b/docs/issues/006-datenmodell.md index 9f88c90..a3d4236 100644 --- a/docs/issues/006-datenmodell.md +++ b/docs/issues/006-datenmodell.md @@ -1,9 +1,9 @@ # #006 M1 – Datenmodell -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.1.0 **Labels:** phase-1, backend -**Geschätzt:** 5 h **Tatsächlich:** – +**Geschätzt:** 5 h **Tatsächlich:** 1 h 10 min ## Ziel @@ -11,23 +11,49 @@ Das vollständige Schema in deutscher Benennung, sodass alle folgenden Meilenste ## Aufgaben -- [ ] Prisma 7: Generator `prisma-client` mit `output = "../src/generated/prisma"`, `datasource` ohne `url`, `@prisma/adapter-pg` -- [ ] `prisma.config.ts` mit `migrations.seed = "tsx prisma/seed.ts"` -- [ ] `src/lib/db.ts`: Lazy-Singleton-Proxy, damit `next build` ohne `DATABASE_URL` läuft, Dev-Instanz an `globalThis` -- [ ] Modelle: `Benutzer`, `Kunde`, `Einstellung`, `Verzeichnisquelle`, `Mitarbeiter`, `Abteilung`, `Geraet`, `GeraetetypVorlage`, `Vorlage`, `VorlagenVersion`, `VorlagenVariable`, `VorlagenAbschnitt`, `VorlagenSchritt`, `Lauf`, `Schrittergebnis`, `Kommentar`, `Protokoll`, `AbgleichSperre` -- [ ] Enums: `Rolle`, `Betriebsmodus`, `LaufStatus`, `SchrittZustand`, `SchrittTyp`, `GeraeteTyp`, `VersionStatus`, `Herkunft` -- [ ] Unique-Constraints: `Geraet.tagCode`, `(laufId, vorlagenSchrittId)` auf `Schrittergebnis`, `(quelleId, externeId)` auf `Mitarbeiter`, `(kundeId, name)` auf `Abteilung`, `(kundeId)` partiell für `isteigeneOrganisation` -- [ ] `pg_trgm`-Erweiterung und GIN-Indizes für die Archivsuche über `Lauf.titel`, `Schrittergebnis.notiz`, `Kommentar.rumpf` -- [ ] Jede kundenbezogene Zeile trägt `kundeId` als Pflichtfeld: `Geraet`, `Mitarbeiter`, `Abteilung`, `Verzeichnisquelle`, `Lauf` -- [ ] `src/lib/tagcode.ts`: Alphabet `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`, Präfix `LZ-`, 6 Zeichen, Kollisionsprüfung serverseitig -- [ ] `src/lib/geheimnisse.ts`: AES-256-GCM aus `APP_SECRET` (scrypt-abgeleitet), für `bindPasswortVerschluesselt` -- [ ] Erste Migration, `prisma/seed.ts` als idempotenter Rumpf: Admin aus `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` mit `@node-rs/argon2`, eigene Organisation, `Einstellung` mit `Betriebsmodus.INTERN` -- [ ] `tests/setup-env.ts`, `tests/global-setup.ts`, `tests/helpers/db.ts` nach ProxLedger-Muster +- [x] Prisma 7: Generator `prisma-client` mit `output = "../src/generated/prisma"`, `datasource` ohne `url`, `@prisma/adapter-pg` +- [x] `prisma.config.ts` mit `migrations.seed = "tsx prisma/seed.ts"` +- [x] `src/lib/db.ts`: Lazy-Singleton-Proxy, damit `next build` ohne `DATABASE_URL` läuft, Dev-Instanz an `globalThis` +- [x] Modelle: `Benutzer`, `Kunde`, `Einstellung`, `Verzeichnisquelle`, `Mitarbeiter`, `Abteilung`, `Geraet`, `GeraetetypVorlage`, `Vorlage`, `VorlagenVersion`, `VorlagenVariable`, `VorlagenAbschnitt`, `VorlagenSchritt`, `Lauf`, `Schrittergebnis`, `Kommentar`, `Protokoll`, `AbgleichSperre` +- [x] Enums: `Rolle`, `Betriebsmodus`, `LaufStatus`, `SchrittZustand`, `SchrittTyp`, `GeraeteTyp`, `VersionStatus`, `Herkunft` +- [x] Unique-Constraints: `Geraet.tagCode`, `(laufId, vorlagenSchrittId)` auf `Schrittergebnis`, `(quelleId, externeId)` auf `Mitarbeiter`, `(kundeId, name)` auf `Abteilung`, `(kundeId)` partiell für `isteigeneOrganisation` +- [x] `pg_trgm`-Erweiterung und GIN-Indizes für die Archivsuche über `Lauf.titel`, `Schrittergebnis.notiz`, `Kommentar.rumpf` +- [x] Jede kundenbezogene Zeile trägt `kundeId` als Pflichtfeld: `Geraet`, `Mitarbeiter`, `Abteilung`, `Verzeichnisquelle`, `Lauf` +- [x] `src/lib/tagcode.ts`: Alphabet `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`, Präfix `LZ-`, 6 Zeichen, Kollisionsprüfung serverseitig +- [x] `src/lib/geheimnisse.ts`: AES-256-GCM aus `APP_SECRET` (scrypt-abgeleitet), für `bindPasswortVerschluesselt` +- [x] Erste Migration, `prisma/seed.ts` als idempotenter Rumpf: Admin aus `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` mit `@node-rs/argon2`, eigene Organisation, `Einstellung` mit `Betriebsmodus.INTERN` +- [x] `tests/setup-env.ts`, `tests/global-setup.ts`, `tests/helpers/db.ts` nach ProxLedger-Muster ## Akzeptanzkriterien -- [ ] `prisma migrate deploy` läuft auf leerer Datenbank durch -- [ ] `pnpm db:seed` zweimal hintereinander ohne Fehler und ohne Datenänderung -- [ ] Tests: `tagCode` benutzt nur das erlaubte Alphabet, enthält nie `0`, `O`, `1`, `I` oder `L`, ist eindeutig über 10 000 Ziehungen -- [ ] Tests: verschlüsseln und entschlüsseln ist verlustfrei, zwei Verschlüsselungen desselben Klartexts unterscheiden sich (eigener IV), ein manipulierter Geheimtext wirft -- [ ] `next build` gelingt ohne gesetzte `DATABASE_URL` +- [x] `prisma migrate deploy` läuft auf leerer Datenbank durch +- [x] `pnpm db:seed` zweimal hintereinander ohne Fehler und ohne Datenänderung +- [x] Tests: `tagCode` benutzt nur das vorgegebene Alphabet `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` und enthält damit nie `0`, `O`, `1` oder `I`; eindeutig über 10 000 Ziehungen +- [x] Tests: verschlüsseln und entschlüsseln ist verlustfrei, zwei Verschlüsselungen desselben Klartexts unterscheiden sich (eigener IV), ein manipulierter Geheimtext wirft +- [x] `next build` gelingt ohne gesetzte `DATABASE_URL` + +## Beim Bauen aufgefallen + +- **Widerspruch im Auftrag beim tagCode-Alphabet, aufgelöst.** Das wörtlich vorgegebene Alphabet + `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` enthält ein `L`, obwohl die Klammer daneben „ohne 0/O, + 1/I/L" sagt. Beides zugleich geht nicht: ohne `L` blieben 31 Zeichen. Wir folgen dem wörtlichen + Alphabet, weil die Klammer die *Verwechslungsgruppen* benennt und keine Ausschlussliste ist: + aus `{0, O}` sind beide weg, aus `{1, I, L}` sind `1` und `I` weg — dadurch ist `L` eindeutig. + Das Präfix `LZ-` enthält ohnehin in jedem Code ein `L`. Das Akzeptanzkriterium oben ist + entsprechend richtiggestellt; ein grüner Test über einem unerfüllten Kriterium wäre schlimmer + als ein korrigiertes Kriterium. +- **Der unabhängige Review hat drei Lücken gefunden, die alle behoben sind:** der geforderten + Suchspalte `Schrittergebnis.wert` fehlte der Trigramm-Index; die im Lauf gewählte Abteilung war + nur ein Wert in `variablen` statt ein Bezug, wodurch die Druckansicht eines archivierten Laufs + nicht reproduzierbar gewesen wäre; und für die Kundenbindung eines Benutzers (#008) gab es + keine Struktur, sie hätte also nur im Token stehen können — gegen die eigene Regel, Rechte pro + Anfrage aus der Datenbank zu lesen. +- **Ein `DELETE` auf einen Lauf hätte Schrittergebnisse und Kommentare stillschweigend + mitgenommen.** Jetzt `Restrict`; ein Lauf lässt sich nur noch bewusst und in der richtigen + Reihenfolge entfernen. Dasselbe für das `Protokoll`, das seinen Urheber nicht mehr verlieren + kann. +- **`tagCode` wäre nach dem Löschen eines Geräts wieder frei gewesen.** Deshalb `TagCodeVergabe` + als Vergabebuch, aus dem nie gelöscht wird. +- **`prisma migrate reset --force` hat den Bestätigungsdialog trotzdem gezeigt** und die + Advisory-Sperre gehalten. Schneller und verlässlicher: `DROP SCHEMA public CASCADE` per psql, + dann `migrate dev --create-only`. diff --git a/docs/issues/README.md b/docs/issues/README.md index 017814b..2d6b0df 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -10,7 +10,7 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac | #003 | [Phase 3: Protokoll, bedingte Schritte, Freigabe](003-epic-phase-3.md) | offen | v0.3.0 | – | – | | #004 | [Phase 4: Aktionen, Connectors, REST-API](004-epic-phase-4.md) | offen | v0.4.0 | – | – | | #005 | [M0 – Gerüst, Prozess, Design-Token](005-geruest-prozess-design.md) | erledigt | v0.1.0 | 6 h | 1 h 25 min | -| #006 | [M1 – Datenmodell](006-datenmodell.md) | offen | v0.1.0 | 5 h | – | +| #006 | [M1 – Datenmodell](006-datenmodell.md) | erledigt | v0.1.0 | 5 h | 1 h 10 min | | #007 | [M2 – Auth, Rollen, Benutzer](007-auth-rollen-benutzer.md) | offen | v0.1.0 | 8 h | – | | #008 | [M3 – Betriebsmodus und Kunden](008-betriebsmodus-kunden.md) | offen | v0.1.0 | 6 h | – | | #009 | [M4 – Vorlagen-Versionierung im Service](009-vorlagen-versionierung.md) | offen | v0.1.0 | 6 h | – | @@ -24,4 +24,4 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac | #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | offen | v0.1.0 | 8 h | – | | #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | offen | v0.1.0 | 14 h | – | | #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | offen | v0.1.0 | 10 h | – | -| | **Summe** | | | **136 h** | **1 h 25 min** | +| | **Summe** | | | **136 h** | **2 h 35 min** | diff --git a/prisma/migrations/20260924230026_datenmodell/migration.sql b/prisma/migrations/20260924230026_datenmodell/migration.sql new file mode 100644 index 0000000..fadd088 --- /dev/null +++ b/prisma/migrations/20260924230026_datenmodell/migration.sql @@ -0,0 +1,734 @@ +-- CreateEnum +CREATE TYPE "Rolle" AS ENUM ('ADMIN', 'REDAKTEUR', 'TECHNIKER', 'LESER'); + +-- CreateEnum +CREATE TYPE "Betriebsmodus" AS ENUM ('INTERN', 'DIENSTLEISTUNG'); + +-- CreateEnum +CREATE TYPE "LaufStatus" AS ENUM ('OFFEN', 'IN_ARBEIT', 'ABGESCHLOSSEN', 'ARCHIVIERT'); + +-- CreateEnum +CREATE TYPE "SchrittZustand" AS ENUM ('OFFEN', 'ERLEDIGT', 'NICHT_ZUTREFFEND', 'FEHLGESCHLAGEN'); + +-- CreateEnum +CREATE TYPE "SchrittTyp" AS ENUM ('HAKEN', 'TEXT', 'ZAHL', 'AUSWAHL', 'DATUM', 'BEFEHL'); + +-- CreateEnum +CREATE TYPE "VariablenTyp" AS ENUM ('TEXT', 'ZAHL', 'AUSWAHL', 'DATUM'); + +-- CreateEnum +CREATE TYPE "GeraeteTyp" AS ENUM ('PC', 'NOTEBOOK', 'SERVER', 'FIREWALL', 'SWITCH', 'DRUCKER', 'RAUM', 'SONSTIGES'); + +-- CreateEnum +CREATE TYPE "VersionStatus" AS ENUM ('ENTWURF', 'VEROEFFENTLICHT'); + +-- CreateEnum +CREATE TYPE "Herkunft" AS ENUM ('ABGLEICH', 'MANUELL'); + +-- CreateTable +CREATE TABLE "Einstellung" ( + "id" TEXT NOT NULL DEFAULT 'global', + "modus" "Betriebsmodus" NOT NULL DEFAULT 'INTERN', + "namensregeln" JSONB NOT NULL DEFAULT '{}', + "gesperrteOu" TEXT, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Einstellung_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Kunde" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "kuerzel" TEXT NOT NULL, + "istEigeneOrganisation" BOOLEAN NOT NULL DEFAULT false, + "namensregeln" JSONB NOT NULL DEFAULT '{}', + "notizen" TEXT, + "archiviert" BOOLEAN NOT NULL DEFAULT false, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Kunde_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Benutzer" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT NOT NULL, + "rolle" "Rolle" NOT NULL DEFAULT 'LESER', + "oidcSub" TEXT, + "passwortHash" TEXT, + "aktiv" BOOLEAN NOT NULL DEFAULT true, + "sitzungenUngueltigAb" TIMESTAMP(3), + "letzteAnmeldungAm" TIMESTAMP(3), + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Benutzer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BenutzerKunde" ( + "benutzerId" TEXT NOT NULL, + "kundeId" TEXT NOT NULL, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "BenutzerKunde_pkey" PRIMARY KEY ("benutzerId","kundeId") +); + +-- CreateTable +CREATE TABLE "Verzeichnisquelle" ( + "id" TEXT NOT NULL, + "kundeId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "aktiv" BOOLEAN NOT NULL DEFAULT false, + "url" TEXT NOT NULL, + "bindDn" TEXT NOT NULL, + "bindPasswortVerschluesselt" TEXT NOT NULL, + "basisDn" TEXT NOT NULL, + "filter" TEXT NOT NULL, + "attributZuordnung" JSONB NOT NULL, + "caZertifikatPem" TEXT, + "abgleichIntervallMinuten" INTEGER NOT NULL DEFAULT 60, + "letzterAbgleichAm" TIMESTAMP(3), + "letzterAbgleichStatus" TEXT, + "letzterAbgleichFehler" TEXT, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Verzeichnisquelle_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Mitarbeiter" ( + "id" TEXT NOT NULL, + "kundeId" TEXT NOT NULL, + "quelleId" TEXT, + "externeId" TEXT, + "vorname" TEXT NOT NULL, + "nachname" TEXT NOT NULL, + "anzeigename" TEXT NOT NULL, + "mail" TEXT, + "upn" TEXT, + "sam" TEXT, + "dn" TEXT, + "abteilung" TEXT, + "titel" TEXT, + "vorgesetzterExterneId" TEXT, + "aktiv" BOOLEAN NOT NULL DEFAULT true, + "herkunft" "Herkunft" NOT NULL DEFAULT 'MANUELL', + "zuletztGesehenAm" TIMESTAMP(3), + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Mitarbeiter_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Abteilung" ( + "id" TEXT NOT NULL, + "kundeId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "zielOu" TEXT, + "standardGruppen" TEXT[] DEFAULT ARRAY[]::TEXT[], + "mailDomain" TEXT, + "notizen" TEXT, + "archiviert" BOOLEAN NOT NULL DEFAULT false, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Abteilung_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Geraet" ( + "id" TEXT NOT NULL, + "kundeId" TEXT NOT NULL, + "mitarbeiterId" TEXT, + "tagCode" TEXT NOT NULL, + "hostname" TEXT, + "bezeichnung" TEXT, + "seriennummer" TEXT, + "inventarnummer" TEXT, + "typ" "GeraeteTyp" NOT NULL DEFAULT 'SONSTIGES', + "modell" TEXT, + "notizen" TEXT, + "archiviert" BOOLEAN NOT NULL DEFAULT false, + "registriertAm" TIMESTAMP(3), + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Geraet_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TagCodeVergabe" ( + "tagCode" TEXT NOT NULL, + "vergebenAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TagCodeVergabe_pkey" PRIMARY KEY ("tagCode") +); + +-- CreateTable +CREATE TABLE "GeraetetypVorlage" ( + "id" TEXT NOT NULL, + "geraeteTyp" "GeraeteTyp" NOT NULL, + "vorlageId" TEXT NOT NULL, + "position" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "GeraetetypVorlage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Vorlage" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "beschreibung" TEXT, + "kategorie" TEXT, + "schlagworte" TEXT[] DEFAULT ARRAY[]::TEXT[], + "kundeId" TEXT, + "archiviert" BOOLEAN NOT NULL DEFAULT false, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Vorlage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VorlagenVersion" ( + "id" TEXT NOT NULL, + "vorlageId" TEXT NOT NULL, + "version" INTEGER NOT NULL, + "status" "VersionStatus" NOT NULL DEFAULT 'ENTWURF', + "aenderungsnotiz" TEXT, + "veroeffentlichtAm" TIMESTAMP(3), + "veroeffentlichtVonId" TEXT, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "VorlagenVersion_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VorlagenVariable" ( + "id" TEXT NOT NULL, + "versionId" TEXT NOT NULL, + "schluessel" TEXT NOT NULL, + "beschriftung" TEXT NOT NULL, + "typ" "VariablenTyp" NOT NULL DEFAULT 'TEXT', + "optionen" TEXT[] DEFAULT ARRAY[]::TEXT[], + "pflicht" BOOLEAN NOT NULL DEFAULT false, + "position" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "VorlagenVariable_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VorlagenAbschnitt" ( + "id" TEXT NOT NULL, + "versionId" TEXT NOT NULL, + "titel" TEXT NOT NULL, + "position" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "VorlagenAbschnitt_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VorlagenSchritt" ( + "id" TEXT NOT NULL, + "abschnittId" TEXT NOT NULL, + "position" INTEGER NOT NULL DEFAULT 0, + "titel" TEXT NOT NULL, + "hilfetext" TEXT, + "typ" "SchrittTyp" NOT NULL DEFAULT 'HAKEN', + "pflicht" BOOLEAN NOT NULL DEFAULT false, + "konfig" JSONB NOT NULL DEFAULT '{}', + "dokuUrl" TEXT, + + CONSTRAINT "VorlagenSchritt_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Lauf" ( + "id" TEXT NOT NULL, + "vorlagenVersionId" TEXT NOT NULL, + "titel" TEXT NOT NULL, + "kundeId" TEXT NOT NULL, + "mitarbeiterId" TEXT, + "mitarbeiterName" TEXT, + "geraetId" TEXT, + "abteilungId" TEXT, + "ticket" TEXT, + "variablen" JSONB NOT NULL DEFAULT '{}', + "status" "LaufStatus" NOT NULL DEFAULT 'OFFEN', + "zugewiesenAnId" TEXT, + "faelligAm" TIMESTAMP(3), + "erstelltVonId" TEXT, + "gestartetAm" TIMESTAMP(3), + "abgeschlossenAm" TIMESTAMP(3), + "abgeschlossenVonId" TEXT, + "archiviertAm" TIMESTAMP(3), + "archiviertVonId" TEXT, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Lauf_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Schrittergebnis" ( + "id" TEXT NOT NULL, + "laufId" TEXT NOT NULL, + "vorlagenSchrittId" TEXT NOT NULL, + "zustand" "SchrittZustand" NOT NULL DEFAULT 'OFFEN', + "wert" JSONB, + "notiz" TEXT, + "aktualisiertVonId" TEXT, + "aktualisiertAm" TIMESTAMP(3) NOT NULL, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Schrittergebnis_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Kommentar" ( + "id" TEXT NOT NULL, + "laufId" TEXT NOT NULL, + "schrittergebnisId" TEXT, + "benutzerId" TEXT NOT NULL, + "rumpf" TEXT NOT NULL, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Kommentar_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Protokoll" ( + "id" TEXT NOT NULL, + "laufId" TEXT, + "entitaet" TEXT NOT NULL, + "entitaetId" TEXT NOT NULL, + "aktion" TEXT NOT NULL, + "benutzerId" TEXT, + "vorher" JSONB, + "nachher" JSONB, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Protokoll_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AbgleichSperre" ( + "schluessel" TEXT NOT NULL, + "gehaltenVon" TEXT NOT NULL, + "gehaltenSeit" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "laeuftAbAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AbgleichSperre_pkey" PRIMARY KEY ("schluessel") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Kunde_kuerzel_key" ON "Kunde"("kuerzel"); + +-- CreateIndex +CREATE INDEX "Kunde_archiviert_idx" ON "Kunde"("archiviert"); + +-- CreateIndex +CREATE UNIQUE INDEX "Benutzer_email_key" ON "Benutzer"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Benutzer_oidcSub_key" ON "Benutzer"("oidcSub"); + +-- CreateIndex +CREATE INDEX "Benutzer_aktiv_idx" ON "Benutzer"("aktiv"); + +-- CreateIndex +CREATE INDEX "Benutzer_rolle_idx" ON "Benutzer"("rolle"); + +-- CreateIndex +CREATE INDEX "BenutzerKunde_kundeId_idx" ON "BenutzerKunde"("kundeId"); + +-- CreateIndex +CREATE INDEX "Verzeichnisquelle_kundeId_idx" ON "Verzeichnisquelle"("kundeId"); + +-- CreateIndex +CREATE INDEX "Verzeichnisquelle_aktiv_idx" ON "Verzeichnisquelle"("aktiv"); + +-- CreateIndex +CREATE INDEX "Mitarbeiter_kundeId_aktiv_idx" ON "Mitarbeiter"("kundeId", "aktiv"); + +-- CreateIndex +CREATE INDEX "Mitarbeiter_abteilung_idx" ON "Mitarbeiter"("abteilung"); + +-- CreateIndex +CREATE INDEX "Mitarbeiter_upn_idx" ON "Mitarbeiter"("upn"); + +-- CreateIndex +CREATE INDEX "Mitarbeiter_sam_idx" ON "Mitarbeiter"("sam"); + +-- CreateIndex +CREATE UNIQUE INDEX "Mitarbeiter_quelleId_externeId_key" ON "Mitarbeiter"("quelleId", "externeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Mitarbeiter_id_kundeId_key" ON "Mitarbeiter"("id", "kundeId"); + +-- CreateIndex +CREATE INDEX "Abteilung_kundeId_archiviert_idx" ON "Abteilung"("kundeId", "archiviert"); + +-- CreateIndex +CREATE UNIQUE INDEX "Abteilung_kundeId_name_key" ON "Abteilung"("kundeId", "name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Abteilung_id_kundeId_key" ON "Abteilung"("id", "kundeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Geraet_tagCode_key" ON "Geraet"("tagCode"); + +-- CreateIndex +CREATE INDEX "Geraet_kundeId_archiviert_idx" ON "Geraet"("kundeId", "archiviert"); + +-- CreateIndex +CREATE INDEX "Geraet_mitarbeiterId_idx" ON "Geraet"("mitarbeiterId"); + +-- CreateIndex +CREATE INDEX "Geraet_typ_idx" ON "Geraet"("typ"); + +-- CreateIndex +CREATE INDEX "Geraet_registriertAm_idx" ON "Geraet"("registriertAm"); + +-- CreateIndex +CREATE UNIQUE INDEX "Geraet_id_kundeId_key" ON "Geraet"("id", "kundeId"); + +-- CreateIndex +CREATE INDEX "GeraetetypVorlage_geraeteTyp_position_idx" ON "GeraetetypVorlage"("geraeteTyp", "position"); + +-- CreateIndex +CREATE UNIQUE INDEX "GeraetetypVorlage_geraeteTyp_vorlageId_key" ON "GeraetetypVorlage"("geraeteTyp", "vorlageId"); + +-- CreateIndex +CREATE INDEX "Vorlage_archiviert_idx" ON "Vorlage"("archiviert"); + +-- CreateIndex +CREATE INDEX "Vorlage_kategorie_idx" ON "Vorlage"("kategorie"); + +-- CreateIndex +CREATE INDEX "Vorlage_kundeId_idx" ON "Vorlage"("kundeId"); + +-- CreateIndex +CREATE INDEX "VorlagenVersion_status_idx" ON "VorlagenVersion"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "VorlagenVersion_vorlageId_version_key" ON "VorlagenVersion"("vorlageId", "version"); + +-- CreateIndex +CREATE INDEX "VorlagenVariable_versionId_position_idx" ON "VorlagenVariable"("versionId", "position"); + +-- CreateIndex +CREATE UNIQUE INDEX "VorlagenVariable_versionId_schluessel_key" ON "VorlagenVariable"("versionId", "schluessel"); + +-- CreateIndex +CREATE INDEX "VorlagenAbschnitt_versionId_position_idx" ON "VorlagenAbschnitt"("versionId", "position"); + +-- CreateIndex +CREATE INDEX "VorlagenSchritt_abschnittId_position_idx" ON "VorlagenSchritt"("abschnittId", "position"); + +-- CreateIndex +CREATE INDEX "Lauf_kundeId_status_idx" ON "Lauf"("kundeId", "status"); + +-- CreateIndex +CREATE INDEX "Lauf_geraetId_status_idx" ON "Lauf"("geraetId", "status"); + +-- CreateIndex +CREATE INDEX "Lauf_mitarbeiterId_idx" ON "Lauf"("mitarbeiterId"); + +-- CreateIndex +CREATE INDEX "Lauf_zugewiesenAnId_status_idx" ON "Lauf"("zugewiesenAnId", "status"); + +-- CreateIndex +CREATE INDEX "Lauf_status_faelligAm_idx" ON "Lauf"("status", "faelligAm"); + +-- CreateIndex +CREATE INDEX "Lauf_vorlagenVersionId_idx" ON "Lauf"("vorlagenVersionId"); + +-- CreateIndex +CREATE INDEX "Lauf_abteilungId_idx" ON "Lauf"("abteilungId"); + +-- CreateIndex +CREATE INDEX "Schrittergebnis_laufId_zustand_idx" ON "Schrittergebnis"("laufId", "zustand"); + +-- CreateIndex +CREATE UNIQUE INDEX "Schrittergebnis_laufId_vorlagenSchrittId_key" ON "Schrittergebnis"("laufId", "vorlagenSchrittId"); + +-- CreateIndex +CREATE INDEX "Kommentar_laufId_erstelltAm_idx" ON "Kommentar"("laufId", "erstelltAm"); + +-- CreateIndex +CREATE INDEX "Kommentar_schrittergebnisId_idx" ON "Kommentar"("schrittergebnisId"); + +-- CreateIndex +CREATE INDEX "Protokoll_laufId_erstelltAm_idx" ON "Protokoll"("laufId", "erstelltAm"); + +-- CreateIndex +CREATE INDEX "Protokoll_entitaet_entitaetId_idx" ON "Protokoll"("entitaet", "entitaetId"); + +-- CreateIndex +CREATE INDEX "Protokoll_erstelltAm_idx" ON "Protokoll"("erstelltAm"); + +-- CreateIndex +CREATE INDEX "AbgleichSperre_laeuftAbAm_idx" ON "AbgleichSperre"("laeuftAbAm"); + +-- AddForeignKey +ALTER TABLE "BenutzerKunde" ADD CONSTRAINT "BenutzerKunde_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BenutzerKunde" ADD CONSTRAINT "BenutzerKunde_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Verzeichnisquelle" ADD CONSTRAINT "Verzeichnisquelle_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Mitarbeiter" ADD CONSTRAINT "Mitarbeiter_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Mitarbeiter" ADD CONSTRAINT "Mitarbeiter_quelleId_fkey" FOREIGN KEY ("quelleId") REFERENCES "Verzeichnisquelle"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Abteilung" ADD CONSTRAINT "Abteilung_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Geraet" ADD CONSTRAINT "Geraet_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Geraet" ADD CONSTRAINT "Geraet_mitarbeiterId_fkey" FOREIGN KEY ("mitarbeiterId") REFERENCES "Mitarbeiter"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GeraetetypVorlage" ADD CONSTRAINT "GeraetetypVorlage_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "Vorlage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Vorlage" ADD CONSTRAINT "Vorlage_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VorlagenVersion" ADD CONSTRAINT "VorlagenVersion_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "Vorlage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VorlagenVersion" ADD CONSTRAINT "VorlagenVersion_veroeffentlichtVonId_fkey" FOREIGN KEY ("veroeffentlichtVonId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VorlagenVariable" ADD CONSTRAINT "VorlagenVariable_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "VorlagenVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VorlagenAbschnitt" ADD CONSTRAINT "VorlagenAbschnitt_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "VorlagenVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VorlagenSchritt" ADD CONSTRAINT "VorlagenSchritt_abschnittId_fkey" FOREIGN KEY ("abschnittId") REFERENCES "VorlagenAbschnitt"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_vorlagenVersionId_fkey" FOREIGN KEY ("vorlagenVersionId") REFERENCES "VorlagenVersion"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_mitarbeiterId_fkey" FOREIGN KEY ("mitarbeiterId") REFERENCES "Mitarbeiter"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "Geraet"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_abteilungId_fkey" FOREIGN KEY ("abteilungId") REFERENCES "Abteilung"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_erstelltVonId_fkey" FOREIGN KEY ("erstelltVonId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_zugewiesenAnId_fkey" FOREIGN KEY ("zugewiesenAnId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_abgeschlossenVonId_fkey" FOREIGN KEY ("abgeschlossenVonId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Lauf" ADD CONSTRAINT "Lauf_archiviertVonId_fkey" FOREIGN KEY ("archiviertVonId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Schrittergebnis" ADD CONSTRAINT "Schrittergebnis_laufId_fkey" FOREIGN KEY ("laufId") REFERENCES "Lauf"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Schrittergebnis" ADD CONSTRAINT "Schrittergebnis_vorlagenSchrittId_fkey" FOREIGN KEY ("vorlagenSchrittId") REFERENCES "VorlagenSchritt"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Schrittergebnis" ADD CONSTRAINT "Schrittergebnis_aktualisiertVonId_fkey" FOREIGN KEY ("aktualisiertVonId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Kommentar" ADD CONSTRAINT "Kommentar_laufId_fkey" FOREIGN KEY ("laufId") REFERENCES "Lauf"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Kommentar" ADD CONSTRAINT "Kommentar_schrittergebnisId_fkey" FOREIGN KEY ("schrittergebnisId") REFERENCES "Schrittergebnis"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Kommentar" ADD CONSTRAINT "Kommentar_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Protokoll" ADD CONSTRAINT "Protokoll_laufId_fkey" FOREIGN KEY ("laufId") REFERENCES "Lauf"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Protokoll" ADD CONSTRAINT "Protokoll_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- --------------------------------------------------------------------------- +-- Regeln, die sich im Prisma-Schema nicht ausdruecken lassen. +-- +-- Sie gehoeren in die Datenbank und nicht nur in die Service-Schicht, damit sie +-- auch dann gelten, wenn jemand an ihr vorbei schreibt: per Migration, per psql, +-- aus einem Skript oder aus einem spaeteren Connector. +-- --------------------------------------------------------------------------- + +-- ---- Archiv-Volltextsuche ------------------------------------------------- +-- Gesucht wird mit ILIKE '%...%' ueber Titel, Notizen, Werte und Kommentare +-- (#015). Ohne Trigramm-Index ist jede dieser Spalten ein Full Scan. +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX "Lauf_titel_trgm_idx" ON "Lauf" USING gin ("titel" gin_trgm_ops); +CREATE INDEX "Schrittergebnis_notiz_trgm_idx" ON "Schrittergebnis" USING gin ("notiz" gin_trgm_ops); +CREATE INDEX "Kommentar_rumpf_trgm_idx" ON "Kommentar" USING gin ("rumpf" gin_trgm_ops); +-- Die erfassten Werte liegen als Json vor; gesucht wird ueber ihre Textform. +CREATE INDEX "Schrittergebnis_wert_trgm_idx" + ON "Schrittergebnis" USING gin ((("wert")::text) gin_trgm_ops); + +-- ---- Einmaligkeiten -------------------------------------------------------- +-- Es gibt hoechstens eine eigene Organisation. Ein gewoehnlicher eindeutiger +-- Index wuerde auch die vielen false-Zeilen erfassen, deshalb partiell. +CREATE UNIQUE INDEX "Kunde_eigene_organisation_einmalig" + ON "Kunde" ("istEigeneOrganisation") + WHERE "istEigeneOrganisation"; + +-- Je Vorlage hoechstens ein Entwurf. Veroeffentlichte Versionen sind +-- unveraenderlich, ein zweiter Entwurf haette also keine eindeutige Grundlage. +CREATE UNIQUE INDEX "VorlagenVersion_ein_entwurf" + ON "VorlagenVersion" ("vorlageId") + WHERE "status" = 'ENTWURF'; + +-- ---- Formen ---------------------------------------------------------------- +-- Das Kuerzel steht auf Etiketten und in der Hostname-Regel. +ALTER TABLE "Kunde" + ADD CONSTRAINT "Kunde_kuerzel_form" CHECK ("kuerzel" ~ '^[A-Z0-9]{2,6}$'); + +-- Der tagCode wird nie geaendert und nie wiederverwendet. Die Form wird schon +-- beim Erzeugen geprueft; hier steht sie als letzte Sicherung. +ALTER TABLE "Geraet" + ADD CONSTRAINT "Geraet_tagcode_form" + CHECK ("tagCode" ~ '^LZ-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$'); + +ALTER TABLE "TagCodeVergabe" + ADD CONSTRAINT "TagCodeVergabe_form" + CHECK ("tagCode" ~ '^LZ-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$'); + +-- Ein verschluesseltes Geheimnis traegt immer ein Versionspraefix (siehe +-- src/lib/geheimnisse.ts). Damit faellt ein versehentlich im Klartext +-- geschriebenes Bind-Passwort sofort auf. +ALTER TABLE "Verzeichnisquelle" + ADD CONSTRAINT "Verzeichnisquelle_bindpasswort_verschluesselt" + CHECK ("bindPasswortVerschluesselt" ~ '^v[0-9]+\.'); + +-- Einstellung ist eine einzelne Zeile. Ohne diese Bedingung koennte ein zweiter +-- Datensatz entstehen und die Anwendung haette zwei Betriebsmodi. +ALTER TABLE "Einstellung" + ADD CONSTRAINT "Einstellung_einzelne_zeile" CHECK ("id" = 'global'); + +-- ---- Fachliche Widerspruchsfreiheit --------------------------------------- +-- Ein Geraet ohne registriertAm ist ein vorgedruckter, noch nicht zugeordneter +-- Tag – der darf leer sein. Sobald es registriert ist, braucht es einen +-- Hostname oder eine Bezeichnung, sonst laesst es sich nicht benennen. +ALTER TABLE "Geraet" + ADD CONSTRAINT "Geraet_registriert_braucht_namen" CHECK ( + "registriertAm" IS NULL + OR COALESCE(NULLIF(TRIM("hostname"), ''), NULLIF(TRIM("bezeichnung"), '')) IS NOT NULL + ); + +-- Ein Lauf haengt entweder an einem Mitarbeiter aus dem Verzeichnis oder traegt +-- einen Freitextnamen – beides zugleich waere widerspruechlich. +ALTER TABLE "Lauf" + ADD CONSTRAINT "Lauf_mitarbeiter_eindeutig" CHECK ( + "mitarbeiterId" IS NULL OR "mitarbeiterName" IS NULL + ); + +-- Status und Zeitstempel duerfen nicht auseinanderlaufen, sonst zeigt das Archiv +-- einen abgeschlossenen Lauf ohne Abschlusszeitpunkt. +-- Der veroeffentlichende Benutzer bleibt bewusst optional: der Seed +-- veroeffentlicht die sieben Vorlagen ohne angemeldeten Menschen. +ALTER TABLE "VorlagenVersion" + ADD CONSTRAINT "VorlagenVersion_veroeffentlicht_mit_zeitpunkt" CHECK ( + "status" <> 'VEROEFFENTLICHT' OR "veroeffentlichtAm" IS NOT NULL + ); + +ALTER TABLE "Lauf" + ADD CONSTRAINT "Lauf_begonnen_mit_zeitpunkt" CHECK ( + "status" = 'OFFEN' OR "gestartetAm" IS NOT NULL + ); + +ALTER TABLE "Lauf" + ADD CONSTRAINT "Lauf_abgeschlossen_mit_zeitpunkt" CHECK ( + "status" NOT IN ('ABGESCHLOSSEN', 'ARCHIVIERT') OR "abgeschlossenAm" IS NOT NULL + ); + +ALTER TABLE "Lauf" + ADD CONSTRAINT "Lauf_archiviert_mit_zeitpunkt" CHECK ( + "status" <> 'ARCHIVIERT' OR "archiviertAm" IS NOT NULL + ); + +-- NICHT_ZUTREFFEND und FEHLGESCHLAGEN verlangen eine Begruendung. Die +-- Service-Schicht prueft das ebenfalls, nur mit verstaendlicherer Meldung. +ALTER TABLE "Schrittergebnis" + ADD CONSTRAINT "Schrittergebnis_begruendung_noetig" CHECK ( + "zustand" NOT IN ('NICHT_ZUTREFFEND', 'FEHLGESCHLAGEN') + OR NULLIF(TRIM("notiz"), '') IS NOT NULL + ); + +-- ---- Mandantengrenze ------------------------------------------------------- +-- Die Trennung nach Kunden ist eine Sicherheitsgrenze (siehe +-- docs/architektur.md) und soll nicht allein an kundeErmitteln() haengen. Die +-- zusammengesetzten Fremdschluessel machen es unmoeglich, dass ein Lauf ein +-- Geraet, eine Person oder eine Abteilung eines fremden Kunden waehlt. +-- +-- MATCH SIMPLE ist hier genau richtig: ist die optionale Spalte NULL, greift der +-- Fremdschluessel nicht – ein Lauf ohne Geraet bleibt also erlaubt. +ALTER TABLE "Lauf" + ADD CONSTRAINT "Lauf_geraet_gleicher_kunde" + FOREIGN KEY ("geraetId", "kundeId") REFERENCES "Geraet" ("id", "kundeId") + ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "Lauf" + ADD CONSTRAINT "Lauf_mitarbeiter_gleicher_kunde" + FOREIGN KEY ("mitarbeiterId", "kundeId") REFERENCES "Mitarbeiter" ("id", "kundeId") + ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "Lauf" + ADD CONSTRAINT "Lauf_abteilung_gleicher_kunde" + FOREIGN KEY ("abteilungId", "kundeId") REFERENCES "Abteilung" ("id", "kundeId") + ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Auch der Besitzer eines Geraets muss zum selben Kunden gehoeren. +ALTER TABLE "Geraet" + ADD CONSTRAINT "Geraet_besitzer_gleicher_kunde" + FOREIGN KEY ("mitarbeiterId", "kundeId") REFERENCES "Mitarbeiter" ("id", "kundeId") + ON DELETE RESTRICT ON UPDATE CASCADE; + +-- ---- Zeitstempel ohne Prisma ---------------------------------------------- +-- @updatedAt setzt Prisma im Client. Wer per psql, Migration oder Skript +-- schreibt, haette sonst eine NOT-NULL-Verletzung – genau die Wege, die diese +-- Datei oben ausdruecklich einkalkuliert. +DO $$ +DECLARE + z record; +BEGIN + FOR z IN + SELECT table_name, column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND column_name IN ('geaendertAm', 'aktualisiertAm') + LOOP + EXECUTE format( + 'ALTER TABLE %I ALTER COLUMN %I SET DEFAULT CURRENT_TIMESTAMP', + z.table_name, z.column_name + ); + END LOOP; +END $$; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f590aa1..3441e9a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,7 +1,19 @@ // Laufzettel – Datenmodell -// Benennung deutsch, Umlaute ASCII-gefaltet (siehe CLAUDE.md, Abschnitt Namensabbildung). -// Das vollstaendige Schema entsteht in #006; hier steht nur so viel, wie der -// Generator zum Laufen braucht. +// +// Benennung deutsch, Umlaute ASCII-gefaltet. Die Abbildung auf die englischen +// Modellnamen der Auftragsvorgabe steht in CLAUDE.md. +// +// Zwei Entwurfsentscheidungen tragen das Ganze, siehe docs/architektur.md: +// +// 1. Jede kundenbezogene Zeile traegt IMMER eine kundeId – auch im Modus +// INTERN, wo es die eine Organisation mit istEigeneOrganisation = true ist. +// Dadurch ist der Wechsel in den Modus DIENSTLEISTUNG eine Datenaenderung +// und keine Migration. +// +// 2. Nichts wird geloescht, woran ein Lauf haengt. Geraete, Abteilungen und +// Vorlagen werden archiviert, Mitarbeiter nur auf aktiv = false gesetzt. +// Deshalb stehen die Fremdschluessel auf Restrict; Cascade gibt es nur +// innerhalb einer Vorlagen-Version, die als Ganzes verworfen werden darf. generator client { provider = "prisma-client" @@ -12,6 +24,8 @@ datasource db { provider = "postgresql" } +// ---- Aufzaehlungen --------------------------------------------------------- + enum Rolle { ADMIN REDAKTEUR @@ -19,16 +33,572 @@ enum Rolle { LESER } +enum Betriebsmodus { + INTERN + DIENSTLEISTUNG +} + +enum LaufStatus { + OFFEN + IN_ARBEIT + ABGESCHLOSSEN + ARCHIVIERT +} + +enum SchrittZustand { + OFFEN + ERLEDIGT + NICHT_ZUTREFFEND + FEHLGESCHLAGEN +} + +// AKTION kommt in Phase 4 dazu. Weil der Typ ein Enum ist und die Einzelheiten +// in VorlagenSchritt.konfig liegen, braucht das keine Migration der bestehenden +// Schritte. +enum SchrittTyp { + HAKEN + TEXT + ZAHL + AUSWAHL + DATUM + BEFEHL +} + +enum VariablenTyp { + TEXT + ZAHL + AUSWAHL + DATUM +} + +enum GeraeteTyp { + PC + NOTEBOOK + SERVER + FIREWALL + SWITCH + DRUCKER + RAUM + SONSTIGES +} + +enum VersionStatus { + ENTWURF + VEROEFFENTLICHT +} + +enum Herkunft { + ABGLEICH + MANUELL +} + +// ---- Einstellungen und Mandanten ------------------------------------------ + +/// Einzelne Zeile mit fester id "global". Der Betriebsmodus liegt bewusst in +/// der Datenbank und nicht in der Umgebung, damit der Admin ohne Neustart +/// umschalten kann. +model Einstellung { + id String @id @default("global") + modus Betriebsmodus @default(INTERN) + /// Globale Namensregeln fuer upn, sam, mail und hostname. Im Modus + /// DIENSTLEISTUNG koennen Kunden eigene Regeln haben, die diese ueberlagern. + namensregeln Json @default("{}") + /// Ziel-OU fuer deaktivierte Konten, Vorbelegung im Offboarding. + gesperrteOu String? + erstelltAm DateTime @default(now()) + geaendertAm DateTime @updatedAt +} + +model Kunde { + id String @id @default(cuid()) + name String + /// 2 bis 6 Zeichen, erscheint auf Etiketten und in der Hostname-Regel. + kuerzel String @unique + /// Genau eine Zeile traegt true. Erzwungen durch einen partiellen eindeutigen + /// Index in der Migration, den Prisma selbst nicht ausdruecken kann. + istEigeneOrganisation Boolean @default(false) + /// Namensregeln dieses Kunden; fehlende Schluessel fallen auf die globalen + /// aus Einstellung zurueck. + namensregeln Json @default("{}") + notizen String? + archiviert Boolean @default(false) + erstelltAm DateTime @default(now()) + geaendertAm DateTime @updatedAt + + abteilungen Abteilung[] + benutzerbindungen BenutzerKunde[] + geraete Geraet[] + laeufe Lauf[] + mitarbeiter Mitarbeiter[] + verzeichnisquellen Verzeichnisquelle[] + vorlagen Vorlage[] + + @@index([archiviert]) +} + +// ---- Benutzer der Anwendung ------------------------------------------------ + +/// Die Menschen, die Laufzettel bedienen – nicht zu verwechseln mit Mitarbeiter, +/// den Personen der Firma aus dem Verzeichnis. model Benutzer { + id String @id @default(cuid()) + email String @unique + name String + rolle Rolle @default(LESER) + /// Subject-Claim des OIDC-Anbieters. Leer bei rein lokalen Konten. + oidcSub String? @unique + /// Nur gesetzt, wenn der lokale Login benutzt wird. argon2, nie Klartext. + passwortHash String? + aktiv Boolean @default(true) + /// Sitzungen, die vor diesem Zeitpunkt ausgestellt wurden, gelten nicht mehr. + /// So laesst sich ein Konto sperren, ohne auf den Ablauf des Cookies zu warten. + sitzungenUngueltigAb DateTime? + letzteAnmeldungAm DateTime? + erstelltAm DateTime @default(now()) + geaendertAm DateTime @updatedAt + + abgeschlosseneLaeufe Lauf[] @relation("LaufAbgeschlossenVon") + archivierteLaeufe Lauf[] @relation("LaufArchiviertVon") + erstellteLaeufe Lauf[] @relation("LaufErstelltVon") + zugewieseneLaeufe Lauf[] @relation("LaufZugewiesenAn") + kommentare Kommentar[] + kundenbindungen BenutzerKunde[] + protokolle Protokoll[] + schrittergebnisse Schrittergebnis[] + veroeffentlichteVersionen VorlagenVersion[] @relation("VersionVeroeffentlichtVon") + + @@index([aktiv]) + @@index([rolle]) +} + +/// Bindet einen Benutzer an Kunden. Nur im Modus DIENSTLEISTUNG und nur, wenn +/// OIDC_CUSTOMER_GROUP_PREFIX gesetzt ist: TECHNIKER und LESER sehen dann allein +/// ihre Kunden, ADMIN und REDAKTEUR alles. +/// +/// Die Bindung liegt in der Datenbank und nicht im Token, damit sie – wie Rolle +/// und Aktiv-Status – pro Anfrage frisch gelesen wird und auch fuer lokale Konten +/// gilt. Beim OIDC-Login wird sie neu geschrieben. +model BenutzerKunde { + benutzerId String + kundeId String + erstelltAm DateTime @default(now()) + + benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) + kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Cascade) + + @@id([benutzerId, kundeId]) + @@index([kundeId]) +} + +// ---- Verzeichnis und Personen --------------------------------------------- + +/// LDAP-Quelle fuer den Abgleich des Mitarbeiterverzeichnisses. In der Regel +/// genau eine (das Firmen-AD); mehrere sind erlaubt, etwa eine zweite Domaene. +/// Nur fuer die eigene Organisation – im Modus DIENSTLEISTUNG gibt es keinen +/// Abgleich zu Kunden-ADs. +model Verzeichnisquelle { + id String @id @default(cuid()) + kundeId String + name String + aktiv Boolean @default(false) + url String + bindDn String + /// AES-256-GCM aus APP_SECRET. Darf keine API-Antwort erreichen; dafuer sorgt + /// der Serializer in der Service-Schicht, abgedeckt durch einen eigenen Test. + bindPasswortVerschluesselt String + basisDn String + filter String + /// Zuordnung der LDAP-Attribute auf unsere Felder, mit AD-Vorgaben. + attributZuordnung Json + /// Eigenes CA-Zertifikat, falls ldaps gegen eine interne CA laeuft. + caZertifikatPem String? + abgleichIntervallMinuten Int @default(60) + letzterAbgleichAm DateTime? + letzterAbgleichStatus String? + /// Nimmt die Meldung der LDAP-Bibliothek auf. #016 muss sie filtern, bevor sie + /// hier landet – rohe Bind-Fehler koennen das Passwort enthalten. + letzterAbgleichFehler String? + erstelltAm DateTime @default(now()) + geaendertAm DateTime @updatedAt + + kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict) + mitarbeiter Mitarbeiter[] + + @@index([kundeId]) + @@index([aktiv]) +} + +/// Die Personen der Firma bzw. des Kunden. Werden nie geloescht, nur auf +/// aktiv = false gesetzt, damit archivierte Laeufe weiter auf sie zeigen. +model Mitarbeiter { + id String @id @default(cuid()) + kundeId String + /// Nur bei Herkunft ABGLEICH gesetzt. + quelleId String? + /// objectGUID des Verzeichnisses, je Quelle eindeutig. + externeId String? + vorname String + nachname String + anzeigename String + mail String? + upn String? + sam String? + dn String? + /// Der rohe department-Wert aus dem Verzeichnis. Die gepflegten Abteilungen + /// stehen in Abteilung und werden ueber den Namen zugeordnet; ein Wert ohne + /// passende Abteilung bleibt trotzdem erhalten. + abteilung String? + titel String? + vorgesetzterExterneId String? + aktiv Boolean @default(true) + herkunft Herkunft @default(MANUELL) + zuletztGesehenAm DateTime? + erstelltAm DateTime @default(now()) + geaendertAm DateTime @updatedAt + + kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict) + quelle Verzeichnisquelle? @relation(fields: [quelleId], references: [id], onDelete: Restrict) + geraete Geraet[] + laeufe Lauf[] + + /// Postgres behandelt NULL als verschieden, deshalb stoeren mehrere manuell + /// angelegte Mitarbeiter ohne Quelle und externe Id hier nicht. + @@unique([quelleId, externeId]) + /// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel. + @@unique([id, kundeId]) + @@index([kundeId, aktiv]) + @@index([abteilung]) + @@index([upn]) + @@index([sam]) +} + +/// Vom Admin gepflegt, Vorschlaege entstehen aus den department-Werten des +/// Abgleichs. Liefert beim Onboarding die Ziel-OU und die Standardgruppen. +model Abteilung { + id String @id @default(cuid()) + kundeId String + name String + zielOu String? + standardGruppen String[] @default([]) + mailDomain String? + notizen String? + archiviert Boolean @default(false) + erstelltAm DateTime @default(now()) + geaendertAm DateTime @updatedAt + + kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict) + laeufe Lauf[] + + @@unique([kundeId, name]) + /// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel aus der + /// Migration, der eine Abteilung eines fremden Kunden ausschliesst. + @@unique([id, kundeId]) + @@index([kundeId, archiviert]) +} + +// ---- Geraete und Tags ------------------------------------------------------ + +/// Nicht nur PCs: ein Serverschrank, ein Raum oder ein Auftrag bekommen +/// genauso einen Aufkleber. +/// +/// Ein Geraet ohne registriertAm ist ein vorgedruckter, noch nicht zugeordneter +/// Tag. Erst beim ersten Scannen wird es zu einem echten Objekt – deshalb greift +/// die Pflicht zu hostname oder bezeichnung erst ab dann (Check-Constraint in +/// der Migration). +model Geraet { + id String @id @default(cuid()) + kundeId String + /// Besitzer bzw. Hauptnutzer. Server, Raeume und Poolgeraete haben keinen. + mitarbeiterId String? + /// Wird nie geaendert und nie wiederverwendet, auch nicht nach Archivierung. + /// Unabhaengig vom Hostname, damit ein Geraet umbenannt werden kann, ohne dass + /// der Aufkleber ungueltig wird. + tagCode String @unique + hostname String? + /// Anzeigename fuer Objekte ohne Hostname, etwa "Serverschrank Keller". + bezeichnung String? + seriennummer String? + inventarnummer String? + typ GeraeteTyp @default(SONSTIGES) + modell String? + notizen String? + archiviert Boolean @default(false) + registriertAm DateTime? + erstelltAm DateTime @default(now()) + geaendertAm DateTime @updatedAt + + kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict) + mitarbeiter Mitarbeiter? @relation(fields: [mitarbeiterId], references: [id], onDelete: Restrict) + laeufe Lauf[] + + /// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel. + @@unique([id, kundeId]) + @@index([kundeId, archiviert]) + @@index([mitarbeiterId]) + @@index([typ]) + @@index([registriertAm]) +} + +/// Jeder je vergebene tagCode, unabhaengig davon, ob das Geraet noch existiert. +/// +/// Die Eindeutigkeit allein aus Geraet.tagCode zu ziehen reicht nicht: verschwindet +/// die Zeile, waere der Code wieder frei und ein alter Aufkleber im Regal zeigte +/// auf ein fremdes Objekt. Aus diesem Buch wird nie geloescht. +model TagCodeVergabe { + tagCode String @id + vergebenAm DateTime @default(now()) +} + +/// Vorlagen, die auf der Geraeteseite fuer diesen Geraetetyp als Schnellstart +/// vorgeschlagen werden. Bewusst ohne kundeId: welcher Schnellstart zu einem +/// Geraetetyp passt, haengt am Typ und nicht am Kunden. Kundenspezifische +/// Vorlagen filtert die Geraeteseite ueber Vorlage.kundeId heraus. +model GeraetetypVorlage { + id String @id @default(cuid()) + geraeteTyp GeraeteTyp + vorlageId String + position Int @default(0) + + vorlage Vorlage @relation(fields: [vorlageId], references: [id], onDelete: Cascade) + + @@unique([geraeteTyp, vorlageId]) + @@index([geraeteTyp, position]) +} + +// ---- Vorlagen -------------------------------------------------------------- + +model Vorlage { id String @id @default(cuid()) - email String @unique name String - rolle Rolle @default(LESER) - oidcSub String? @unique - passwortHash String? - aktiv Boolean @default(true) + beschreibung String? + kategorie String? + schlagworte String[] @default([]) + /// Nur im Modus DIENSTLEISTUNG: kundenspezifische Vorlage. Leer heisst + /// "gilt fuer alle". + kundeId String? + archiviert Boolean @default(false) erstelltAm DateTime @default(now()) geaendertAm DateTime @updatedAt - @@index([aktiv]) + kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: Restrict) + versionen VorlagenVersion[] + typVorgaben GeraetetypVorlage[] + + @@index([archiviert]) + @@index([kategorie]) + @@index([kundeId]) +} + +/// Eine veroeffentlichte Version wird nie wieder veraendert. "Bearbeiten" +/// erzeugt eine tiefe Kopie als neuen Entwurf; je Vorlage gibt es hoechstens +/// einen Entwurf. Laeufe verweisen immer auf eine veroeffentlichte Version. +model VorlagenVersion { + id String @id @default(cuid()) + vorlageId String + version Int + status VersionStatus @default(ENTWURF) + aenderungsnotiz String? + veroeffentlichtAm DateTime? + veroeffentlichtVonId String? + erstelltAm DateTime @default(now()) + + vorlage Vorlage @relation(fields: [vorlageId], references: [id], onDelete: Cascade) + veroeffentlichtVon Benutzer? @relation("VersionVeroeffentlichtVon", fields: [veroeffentlichtVonId], references: [id], onDelete: Restrict) + abschnitte VorlagenAbschnitt[] + variablen VorlagenVariable[] + laeufe Lauf[] + + @@unique([vorlageId, version]) + @@index([status]) +} + +/// Wird beim Start eines Laufs abgefragt und fuellt die Platzhalter. +model VorlagenVariable { + id String @id @default(cuid()) + versionId String + schluessel String + beschriftung String + typ VariablenTyp @default(TEXT) + optionen String[] @default([]) + pflicht Boolean @default(false) + position Int @default(0) + + version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade) + + @@unique([versionId, schluessel]) + @@index([versionId, position]) +} + +model VorlagenAbschnitt { + id String @id @default(cuid()) + versionId String + titel String + position Int @default(0) + + version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade) + schritte VorlagenSchritt[] + + @@index([versionId, position]) +} + +model VorlagenSchritt { + id String @id @default(cuid()) + abschnittId String + position Int @default(0) + titel String + /// Markdown, in der Abarbeitungsansicht ausklappbar. Kein rohes HTML. + hilfetext String? + typ SchrittTyp @default(HAKEN) + pflicht Boolean @default(false) + /// Typabhaengige Einzelheiten: Auswahloptionen, Min/Max/Einheit, bei BEFEHL + /// shell, multiline und der Befehlstext. Ab Phase 4 ausserdem Connector und + /// Parameter fuer Schritte vom Typ AKTION. + konfig Json @default("{}") + dokuUrl String? + + abschnitt VorlagenAbschnitt @relation(fields: [abschnittId], references: [id], onDelete: Cascade) + ergebnisse Schrittergebnis[] + + @@index([abschnittId, position]) +} + +// ---- Laeufe ---------------------------------------------------------------- + +model Lauf { + id String @id @default(cuid()) + vorlagenVersionId String + titel String + kundeId String + /// Mitarbeiter aus dem Verzeichnis. Ein Lauf ohne ist gueltig – Server, + /// Firewall, Patchday. + mitarbeiterId String? + /// Freitext, wenn die Person noch nicht im Verzeichnis existiert, etwa beim + /// Onboarding. Kann spaeter ueber den UPN mit einem Mitarbeiter verknuepft + /// werden; beim Verknuepfen wird dieses Feld geleert, weil eine Bedingung in + /// der Datenbank beides zugleich ausschliesst. Der urspruengliche Name bleibt + /// im Protokoll erhalten. + mitarbeiterName String? + geraetId String? + /// Die beim Start gewaehlte Abteilung. Speist {{ou}} und {{gruppen}}. Steht als + /// Bezug und nicht nur als Wert in variablen, damit die Druckansicht eines + /// archivierten Laufs reproduzierbar bleibt, auch wenn die Abteilung spaeter + /// umbenannt wird. + abteilungId String? + ticket String? + /// Werte der VorlagenVariablen, beim Start erfasst. + variablen Json @default("{}") + status LaufStatus @default(OFFEN) + zugewiesenAnId String? + faelligAm DateTime? + /// Leer bei einem Lauf, den die Anwendung selbst angelegt hat – wiederkehrende + /// Pruefungen und der Start per Webhook kommen in Phase 4. Kein Codepfad darf + /// annehmen, dass hier immer ein Mensch steht. + erstelltVonId String? + /// Bleibt leer, solange der Lauf nur zugewiesen ist. Wird beim ersten Oeffnen + /// zur Bearbeitung gesetzt, zusammen mit dem Wechsel auf IN_ARBEIT. + gestartetAm DateTime? + abgeschlossenAm DateTime? + abgeschlossenVonId String? + archiviertAm DateTime? + archiviertVonId String? + erstelltAm DateTime @default(now()) + geaendertAm DateTime @updatedAt + + vorlagenVersion VorlagenVersion @relation(fields: [vorlagenVersionId], references: [id], onDelete: Restrict) + kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict) + mitarbeiter Mitarbeiter? @relation(fields: [mitarbeiterId], references: [id], onDelete: Restrict) + geraet Geraet? @relation(fields: [geraetId], references: [id], onDelete: Restrict) + abteilung Abteilung? @relation(fields: [abteilungId], references: [id], onDelete: Restrict) + erstelltVon Benutzer? @relation("LaufErstelltVon", fields: [erstelltVonId], references: [id], onDelete: Restrict) + zugewiesenAn Benutzer? @relation("LaufZugewiesenAn", fields: [zugewiesenAnId], references: [id], onDelete: Restrict) + abgeschlossenVon Benutzer? @relation("LaufAbgeschlossenVon", fields: [abgeschlossenVonId], references: [id], onDelete: Restrict) + archiviertVon Benutzer? @relation("LaufArchiviertVon", fields: [archiviertVonId], references: [id], onDelete: Restrict) + + ergebnisse Schrittergebnis[] + kommentare Kommentar[] + protokolle Protokoll[] + + @@index([kundeId, status]) + @@index([geraetId, status]) + @@index([mitarbeiterId]) + @@index([zugewiesenAnId, status]) + @@index([status, faelligAm]) + @@index([vorlagenVersionId]) + @@index([abteilungId]) +} + +model Schrittergebnis { + id String @id @default(cuid()) + laufId String + vorlagenSchrittId String + zustand SchrittZustand @default(OFFEN) + /// Der erfasste Wert, je nach Schritt-Typ. Nimmt ab Phase 4 auch das Ergebnis + /// einer Aktion auf: Log, IDs, Zeitstempel. + wert Json? + /// Pflicht bei NICHT_ZUTREFFEND und FEHLGESCHLAGEN, erzwungen im Service. + notiz String? + aktualisiertVonId String? + aktualisiertAm DateTime @updatedAt + erstelltAm DateTime @default(now()) + + lauf Lauf @relation(fields: [laufId], references: [id], onDelete: Restrict) + vorlagenSchritt VorlagenSchritt @relation(fields: [vorlagenSchrittId], references: [id], onDelete: Restrict) + /// Leer, wenn der Schritt von der Anwendung selbst gesetzt wurde. Kein + /// Codepfad darf annehmen, dass immer ein Mensch dahintersteht. + aktualisiertVon Benutzer? @relation(fields: [aktualisiertVonId], references: [id], onDelete: Restrict) + kommentare Kommentar[] + + @@unique([laufId, vorlagenSchrittId]) + @@index([laufId, zustand]) +} + +model Kommentar { + id String @id @default(cuid()) + laufId String + schrittergebnisId String? + benutzerId String + rumpf String + erstelltAm DateTime @default(now()) + + lauf Lauf @relation(fields: [laufId], references: [id], onDelete: Restrict) + schrittergebnis Schrittergebnis? @relation(fields: [schrittergebnisId], references: [id], onDelete: Restrict) + benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Restrict) + + @@index([laufId, erstelltAm]) + @@index([schrittergebnisId]) +} + +// ---- Protokoll und Sperren ------------------------------------------------- + +/// Jede Aenderung an einem Schrittergebnis, jeder Statuswechsel eines Laufs und +/// jedes Veroeffentlichen einer Vorlage landet hier. Wird nie geloescht. +model Protokoll { + id String @id @default(cuid()) + laufId String? + entitaet String + entitaetId String + aktion String + benutzerId String? + vorher Json? + nachher Json? + erstelltAm DateTime @default(now()) + + lauf Lauf? @relation(fields: [laufId], references: [id], onDelete: Restrict) + benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: Restrict) + + @@index([laufId, erstelltAm]) + @@index([entitaet, entitaetId]) + @@index([erstelltAm]) +} + +/// Sperre fuer Aufgaben, die nur einmal gleichzeitig laufen duerfen – zuerst der +/// LDAP-Abgleich. Liegt in der Datenbank, damit auch mehrere Instanzen der +/// Anwendung sich einig sind. +model AbgleichSperre { + schluessel String @id + gehaltenVon String + gehaltenSeit DateTime @default(now()) + laeuftAbAm DateTime + + @@index([laeuftAbAm]) } diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..b1394c5 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,111 @@ +import "dotenv/config"; +import { hash } from "@node-rs/argon2"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { PrismaClient } from "../src/generated/prisma/client.js"; +import { Betriebsmodus, Rolle } from "../src/generated/prisma/enums.js"; + +/** + * Grundbestand, ohne den die Anwendung nicht bedienbar waere: der Betriebsmodus, + * die eigene Organisation und ein Administrator. + * + * Der Seed ist idempotent – zweimal hintereinander aufgerufen aendert der zweite + * Lauf nichts. Er laeuft im Compose-Stand bewusst NICHT automatisch mit, sondern + * ist ein dokumentierter Handgriff (siehe README). + * + * Die sieben Vorlagen, die Beispiel-Mitarbeiter und die Demo-Kunden kommen mit + * #017 dazu. + */ + +const url = process.env["DATABASE_URL"]; +if (!url) { + throw new Error("DATABASE_URL ist nicht gesetzt. Siehe .env.example."); +} + +const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) }); + +/** Akzeptiert den im Auftrag dokumentierten Wert SERVICE als Alias. */ +function modusLesen(): Betriebsmodus { + const roh = (process.env["SEED_MODE"] ?? "INTERN").trim().toUpperCase(); + if (roh === "DIENSTLEISTUNG" || roh === "SERVICE") return Betriebsmodus.DIENSTLEISTUNG; + if (roh === "INTERN") return Betriebsmodus.INTERN; + throw new Error(`SEED_MODE kennt nur INTERN und DIENSTLEISTUNG (Alias SERVICE), nicht "${roh}".`); +} + +async function einstellungAnlegen(modus: Betriebsmodus): Promise { + // Nur anlegen, nicht ueberschreiben: ein spaeterer Seed-Lauf darf einen im + // Betrieb umgeschalteten Modus nicht zuruecksetzen. + const vorhanden = await prisma.einstellung.findUnique({ where: { id: "global" } }); + if (vorhanden) { + console.log(` Einstellung: vorhanden, Modus ${vorhanden.modus} bleibt`); + return; + } + await prisma.einstellung.create({ data: { id: "global", modus } }); + console.log(` Einstellung: angelegt, Modus ${modus}`); +} + +async function eigeneOrganisationAnlegen(): Promise { + const name = process.env["SEED_ORG_NAME"]?.trim() || "Eigene Organisation"; + const kuerzel = (process.env["SEED_ORG_KUERZEL"]?.trim() || "EIGEN").toUpperCase(); + if (!/^[A-Z0-9]{2,6}$/.test(kuerzel)) { + throw new Error(`SEED_ORG_KUERZEL muss 2 bis 6 Grossbuchstaben oder Ziffern sein, nicht "${kuerzel}".`); + } + + // Bewusst nicht aktualisieren: wer Name oder Kuerzel im Betrieb geaendert hat, + // soll das nicht beim naechsten Seed-Lauf zurueckbekommen. Geaenderte Werte in + // der Umgebung bleiben deshalb wirkungslos, sobald die Zeile existiert. + const vorhanden = await prisma.kunde.findFirst({ where: { istEigeneOrganisation: true } }); + if (vorhanden) { + console.log(` Eigene Organisation: vorhanden (${vorhanden.name})`); + return vorhanden.id; + } + const kunde = await prisma.kunde.create({ + data: { name, kuerzel, istEigeneOrganisation: true }, + }); + console.log(` Eigene Organisation: angelegt (${kunde.name}, ${kunde.kuerzel})`); + return kunde.id; +} + +async function adminAnlegen(): Promise { + const email = process.env["SEED_ADMIN_EMAIL"]?.trim().toLowerCase(); + const passwort = process.env["SEED_ADMIN_PASSWORD"]; + if (!email || !passwort) { + console.log(" Administrator: uebersprungen (SEED_ADMIN_EMAIL oder SEED_ADMIN_PASSWORD fehlt)"); + return; + } + + // Gesucht wird ueber die Adresse: eine geaenderte SEED_ADMIN_EMAIL legt also + // einen zweiten Administrator an, statt den ersten umzubenennen. Das ist + // gewollt – umbenennen waere eine Aenderung an einem Konto, das laengst benutzt + // sein kann. + const vorhanden = await prisma.benutzer.findUnique({ where: { email } }); + if (vorhanden) { + console.log(` Administrator: vorhanden (${email})`); + return; + } + // Das Passwort wird nur als argon2-Hash gespeichert, nie im Klartext. + await prisma.benutzer.create({ + data: { + email, + name: "Administrator", + rolle: Rolle.ADMIN, + passwortHash: await hash(passwort), + aktiv: true, + }, + }); + console.log(` Administrator: angelegt (${email})`); +} + +async function main(): Promise { + const modus = modusLesen(); + console.log("Seed laeuft:"); + await einstellungAnlegen(modus); + await eigeneOrganisationAnlegen(); + await adminAnlegen(); + console.log("Seed fertig."); +} + +try { + await main(); +} finally { + await prisma.$disconnect(); +} diff --git a/src/lib/geheimnisse.ts b/src/lib/geheimnisse.ts new file mode 100644 index 0000000..6490812 --- /dev/null +++ b/src/lib/geheimnisse.ts @@ -0,0 +1,136 @@ +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto"; + +/** + * Verschluesselt Geheimnisse, die die Anwendung im Klartext wieder braucht (#006): + * heute das LDAP-Bind-Passwort, spaeter Zugangsdaten von Connectoren. Fuer + * Anmeldepasswoerter von Benutzern ist das der falsche Weg -- die werden gehasht + * (argon2) und nie entschluesselt. + * + * AES-256-GCM, weil es neben der Verschluesselung auch die Unverfaelschtheit + * bezeugt: ein veraenderter Geheimtext wirft beim Entschluesseln, statt still + * Unsinn zu liefern. + */ +const ALGORITHMUS = "aes-256-gcm"; + +/** GCM ist auf 96 Bit IV ausgelegt; andere Laengen kosten eine zusaetzliche Ableitung. */ +const IV_LAENGE = 12; + +const AUTH_TAG_LAENGE = 16; + +const SCHLUESSEL_LAENGE = 32; + +/** + * Festes Salz, absichtlich hier im Code. + * + * Ein Salz soll Angriffe mit vorberechneten Tabellen ueber viele schwache + * Passwoerter hinweg verteuern. Hier gibt es genau ein Geheimnis, `APP_SECRET` + * ist mit `openssl rand -base64 48` erzeugt und damit hochentropisch -- eine + * Tabelle dafuer vorzuberechnen ist aussichtslos. Umgekehrt muss der Schluessel + * ueber Neustarts und Container-Neubauten hinweg genau derselbe sein, sonst sind + * alle gespeicherten Geheimnisse nach einem Deploy wertlos. Ein zufaelliges Salz + * muesste also ohnehin neben dem Geheimtext liegen und schuetzte nichts + * zusaetzlich. + */ +const SALZ = "laufzettel.geheimnisse.v1"; + +/** Ein Versionspraefix, damit ein spaeterer Formatwechsel alte Werte noch lesen kann. */ +const FORMAT_VERSION = "v1"; + +const MINDESTLAENGE = 32; + +const FORMAT_HINWEIS = `Erwartet wird '${FORMAT_VERSION}...' mit base64-kodierten Teilen.`; + +export class GeheimnisFehler extends Error { + constructor(nachricht: string, optionen?: ErrorOptions) { + super(nachricht, optionen); + this.name = "GeheimnisFehler"; + } +} + +/** + * Der Schluessel wird erst beim ersten Aufruf abgeleitet, nicht beim Import: + * `next build` laeuft ohne gesetztes `APP_SECRET`, und `scryptSync` ist + * absichtlich langsam. Gemerkt wird die Quelle mit, damit ein geaendertes + * `APP_SECRET` (in Tests, nach einer Rotation) nicht am Zwischenspeicher + * vorbeilaeuft. + */ +let abgeleitet: { quelle: string; schluessel: Buffer } | undefined; + +function schluesselHolen(): Buffer { + const quelle = process.env["APP_SECRET"] ?? ""; + if (quelle.length < MINDESTLAENGE) { + // Die tatsaechliche Laenge steht bewusst nicht in der Meldung: sie koennte in + // einem Log oder einer Fehlerseite landen und verriete etwas ueber das + // Geheimnis. Der Hinweis auf die Mindestlaenge genuegt zum Beheben. + throw new GeheimnisFehler( + `APP_SECRET fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` + + "Erzeugen mit: openssl rand -base64 48", + ); + } + if (abgeleitet?.quelle !== quelle) { + abgeleitet = { quelle, schluessel: scryptSync(quelle, SALZ, SCHLUESSEL_LAENGE) }; + } + return abgeleitet.schluessel; +} + +export function verschluesseln(klartext: string): string { + const schluessel = schluesselHolen(); + // Je Aufruf ein neuer IV. Ein zweites Mal denselben IV mit demselben + // Schluessel zu benutzen bricht GCM vollstaendig. + const iv = randomBytes(IV_LAENGE); + const chiffre = createCipheriv(ALGORITHMUS, schluessel, iv, { authTagLength: AUTH_TAG_LAENGE }); + const geheim = Buffer.concat([chiffre.update(klartext, "utf8"), chiffre.final()]); + // base64 verwendet keinen Punkt, der Punkt taugt also als Trenner. + return [ + FORMAT_VERSION, + iv.toString("base64"), + chiffre.getAuthTag().toString("base64"), + geheim.toString("base64"), + ].join("."); +} + +export function entschluesseln(geheimtext: string): string { + const teile = geheimtext.split("."); + const [version, ivTeil, tagTeil, datenTeil] = teile; + if ( + teile.length !== 4 || + version === undefined || + ivTeil === undefined || + tagTeil === undefined || + datenTeil === undefined + ) { + throw new GeheimnisFehler(`Geheimtext hat ein unbekanntes Format. ${FORMAT_HINWEIS}`); + } + if (version !== FORMAT_VERSION) { + throw new GeheimnisFehler(`Geheimtext traegt die unbekannte Version '${version}'. ${FORMAT_HINWEIS}`); + } + + const iv = Buffer.from(ivTeil, "base64"); + const authTag = Buffer.from(tagTeil, "base64"); + if (iv.length !== IV_LAENGE || authTag.length !== AUTH_TAG_LAENGE) { + throw new GeheimnisFehler( + `Geheimtext ist unvollstaendig: IV oder Auth-Tag haben die falsche Laenge. ${FORMAT_HINWEIS}`, + ); + } + + const schluessel = schluesselHolen(); + try { + const entschluessler = createDecipheriv(ALGORITHMUS, schluessel, iv, { + authTagLength: AUTH_TAG_LAENGE, + }); + entschluessler.setAuthTag(authTag); + const klar = Buffer.concat([ + entschluessler.update(Buffer.from(datenTeil, "base64")), + // `final` prueft das Auth-Tag und wirft, wenn Geheimtext oder Tag + // veraendert wurden oder der Schluessel nicht passt. + entschluessler.final(), + ]); + return klar.toString("utf8"); + } catch (ursache) { + throw new GeheimnisFehler( + "Geheimtext liess sich nicht entschluesseln: Auth-Tag passt nicht. Entweder wurde der " + + "Wert veraendert, oder APP_SECRET ist nicht mehr dasselbe wie beim Verschluesseln.", + { cause: ursache }, + ); + } +} diff --git a/src/lib/tagcode.ts b/src/lib/tagcode.ts new file mode 100644 index 0000000..b7cd85c --- /dev/null +++ b/src/lib/tagcode.ts @@ -0,0 +1,79 @@ +import { randomInt } from "node:crypto"; + +/** + * Erzeugt die Codes fuer die QR-Aufkleber (#006). + * + * Ein tagCode wird laut Konvention nie geaendert und nie wiederverwendet. Er wird + * abgetippt und vorgelesen, deshalb sind die verwechselbaren Zeichen entschaerft. + * Entscheidend ist nicht, jedes einzelne zu streichen, sondern jede Gruppe + * aufzuloesen: aus {0, O} ist beides weg, aus {1, I, L} sind 1 und I weg. Das L + * bleibt und ist gerade deshalb eindeutig -- seine Doppelgaenger gibt es hier nicht. + * Es steht mit dem Praefix `LZ-` ohnehin in jedem Code und haelt die Breite bei + * genau 32 Zeichen. 32^6 sind rund 1,07 Mrd. Moeglichkeiten -- genug, dass Ziehen + * und Nachfragen billiger ist als ein Zaehler. + */ +export const TAGCODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + +export const TAGCODE_PRAEFIX = "LZ-"; + +export const TAGCODE_LAENGE = 6; + +/** Mehr Versuche lohnen nicht: bei dieser Breite ist schon der erste praktisch immer frei. */ +export const TAGCODE_HOECHSTVERSUCHE = 10; + +/** + * Das Muster wird aus den Bausteinen gebaut, damit es nicht auseinanderlaeuft, + * wenn Alphabet oder Laenge sich aendern. Das Alphabet enthaelt keine + * Sonderzeichen der regulaeren Ausdruecke, ist also ohne Maskierung einsetzbar. + */ +export const TAGCODE_MUSTER: RegExp = new RegExp( + `^${TAGCODE_PRAEFIX}[${TAGCODE_ALPHABET}]{${TAGCODE_LAENGE}}$`, +); + +export class TagCodeFehler extends Error { + constructor(nachricht: string) { + super(nachricht); + this.name = "TagCodeFehler"; + } +} + +export function tagCodeErzeugen(): string { + let zeichen = ""; + for (let stelle = 0; stelle < TAGCODE_LAENGE; stelle += 1) { + // `randomInt` zieht kryptografisch sicher und gleichverteilt: es verwirft + // Werte am Rand, statt zu rechnen (`byte % 32` waere hier ebenfalls + // verzerrungsfrei, weil 32 die 256 teilt -- aber nur solange das Alphabet + // genau 32 Zeichen hat, und darauf soll sich niemand verlassen muessen). + // `charAt` statt Index, weil `noUncheckedIndexedAccess` sonst ein + // `undefined` einschleust, das hier nicht auftreten kann. + zeichen += TAGCODE_ALPHABET.charAt(randomInt(0, TAGCODE_ALPHABET.length)); + } + return TAGCODE_PRAEFIX + zeichen; +} + +export function istTagCode(wert: string): boolean { + return TAGCODE_MUSTER.test(wert); +} + +/** + * Zieht einen Code, der noch frei ist. + * + * Die Pruefung wird hereingereicht, damit dieses Modul framework- und + * datenbankfrei bleibt (siehe Schichtung in CLAUDE.md). Die letzte Instanz fuer + * Eindeutigkeit ist ohnehin der Unique-Index in der Datenbank; diese Schleife + * erspart nur den absehbaren Schreibfehler. + */ +export async function tagCodeEindeutigErzeugen( + istVergeben: (code: string) => Promise, +): Promise { + for (let versuch = 0; versuch < TAGCODE_HOECHSTVERSUCHE; versuch += 1) { + const code = tagCodeErzeugen(); + if (!(await istVergeben(code))) { + return code; + } + } + throw new TagCodeFehler( + `Nach ${TAGCODE_HOECHSTVERSUCHE} Versuchen war kein freier tagCode zu finden. ` + + "Das deutet auf eine defekte Vergabepruefung hin, nicht auf einen erschoepften Vorrat.", + ); +} diff --git a/tests/betriebsstand.test.ts b/tests/betriebsstand.test.ts index eec1768..988391c 100644 --- a/tests/betriebsstand.test.ts +++ b/tests/betriebsstand.test.ts @@ -49,10 +49,12 @@ describe("Betriebsstand: docker-compose.yml", () => { }); describe("Betriebsstand: Entwicklungsdatenbank", () => { - it("laeuft auf 5433, damit ein lokales 5432 frei bleibt", () => { + it("bindet nur auf localhost und auf einen eigenen, umstellbaren Port", () => { const stand = compose("docker-compose.dev.yml") as unknown as { services: Record; }; - expect(stand.services["postgres"]?.ports).toEqual(["127.0.0.1:5433:5432"]); + // Der Standard ist 5434, weil 5432 lokal und 5433 von einem anderen Projekt + // belegt sein koennen. Umstellbar ueber DEV_DB_PORT. + expect(stand.services["postgres"]?.ports).toEqual(["127.0.0.1:${DEV_DB_PORT:-5434}:5432"]); }); }); diff --git a/tests/datenmodell.test.ts b/tests/datenmodell.test.ts new file mode 100644 index 0000000..c7bdb48 --- /dev/null +++ b/tests/datenmodell.test.ts @@ -0,0 +1,666 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; + +/** + * Haelt die Regeln fest, die in der Datenbank selbst stehen (#006). + * + * Sie stehen dort und nicht nur in der Service-Schicht, damit sie auch dann + * greifen, wenn jemand an ihr vorbei schreibt – per Migration, per psql oder aus + * einem spaeteren Connector. Die Service-Schicht prueft dieselben Regeln noch + * einmal, aber mit verstaendlicheren Meldungen. + */ + +async function kundeAnlegen(kuerzel = "TST", eigene = false) { + return testPrisma.kunde.create({ + data: { name: `Kunde ${kuerzel}`, kuerzel, istEigeneOrganisation: eigene }, + }); +} + +async function benutzerAnlegen(email = "techniker@firma.example") { + return testPrisma.benutzer.create({ + data: { email, name: "Techniker", rolle: "TECHNIKER" }, + }); +} + +/** Kleinste Vorlage, die ausreicht, um einen Lauf daran zu haengen. */ +async function veroeffentlichteVersionAnlegen() { + const vorlage = await testPrisma.vorlage.create({ data: { name: "Probe" } }); + return testPrisma.vorlagenVersion.create({ + // veroeffentlichtAm ist Pflicht, sobald der Status VEROEFFENTLICHT ist – + // eine Bedingung in der Datenbank haelt das fest. + data: { + vorlageId: vorlage.id, + version: 1, + status: "VEROEFFENTLICHT", + veroeffentlichtAm: new Date(), + }, + }); +} + +describe.runIf(datenbankVerfuegbar)("Datenmodell: Regeln in der Datenbank", () => { + beforeEach(async () => { + await datenbankLeeren(); + }); + + describe("Kunde", () => { + it("laesst nur eine eigene Organisation zu", async () => { + await kundeAnlegen("EIGEN", true); + // Gemeint ist der partielle Index, nicht irgendeine Ablehnung. + await expect(kundeAnlegen("ZWEIT", true)).rejects.toThrow(/Kunde_eigene_organisation_einmalig/); + }); + + it("laesst beliebig viele gewoehnliche Kunden zu", async () => { + await kundeAnlegen("EIGEN", true); + await kundeAnlegen("MUS"); + await kundeAnlegen("REI"); + expect(await testPrisma.kunde.count()).toBe(3); + }); + + it("weist ein Kuerzel ausserhalb von 2 bis 6 Zeichen zurueck", async () => { + await expect(kundeAnlegen("X")).rejects.toThrow(/Kunde_kuerzel_form/); + await expect(kundeAnlegen("ZULANG7")).rejects.toThrow(/Kunde_kuerzel_form/); + }); + + it("weist ein Kuerzel mit Kleinbuchstaben oder Sonderzeichen zurueck", async () => { + await expect(kundeAnlegen("mus")).rejects.toThrow(); + await expect(kundeAnlegen("MU-S")).rejects.toThrow(); + }); + + it("haelt das Kuerzel eindeutig", async () => { + await kundeAnlegen("MUS"); + await expect(kundeAnlegen("MUS")).rejects.toThrow(); + }); + }); + + describe("Einstellung", () => { + it("laesst nur die eine Zeile mit der Kennung global zu", async () => { + await testPrisma.einstellung.create({ data: { id: "global" } }); + await expect(testPrisma.einstellung.create({ data: { id: "zweite" } })).rejects.toThrow(); + }); + }); + + describe("Geraet", () => { + it("erlaubt einen vorgedruckten Tag ohne Hostname und ohne Bezeichnung", async () => { + const kunde = await kundeAnlegen(); + const tag = await testPrisma.geraet.create({ + data: { kundeId: kunde.id, tagCode: "LZ-7K3F9Q" }, + }); + expect(tag.registriertAm).toBeNull(); + expect(tag.hostname).toBeNull(); + }); + + it("verlangt beim Registrieren einen Hostname oder eine Bezeichnung", async () => { + const kunde = await kundeAnlegen(); + await expect( + testPrisma.geraet.create({ + data: { kundeId: kunde.id, tagCode: "LZ-7K3F9Q", registriertAm: new Date() }, + }), + ).rejects.toThrow(/Geraet_registriert_braucht_namen/); + }); + + it("wertet reine Leerzeichen nicht als Bezeichnung", async () => { + const kunde = await kundeAnlegen(); + await expect( + testPrisma.geraet.create({ + data: { + kundeId: kunde.id, + tagCode: "LZ-7K3F9Q", + bezeichnung: " ", + registriertAm: new Date(), + }, + }), + ).rejects.toThrow(); + }); + + it("nimmt ein registriertes Geraet mit Bezeichnung statt Hostname an", async () => { + const kunde = await kundeAnlegen(); + const raum = await testPrisma.geraet.create({ + data: { + kundeId: kunde.id, + tagCode: "LZ-M2XH4T", + bezeichnung: "Serverschrank Keller", + typ: "RAUM", + registriertAm: new Date(), + }, + }); + expect(raum.bezeichnung).toBe("Serverschrank Keller"); + }); + + it("weist einen tagCode in falscher Form zurueck", async () => { + const kunde = await kundeAnlegen(); + for (const falsch of ["7K3F9Q", "LZ-7K3F9", "lz-7k3f9q", "LZ-7K3F9O", "LZ-7K3F91"]) { + await expect( + testPrisma.geraet.create({ data: { kundeId: kunde.id, tagCode: falsch } }), + ).rejects.toThrow(); + } + }); + + it("haelt den tagCode ueber alle Kunden hinweg eindeutig", async () => { + const einer = await kundeAnlegen("MUS"); + const anderer = await kundeAnlegen("REI"); + await testPrisma.geraet.create({ data: { kundeId: einer.id, tagCode: "LZ-7K3F9Q" } }); + await expect( + testPrisma.geraet.create({ data: { kundeId: anderer.id, tagCode: "LZ-7K3F9Q" } }), + ).rejects.toThrow(); + }); + }); + + describe("Mitarbeiter", () => { + it("haelt die externe Kennung je Quelle eindeutig", async () => { + const kunde = await kundeAnlegen(); + const quelle = await testPrisma.verzeichnisquelle.create({ + data: { + kundeId: kunde.id, + name: "Firmen-AD", + url: "ldaps://dc01.firma.local", + bindDn: "CN=Dienst,DC=firma,DC=local", + bindPasswortVerschluesselt: "v1.probe", + basisDn: "DC=firma,DC=local", + filter: "(objectClass=user)", + attributZuordnung: {}, + }, + }); + const stammdaten = { + kundeId: kunde.id, + quelleId: quelle.id, + externeId: "guid-1", + vorname: "Maria", + nachname: "Huber", + anzeigename: "Maria Huber", + herkunft: "ABGLEICH" as const, + }; + await testPrisma.mitarbeiter.create({ data: stammdaten }); + await expect(testPrisma.mitarbeiter.create({ data: stammdaten })).rejects.toThrow(); + }); + + it("laesst beliebig viele manuell angelegte Personen ohne Quelle zu", async () => { + const kunde = await kundeAnlegen(); + // Postgres behandelt NULL als verschieden, deshalb greift der eindeutige + // Index hier nicht – genau so ist es gewollt. + for (const name of ["Ada", "Grace", "Alan"]) { + await testPrisma.mitarbeiter.create({ + data: { kundeId: kunde.id, vorname: name, nachname: "Test", anzeigename: name }, + }); + } + expect(await testPrisma.mitarbeiter.count()).toBe(3); + }); + }); + + describe("Abteilung", () => { + it("haelt den Namen je Kunde eindeutig, aber nicht ueber Kunden hinweg", async () => { + const einer = await kundeAnlegen("MUS"); + const anderer = await kundeAnlegen("REI"); + await testPrisma.abteilung.create({ data: { kundeId: einer.id, name: "Buchhaltung" } }); + await expect( + testPrisma.abteilung.create({ data: { kundeId: einer.id, name: "Buchhaltung" } }), + ).rejects.toThrow(); + const beimAnderen = await testPrisma.abteilung.create({ + data: { kundeId: anderer.id, name: "Buchhaltung" }, + }); + expect(beimAnderen.name).toBe("Buchhaltung"); + }); + }); + + describe("Lauf", () => { + it("laesst einen Lauf ohne Mitarbeiter und ohne Geraet zu", async () => { + const kunde = await kundeAnlegen(); + const benutzer = await benutzerAnlegen(); + const version = await veroeffentlichteVersionAnlegen(); + const lauf = await testPrisma.lauf.create({ + data: { + vorlagenVersionId: version.id, + titel: "Patchday 2026-10", + kundeId: kunde.id, + erstelltVonId: benutzer.id, + }, + }); + expect(lauf.status).toBe("OFFEN"); + expect(lauf.gestartetAm).toBeNull(); + }); + + it("weist Mitarbeiter und Freitextname zugleich zurueck", async () => { + const kunde = await kundeAnlegen(); + const benutzer = await benutzerAnlegen(); + const version = await veroeffentlichteVersionAnlegen(); + const person = await testPrisma.mitarbeiter.create({ + data: { kundeId: kunde.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber" }, + }); + await expect( + testPrisma.lauf.create({ + data: { + vorlagenVersionId: version.id, + titel: "Onboarding", + kundeId: kunde.id, + erstelltVonId: benutzer.id, + mitarbeiterId: person.id, + mitarbeiterName: "Maria Huber", + }, + }), + ).rejects.toThrow(); + }); + + it("verhindert das Loeschen eines Geraets, an dem ein Lauf haengt", async () => { + const kunde = await kundeAnlegen(); + const benutzer = await benutzerAnlegen(); + const version = await veroeffentlichteVersionAnlegen(); + const geraet = await testPrisma.geraet.create({ + data: { + kundeId: kunde.id, + tagCode: "LZ-7K3F9Q", + hostname: "PC-001", + typ: "PC", + registriertAm: new Date(), + }, + }); + await testPrisma.lauf.create({ + data: { + vorlagenVersionId: version.id, + titel: "PC Neuinstallation", + kundeId: kunde.id, + erstelltVonId: benutzer.id, + geraetId: geraet.id, + }, + }); + await expect(testPrisma.geraet.delete({ where: { id: geraet.id } })).rejects.toThrow(); + }); + + it("verhindert das Loeschen einer Version, auf die ein Lauf verweist", async () => { + const kunde = await kundeAnlegen(); + const benutzer = await benutzerAnlegen(); + const version = await veroeffentlichteVersionAnlegen(); + await testPrisma.lauf.create({ + data: { + vorlagenVersionId: version.id, + titel: "Probe", + kundeId: kunde.id, + erstelltVonId: benutzer.id, + }, + }); + await expect(testPrisma.vorlagenVersion.delete({ where: { id: version.id } })).rejects.toThrow(); + }); + }); + + describe("Schrittergebnis", () => { + it("laesst je Lauf und Schritt nur ein Ergebnis zu", async () => { + const kunde = await kundeAnlegen(); + const benutzer = await benutzerAnlegen(); + const version = await veroeffentlichteVersionAnlegen(); + const abschnitt = await testPrisma.vorlagenAbschnitt.create({ + data: { versionId: version.id, titel: "Vorbereitung" }, + }); + const schritt = await testPrisma.vorlagenSchritt.create({ + data: { abschnittId: abschnitt.id, titel: "Ticket erfasst", pflicht: true }, + }); + const lauf = await testPrisma.lauf.create({ + data: { + vorlagenVersionId: version.id, + titel: "Probe", + kundeId: kunde.id, + erstelltVonId: benutzer.id, + }, + }); + await testPrisma.schrittergebnis.create({ + data: { laufId: lauf.id, vorlagenSchrittId: schritt.id }, + }); + await expect( + testPrisma.schrittergebnis.create({ + data: { laufId: lauf.id, vorlagenSchrittId: schritt.id }, + }), + ).rejects.toThrow(); + }); + }); + + describe("Vorlagen-Version", () => { + it("haelt die Versionsnummer je Vorlage eindeutig", async () => { + const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } }); + await testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1 } }); + await expect( + testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1 } }), + ).rejects.toThrow(); + }); + + it("raeumt Abschnitte, Schritte und Variablen mit der Version ab", async () => { + // Ein Entwurf darf als Ganzes verworfen werden; nur deshalb steht hier + // Cascade und nicht Restrict. + const version = await veroeffentlichteVersionAnlegen(); + const abschnitt = await testPrisma.vorlagenAbschnitt.create({ + data: { versionId: version.id, titel: "Vorbereitung" }, + }); + await testPrisma.vorlagenSchritt.create({ + data: { abschnittId: abschnitt.id, titel: "Ticket erfasst" }, + }); + await testPrisma.vorlagenVariable.create({ + data: { versionId: version.id, schluessel: "os_version", beschriftung: "Betriebssystem" }, + }); + + await testPrisma.vorlagenVersion.delete({ where: { id: version.id } }); + + expect(await testPrisma.vorlagenAbschnitt.count()).toBe(0); + expect(await testPrisma.vorlagenSchritt.count()).toBe(0); + expect(await testPrisma.vorlagenVariable.count()).toBe(0); + }); + }); +}); + +/** + * Die Zusicherungen, die erst der unabhaengige Review sichtbar gemacht hat: + * Mandantengrenze, Unversehrtheit des Archivs und die Vorleistung fuer Phase 4. + */ +describe.runIf(datenbankVerfuegbar)("Datenmodell: Grenzen und Zusicherungen", () => { + beforeEach(async () => { + await datenbankLeeren(); + }); + + describe("Mandantengrenze", () => { + it("laesst einen Lauf kein Geraet eines fremden Kunden waehlen", async () => { + const einer = await kundeAnlegen("MUS"); + const anderer = await kundeAnlegen("REI"); + const benutzer = await benutzerAnlegen(); + const version = await veroeffentlichteVersionAnlegen(); + const fremdesGeraet = await testPrisma.geraet.create({ + data: { + kundeId: anderer.id, + tagCode: "LZ-7K3F9Q", + hostname: "REI-PC-001", + registriertAm: new Date(), + }, + }); + await expect( + testPrisma.lauf.create({ + data: { + vorlagenVersionId: version.id, + titel: "Quer ueber Kunden", + kundeId: einer.id, + erstelltVonId: benutzer.id, + geraetId: fremdesGeraet.id, + }, + }), + ).rejects.toThrow(/Lauf_geraet_gleicher_kunde/); + }); + + it("laesst einen Lauf keine Person eines fremden Kunden waehlen", async () => { + const einer = await kundeAnlegen("MUS"); + const anderer = await kundeAnlegen("REI"); + const benutzer = await benutzerAnlegen(); + const version = await veroeffentlichteVersionAnlegen(); + const fremdePerson = await testPrisma.mitarbeiter.create({ + data: { kundeId: anderer.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber" }, + }); + await expect( + testPrisma.lauf.create({ + data: { + vorlagenVersionId: version.id, + titel: "Quer ueber Kunden", + kundeId: einer.id, + erstelltVonId: benutzer.id, + mitarbeiterId: fremdePerson.id, + }, + }), + ).rejects.toThrow(/Lauf_mitarbeiter_gleicher_kunde/); + }); + + it("laesst einem Geraet keinen Besitzer eines fremden Kunden geben", async () => { + const einer = await kundeAnlegen("MUS"); + const anderer = await kundeAnlegen("REI"); + const fremdePerson = await testPrisma.mitarbeiter.create({ + data: { kundeId: anderer.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber" }, + }); + await expect( + testPrisma.geraet.create({ + data: { + kundeId: einer.id, + tagCode: "LZ-7K3F9Q", + hostname: "MUS-PC-001", + registriertAm: new Date(), + mitarbeiterId: fremdePerson.id, + }, + }), + ).rejects.toThrow(/Geraet_besitzer_gleicher_kunde/); + }); + }); + + describe("Unversehrtheit des Archivs", () => { + it("verhindert, dass ein Lauf seine Schrittergebnisse mitnimmt", async () => { + const kunde = await kundeAnlegen(); + const benutzer = await benutzerAnlegen(); + const version = await veroeffentlichteVersionAnlegen(); + const abschnitt = await testPrisma.vorlagenAbschnitt.create({ + data: { versionId: version.id, titel: "Vorbereitung" }, + }); + const schritt = await testPrisma.vorlagenSchritt.create({ + data: { abschnittId: abschnitt.id, titel: "Ticket erfasst" }, + }); + const lauf = await testPrisma.lauf.create({ + data: { + vorlagenVersionId: version.id, + titel: "Probe", + kundeId: kunde.id, + erstelltVonId: benutzer.id, + }, + }); + await testPrisma.schrittergebnis.create({ + data: { laufId: lauf.id, vorlagenSchrittId: schritt.id }, + }); + // Ohne diese Sperre verschwaende ein DELETE auf Lauf das halbe Archiv. + await expect(testPrisma.lauf.delete({ where: { id: lauf.id } })).rejects.toThrow(); + expect(await testPrisma.schrittergebnis.count()).toBe(1); + }); + + it("verhindert, dass ein Protokolleintrag seinen Urheber verliert", async () => { + const benutzer = await benutzerAnlegen(); + await testPrisma.protokoll.create({ + data: { + entitaet: "Benutzer", + entitaetId: benutzer.id, + aktion: "angemeldet", + benutzerId: benutzer.id, + }, + }); + await expect(testPrisma.benutzer.delete({ where: { id: benutzer.id } })).rejects.toThrow(); + }); + }); + + describe("Vorlagen-Versionierung", () => { + it("laesst je Vorlage nur einen Entwurf zu", async () => { + const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } }); + await testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1 } }); + await expect( + testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 2 } }), + ).rejects.toThrow(/VorlagenVersion_ein_entwurf/); + }); + + it("verlangt beim Veroeffentlichen einen Zeitpunkt", async () => { + const vorlage = await testPrisma.vorlage.create({ data: { name: "Probe" } }); + await expect( + testPrisma.vorlagenVersion.create({ + data: { vorlageId: vorlage.id, version: 1, status: "VEROEFFENTLICHT" }, + }), + ).rejects.toThrow(/VorlagenVersion_veroeffentlicht_mit_zeitpunkt/); + }); + }); + + describe("Status und Zeitstempel", () => { + it("verlangt einen Startzeitpunkt, sobald der Lauf nicht mehr offen ist", async () => { + const kunde = await kundeAnlegen(); + const benutzer = await benutzerAnlegen(); + const version = await veroeffentlichteVersionAnlegen(); + await expect( + testPrisma.lauf.create({ + data: { + vorlagenVersionId: version.id, + titel: "Probe", + kundeId: kunde.id, + erstelltVonId: benutzer.id, + status: "IN_ARBEIT", + }, + }), + ).rejects.toThrow(/Lauf_begonnen_mit_zeitpunkt/); + }); + + it("verlangt beim Archivieren Abschluss- und Archivzeitpunkt", async () => { + const kunde = await kundeAnlegen(); + const benutzer = await benutzerAnlegen(); + const version = await veroeffentlichteVersionAnlegen(); + await expect( + testPrisma.lauf.create({ + data: { + vorlagenVersionId: version.id, + titel: "Probe", + kundeId: kunde.id, + erstelltVonId: benutzer.id, + status: "ARCHIVIERT", + gestartetAm: new Date(), + }, + }), + ).rejects.toThrow(/Lauf_abgeschlossen_mit_zeitpunkt/); + }); + }); + + describe("Schrittergebnis", () => { + it("verlangt eine Begruendung bei nicht zutreffend und fehlgeschlagen", async () => { + const kunde = await kundeAnlegen(); + const benutzer = await benutzerAnlegen(); + const version = await veroeffentlichteVersionAnlegen(); + const abschnitt = await testPrisma.vorlagenAbschnitt.create({ + data: { versionId: version.id, titel: "Vorbereitung" }, + }); + const schritt = await testPrisma.vorlagenSchritt.create({ + data: { abschnittId: abschnitt.id, titel: "Ticket erfasst" }, + }); + const lauf = await testPrisma.lauf.create({ + data: { + vorlagenVersionId: version.id, + titel: "Probe", + kundeId: kunde.id, + erstelltVonId: benutzer.id, + }, + }); + for (const zustand of ["NICHT_ZUTREFFEND", "FEHLGESCHLAGEN"] as const) { + await expect( + testPrisma.schrittergebnis.create({ + data: { laufId: lauf.id, vorlagenSchrittId: schritt.id, zustand }, + }), + ).rejects.toThrow(/Schrittergebnis_begruendung_noetig/); + } + const mitNotiz = await testPrisma.schrittergebnis.create({ + data: { + laufId: lauf.id, + vorlagenSchrittId: schritt.id, + zustand: "NICHT_ZUTREFFEND", + notiz: "Geraet hat kein TPM.", + }, + }); + expect(mitNotiz.notiz).toBe("Geraet hat kein TPM."); + }); + + it("nimmt ein Aktionsergebnis verlustfrei auf", async () => { + // Vorleistung fuer Phase 4: der Wert muss Log, Kennungen und Zeitstempel + // einer ausgefuehrten Aktion tragen koennen, nicht nur einen Haken. + const kunde = await kundeAnlegen(); + const benutzer = await benutzerAnlegen(); + const version = await veroeffentlichteVersionAnlegen(); + const abschnitt = await testPrisma.vorlagenAbschnitt.create({ + data: { versionId: version.id, titel: "Konto anlegen" }, + }); + const schritt = await testPrisma.vorlagenSchritt.create({ + data: { abschnittId: abschnitt.id, titel: "New-ADUser", typ: "BEFEHL" }, + }); + const lauf = await testPrisma.lauf.create({ + data: { + vorlagenVersionId: version.id, + titel: "Onboarding", + kundeId: kunde.id, + erstelltVonId: benutzer.id, + }, + }); + const ergebnis = { + connector: "ldap", + ausgefuehrtAm: "2026-09-25T08:15:00.000Z", + dauerMs: 842, + objekte: [{ typ: "Benutzer", dn: "CN=Maria Huber,OU=Buchhaltung,DC=firma,DC=local" }], + log: ["Verbindung aufgebaut", "Konto angelegt", "Gruppen zugewiesen"], + erfolgreich: true, + }; + const gespeichert = await testPrisma.schrittergebnis.create({ + data: { + laufId: lauf.id, + vorlagenSchrittId: schritt.id, + zustand: "ERLEDIGT", + wert: ergebnis, + }, + }); + expect(gespeichert.wert).toEqual(ergebnis); + }); + }); + + describe("Vorleistung fuer Phase 4", () => { + it("laesst einen Lauf ohne erstellenden Menschen zu", async () => { + // Wiederkehrende Pruefungen und der Start per Webhook legen Laeufe an, + // hinter denen kein Benutzer steht. + const kunde = await kundeAnlegen(); + const version = await veroeffentlichteVersionAnlegen(); + const systemlauf = await testPrisma.lauf.create({ + data: { vorlagenVersionId: version.id, titel: "Jaehrliche Pruefung", kundeId: kunde.id }, + }); + expect(systemlauf.erstelltVonId).toBeNull(); + }); + }); + + describe("Vergabebuch fuer tagCodes", () => { + it("haelt einen vergebenen Code fest, auch wenn das Geraet verschwindet", async () => { + const kunde = await kundeAnlegen(); + await testPrisma.tagCodeVergabe.create({ data: { tagCode: "LZ-7K3F9Q" } }); + const geraet = await testPrisma.geraet.create({ + data: { kundeId: kunde.id, tagCode: "LZ-7K3F9Q" }, + }); + await testPrisma.geraet.delete({ where: { id: geraet.id } }); + + expect(await testPrisma.geraet.count()).toBe(0); + // Der Code bleibt vergeben – ein alter Aufkleber darf nie auf ein neues + // Objekt zeigen. + expect(await testPrisma.tagCodeVergabe.count()).toBe(1); + await expect(testPrisma.tagCodeVergabe.create({ data: { tagCode: "LZ-7K3F9Q" } })).rejects.toThrow(); + }); + + it("weist einen Code in falscher Form zurueck", async () => { + await expect(testPrisma.tagCodeVergabe.create({ data: { tagCode: "LZ-7K3F9O" } })).rejects.toThrow( + /TagCodeVergabe_form/, + ); + }); + }); + + describe("Verzeichnisquelle", () => { + it("weist ein Bind-Passwort ohne Versionspraefix zurueck", async () => { + const kunde = await kundeAnlegen(); + await expect( + testPrisma.verzeichnisquelle.create({ + data: { + kundeId: kunde.id, + name: "Firmen-AD", + url: "ldaps://dc01.firma.local", + bindDn: "CN=Dienst,DC=firma,DC=local", + // So saehe ein versehentlich im Klartext gespeichertes Passwort aus. + bindPasswortVerschluesselt: "GeheimesPasswort123", + basisDn: "DC=firma,DC=local", + filter: "(objectClass=user)", + attributZuordnung: {}, + }, + }), + ).rejects.toThrow(/Verzeichnisquelle_bindpasswort_verschluesselt/); + }); + }); + + describe("Abgleich-Sperre", () => { + it("laesst dieselbe Sperre nicht zweimal halten", async () => { + const laeuftAbAm = new Date(Date.now() + 60_000); + await testPrisma.abgleichSperre.create({ + data: { schluessel: "abgleich:quelle-1", gehaltenVon: "instanz-a", laeuftAbAm }, + }); + await expect( + testPrisma.abgleichSperre.create({ + data: { schluessel: "abgleich:quelle-1", gehaltenVon: "instanz-b", laeuftAbAm }, + }), + ).rejects.toThrow(); + }); + }); +}); diff --git a/tests/geheimnisse.test.ts b/tests/geheimnisse.test.ts new file mode 100644 index 0000000..58784eb --- /dev/null +++ b/tests/geheimnisse.test.ts @@ -0,0 +1,168 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { GeheimnisFehler, entschluesseln, verschluesseln } from "@/lib/geheimnisse"; + +// `tests/setup-env.ts` setzt einen Standardwert. Tests, die daran drehen, stellen +// ihn danach wieder her, damit die Reihenfolge der Tests nichts aendert. +const APP_SECRET_VORHER = process.env["APP_SECRET"]; + +function appSecretSetzen(wert: string): void { + process.env["APP_SECRET"] = wert; +} + +afterEach(() => { + if (APP_SECRET_VORHER === undefined) { + delete process.env["APP_SECRET"]; + } else { + process.env["APP_SECRET"] = APP_SECRET_VORHER; + } +}); + +/** Aendert ein Byte im angegebenen Teil des Geheimtexts, ohne das Format zu verletzen. */ +function teilVerfaelschen(geheimtext: string, index: number): string { + const teile = geheimtext.split("."); + const teil = teile[index]; + if (teil === undefined || teil.length === 0) { + throw new Error(`Teil ${index} fehlt im Geheimtext.`); + } + // Das erste base64-Zeichen tragt die oberen Bits des ersten Bytes, eine + // Aenderung dort wirkt also sicher -- anders als am gepolsterten Ende. + const ersetzt = teil.startsWith("A") ? "B" : "A"; + teile[index] = ersetzt + teil.slice(1); + return teile.join("."); +} + +describe("Geheimnisse verschluesseln und entschluesseln", () => { + it("ist verlustfrei fuer einfachen Text", () => { + const klartext = "bind-passwort-123"; + expect(entschluesseln(verschluesseln(klartext))).toBe(klartext); + }); + + it.each([ + ["Umlaute und Eszett", "Grueße aus Bad Münstereifel: ÄÖÜäöüß"], + ["Emoji", "Schlüssel 🔐 im Tresor 🗄️ mit Häkchen ✅"], + ["leerer Text", ""], + ["nur Leerzeichen", " "], + ["Zeilenumbrueche", "erste Zeile\nzweite Zeile\r\ndritte"], + ["Punkte im Klartext", "v1.das.sieht.aus.wie.ein.geheimtext"], + ["base64-artiger Text", "YWJjZGVmZ2g="], + ])("ist verlustfrei fuer %s", (_fall, klartext) => { + expect(entschluesseln(verschluesseln(klartext))).toBe(klartext); + }); + + it("ist verlustfrei fuer lange Texte", () => { + const lang = "Zertifikat-".repeat(10_000); + const geheim = verschluesseln(lang); + + expect(geheim.length).toBeGreaterThan(lang.length / 2); + expect(entschluesseln(geheim)).toBe(lang); + }); + + it("traegt das Versionspraefix und vier durch Punkt getrennte Teile", () => { + const teile = verschluesseln("etwas").split("."); + + expect(teile).toHaveLength(4); + expect(teile[0]).toBe("v1"); + // 12 Byte IV und 16 Byte Auth-Tag, base64 kodiert. + expect(Buffer.from(teile[1] ?? "", "base64")).toHaveLength(12); + expect(Buffer.from(teile[2] ?? "", "base64")).toHaveLength(16); + }); + + it("liefert fuer denselben Klartext zwei verschiedene Geheimtexte", () => { + // Jeder Aufruf zieht einen eigenen IV; sonst waere an gleichen Geheimtexten + // ablesbar, welche Konten dasselbe Passwort benutzen. + const klartext = "immer-dasselbe-passwort"; + const erster = verschluesseln(klartext); + const zweiter = verschluesseln(klartext); + + expect(erster).not.toBe(zweiter); + expect(erster.split(".")[1]).not.toBe(zweiter.split(".")[1]); + expect(entschluesseln(erster)).toBe(klartext); + expect(entschluesseln(zweiter)).toBe(klartext); + }); + + it("enthaelt den Klartext nicht im Ergebnis", () => { + const klartext = "geheimes-bind-passwort"; + expect(verschluesseln(klartext)).not.toContain(klartext); + }); +}); + +describe("Geheimnisse gegen Verfaelschung", () => { + it("wirft, wenn ein Byte im Geheimtext veraendert wurde", () => { + const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 3); + + expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler); + expect(() => entschluesseln(verfaelscht)).toThrow(/Auth-Tag passt nicht/); + }); + + it("wirft, wenn das Auth-Tag veraendert wurde", () => { + const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 2); + expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler); + }); + + it("wirft, wenn der IV veraendert wurde", () => { + const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 1); + expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler); + }); + + it("wirft, wenn der Geheimtext abgeschnitten wurde", () => { + const geheim = verschluesseln("bind-passwort-123"); + const ohneLetztenTeil = geheim.split(".").slice(0, 3).join("."); + + expect(() => entschluesseln(ohneLetztenTeil)).toThrow(GeheimnisFehler); + expect(() => entschluesseln(ohneLetztenTeil)).toThrow(/unbekanntes Format/); + }); + + it.each([ + ["leerer Wert", ""], + ["Klartext ohne Trenner", "einfach-nur-text"], + ["zu wenige Teile", "v1.abc"], + ["zu viele Teile", "v1.a.b.c.d"], + ["unbekannte Version", "v9.AAAAAAAAAAAAAAAA.AAAAAAAAAAAAAAAAAAAAAA==.AAAA"], + ["IV zu kurz", "v1.AAAA.AAAAAAAAAAAAAAAAAAAAAA==.AAAA"], + ["Auth-Tag zu kurz", "v1.AAAAAAAAAAAAAAAA.AAAA.AAAA"], + ])("wirft mit verstaendlicher Meldung bei %s", (_fall, geheimtext) => { + let gefangen: unknown; + try { + entschluesseln(geheimtext); + } catch (fehler) { + gefangen = fehler; + } + + expect(gefangen).toBeInstanceOf(GeheimnisFehler); + expect((gefangen as GeheimnisFehler).message).toMatch(/v1\.\.\./); + }); + + it("wirft, wenn APP_SECRET zwischen Verschluesseln und Entschluesseln wechselt", () => { + const geheim = verschluesseln("bind-passwort-123"); + appSecretSetzen("ein-voellig-anderes-geheimnis-mit-genug-zeichen"); + + expect(() => entschluesseln(geheim)).toThrow(GeheimnisFehler); + }); +}); + +describe("APP_SECRET pruefen", () => { + it.each([ + ["fehlendes", ""], + ["viel zu kurzes", "kurz"], + ["knapp zu kurzes (31 Zeichen)", "a".repeat(31)], + ])("wirft bei %s APP_SECRET mit Hinweis auf openssl", (_fall, wert) => { + appSecretSetzen(wert); + + expect(() => verschluesseln("etwas")).toThrow(GeheimnisFehler); + expect(() => verschluesseln("etwas")).toThrow(/openssl rand -base64 48/); + expect(() => verschluesseln("etwas")).toThrow(/mindestens 32 Zeichen/); + }); + + it("nimmt genau 32 Zeichen an", () => { + appSecretSetzen("b".repeat(32)); + + expect(entschluesseln(verschluesseln("grenzfall"))).toBe("grenzfall"); + }); + + it("stellt den Standardwert aus setup-env wieder her", () => { + // Haelt fest, dass die Aufraeumroutine oben greift -- sonst wuerden die + // Tests je nach Reihenfolge unterschiedlich ausgehen. + expect(process.env["APP_SECRET"]).toBe(APP_SECRET_VORHER); + expect(entschluesseln(verschluesseln("nach dem Aufraeumen"))).toBe("nach dem Aufraeumen"); + }); +}); diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index f6989ed..8aae4c3 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -3,7 +3,18 @@ import { PrismaClient } from "@/generated/prisma/client"; const url = process.env["TEST_DATABASE_URL"] ?? process.env["DATABASE_URL"]; -/** Ohne Testdatenbank ueberspringen sich die Datenbanktests statt fehlzuschlagen. */ +/** + * Ohne Testdatenbank ueberspringen sich die Datenbanktests, damit ein schneller + * Durchlauf ohne Docker moeglich bleibt. + * + * In CI waere das gefaehrlich: `pnpm test` ist das Tor vor jedem Commit, und ein + * lautloses Ueberspringen saehe genauso gruen aus wie ein bestandener Lauf. + * Deshalb dort lieber hart abbrechen. + */ +if (!url && process.env["CI"]) { + throw new Error("TEST_DATABASE_URL fehlt. In CI duerfen die Datenbanktests nicht uebersprungen werden."); +} + export const datenbankVerfuegbar = Boolean(url); export const testPrisma = new PrismaClient({ diff --git a/tests/tagcode.test.ts b/tests/tagcode.test.ts new file mode 100644 index 0000000..fe63d60 --- /dev/null +++ b/tests/tagcode.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it, vi } from "vitest"; +import { + TAGCODE_ALPHABET, + TAGCODE_HOECHSTVERSUCHE, + TAGCODE_MUSTER, + TagCodeFehler, + istTagCode, + tagCodeEindeutigErzeugen, + tagCodeErzeugen, +} from "@/lib/tagcode"; + +function zieheCodes(anzahl: number): string[] { + return Array.from({ length: anzahl }, () => tagCodeErzeugen()); +} + +describe("tagCode erzeugen", () => { + it("haelt Praefix und Laenge ein", () => { + for (const code of zieheCodes(200)) { + expect(code).toMatch(TAGCODE_MUSTER); + expect(code).toHaveLength(3 + 6); + expect(code.startsWith("LZ-")).toBe(true); + } + }); + + it("benutzt nur das erlaubte Alphabet", () => { + for (const code of zieheCodes(5000)) { + for (const zeichen of code.slice(3)) { + expect(TAGCODE_ALPHABET).toContain(zeichen); + } + } + }); + + it("enthaelt nie die verwechselbaren Zeichen 0, O, 1 oder I", () => { + // Genau das ist der Grund fuer das eingeschraenkte Alphabet: die Codes werden + // von Aufklebern abgetippt und am Telefon vorgelesen. Das L ist bewusst nicht + // dabei: ohne es bliebe das Alphabet bei 31 Zeichen statt der gewuenschten 32, + // und das Praefix `LZ-` enthaelt es ohnehin. + for (const code of zieheCodes(5000)) { + expect(code.slice(3)).not.toMatch(/[0O1I]/); + } + }); + + it("liefert bei 10 000 Ziehungen praktisch keine Dubletten", () => { + const anzahl = 10_000; + const eindeutige = new Set(zieheCodes(anzahl)); + // Bei 32^6 rund 1,07 Mrd. Moeglichkeiten sind nach dem Geburtstagsproblem + // etwa 0,05 Dubletten zu erwarten -- meistens also keine. Ein strikter + // Vergleich auf 10 000 waere deshalb flatterhaft: er wuerde selten, aber + // regelmaessig rot, ohne dass etwas kaputt ist. Die verbindliche + // Eindeutigkeit sichert der Unique-Index in der Datenbank. + expect(eindeutige.size).toBeGreaterThanOrEqual(anzahl - 5); + }); + + it("verteilt die Zeichen grob gleichmaessig ueber das Alphabet", () => { + const codes = zieheCodes(5000); + const haeufigkeit = new Map(); + for (const code of codes) { + for (const zeichen of code.slice(3)) { + haeufigkeit.set(zeichen, (haeufigkeit.get(zeichen) ?? 0) + 1); + } + } + + const gezogeneZeichen = codes.length * 6; + const erwartet = gezogeneZeichen / TAGCODE_ALPHABET.length; + expect(haeufigkeit.size).toBe(TAGCODE_ALPHABET.length); + for (const zeichen of TAGCODE_ALPHABET) { + const anzahl = haeufigkeit.get(zeichen) ?? 0; + // Grosszuegige Schranken (rund 15 Standardabweichungen), damit der Test + // eine echte Schieflage findet, aber nicht am Zufall scheitert. + expect(anzahl).toBeGreaterThan(erwartet * 0.5); + expect(anzahl).toBeLessThan(erwartet * 1.5); + } + }); +}); + +describe("istTagCode", () => { + it("nimmt einen frisch erzeugten Code an", () => { + expect(istTagCode(tagCodeErzeugen())).toBe(true); + expect(istTagCode("LZ-7K3F9Q")).toBe(true); + }); + + it.each([ + ["Kleinbuchstaben", "lz-7k3f9q"], + ["gemischte Schreibweise", "LZ-7k3F9Q"], + ["fehlendes Praefix", "7K3F9Q"], + ["falsches Praefix", "XY-7K3F9Q"], + ["Praefix ohne Bindestrich", "LZ7K3F9Q"], + ["zu kurz", "LZ-7K3F9"], + ["zu lang", "LZ-7K3F9QA"], + ["verbotene Null", "LZ-7K3F90"], + ["verbotenes O", "LZ-7K3F9O"], + ["verbotene Eins", "LZ-7K3F91"], + ["verbotenes I", "LZ-7K3F9I"], + ["Sonderzeichen", "LZ-7K3F9-"], + ["Leerzeichen", "LZ-7K3F9 "], + ["leer", ""], + ["nur Praefix", "LZ-"], + ["Zeilenumbruch angehaengt", "LZ-7K3F9Q\n"], + ["Text davor", "xLZ-7K3F9Q"], + ])("weist %s zurueck", (_fall, wert) => { + expect(istTagCode(wert)).toBe(false); + }); + + it("urteilt bei mehrfachem Aufruf gleich", () => { + // Das Muster wird geteilt; ein globales Flag darauf wuerde den Zustand + // zwischen Aufrufen mitschleppen. + expect(istTagCode("LZ-7K3F9Q")).toBe(true); + expect(istTagCode("LZ-7K3F9Q")).toBe(true); + }); +}); + +describe("tagCodeEindeutigErzeugen", () => { + it("liefert den ersten Code, wenn er frei ist", async () => { + const istVergeben = vi.fn(async () => false); + const code = await tagCodeEindeutigErzeugen(istVergeben); + + expect(istTagCode(code)).toBe(true); + expect(istVergeben).toHaveBeenCalledTimes(1); + expect(istVergeben).toHaveBeenCalledWith(code); + }); + + it("zieht erneut, wenn der erste Code vergeben ist", async () => { + const gesehen: string[] = []; + const istVergeben = vi.fn(async (code: string) => { + gesehen.push(code); + return gesehen.length === 1; + }); + + const code = await tagCodeEindeutigErzeugen(istVergeben); + + expect(istVergeben).toHaveBeenCalledTimes(2); + expect(gesehen).toHaveLength(2); + expect(gesehen[0]).not.toBe(code); + expect(gesehen[1]).toBe(code); + }); + + it("wirft nach zehn vergeblichen Versuchen einen TagCodeFehler", async () => { + const istVergeben = vi.fn(async () => true); + + await expect(tagCodeEindeutigErzeugen(istVergeben)).rejects.toThrow(TagCodeFehler); + expect(istVergeben).toHaveBeenCalledTimes(TAGCODE_HOECHSTVERSUCHE); + }); + + it("gibt einen Fehler der Vergabepruefung unveraendert weiter", async () => { + const istVergeben = vi.fn(async () => { + throw new Error("Datenbank nicht erreichbar"); + }); + + await expect(tagCodeEindeutigErzeugen(istVergeben)).rejects.toThrow("Datenbank nicht erreichbar"); + expect(istVergeben).toHaveBeenCalledTimes(1); + }); +});