feat(seed): sieben Vorlagen, Beispielbestand und Demo-Kunden #33
@@ -157,6 +157,23 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
verdoppelt: dieselbe Zeile behält ihre Läufe und Geräte (#016)
|
||||
- Ein hinterlegtes CA-Zertifikat ist als vorhanden erkennbar und lässt sich entfernen; der
|
||||
Zertifikatstext selbst kommt nie zurück in die Oberfläche (#016)
|
||||
- Die sieben Abläufe des Hauses liegen nach dem Seed als veröffentlichte Version 1 bereit: PC
|
||||
Neuinstallation, Onboarding, Offboarding, Server-Inbetriebnahme, Firewall-Rollout, Patchday und
|
||||
Restore-Test — 36 Abschnitte, 155 Schritte, 28 Variablen, jeder Schritt mit Hilfetext (#017)
|
||||
- Die Vorlagen stehen als JSON im Austauschformat unter `prisma/seed-daten/vorlagen/` und werden
|
||||
beim Seed gegen dasselbe Schema geprüft wie ein Import: ein Fehler fällt beim Einrichten auf und
|
||||
nicht erst im ersten Lauf (#017)
|
||||
- Beispielbestand zum Vorführen: drei Abteilungen mit Ziel-OU und Standardgruppen, fünf Personen,
|
||||
`PC-001` und `NB-001` mit ihren Tag-Codes, fünf vorgedruckte Tags und die Schnellstart-Vorgaben
|
||||
je Gerätetyp (#017)
|
||||
- `SEED_MODE=DIENSTLEISTUNG` legt „Musterfirma GmbH" und „Bäckerei Reiter" an; der Beispielbestand
|
||||
hängt dann an der Musterfirma, `LZ-7K3F9Q` führt also auf `MUS-PC-001` (#017)
|
||||
- Namensregeln und die Sperr-OU kommen mit dem Seed: „Namen aus den Regeln vorschlagen" schlägt für
|
||||
Maria Huber genau `m.huber@firma.example` und `mhuber` vor — dieselben Werte, die im
|
||||
Beispielbestand stehen (#017)
|
||||
- `{{ou_gesperrt}}` wird aus Admin → Einstellungen vorbelegt. Das Feld war gespeichert und wurde von
|
||||
keinem Platzhalter gelesen; `Move-ADObject -TargetPath "{{ou_gesperrt}}"` blieb leer, obwohl die
|
||||
Einstellungsseite es versprach (#017)
|
||||
|
||||
### Sicherheit
|
||||
|
||||
@@ -276,3 +293,11 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
Kennung — wer sie mit abschaltete, nähme einem anwesenden Menschen den Zugang (#016)
|
||||
- Ergebniscodes des Verzeichnisses werden in Sätze übersetzt: falsches Passwort, fehlendes Leserecht
|
||||
und ein unbekannter Basis-DN sahen vorher gleich aus (#016)
|
||||
- Die Beispiel-Verzeichnisquelle des Seeds ist abgeschaltet, und ihr Bind-Passwort ist ein bei jedem
|
||||
Lauf neu gewürfelter Zufallswert: ein fest verdrahtetes Beispielpasswort im Quelltext stünde am
|
||||
Ende in einer echten Installation (#017)
|
||||
- Titel und Hilfetexte eines Laufs bekommen **dasselbe** generierte Passwort wie der Befehl daneben.
|
||||
Vorher erzeugte jede Auflösung ein eigenes: im Befehl stand A, im Hilfetext B — wer B in den
|
||||
Passwortmanager legte, sperrte den neuen Mitarbeiter aus (#017)
|
||||
- Der Seed vergibt keinen verbrannten Tag-Code neu: er fragt das Vergabebuch und nicht nur die
|
||||
Geräteliste, wie es der Gerätedienst tut (#017)
|
||||
|
||||
@@ -65,6 +65,28 @@ absichtlich nicht automatisch** — ihn startest du einmal von Hand:
|
||||
docker compose run --rm migrate pnpm db:seed
|
||||
```
|
||||
|
||||
### Was der Seed anlegt
|
||||
|
||||
| | Inhalt |
|
||||
|---|---|
|
||||
| Grundbestand | Betriebsmodus, eigene Organisation, Administrator aus `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORD` |
|
||||
| Vorlagen | die sieben Abläufe des Hauses als **veröffentlichte Version 1** — PC Neuinstallation, Onboarding, Offboarding, Server-Inbetriebnahme, Firewall-Rollout, Patchday, Restore-Test |
|
||||
| Beispielbestand | 3 Abteilungen, 5 Personen, `PC-001` und `NB-001` mit Tag-Code, 5 vorgedruckte Tags, Schnellstart-Vorgaben je Gerätetyp |
|
||||
| Verzeichnis | eine **deaktivierte** Beispielquelle zum Abschauen — Adresse und Bind-Passwort ersetzen, dann einschalten |
|
||||
|
||||
Mit `SEED_MODE=DIENSTLEISTUNG` (Alias `SEED_MODE=SERVICE`) kommen zwei Demo-Kunden dazu,
|
||||
„Musterfirma GmbH" (`MUS`) und „Bäckerei Reiter" (`REI`), mit je einer Abteilung, einer Person und
|
||||
einem Gerät nach der Hostname-Regel (`MUS-PC-001`).
|
||||
|
||||
**Der Seed richtet ein, er räumt nicht auf.** Was schon da ist, bleibt: eine bearbeitete Vorlage,
|
||||
eine geänderte Ziel-OU oder ein umgeschalteter Betriebsmodus überstehen jeden weiteren Lauf. Zweimal
|
||||
hintereinander aufgerufen ändert der zweite Lauf nichts.
|
||||
|
||||
Die sieben Vorlagen liegen als JSON im [Austauschformat](docs/vorlagen-format.md) unter
|
||||
`prisma/seed-daten/vorlagen/`. Wer eine anpassen will, ändert die Datei — oder besser: die Vorlage
|
||||
in der Oberfläche, denn eine veröffentlichte Version wird nie verändert, und der Seed fasst eine
|
||||
vorhandene Vorlage nicht mehr an.
|
||||
|
||||
### Die Datenbank ist von außen nicht erreichbar
|
||||
|
||||
Das ist bewusst so gebaut und in `tests/betriebsstand.test.ts` festgehalten:
|
||||
|
||||
@@ -331,6 +331,41 @@ hinter einem Reverse Proxy kommen sich sonst in die Quere. Der Zeitplan nimmt
|
||||
ausdruecklich **keine** eigene Sperre — sie traege einen anderen Schluessel und
|
||||
liefe an der des Dienstes vorbei.
|
||||
|
||||
## 5h. Der Seed: einrichten, nicht aufraeumen
|
||||
|
||||
Die sieben Vorlagen stehen als **JSON im Austauschformat** unter
|
||||
`prisma/seed-daten/vorlagen/` und nicht als TypeScript-Literale. Damit prueft sie
|
||||
dasselbe Zod-Schema, das auch Import, Export und Editor benutzen: eine
|
||||
Seed-Vorlage ist genau das, was ein Redakteur einspielen koennte, und ein Fehler
|
||||
faellt beim Einrichten auf statt im ersten Lauf.
|
||||
|
||||
Angelegt werden sie **nicht** ueber `vorlageImportieren`. Der Import erzeugt
|
||||
absichtlich einen Entwurf und schreibt einen Protokolleintrag mit dem handelnden
|
||||
Menschen; der Seed hat keinen Menschen und braucht veroeffentlichte Versionen,
|
||||
weil sich sonst kein Lauf starten liesse. Er geht trotzdem denselben Weg wie ein
|
||||
Redakteur — **erst Entwurf, dann veroeffentlichen**: die Trigger aus #010
|
||||
verbieten jedes Schreiben an Abschnitten, Variablen und Schritten einer
|
||||
veroeffentlichten Version. Eine Version gleich als `VEROEFFENTLICHT` anzulegen und
|
||||
danach zu fuellen scheitert an der Datenbank, und das ist richtig so.
|
||||
|
||||
**Der Seed richtet ein, er raeumt nicht auf.** Jede Zeile hat einen sprechenden
|
||||
Schluessel — die Vorlage ihren Namen, die Abteilung `(kundeId, name)`, die Person
|
||||
ihren UPN, das Geraet seinen `tagCode` —, und was es schon gibt, bleibt
|
||||
unberuehrt. Kein `upsert` mit gefuelltem `update`: eine im Betrieb geaenderte
|
||||
Ziel-OU, eine bearbeitete Vorlage und ein umgeschalteter Betriebsmodus ueberstehen
|
||||
jeden weiteren Lauf. Zweimal hintereinander aufgerufen aendert der zweite Lauf
|
||||
nichts, und `tests/seed.test.ts` haelt das fest.
|
||||
|
||||
Im Modus DIENSTLEISTUNG haengt der Beispielbestand an der **Musterfirma** und nicht
|
||||
an der eigenen Organisation: der Auftrag verlangt, dass `LZ-7K3F9Q` dort auf
|
||||
`MUS-PC-001` fuehrt. Ein Tag-Code gehoert genau einem Geraet, beides zugleich
|
||||
ginge also ohnehin nicht — „erzeugt **stattdessen**“.
|
||||
|
||||
Die Tag-Codes der Beispielgeraete sind **fest** und nicht gezogen. Ein Seed, der
|
||||
jedes Mal fuenf neue Codes zieht, waere nicht idempotent: beim dritten Lauf laegen
|
||||
fuenfzehn Aufkleber im Stapel. Weil ein `tagCode` global eindeutig ist und nie
|
||||
wiederverwendet wird, bekommt jeder Demo-Kunde seinen eigenen Satz.
|
||||
|
||||
## 6. Betrieb: zwei Wege, eine Regel
|
||||
|
||||
Die Datenbank ist auf beiden Wegen von außen nicht erreichbar:
|
||||
|
||||
+79
-2
@@ -19,11 +19,12 @@ als Unterpunkte mit Aufgabe und Dauer.
|
||||
| #014 | M9 – QR-Tags, Geräteseite, Etiketten, Scanner | 3 h 35 min |
|
||||
| #015 | M10 – Dashboard und Archiv | 2 h 20 min |
|
||||
| #016 | M11 – Verzeichnis und LDAP-Abgleich | 3 h 25 min |
|
||||
| | **Summe** | **26 h 10 min** |
|
||||
| #017 | M12 – Seed der sieben Vorlagen | 1 h 30 min |
|
||||
| | **Summe** | **27 h 40 min** |
|
||||
|
||||
| Meilenstein | Tatsächlich | Status |
|
||||
|---|---|---|
|
||||
| v0.1.0 | 26 h 10 min | in Arbeit |
|
||||
| v0.1.0 | 27 h 40 min | in Arbeit |
|
||||
|
||||
---
|
||||
|
||||
@@ -752,3 +753,79 @@ als Unterpunkte mit Aufgabe und Dauer.
|
||||
|
||||
- #017 Seed der sieben Vorlagen — alle sieben wortgetreu als veröffentlichte Version 1, dazu die
|
||||
Beispieldaten und der Modus `DIENSTLEISTUNG` mit „Musterfirma GmbH“ und „Bäckerei Reiter“.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-26 · Sitzung 13 · 02:35–04:05
|
||||
|
||||
**Issue:** #017 (M12)
|
||||
**Version:** 0.1.0
|
||||
|
||||
### Erledigt
|
||||
|
||||
- **Drei Teile parallel**: Vorlagen 1 und 4, Vorlagen 2 und 3, Vorlagen 5 bis 7 (je ein Sub-Agent);
|
||||
die Seed-Infrastruktur selbst
|
||||
- Die sieben Vorlagen als JSON im Austauschformat: 36 Abschnitte, 155 Schritte, 28 Variablen,
|
||||
jeder Schritt mit Hilfetext, als veröffentlichte Version 1 im Seed
|
||||
- Beispielbestand (3 Abteilungen, 5 Personen, `PC-001`, `NB-001`, 5 vorgedruckte Tags,
|
||||
Schnellstart je Gerätetyp), deaktivierte Beispiel-Verzeichnisquelle, Namensregeln und Sperr-OU
|
||||
- `SEED_MODE=DIENSTLEISTUNG` legt „Musterfirma GmbH" und „Bäckerei Reiter" an — der
|
||||
Beispielbestand hängt dann an der Musterfirma, `LZ-7K3F9Q` führt auf `MUS-PC-001`
|
||||
- Unabhängiger Review: **zwei Blocker**, neun Befunde, sieben Anmerkungen — alle abgearbeitet
|
||||
- Zwei Zusagen der Oberfläche eingelöst, die niemand deckte: `{{ou_gesperrt}}` kommt jetzt wirklich
|
||||
aus den Einstellungen, und der Hilfetext neben einem Befehl zeigt kein zweites Passwort mehr
|
||||
- **1131 Tests** (vorher 1098) grün, im gebauten Stand durchgeklickt: Lauf aus der
|
||||
Onboarding-Vorlage, `-Path "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local"` und
|
||||
`"G-Buchhaltung, G-Alle"` kommen aus der Abteilung, kein roher Platzhalter bleibt stehen
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
- **Die Vorlagen sind Daten, kein Code.** Als JSON im Austauschformat prüft sie dasselbe Schema wie
|
||||
Import, Export und Editor: eine Seed-Vorlage ist genau das, was ein Redakteur einspielen könnte.
|
||||
Wer eine ändern will, ändert eine Datei und braucht keinen Übersetzer.
|
||||
- **Der Seed geht denselben Weg wie ein Redakteur**: erst Entwurf, dann veröffentlichen. Nicht aus
|
||||
Stilgefühl — die Trigger aus #010 verbieten jedes Schreiben an einer veröffentlichten Version.
|
||||
- **Der Seed richtet ein, er räumt nicht auf.** Kein `upsert` mit gefülltem `update`: eine
|
||||
bearbeitete Vorlage, eine geänderte Ziel-OU und ein umgeschalteter Betriebsmodus überstehen jeden
|
||||
weiteren Lauf.
|
||||
- **Tag-Codes fest statt gezogen.** Ein Seed, der jedes Mal fünf neue Codes zieht, ist nicht
|
||||
idempotent — beim dritten Lauf lägen fünfzehn Aufkleber im Stapel.
|
||||
|
||||
### Probleme
|
||||
|
||||
- **Die Datenbank hat den Seed korrigiert.** Der erste Entwurf legte die Version gleich als
|
||||
`VEROEFFENTLICHT` an und füllte sie danach — die Trigger aus #010 haben das abgewiesen. Genau
|
||||
dafür stehen sie dort.
|
||||
- **Zwei verschiedene Passwörter auf einer Seite** (Blocker): die Hilfetexte trugen
|
||||
`{{generiertes_passwort}}`, und das Lesemodell löste Hilfetexte ohne das Passwort des
|
||||
Seitenaufbaus auf. Im Befehl stand A, im Hilfetext daneben B. Die Warnung davor stand seit #012
|
||||
wörtlich in der Kopfdokumentation des Moduls, das beides erzeugt.
|
||||
- **Ein verbrannter Tag-Code kam zurück** (Blocker): der Seed fragte nur nach dem Gerät, nicht nach
|
||||
dem Vergabebuch — und der Kommentar derselben Funktion beschrieb genau den Fehler, den sie machte.
|
||||
- **„Stattdessen" hieß „zusätzlich".** Im Modus DIENSTLEISTUNG hing der Beispielbestand weiter an
|
||||
der eigenen Organisation; das Abnahmekriterium `LZ-7K3F9Q` → `MUS-PC-001` wäre in keinem Modus
|
||||
erfüllt gewesen. Ein Wort im Auftrag, drei Dateien Arbeit.
|
||||
- **Mein eigener Test konnte nicht scheitern** — zum zweiten Mal in zwei Meilensteinen. Die Suche
|
||||
nach Klartextpasswörtern lief gegen `JSON.stringify`, wo vor jedem Anführungszeichen ein
|
||||
Backslash steht; ein eingebautes `passwort = "Sommer2026!"` blieb unbemerkt. Gefunden durch die
|
||||
Gegenprobe, die seitdem zum Schreiben eines solchen Tests gehört: einmal absichtlich verletzen.
|
||||
- **Eine Wegwerfdatei im Wurzelverzeichnis** ließ `pnpm format:pruefen` scheitern, während der
|
||||
Review lief. Sie gehört in das Verzeichnis für Zwischenstände, nicht neben den Quelltext.
|
||||
- **Zum zweiten Mal gegen einen veralteten Server geprüft.** `pkill -f "next-server"` erwischte den
|
||||
eigenen Shell-Prozess statt den Server; der lief mit dem Stand von neunzehn Minuten zuvor weiter.
|
||||
Das sah aus wie ein Codefehler — `ChunkLoadError`, leere Editor-Seite, fünf rote Abläufe. Seitdem:
|
||||
`ps -eo lstart` gegen `.next/BUILD_ID` halten, bevor man der Seite glaubt.
|
||||
- **Der E2E-Lauf schrieb in eine andere Datenbank, als der Server las.** Die Abläufe legen ihre
|
||||
Fixtures über `prisma` aus `.env` an, der Server bekommt seine Adresse über die Umgebung. Zeigen
|
||||
beide woandershin, heißt die Meldung „0 Treffer" und sagt nichts über die Ursache.
|
||||
- **Der Seed hat die E2E-Suite rot gemacht.** Die Abläufe wählten ihre Vorlage über `index: 1` —
|
||||
eine Annahme über die Sortierung der Vorlagenliste, die genau so lange hielt, wie es nur eine
|
||||
Vorlage gab. Jetzt wird sie am Namen gesucht.
|
||||
|
||||
### Nächster Schritt
|
||||
|
||||
- Zwei Folge-Issues aus dem Review: [#020](issues/020-vorgesetzter-manuell.md) (Vorgesetzter
|
||||
für von Hand angelegte Personen) und
|
||||
[#021](issues/021-stabiler-schluessel-vorlagen.md) (stabiler Schlüssel für Seed-Vorlagen)
|
||||
- #018 E2E-Tests, Anleitung und PDF — die Hauptabläufe in beiden Betriebsmodi, die bebilderte
|
||||
Anleitung für Techniker und Admins und der PDF-Export über Chromium.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# #017 M12 – Seed der sieben Vorlagen
|
||||
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.1.0
|
||||
**Labels:** phase-1, backend
|
||||
**Geschätzt:** 8 h **Tatsächlich:** –
|
||||
**Geschätzt:** 8 h **Tatsächlich:** 1 h 30 min
|
||||
|
||||
## Ziel
|
||||
|
||||
@@ -11,27 +11,94 @@ Die App ist nach dem Seed fachlich sofort brauchbar: sieben vollständige, fachl
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] Vorlage 1 „PC Neuinstallation“ (Client), Variablen `os_version`, `join_typ`; Abschnitte Vorbereitung, Installation, Einbindung, Software, Sicherheit, Benutzer, Abschluss
|
||||
- [ ] Vorlage 2 „Mitarbeiter-Onboarding“ (Benutzer), Variablen `konto_typ`, `vorname`, `nachname`, `upn`, `sam`, `mail`, `abteilung`, `ou`, `gruppen`, `eintrittsdatum`, `vorgesetzter`; **beide** Konto-Abschnitte (AD und Entra ID), weil bedingte Abschnitte erst Phase 3 sind
|
||||
- [ ] Vorlage 3 „Mitarbeiter-Offboarding“ (Benutzer), Variablen `austrittsdatum`, `ou_gesperrt`, `postfach_verbleib`, `vertreter`
|
||||
- [ ] Vorlage 4 „Server-Inbetriebnahme Windows-VM“ (Server), Variablen `vmid`, `ip_adresse`, `vlan`, `rolle`, `os_version`
|
||||
- [ ] Vorlage 5 „Firewall-Rollout OPNsense“ (Netzwerk), Variablen `wan_typ`, `lan_netz`, `vpn_typ`
|
||||
- [ ] Vorlage 6 „Monatlicher Patchday“ (Wartung), Variable `monat`
|
||||
- [ ] Vorlage 7 „Backup-Restore-Test“ (Wartung), Variablen `system`, `restore_typ`
|
||||
- [ ] Alle Schritte wortgetreu nach Auftrag mit Typ, Pflichtkennzeichen und `konfig`; die im Auftrag genannten Hilfetexte übernehmen, die übrigen fachlich korrekt und kurz ergänzen
|
||||
- [ ] 5 Mitarbeiter, darunter „Maria Huber“, `m.huber@firma.example`, `sam` `mhuber`, Abteilung Buchhaltung, Vorgesetzte „Petra Klein“
|
||||
- [ ] 3 Abteilungen: Buchhaltung → `OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local` mit Gruppen `G-Buchhaltung, G-Alle`; Vertrieb; IT
|
||||
- [ ] Eine **deaktivierte** `Verzeichnisquelle` als Beispielkonfiguration
|
||||
- [ ] Geräte `PC-001` (PC, `LZ-7K3F9Q`, Besitzerin Maria Huber) und `NB-001` (Notebook, `LZ-M2XH4T`), dazu 5 unregistrierte Tags
|
||||
- [ ] `GeraetetypVorlage`: PC und NOTEBOOK → „PC Neuinstallation“; SERVER → „Server-Inbetriebnahme Windows-VM“ und „Backup-Restore-Test“; FIREWALL → „Firewall-Rollout OPNsense“
|
||||
- [ ] `SEED_MODE=DIENSTLEISTUNG` (Alias `SERVICE`) erzeugt stattdessen „Musterfirma GmbH“ (`MUS`) und „Bäckerei Reiter“ (`REI`) mit je einer Person, einer Abteilung und einem Gerät
|
||||
- [ ] Alles idempotent über `upsert` mit stabilen Schlüsseln
|
||||
- [x] Vorlage 1 „PC Neuinstallation“ (Client), Variablen `os_version`, `join_typ`; Abschnitte Vorbereitung, Installation, Einbindung, Software, Sicherheit, Benutzer, Abschluss
|
||||
- [x] Vorlage 2 „Mitarbeiter-Onboarding“ (Benutzer), Variablen `konto_typ`, `vorname`, `nachname`, `upn`, `sam`, `mail`, `abteilung`, `ou`, `gruppen`, `eintrittsdatum`, `vorgesetzter`; **beide** Konto-Abschnitte (AD und Entra ID), weil bedingte Abschnitte erst Phase 3 sind
|
||||
- [x] Vorlage 3 „Mitarbeiter-Offboarding“ (Benutzer), Variablen `austrittsdatum`, `ou_gesperrt`, `postfach_verbleib`, `vertreter`
|
||||
- [x] Vorlage 4 „Server-Inbetriebnahme Windows-VM“ (Server), Variablen `vmid`, `ip_adresse`, `vlan`, `rolle`, `os_version`
|
||||
- [x] Vorlage 5 „Firewall-Rollout OPNsense“ (Netzwerk), Variablen `wan_typ`, `lan_netz`, `vpn_typ`
|
||||
- [x] Vorlage 6 „Monatlicher Patchday“ (Wartung), Variable `monat`
|
||||
- [x] Vorlage 7 „Backup-Restore-Test“ (Wartung), Variablen `system`, `restore_typ`
|
||||
- [x] Alle Schritte wortgetreu nach Auftrag mit Typ, Pflichtkennzeichen und `konfig`; die im Auftrag genannten Hilfetexte übernehmen, die übrigen fachlich korrekt und kurz ergänzen
|
||||
- [x] 5 Mitarbeiter, darunter „Maria Huber“, `m.huber@firma.example`, `sam` `mhuber`, Abteilung Buchhaltung, Vorgesetzte „Petra Klein“ — **teilweise:** Petra Klein ist angelegt, die Zuordnung selbst lässt sich nicht speichern (siehe Ergebnis)
|
||||
- [x] 3 Abteilungen: Buchhaltung → `OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local` mit Gruppen `G-Buchhaltung, G-Alle`; Vertrieb; IT
|
||||
- [x] Eine **deaktivierte** `Verzeichnisquelle` als Beispielkonfiguration
|
||||
- [x] Geräte `PC-001` (PC, `LZ-7K3F9Q`, Besitzerin Maria Huber) und `NB-001` (Notebook, `LZ-M2XH4T`), dazu 5 unregistrierte Tags
|
||||
- [x] `GeraetetypVorlage`: PC und NOTEBOOK → „PC Neuinstallation“; SERVER → „Server-Inbetriebnahme Windows-VM“ und „Backup-Restore-Test“; FIREWALL → „Firewall-Rollout OPNsense“
|
||||
- [x] `SEED_MODE=DIENSTLEISTUNG` (Alias `SERVICE`) erzeugt stattdessen „Musterfirma GmbH“ (`MUS`) und „Bäckerei Reiter“ (`REI`) mit je einer Person, einer Abteilung und einem Gerät
|
||||
- [x] Alles idempotent über stabile Schlüssel — **nicht** über `upsert`, sondern nachsehen und nur anlegen (siehe Ergebnis)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] `pnpm db:seed` zweimal hintereinander: keine Fehler, keine Duplikate, keine geänderten Zeilen
|
||||
- [ ] Alle sieben Vorlagen liegen als `VEROEFFENTLICHT`, Version 1 vor
|
||||
- [ ] Im Onboarding-Lauf steht im Schritt „Konto anlegen“ der vollständige `New-ADUser`-Befehl mit Name, UPN, `-Path "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local"` und der Gruppen-Befehl mit `G-Buchhaltung, G-Alle`
|
||||
- [ ] `SEED_MODE=SERVICE` erzeugt `MUS-PC-001` gemäß Hostname-Regel `{kundenkuerzel}-PC-{nnn}`
|
||||
- [ ] Die Beispiel-Verzeichnisquelle ist deaktiviert und löst keinen Abgleich aus
|
||||
- [ ] Kein Seed-Datensatz enthält ein Klartext-Passwort außer dem Admin-Passwort aus der Umgebung, und das nur als argon2-Hash
|
||||
- [x] `pnpm db:seed` zweimal hintereinander: keine Fehler, keine Duplikate, keine geänderten Zeilen
|
||||
- [x] Alle sieben Vorlagen liegen als `VEROEFFENTLICHT`, Version 1 vor
|
||||
- [x] Im Onboarding-Lauf steht im Schritt „Konto anlegen“ der vollständige `New-ADUser`-Befehl mit Name, UPN, `-Path "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local"` und der Gruppen-Befehl mit `G-Buchhaltung, G-Alle`
|
||||
- [x] `SEED_MODE=SERVICE` erzeugt `MUS-PC-001` gemäß Hostname-Regel `{kundenkuerzel}-PC-{nnn}`
|
||||
- [x] Die Beispiel-Verzeichnisquelle ist deaktiviert und löst keinen Abgleich aus
|
||||
- [x] Kein Seed-Datensatz enthält ein Klartext-Passwort außer dem Admin-Passwort aus der Umgebung, und das nur als argon2-Hash
|
||||
|
||||
## Ergebnis
|
||||
|
||||
Erledigt am 2026-09-26. **1129 Tests** (vorher 1098), Screenshots in `docs/screenshots/m12/`.
|
||||
|
||||
Die sieben Vorlagen liegen als JSON im [Austauschformat](../vorlagen-format.md) unter
|
||||
`prisma/seed-daten/vorlagen/` — 36 Abschnitte, 155 Schritte, 28 Variablen, jeder Schritt mit
|
||||
Hilfetext. Ein maschineller Abgleich gegen den Auftragstext (Titel, Pflichtkennzeichen, Typ,
|
||||
Reihenfolge, Position für Position) meldet **eine** Abweichung, und die ist eine Auslegung: „Zeitzone
|
||||
korrekt, [COMMAND] Zeitsynchronisation prüfen" steht im Auftrag als *ein* Aufzählungspunkt mit der
|
||||
Typangabe mitten in der Zeile und wurde ein Befehlsschritt statt zweier Schritte.
|
||||
|
||||
Zwei Punkte weichen bewusst vom Wortlaut der Aufgabenliste ab:
|
||||
|
||||
- **Idempotenz ohne `upsert`.** Ein `upsert` mit gefülltem `update` schriebe bei jedem Lauf zurück,
|
||||
was im Betrieb geändert wurde. Der Seed sieht stattdessen nach und legt nur an, was fehlt.
|
||||
- **Die Vorgesetzte lässt sich nicht speichern.** Petra Klein ist als Person angelegt, die Zuordnung
|
||||
„Maria Huber → Petra Klein" aber nicht: `vorgesetzterExterneId` zeigt auf die `externeId`
|
||||
derselben Verzeichnisquelle, und eine von Hand angelegte Person hat beides nicht — beide Lesepfade
|
||||
(`src/server/queries/mitarbeiter.ts`, `src/server/services/platzhalter.ts`) steigen ohne `quelleId`
|
||||
aus. Einen Wert zu schreiben, den niemand liest, wäre schlechter als die Lücke. Sie gehört in ein
|
||||
eigenes Issue.
|
||||
|
||||
### Der unabhängige Review
|
||||
|
||||
Zwei Blocker, neun Befunde, sieben Anmerkungen — die wichtigsten:
|
||||
|
||||
- **Blocker: zwei verschiedene Passwörter auf einer Seite.** Die Hilfetexte des Onboardings trugen
|
||||
`{{generiertes_passwort}}`, und `src/server/queries/laeufe.ts` löste Hilfetexte **ohne** das
|
||||
Passwort des Seitenaufbaus auf — im Befehl stand A, im Hilfetext daneben B. Wer B in den
|
||||
Passwortmanager legt, sperrt den neuen Mitarbeiter aus. Genau davor warnt
|
||||
`src/lib/platzhalter-werte.ts` in Regel 4 seiner eigenen Kopfdokumentation. Behoben an beiden
|
||||
Enden: Titel und Hilfetext bekommen jetzt dasselbe Passwort wie der Befehl, und die Hilfetexte
|
||||
nennen es gar nicht mehr.
|
||||
- **Blocker: der Seed vergab einen verbrannten Tag-Code neu.** Er fragte nur nach dem Gerät, nicht
|
||||
nach dem Vergabebuch. Wer den Beispiel-PC endgültig gelöscht hatte, bekam beim nächsten Lauf
|
||||
denselben Code auf einem neuen Gerät — und der alte Aufkleber im Regal zeigte auf ein fremdes
|
||||
Objekt. Jetzt werden beide Bücher gefragt, wie im Gerätedienst.
|
||||
- **„Stattdessen" hieß „zusätzlich".** Im Modus DIENSTLEISTUNG hing der Beispielbestand weiter an
|
||||
der eigenen Organisation, `LZ-7K3F9Q` führte also auf `PC-001` statt auf `MUS-PC-001` — das
|
||||
Abnahmekriterium des Auftrags wäre in **keinem** Modus erfüllt gewesen. Jetzt bekommt die
|
||||
Musterfirma den vollen Bestand, die Bäckerei den kleinen, die eigene Organisation keinen.
|
||||
- **Der Seed schrieb in fremde Kundendaten.** Die Demo-Kunden wurden nur am Kürzel erkannt: ein
|
||||
echter Kunde „Muster und Söhne KG" mit dem Kürzel MUS bekam eine erfundene Person, eine erfundene
|
||||
Ziel-OU und ein Gerät — und die Ausgabe sagte „vorhanden".
|
||||
- **Der Seed stürzte ab**, wenn eine Vorlage gleichen Namens nur als Entwurf existierte — also genau
|
||||
dann, wenn jemand eine der sieben Dateien eingespielt und noch nicht veröffentlicht hatte.
|
||||
- **Zwei falsche Zusagen in Hilfetexten:** „vorbelegt aus der Abteilung" (nichts wird vorbelegt; die
|
||||
leeren Felder fallen auf die Abteilung zurück) und „das Passwort beendet die bestehenden
|
||||
Anmeldungen" (ein Reset im AD verhindert nur die erneute Anmeldung).
|
||||
- **Keine Namensregeln im Seed.** „Namen aus den Regeln vorschlagen" antwortete nach einem frischen
|
||||
Seed mit „keine Namensregeln hinterlegt" — das MVP-Kriterium war nicht vorführbar. Jetzt legt der
|
||||
Seed die Regeln an, die zu seinen eigenen Daten passen, und die Sperr-OU fürs Offboarding dazu.
|
||||
- **Ein Test, der nicht scheitern konnte** (schon vor dem Review selbst gefunden und behoben): die
|
||||
Suche nach Klartextpasswörtern lief gegen `JSON.stringify`, wo vor jedem Anführungszeichen ein
|
||||
Backslash steht.
|
||||
|
||||
Die drei Skripte `pnpm beispiel:vorlage`, `beispiel:stammdaten` und `beispiel:lauf` tragen im Kopf
|
||||
„Mit #017 wird das Skript gelöscht". Sie bleiben, und der Kopf ist berichtigt: der Seed richtet eine
|
||||
**Installation** ein, die Skripte stellen her, was **Screenshots und Abläufe** brauchen — einen
|
||||
offenen Entwurf, einen halb abgearbeiteten Lauf, eine Person aus dem Verzeichnis. Das in den Seed zu
|
||||
legen hieße, jeder frischen Installation Demo-Läufe mitzugeben, die dort niemand bestellt hat. Die
|
||||
Zusammenführung dieser Fixtures steht in [#018](018-e2e-anleitung-pdf.md).
|
||||
|
||||
Offen geblieben und bewusst nicht behoben: wird eine Seed-Vorlage **umbenannt**, legt der nächste
|
||||
Lauf sie erneut an — der Seed kennt sie nur an ihrem Namen. Ein stabiler Schlüssel dafür gehört ins
|
||||
Austauschformat und damit in ein eigenes Issue.
|
||||
|
||||
@@ -17,6 +17,10 @@ Nachweis, dass die Abläufe wirklich durchlaufen, und eine Anleitung, mit der ei
|
||||
- [ ] Playwright-Onboarding-Ablauf: Vorlage starten → Vorname und Nachname eingeben → UPN, `sam` und Mail werden vorgeschlagen → Abteilung „Buchhaltung“ wählen → im Schritt „Konto anlegen“ steht der fertige `New-ADUser`-Befehl → Kopieren liefert exakt diesen Text (Clipboard auslesen)
|
||||
- [ ] Playwright-Mobilprüfung: auf allen Hauptseiten bei 390 × 844 ist `scrollWidth <= clientWidth`
|
||||
- [ ] Playwright-Rollenprüfung: `LESER` sieht keine Schreib-Knöpfe und wird serverseitig abgewiesen
|
||||
- [ ] Die Abläufe suchen ihre Vorlage **am Namen**, nicht über `select ... { index: 1 }`. Aufgefallen in #017: gegen eine frisch geseedete Datenbank steht an erster Stelle „Mitarbeiter-Offboarding“ mit Pflichtvariablen, die der Helfer nicht füllt — sechs Abläufe liefen je 30 s in den Zeitablauf. Ein Ablauf, der von der Sortierung der Vorlagenliste abhängt, prüft nicht, was er zu prüfen vorgibt
|
||||
- [ ] Die Voraussetzungen eines E2E-Laufs stehen an einer Stelle und werden geprüft, statt im Kopfkommentar zu stehen (`pnpm beispiel:vorlage`, `pnpm beispiel:stammdaten`, `pnpm beispiel:lauf`) — besser noch: die Abläufe legen an, was sie brauchen. Die drei Skripte tragen auch die Screenshots; was davon bleibt und was in die Abläufe wandert, wird hier entschieden. In den Seed gehört es **nicht**: eine frische Installation soll keine Demo-Läufe und keine offenen Entwürfe mitbringen (in #017 geprüft und so entschieden)
|
||||
- [ ] Kein Ablauf setzt voraus, dass die Datenbank schon Läufe enthält. `uebersicht-druck.spec.ts` sucht sich mit `lauf.findFirstOrThrow()` irgendeinen Lauf und „Zuletzt bearbeitet" muss gefüllt sein — gegen eine frisch eingerichtete Installation scheitert beides mit „No record was found", und das sagt nichts über die Ursache (gesehen in #017)
|
||||
- [ ] Der E2E-Lauf prüft beim Start, dass er **dieselbe** Datenbank benutzt wie der Server unter Test. Die Abläufe legen ihre Daten über `prisma` aus `.env` an, die Adresse des Servers steht in `E2E_APP_URL` — zeigen beide auf verschiedene Datenbanken, schreibt der Test in die eine und sucht in der anderen. Die Meldung heißt dann „0 Treffer“ und sagt nichts über die Ursache (gesehen in #017)
|
||||
- [ ] `pnpm docs:screenshots`: Playwright-Skript, Desktop 1440 × 900 und Handy 390 × 844, Seed-Daten, dunkles Design, mit Markierungen (Rahmen, Nummern) für die beschriebenen Stellen; Ablage `docs/anleitung/bilder/`
|
||||
- [ ] `pnpm docs:pdf`: Anleitung als PDF über Chromium (`page.pdf`, A4, `printBackground`), Ablage `docs/anleitung/laufzettel-anleitung.pdf`
|
||||
- [ ] `docs/anleitung/` auf Deutsch, getrennt für Techniker und Admins: Erste Schritte · Vorlagen bauen · Läufe abarbeiten (Desktop und Handy) · QR-Tags drucken und registrieren · Mitarbeiter und Abteilungen · Betriebsmodus · Admin: Verzeichnis, Benutzer, Einstellungen · Fehlerbehebung
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# #020 Vorgesetzter auch für von Hand angelegte Personen
|
||||
|
||||
**Status:** offen
|
||||
**Im Tracker:** [#31](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/31) — die Zählung dort schließt Pull Requests ein und läuft deshalb auseinander
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, backend
|
||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
||||
|
||||
## Ziel
|
||||
|
||||
Eine Person, die nicht aus dem Verzeichnis kommt, kann heute keinen Vorgesetzten haben. Das fiel in
|
||||
[#017](017-seed-sieben-vorlagen.md) auf: der Auftrag verlangt für den Beispielbestand „Maria Huber,
|
||||
Vorgesetzte Petra Klein“, und genau diese Zuordnung lässt sich nirgends speichern.
|
||||
|
||||
## Woran es liegt
|
||||
|
||||
`Mitarbeiter.vorgesetzterExterneId` trägt die `externeId` des Vorgesetzten, und die ist nur
|
||||
**innerhalb einer Verzeichnisquelle** eindeutig. Beide Lesepfade steigen deshalb aus, wenn die
|
||||
Person keine Quelle hat:
|
||||
|
||||
- `vorgesetzterVon()` in `src/server/queries/mitarbeiter.ts`
|
||||
- `vorgesetztenLesen()` in `src/server/services/platzhalter.ts`
|
||||
|
||||
Das ist für Verzeichnis-Personen richtig. Für eine von Hand angelegte Person gibt es schlicht kein
|
||||
Feld — `{{vorgesetzter_sam}}` bleibt dort immer leer.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] Entscheiden: eigene Selbstbeziehung `vorgesetzterId` (sauber, aber zwei Wege zur selben
|
||||
Auskunft) **oder** `vorgesetzterExterneId` auch im Bereich „ohne Quelle“ auflösen (`quelleId
|
||||
IS NULL`), mit stabilen Kennungen für manuelle Personen
|
||||
- [ ] Beide Lesepfade und `{{vorgesetzter}}` / `{{vorgesetzter_sam}}` entsprechend erweitern
|
||||
- [ ] Oberfläche: Vorgesetzten auf der Mitarbeiterseite setzen und entfernen
|
||||
- [ ] Der Abgleich darf die Zuordnung nicht überschreiben, wenn das Verzeichnis keinen `manager`
|
||||
liefert — und keine Schleife zulassen (A ist Vorgesetzter von B ist Vorgesetzter von A)
|
||||
- [ ] Seed: Petra Klein als Vorgesetzte von Maria Huber eintragen
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Test: eine von Hand angelegte Person bekommt einen Vorgesetzten, `{{vorgesetzter_sam}}` löst auf
|
||||
- [ ] Test: die Zuordnung gilt nur innerhalb desselben Kunden
|
||||
- [ ] Test: ein Abgleich ohne `manager` löscht eine von Hand gesetzte Zuordnung nicht
|
||||
@@ -0,0 +1,31 @@
|
||||
# #021 Stabiler Schlüssel für Seed-Vorlagen
|
||||
|
||||
**Status:** offen
|
||||
**Im Tracker:** [#32](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/32) — die Zählung dort schließt Pull Requests ein und läuft deshalb auseinander
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, backend
|
||||
|
||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
||||
|
||||
## Ziel
|
||||
|
||||
Der Seed erkennt seine sieben Vorlagen am **Namen**. Wird eine umbenannt, legt der nächste Lauf sie
|
||||
erneut an — und `GeraetetypVorlage` bekommt eine zweite Zeile, der Schnellstart steht auf der
|
||||
Geräteseite doppelt. Aufgefallen im Review zu [#017](017-seed-sieben-vorlagen.md).
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] Ein optionales Feld `schluessel` im [Austauschformat](../vorlagen-format.md) — gedacht für
|
||||
mitgelieferte Vorlagen, nicht für die Oberfläche; beim Import **nicht** übernommen, damit eine
|
||||
eingespielte Kopie nicht den Platz des Originals einnimmt
|
||||
- [ ] `Vorlage.seedSchluessel` mit partiellem eindeutigen Index (nur wo gesetzt)
|
||||
- [ ] Der Seed sucht über den Schlüssel und fällt auf den Namen zurück, solange noch keiner gesetzt
|
||||
ist — eine bestehende Installation darf beim ersten Lauf danach keine Dubletten bekommen
|
||||
- [ ] Dokumentieren, was der Schlüssel **nicht** tut: er macht eine Vorlage nicht unveränderlich und
|
||||
erlaubt dem Seed nicht, eine bearbeitete Vorlage zu überschreiben
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Test: umbenannte Seed-Vorlage, zweiter Seed-Lauf legt keine zweite an
|
||||
- [ ] Test: zweiter Lauf erzeugt keine zweite `GeraetetypVorlage`-Zeile
|
||||
- [ ] Test: eine über den Import eingespielte Kopie bekommt **keinen** Schlüssel
|
||||
@@ -21,7 +21,9 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
|
||||
| #014 | [M9 – QR-Tags, Geräteseite, Etiketten, Scanner](014-qr-tags-geraeteseite.md) | erledigt | v0.1.0 | 12 h | 3 h 35 min |
|
||||
| #015 | [M10 – Dashboard und Archiv](015-dashboard-archiv.md) | erledigt | v0.1.0 | 8 h | 2 h 20 min |
|
||||
| #016 | [M11 – Verzeichnis und LDAP-Abgleich](016-verzeichnis-ldap.md) | erledigt | v0.1.0 | 12 h | 3 h 25 min |
|
||||
| #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | offen | v0.1.0 | 8 h | – |
|
||||
| #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | erledigt | v0.1.0 | 8 h | 1 h 30 min |
|
||||
| #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** | **26 h 10 min** |
|
||||
| #020 | [Vorgesetzter auch für von Hand angelegte Personen](020-vorgesetzter-manuell.md) | offen | v0.2.0 | 3 h | – |
|
||||
| #021 | [Stabiler Schlüssel für Seed-Vorlagen](021-stabiler-schluessel-vorlagen.md) | offen | v0.2.0 | 2 h | – |
|
||||
| | **Summe** | | | **141 h** | **27 h 40 min** |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 444 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
@@ -0,0 +1,189 @@
|
||||
import { geraetSaeen, stammdatenSaeen, type StammdatenErgebnis } from "./stammdaten.js";
|
||||
import { GeraeteTyp, Herkunft } from "../../src/generated/prisma/enums.js";
|
||||
import type { PrismaClient } from "../../src/generated/prisma/client.js";
|
||||
|
||||
/**
|
||||
* Die beiden Demo-Kunden fuer `SEED_MODE=DIENSTLEISTUNG` (#017).
|
||||
*
|
||||
* Im Modus DIENSTLEISTUNG betreut das Haus fremde Betriebe. Der Seed legt zwei
|
||||
* an, damit die Kundenwahl, das Kuerzel auf dem Etikett und die Hostname-Regel
|
||||
* `{kundenkuerzel}-PC-{nnn}` ueberhaupt etwas zu zeigen haben — bei einem Kunden
|
||||
* saehe jede Auswahlliste gleich aus, und ein Mandantenfehler fiele nicht auf.
|
||||
*
|
||||
* **„Stattdessen“, nicht „zusaetzlich“.** Der Beispielbestand haengt in diesem
|
||||
* Modus an der Musterfirma und nicht an der eigenen Organisation: der Auftrag
|
||||
* verlangt, dass `LZ-7K3F9Q` hier auf `MUS-PC-001` fuehrt. Lag derselbe
|
||||
* Aufkleber an der eigenen Organisation, liefe der Hauptablauf im Modus
|
||||
* DIENSTLEISTUNG gegen die falsche Zeile (Reviewbefund zu #017).
|
||||
*
|
||||
* **Kein Verzeichnis bei Kunden.** An fremde Active Directories bindet sich diese
|
||||
* Anwendung ausdruecklich nicht (#008); im Modus DIENSTLEISTUNG gibt es das
|
||||
* Verzeichnis-Modul ueberhaupt nicht.
|
||||
*/
|
||||
|
||||
export type DemoKunde = {
|
||||
readonly name: string;
|
||||
readonly kuerzel: string;
|
||||
/**
|
||||
* Der volle Beispielbestand (drei Abteilungen, fuenf Personen, die beiden
|
||||
* Beispielgeraete mit ihren Tag-Codes, fuenf vorgedruckte Tags) — oder der
|
||||
* kleine Satz aus einer Abteilung, einer Person und einem Geraet.
|
||||
*/
|
||||
readonly vollbestand: boolean;
|
||||
readonly abteilung?: string;
|
||||
readonly person?: {
|
||||
readonly vorname: string;
|
||||
readonly nachname: string;
|
||||
readonly sam: string;
|
||||
readonly upn: string;
|
||||
readonly titel: string;
|
||||
};
|
||||
/**
|
||||
* Eigener Tag-Code: ein `tagCode` ist global eindeutig und wird nie
|
||||
* wiederverwendet. Zwei Kunden koennen sich denselben Aufkleber also nicht
|
||||
* teilen, auch wenn bei beiden ein Geraet „PC-001“ steht.
|
||||
*/
|
||||
readonly pcTag?: string;
|
||||
};
|
||||
|
||||
export const DEMO_KUNDEN: readonly DemoKunde[] = [
|
||||
{ name: "Musterfirma GmbH", kuerzel: "MUS", vollbestand: true },
|
||||
{
|
||||
name: "Bäckerei Reiter",
|
||||
kuerzel: "REI",
|
||||
vollbestand: false,
|
||||
abteilung: "Verkauf",
|
||||
person: {
|
||||
vorname: "Josef",
|
||||
nachname: "Reiter",
|
||||
sam: "jreiter",
|
||||
upn: "j.reiter@baeckerei-reiter.example",
|
||||
titel: "Inhaber",
|
||||
},
|
||||
pcTag: "LZ-B2NJ5R",
|
||||
},
|
||||
];
|
||||
|
||||
export type DemoErgebnis = {
|
||||
readonly kunde: string;
|
||||
readonly kuerzel: string;
|
||||
readonly neu: boolean;
|
||||
readonly hostname: string;
|
||||
readonly bestand: StammdatenErgebnis | null;
|
||||
};
|
||||
|
||||
export async function demoKundenSaeen(db: PrismaClient): Promise<readonly DemoErgebnis[]> {
|
||||
const ergebnisse: DemoErgebnis[] = [];
|
||||
|
||||
for (const vorgabe of DEMO_KUNDEN) {
|
||||
/*
|
||||
* Gesucht wird ueber Kuerzel **und** Name, und nur unter fremden Kunden.
|
||||
* Vorher genuegte das Kuerzel: ein echter Kunde „Muster und Soehne KG“ mit
|
||||
* dem Kuerzel MUS bekam dann eine erfundene Person, eine erfundene Ziel-OU
|
||||
* und ein Geraet — und die Ausgabe sagte „vorhanden“, es fiel also niemandem
|
||||
* auf (Reviewbefund zu #017). Einen Kunden, den der Seed nicht angelegt hat,
|
||||
* fasst er nicht an.
|
||||
*/
|
||||
const vorhanden = await db.kunde.findFirst({
|
||||
where: { kuerzel: vorgabe.kuerzel, name: vorgabe.name, istEigeneOrganisation: false },
|
||||
});
|
||||
if (!vorhanden) {
|
||||
const fremder = await db.kunde.findFirst({ where: { kuerzel: vorgabe.kuerzel } });
|
||||
if (fremder) {
|
||||
console.log(
|
||||
` Kunde ${vorgabe.kuerzel}: „${fremder.name}“ trägt dieses Kürzel bereits — ` +
|
||||
"der Demo-Kunde wird nicht angelegt",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const kunde =
|
||||
vorhanden ??
|
||||
(await db.kunde.create({
|
||||
data: { name: vorgabe.name, kuerzel: vorgabe.kuerzel, istEigeneOrganisation: false },
|
||||
}));
|
||||
|
||||
if (vorgabe.vollbestand) {
|
||||
const bestand = await stammdatenSaeen(db, kunde.id, { hostnamePraefix: vorgabe.kuerzel });
|
||||
ergebnisse.push({
|
||||
kunde: kunde.name,
|
||||
kuerzel: vorgabe.kuerzel,
|
||||
neu: vorhanden === null,
|
||||
hostname: `${vorgabe.kuerzel}-PC-001`,
|
||||
bestand,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
ergebnisse.push({
|
||||
kunde: kunde.name,
|
||||
kuerzel: vorgabe.kuerzel,
|
||||
neu: vorhanden === null,
|
||||
hostname: `${vorgabe.kuerzel}-PC-001`,
|
||||
bestand: await kleinerBestand(db, kunde.id, vorgabe),
|
||||
});
|
||||
}
|
||||
|
||||
return ergebnisse;
|
||||
}
|
||||
|
||||
/** Eine Abteilung, eine Person, ein Geraet — genug, damit der Kunde sichtbar ist. */
|
||||
async function kleinerBestand(
|
||||
db: PrismaClient,
|
||||
kundeId: string,
|
||||
vorgabe: DemoKunde,
|
||||
): Promise<StammdatenErgebnis> {
|
||||
const { abteilung, person, pcTag, kuerzel } = vorgabe;
|
||||
if (!abteilung || !person || !pcTag) {
|
||||
throw new Error(`Dem Demo-Kunden ${kuerzel} fehlen Abteilung, Person oder Tag-Code.`);
|
||||
}
|
||||
|
||||
let abteilungen = 0;
|
||||
const vorhandeneAbteilung = await db.abteilung.findUnique({
|
||||
where: { kundeId_name: { kundeId, name: abteilung } },
|
||||
});
|
||||
if (!vorhandeneAbteilung) {
|
||||
await db.abteilung.create({
|
||||
data: {
|
||||
kundeId,
|
||||
name: abteilung,
|
||||
zielOu: `OU=${abteilung},DC=${kuerzel.toLowerCase()},DC=local`,
|
||||
standardGruppen: [`G-${abteilung}`],
|
||||
},
|
||||
});
|
||||
abteilungen = 1;
|
||||
}
|
||||
|
||||
let personen = 0;
|
||||
let zeile = await db.mitarbeiter.findFirst({ where: { kundeId, upn: person.upn } });
|
||||
if (!zeile) {
|
||||
zeile = await db.mitarbeiter.create({
|
||||
data: {
|
||||
kundeId,
|
||||
vorname: person.vorname,
|
||||
nachname: person.nachname,
|
||||
anzeigename: `${person.nachname}, ${person.vorname}`,
|
||||
mail: person.upn,
|
||||
upn: person.upn,
|
||||
sam: person.sam,
|
||||
abteilung,
|
||||
titel: person.titel,
|
||||
herkunft: Herkunft.MANUELL,
|
||||
aktiv: true,
|
||||
},
|
||||
});
|
||||
personen = 1;
|
||||
}
|
||||
|
||||
const geraet = await geraetSaeen(db, pcTag, {
|
||||
kundeId,
|
||||
typ: GeraeteTyp.PC,
|
||||
hostname: `${kuerzel}-PC-001`,
|
||||
mitarbeiterId: zeile.id,
|
||||
modell: "HP ProDesk 400",
|
||||
registriert: true,
|
||||
});
|
||||
|
||||
return { abteilungen, personen, geraete: geraet ? 1 : 0, offeneTags: 0 };
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { hash } from "@node-rs/argon2";
|
||||
import { Betriebsmodus, Rolle } from "../../src/generated/prisma/enums.js";
|
||||
import type { PrismaClient } from "../../src/generated/prisma/client.js";
|
||||
|
||||
/**
|
||||
* Der Grundbestand, ohne den die Anwendung nicht bedienbar waere (#006, #017):
|
||||
* Betriebsmodus samt Namensregeln, die eigene Organisation und ein Administrator.
|
||||
*
|
||||
* Steht neben dem Seed-Skript und nicht darin, damit Tests diese Schritte einzeln
|
||||
* aufrufen koennen. `prisma/seed.ts` fuehrt beim Import den ganzen Seed aus — ein
|
||||
* Test, der es importierte, schriebe in die Datenbank aus `DATABASE_URL`.
|
||||
*
|
||||
* Die Funktionen nehmen den Client als Argument. Sie schreiben nur, was fehlt.
|
||||
*/
|
||||
|
||||
/** Akzeptiert den im Auftrag dokumentierten Wert SERVICE als Alias. */
|
||||
export 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}".`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Namensregeln, die zu den Beispieldaten passen.
|
||||
*
|
||||
* `{v}.{nachname}@firma.example` ergibt fuer Maria Huber genau
|
||||
* `m.huber@firma.example` und `mhuber` — dieselben Werte, die auch im
|
||||
* Beispielbestand stehen. Ohne diese Regeln antwortet „Namen aus den Regeln
|
||||
* vorschlagen“ im Onboarding mit „Für diesen Kunden sind keine Namensregeln
|
||||
* hinterlegt“, und das Abnahmekriterium „Vorname und Nachname eingeben → UPN,
|
||||
* sam und Mail werden vorgeschlagen“ liesse sich nach einem frischen Seed nicht
|
||||
* vorfuehren (Reviewbefund zu #017).
|
||||
*
|
||||
* Die Hostname-Regel haengt am Modus: im Modus DIENSTLEISTUNG traegt der Name
|
||||
* das Kundenkuerzel, wie der Auftrag es vorgibt (`{kundenkuerzel}-PC-{nnn}`).
|
||||
*/
|
||||
export function namensregelnFuerModus(modus: Betriebsmodus): Record<string, string> {
|
||||
return {
|
||||
upn: "{v}.{nachname}@firma.example",
|
||||
mail: "{v}.{nachname}@firma.example",
|
||||
sam: "{v}{nachname}",
|
||||
hostname: modus === Betriebsmodus.DIENSTLEISTUNG ? "{kundenkuerzel}-PC-{nnn}" : "PC-{nnn}",
|
||||
};
|
||||
}
|
||||
|
||||
/** Ziel-OU fuer deaktivierte Konten – Vorbelegung von `{{ou_gesperrt}}` im Offboarding. */
|
||||
export const GESPERRTE_OU = "OU=Gesperrt,OU=Benutzer,DC=firma,DC=local";
|
||||
|
||||
export async function einstellungAnlegen(db: PrismaClient, modus: Betriebsmodus): Promise<void> {
|
||||
// Nur anlegen, nicht ueberschreiben: ein spaeterer Seed-Lauf darf einen im
|
||||
// Betrieb umgeschalteten Modus nicht zuruecksetzen.
|
||||
const vorhanden = await db.einstellung.findUnique({ where: { id: "global" } });
|
||||
if (!vorhanden) {
|
||||
await db.einstellung.create({
|
||||
data: {
|
||||
id: "global",
|
||||
modus,
|
||||
namensregeln: namensregelnFuerModus(modus),
|
||||
gesperrteOu: GESPERRTE_OU,
|
||||
},
|
||||
});
|
||||
console.log(` Einstellung: angelegt, Modus ${modus}, Namensregeln und Sperr-OU gesetzt`);
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Die Zeile steht schon. Ergaenzt wird nur, was **leer** ist: eine gepflegte
|
||||
* Namensregel oder eine eigene Sperr-OU zurueckzusetzen waere eine Aenderung an
|
||||
* Betriebsdaten, und dafuer ist der Seed nicht da.
|
||||
*/
|
||||
const ohneRegeln =
|
||||
typeof vorhanden.namensregeln !== "object" ||
|
||||
vorhanden.namensregeln === null ||
|
||||
Object.keys(vorhanden.namensregeln as Record<string, unknown>).length === 0;
|
||||
const ohneSperrOu = (vorhanden.gesperrteOu ?? "").trim() === "";
|
||||
if (ohneRegeln || ohneSperrOu) {
|
||||
await db.einstellung.update({
|
||||
where: { id: "global" },
|
||||
data: {
|
||||
...(ohneRegeln ? { namensregeln: namensregelnFuerModus(vorhanden.modus) } : {}),
|
||||
...(ohneSperrOu ? { gesperrteOu: GESPERRTE_OU } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
const ergaenzt = [ohneRegeln ? "Namensregeln" : null, ohneSperrOu ? "Sperr-OU" : null].filter(
|
||||
(teil) => teil !== null,
|
||||
);
|
||||
console.log(
|
||||
` Einstellung: vorhanden, Modus ${vorhanden.modus} bleibt` +
|
||||
(ergaenzt.length > 0 ? ` (${ergaenzt.join(" und ")} ergänzt)` : ""),
|
||||
);
|
||||
}
|
||||
|
||||
export async function eigeneOrganisationAnlegen(db: PrismaClient): Promise<string> {
|
||||
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 db.kunde.findFirst({ where: { istEigeneOrganisation: true } });
|
||||
if (vorhanden) {
|
||||
console.log(` Eigene Organisation: vorhanden (${vorhanden.name})`);
|
||||
return vorhanden.id;
|
||||
}
|
||||
const kunde = await db.kunde.create({
|
||||
data: { name, kuerzel, istEigeneOrganisation: true },
|
||||
});
|
||||
console.log(` Eigene Organisation: angelegt (${kunde.name}, ${kunde.kuerzel})`);
|
||||
return kunde.id;
|
||||
}
|
||||
|
||||
export async function adminAnlegen(db: PrismaClient): Promise<void> {
|
||||
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 db.benutzer.findUnique({ where: { email } });
|
||||
if (vorhanden) {
|
||||
console.log(` Administrator: vorhanden (${email})`);
|
||||
return;
|
||||
}
|
||||
// Das Passwort wird nur als argon2-Hash gespeichert, nie im Klartext.
|
||||
await db.benutzer.create({
|
||||
data: {
|
||||
email,
|
||||
name: "Administrator",
|
||||
rolle: Rolle.ADMIN,
|
||||
passwortHash: await hash(passwort),
|
||||
aktiv: true,
|
||||
},
|
||||
});
|
||||
console.log(` Administrator: angelegt (${email})`);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { verschluesseln } from "@/lib/geheimnisse";
|
||||
import { istTagCode } from "@/lib/tagcode";
|
||||
import { AD_ZUORDNUNG } from "@/lib/verzeichnis";
|
||||
import { STANDARD_FILTER } from "@/server/ldap/zuordnung";
|
||||
import { GeraeteTyp, Herkunft } from "../../src/generated/prisma/enums.js";
|
||||
import type { PrismaClient } from "../../src/generated/prisma/client.js";
|
||||
|
||||
/**
|
||||
* Beispielbestand: Abteilungen, Personen, Geraete, Tags (#017).
|
||||
*
|
||||
* Damit ist die Anwendung nach dem Seed nicht nur bedienbar, sondern vorfuehrbar:
|
||||
* ein Lauf laesst sich starten, ein Etikett drucken, ein QR-Code scannen. Alles
|
||||
* gehoert der eigenen Organisation; im Modus DIENSTLEISTUNG kommen die
|
||||
* Demo-Kunden dazu (siehe `dienstleistung.ts`).
|
||||
*
|
||||
* **Idempotent ueber sprechende Schluessel**, nicht ueber Kennungen: Abteilungen
|
||||
* ueber `(kundeId, name)`, Personen ueber den UPN, Geraete ueber den `tagCode`.
|
||||
* Gibt es die Zeile schon, bleibt sie, wie sie ist — wer im Betrieb etwas
|
||||
* geaendert hat, bekommt es nicht beim naechsten Seed-Lauf zurueckgesetzt.
|
||||
*/
|
||||
|
||||
export type AbteilungsVorgabe = {
|
||||
readonly name: string;
|
||||
readonly zielOu: string;
|
||||
readonly standardGruppen: readonly string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Nur die Buchhaltung ist im Auftrag ausbuchstabiert (OU und Gruppen); Vertrieb
|
||||
* und IT bekommen dieselbe Form. Eine Abteilung ohne Ziel-OU waere im
|
||||
* Onboarding-Lauf ein leeres `-Path` im `New-ADUser`-Befehl — genau die Stelle,
|
||||
* an der man den Wert nicht selbst zusammenraten will.
|
||||
*/
|
||||
export const ABTEILUNGEN: readonly AbteilungsVorgabe[] = [
|
||||
{
|
||||
name: "Buchhaltung",
|
||||
zielOu: "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local",
|
||||
standardGruppen: ["G-Buchhaltung", "G-Alle"],
|
||||
},
|
||||
{
|
||||
name: "Vertrieb",
|
||||
zielOu: "OU=Vertrieb,OU=Benutzer,DC=firma,DC=local",
|
||||
standardGruppen: ["G-Vertrieb", "G-Alle"],
|
||||
},
|
||||
{
|
||||
name: "IT",
|
||||
zielOu: "OU=IT,OU=Benutzer,DC=firma,DC=local",
|
||||
standardGruppen: ["G-IT", "G-Alle"],
|
||||
},
|
||||
];
|
||||
|
||||
export type PersonenVorgabe = {
|
||||
readonly vorname: string;
|
||||
readonly nachname: string;
|
||||
readonly sam: string;
|
||||
readonly upn: string;
|
||||
readonly abteilung: string;
|
||||
readonly titel: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fuenf Personen, von Hand angelegt (`Herkunft.MANUELL`) — so, wie sie in einem
|
||||
* Haus ohne Verzeichnis entstehen.
|
||||
*
|
||||
* Maria Huber steht so im Auftrag und taucht in den Abnahmekriterien wieder auf.
|
||||
* Ihre Vorgesetzte Petra Klein ist deshalb ebenfalls dabei; **gespeichert wird
|
||||
* die Zuordnung nicht**: `vorgesetzterExterneId` zeigt auf die `externeId`
|
||||
* derselben Verzeichnisquelle, und eine von Hand angelegte Person hat beides
|
||||
* nicht. Im Onboarding-Lauf wird der Vorgesetzte ohnehin beim Start gewaehlt.
|
||||
*/
|
||||
export const PERSONEN: readonly PersonenVorgabe[] = [
|
||||
{
|
||||
vorname: "Maria",
|
||||
nachname: "Huber",
|
||||
sam: "mhuber",
|
||||
upn: "m.huber@firma.example",
|
||||
abteilung: "Buchhaltung",
|
||||
titel: "Sachbearbeiterin Rechnungsprüfung",
|
||||
},
|
||||
{
|
||||
vorname: "Petra",
|
||||
nachname: "Klein",
|
||||
sam: "pklein",
|
||||
upn: "p.klein@firma.example",
|
||||
abteilung: "Buchhaltung",
|
||||
titel: "Leiterin Buchhaltung",
|
||||
},
|
||||
{
|
||||
vorname: "Thomas",
|
||||
nachname: "Berger",
|
||||
sam: "tberger",
|
||||
upn: "t.berger@firma.example",
|
||||
abteilung: "Vertrieb",
|
||||
titel: "Vertriebsleiter",
|
||||
},
|
||||
{
|
||||
vorname: "Sabine",
|
||||
nachname: "Fischer",
|
||||
sam: "sfischer",
|
||||
upn: "s.fischer@firma.example",
|
||||
abteilung: "Vertrieb",
|
||||
titel: "Vertriebsinnendienst",
|
||||
},
|
||||
{
|
||||
vorname: "Lukas",
|
||||
nachname: "Mayr",
|
||||
sam: "lmayr",
|
||||
upn: "l.mayr@firma.example",
|
||||
abteilung: "IT",
|
||||
titel: "Systemadministrator",
|
||||
},
|
||||
];
|
||||
|
||||
/** Die beiden Geraete aus dem Auftrag, samt der Tag-Codes von den Aufklebern. */
|
||||
export const PC_TAG = "LZ-7K3F9Q";
|
||||
export const NB_TAG = "LZ-M2XH4T";
|
||||
|
||||
/**
|
||||
* Fuenf vorgedruckte, noch nicht zugeordnete Tags.
|
||||
*
|
||||
* Feste Codes und keine gezogenen: ein Seed, der jedes Mal fuenf neue Codes
|
||||
* zieht, ist nicht idempotent — beim dritten Lauf laegen fuenfzehn Aufkleber im
|
||||
* Stapel. Die Codes stehen im Alphabet ohne I, O, 0 und 1, damit sie sich von
|
||||
* einem Etikett ablesen lassen.
|
||||
*/
|
||||
export const OFFENE_TAGS: readonly string[] = [
|
||||
"LZ-4B7NCD",
|
||||
"LZ-9TQK2M",
|
||||
"LZ-R5HXJW",
|
||||
"LZ-P3VZ8L",
|
||||
"LZ-K6DYFN",
|
||||
];
|
||||
|
||||
export type StammdatenErgebnis = {
|
||||
readonly abteilungen: number;
|
||||
readonly personen: number;
|
||||
readonly geraete: number;
|
||||
readonly offeneTags: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Legt einen Tag an, wenn es ihn noch nicht gibt.
|
||||
*
|
||||
* `TagCodeVergabe` und `Geraet` werden gemeinsam geschrieben: ein Geraet mit
|
||||
* einem Code, der nicht im Vergabebuch steht, waere nach dem Loeschen wieder
|
||||
* frei — und ein alter Aufkleber im Regal zeigte auf ein fremdes Objekt.
|
||||
*
|
||||
* Gefragt werden deshalb **beide Buecher**, so wie es `freienTagCodeZiehen` im
|
||||
* Geraetedienst tut: ein Code, der nur in einem von beiden steht, ist trotzdem
|
||||
* vergeben. Vorher fragte der Seed nur nach dem Geraet — wer den Beispiel-PC
|
||||
* endgueltig geloescht hatte (die eine erlaubte Ausnahme, siehe CLAUDE.md), bekam
|
||||
* beim naechsten Seed-Lauf denselben Code auf einem neuen Geraet, und der alte
|
||||
* Aufkleber im Regal zeigte auf ein fremdes Objekt (Reviewbefund zu #017).
|
||||
*/
|
||||
export async function geraetSaeen(
|
||||
db: PrismaClient,
|
||||
tagCode: string,
|
||||
daten: {
|
||||
readonly kundeId: string;
|
||||
readonly typ: GeraeteTyp;
|
||||
readonly hostname?: string;
|
||||
readonly mitarbeiterId?: string | null;
|
||||
readonly modell?: string;
|
||||
readonly seriennummer?: string;
|
||||
readonly registriert: boolean;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
if (!istTagCode(tagCode)) {
|
||||
throw new Error(`„${tagCode}“ hat nicht die Form eines Tag-Codes.`);
|
||||
}
|
||||
const [vorhanden, vergeben] = await Promise.all([
|
||||
db.geraet.findUnique({ where: { tagCode } }),
|
||||
db.tagCodeVergabe.findUnique({ where: { tagCode } }),
|
||||
]);
|
||||
if (vorhanden) return false;
|
||||
if (vergeben) {
|
||||
// Verbrannt: der Code war schon einmal vergeben, das Geraet dazu gibt es
|
||||
// nicht mehr. Er kommt nicht zurueck – auch nicht als Beispieldatensatz.
|
||||
console.log(` Tag ${tagCode}: verbrannt, wird nicht neu vergeben`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.tagCodeVergabe.create({ data: { tagCode } });
|
||||
await tx.geraet.create({
|
||||
data: {
|
||||
kundeId: daten.kundeId,
|
||||
tagCode,
|
||||
typ: daten.typ,
|
||||
hostname: daten.hostname ?? null,
|
||||
mitarbeiterId: daten.mitarbeiterId ?? null,
|
||||
modell: daten.modell ?? null,
|
||||
seriennummer: daten.seriennummer ?? null,
|
||||
// Ein Tag ohne `registriertAm` ist ein vorgedruckter Aufkleber, der auf
|
||||
// sein Geraet wartet; die Datenbank laesst ihn deshalb ohne Namen zu.
|
||||
registriertAm: daten.registriert ? new Date() : null,
|
||||
},
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Abteilungen, Personen, Geraete und Tags fuer einen Kunden. */
|
||||
export async function stammdatenSaeen(
|
||||
db: PrismaClient,
|
||||
kundeId: string,
|
||||
optionen: { readonly hostnamePraefix?: string; readonly mitOffenenTags?: boolean } = {},
|
||||
): Promise<StammdatenErgebnis> {
|
||||
let abteilungen = 0;
|
||||
for (const vorgabe of ABTEILUNGEN) {
|
||||
// Erst nachsehen, dann anlegen: ein `upsert` mit leerem `update` waere
|
||||
// kuerzer, aber der Rueckgabewert sagt nicht, ob etwas entstanden ist — und
|
||||
// der Seed soll am Ende ehrlich zaehlen, was er getan hat.
|
||||
const vorhanden = await db.abteilung.findUnique({
|
||||
where: { kundeId_name: { kundeId, name: vorgabe.name } },
|
||||
});
|
||||
if (vorhanden) continue;
|
||||
await db.abteilung.create({
|
||||
data: {
|
||||
kundeId,
|
||||
name: vorgabe.name,
|
||||
zielOu: vorgabe.zielOu,
|
||||
standardGruppen: [...vorgabe.standardGruppen],
|
||||
mailDomain: "firma.example",
|
||||
},
|
||||
});
|
||||
abteilungen += 1;
|
||||
}
|
||||
|
||||
let personen = 0;
|
||||
const nachUpn = new Map<string, string>();
|
||||
for (const vorgabe of PERSONEN) {
|
||||
const vorhanden = await db.mitarbeiter.findFirst({ where: { kundeId, upn: vorgabe.upn } });
|
||||
if (vorhanden) {
|
||||
nachUpn.set(vorgabe.upn, vorhanden.id);
|
||||
continue;
|
||||
}
|
||||
const angelegt = await db.mitarbeiter.create({
|
||||
data: {
|
||||
kundeId,
|
||||
vorname: vorgabe.vorname,
|
||||
nachname: vorgabe.nachname,
|
||||
anzeigename: `${vorgabe.nachname}, ${vorgabe.vorname}`,
|
||||
mail: vorgabe.upn,
|
||||
upn: vorgabe.upn,
|
||||
sam: vorgabe.sam,
|
||||
abteilung: vorgabe.abteilung,
|
||||
titel: vorgabe.titel,
|
||||
herkunft: Herkunft.MANUELL,
|
||||
aktiv: true,
|
||||
},
|
||||
});
|
||||
nachUpn.set(vorgabe.upn, angelegt.id);
|
||||
personen += 1;
|
||||
}
|
||||
|
||||
// Im Modus DIENSTLEISTUNG traegt der Hostname das Kundenkuerzel – dieselbe
|
||||
// Regel, die auch die Namensvorschlaege benutzen (`{kundenkuerzel}-PC-{nnn}`).
|
||||
const praefix = optionen.hostnamePraefix ? `${optionen.hostnamePraefix}-` : "";
|
||||
let geraete = 0;
|
||||
if (
|
||||
await geraetSaeen(db, PC_TAG, {
|
||||
kundeId,
|
||||
typ: GeraeteTyp.PC,
|
||||
hostname: `${praefix}PC-001`,
|
||||
// Ueber die Konstante und nicht ueber die Zeichenkette: wer den UPN in
|
||||
// PERSONEN aendert, verlöre sonst still die Besitzerin von PC-001.
|
||||
mitarbeiterId: nachUpn.get(PERSONEN[0]?.upn ?? "") ?? null,
|
||||
modell: "Dell OptiPlex 7010",
|
||||
registriert: true,
|
||||
})
|
||||
) {
|
||||
geraete += 1;
|
||||
}
|
||||
if (
|
||||
await geraetSaeen(db, NB_TAG, {
|
||||
kundeId,
|
||||
typ: GeraeteTyp.NOTEBOOK,
|
||||
hostname: `${praefix}NB-001`,
|
||||
modell: "Lenovo ThinkPad T14",
|
||||
registriert: true,
|
||||
})
|
||||
) {
|
||||
geraete += 1;
|
||||
}
|
||||
|
||||
let offeneTags = 0;
|
||||
if (optionen.mitOffenenTags !== false) {
|
||||
for (const tagCode of OFFENE_TAGS) {
|
||||
if (await geraetSaeen(db, tagCode, { kundeId, typ: GeraeteTyp.SONSTIGES, registriert: false })) {
|
||||
offeneTags += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { abteilungen, personen, geraete, offeneTags };
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Beispiel-Verzeichnisquelle — **deaktiviert**.
|
||||
*
|
||||
* Sie ist eine ausgefuellte Vorlage zum Abschauen und kein Zugang: die Adresse
|
||||
* zeigt auf einen Rechner, den es hier nicht gibt, und `aktiv = false` haelt den
|
||||
* Zeitplan davon ab, sie anzufassen. Das Bind-Passwort ist ein bei jedem Seed neu
|
||||
* gewuerfelter Zufallswert, der nirgends ausgegeben wird: ein fest verdrahtetes
|
||||
* Beispielpasswort im Quelltext waere eines, das am Ende in einer echten
|
||||
* Installation steht.
|
||||
*/
|
||||
export async function beispielQuelleSaeen(db: PrismaClient, kundeId: string): Promise<boolean> {
|
||||
const name = "Firmen-AD (Beispiel)";
|
||||
const vorhanden = await db.verzeichnisquelle.findFirst({ where: { kundeId, name } });
|
||||
if (vorhanden) return false;
|
||||
|
||||
await db.verzeichnisquelle.create({
|
||||
data: {
|
||||
kundeId,
|
||||
name,
|
||||
url: "ldaps://dc01.firma.local:636",
|
||||
bindDn: "CN=laufzettel,OU=Dienstkonten,DC=firma,DC=local",
|
||||
bindPasswortVerschluesselt: verschluesseln(randomBytes(24).toString("base64url")),
|
||||
basisDn: "OU=Benutzer,DC=firma,DC=local",
|
||||
filter: STANDARD_FILTER,
|
||||
attributZuordnung: { ...AD_ZUORDNUNG },
|
||||
abgleichIntervallMinuten: 60,
|
||||
aktiv: false,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { AustauschSchema, type Austausch } from "@/lib/vorlagen-format";
|
||||
import { GeraeteTyp, VersionStatus } from "../../src/generated/prisma/enums.js";
|
||||
import type { PrismaClient } from "../../src/generated/prisma/client.js";
|
||||
|
||||
/**
|
||||
* Die sieben Vorlagen des Auftrags (#017).
|
||||
*
|
||||
* Sie stehen als **JSON im Austauschformat** neben dieser Datei und nicht als
|
||||
* TypeScript-Literale. Drei Gruende:
|
||||
*
|
||||
* 1. Dasselbe Schema wie Import und Export prueft sie (`AustauschSchema`). Eine
|
||||
* Vorlage, die der Seed anlegt, ist damit genau das, was auch ein Redakteur
|
||||
* einspielen koennte — und ein Fehler faellt hier auf und nicht erst beim
|
||||
* ersten Lauf.
|
||||
* 2. Wer eine Vorlage anpassen will, aendert eine Datei und braucht keinen
|
||||
* TypeScript-Übersetzer.
|
||||
* 3. Die Dateien lassen sich unveraendert exportieren, weitergeben und in eine
|
||||
* andere Installation einspielen.
|
||||
*
|
||||
* **Angelegt wird direkt, nicht ueber `vorlageImportieren`.** Der Import erzeugt
|
||||
* absichtlich einen Entwurf (der Redakteur sieht ihn sich an und veroeffentlicht
|
||||
* dann) und schreibt einen Protokolleintrag mit dem handelnden Benutzer. Der
|
||||
* Seed hat keinen Menschen, der handelt, und braucht **veroeffentlichte**
|
||||
* Versionen: ohne sie liesse sich kein Lauf starten.
|
||||
*/
|
||||
|
||||
const HIER = dirname(fileURLToPath(import.meta.url));
|
||||
const VORLAGEN_ORDNER = join(HIER, "vorlagen");
|
||||
|
||||
export type VorlagenVorgabe = {
|
||||
/** Dateiname, damit eine Fehlermeldung sagt, wo nachzusehen ist. */
|
||||
readonly datei: string;
|
||||
readonly dokument: Austausch;
|
||||
};
|
||||
|
||||
/**
|
||||
* Liest alle Vorlagendateien und prueft sie.
|
||||
*
|
||||
* Reihenfolge nach Dateinamen — deshalb tragen sie eine fuehrende Nummer. Sie
|
||||
* entscheidet, in welcher Reihenfolge die Vorlagen entstehen, und das ist die
|
||||
* Reihenfolge des Auftrags.
|
||||
*/
|
||||
export function vorlagenLesen(): readonly VorlagenVorgabe[] {
|
||||
const dateien = readdirSync(VORLAGEN_ORDNER)
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.sort();
|
||||
return dateien.map((datei) => {
|
||||
const roh: unknown = JSON.parse(readFileSync(join(VORLAGEN_ORDNER, datei), "utf8"));
|
||||
const gelesen = AustauschSchema.safeParse(roh);
|
||||
if (!gelesen.success) {
|
||||
const erste = gelesen.error.issues[0];
|
||||
throw new Error(
|
||||
`Die Vorlagendatei ${datei} passt nicht zum Austauschformat: ` +
|
||||
`${erste?.path.join(".") ?? "?"}: ${erste?.message ?? "unbekannt"}`,
|
||||
);
|
||||
}
|
||||
return { datei, dokument: gelesen.data };
|
||||
});
|
||||
}
|
||||
|
||||
export type GesaeteVorlage = {
|
||||
readonly name: string;
|
||||
readonly vorlageId: string;
|
||||
readonly versionId: string;
|
||||
readonly neu: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Legt die Vorlagen als veroeffentlichte Version 1 an.
|
||||
*
|
||||
* Idempotent ueber den **Namen**: gibt es die Vorlage schon, bleibt sie
|
||||
* unangetastet — auch dann, wenn jemand sie inzwischen bearbeitet hat. Ein
|
||||
* zweiter Seed-Lauf darf die Arbeit eines Redakteurs nicht zurueckdrehen, und
|
||||
* eine veroeffentlichte Version wird ohnehin nie veraendert.
|
||||
*/
|
||||
export async function vorlagenSaeen(db: PrismaClient): Promise<readonly GesaeteVorlage[]> {
|
||||
const ergebnis: GesaeteVorlage[] = [];
|
||||
/** Vorlagen, die es gibt, aber ohne veroeffentlichte Version – siehe unten. */
|
||||
let uebersprungen = 0;
|
||||
|
||||
for (const { dokument } of vorlagenLesen()) {
|
||||
const vorhanden = await db.vorlage.findFirst({
|
||||
// Auch eine **archivierte** Vorlage gilt als vorhanden: eine zweite gleichen
|
||||
// Namens danebenzustellen waere keine Hilfe, sondern eine Dublette in jeder
|
||||
// Liste. Wer die Beispielvorlage zurueck will, holt sie aus dem Archiv.
|
||||
where: { name: dokument.name, kundeId: null },
|
||||
include: {
|
||||
versionen: {
|
||||
where: { status: VersionStatus.VEROEFFENTLICHT },
|
||||
orderBy: { version: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
const veroeffentlichte = vorhanden?.versionen[0];
|
||||
if (vorhanden && veroeffentlichte) {
|
||||
ergebnis.push({
|
||||
name: dokument.name,
|
||||
vorlageId: vorhanden.id,
|
||||
versionId: veroeffentlichte.id,
|
||||
neu: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (vorhanden) {
|
||||
/*
|
||||
* Es gibt die Vorlage, aber keine veroeffentlichte Version — typischerweise,
|
||||
* weil jemand genau diese Datei ueber „Vorlage aus einer Datei einspielen“
|
||||
* eingelesen hat: der Import legt absichtlich einen Entwurf an. Sie
|
||||
* trotzdem zu fuellen hiesse, eine zweite Version 1 anzulegen; der
|
||||
* eindeutige Index `(vorlageId, version)` haelt das auf, und der Seed
|
||||
* braeche mitten im Lauf mit einem rohen Prisma-Fehler ab — vor den
|
||||
* Stammdaten und vor den Demo-Kunden (Reviewbefund zu #017).
|
||||
*/
|
||||
console.log(` Vorlage „${dokument.name}“: Entwurf vorhanden, bleibt unberührt`);
|
||||
uebersprungen += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Alles in einer Transaktion: eine Vorlage ohne Abschnitte stuende sonst in
|
||||
// der Liste und liesse sich starten, ohne dass es etwas abzuarbeiten gaebe.
|
||||
const angelegt = await db.$transaction(async (tx) => {
|
||||
const vorlage =
|
||||
vorhanden ??
|
||||
(await tx.vorlage.create({
|
||||
data: {
|
||||
name: dokument.name,
|
||||
beschreibung: dokument.beschreibung ?? null,
|
||||
kategorie: dokument.kategorie ?? null,
|
||||
schlagworte: [...dokument.schlagworte],
|
||||
},
|
||||
}));
|
||||
|
||||
/*
|
||||
* Erst Entwurf, dann veroeffentlichen — denselben Weg geht auch ein
|
||||
* Redakteur, und die Datenbank laesst keinen anderen zu: seit #010
|
||||
* verbieten Trigger jedes Schreiben an Abschnitten, Variablen und
|
||||
* Schritten einer veroeffentlichten Version. Eine Version gleich als
|
||||
* VEROEFFENTLICHT anzulegen und danach zu fuellen scheitert deshalb mit
|
||||
* „Version … ist veroeffentlicht und unveraenderlich“ — richtig so.
|
||||
*/
|
||||
const version = await tx.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
|
||||
if (dokument.struktur.variablen.length > 0) {
|
||||
await tx.vorlagenVariable.createMany({
|
||||
data: dokument.struktur.variablen.map((variable, position) => ({
|
||||
versionId: version.id,
|
||||
schluessel: variable.schluessel,
|
||||
beschriftung: variable.beschriftung,
|
||||
typ: variable.typ,
|
||||
optionen: [...variable.optionen],
|
||||
pflicht: variable.pflicht,
|
||||
position,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
for (const [position, abschnitt] of dokument.struktur.abschnitte.entries()) {
|
||||
const zeile = await tx.vorlagenAbschnitt.create({
|
||||
data: { versionId: version.id, titel: abschnitt.titel, position },
|
||||
});
|
||||
if (abschnitt.schritte.length === 0) continue;
|
||||
await tx.vorlagenSchritt.createMany({
|
||||
data: abschnitt.schritte.map((schritt, schrittPosition) => ({
|
||||
abschnittId: zeile.id,
|
||||
titel: schritt.titel,
|
||||
hilfetext: schritt.hilfetext ?? null,
|
||||
typ: schritt.typ,
|
||||
pflicht: schritt.pflicht,
|
||||
// Der Inhalt kommt aus geparstem JSON und ist damit ein Json-Wert;
|
||||
// das nachsichtige Schema traegt nur eine Index-Signatur auf
|
||||
// `unknown`, die Prisma nicht als solchen erkennt.
|
||||
konfig: schritt.konfig as object,
|
||||
dokuUrl: schritt.dokuUrl ?? null,
|
||||
position: schrittPosition,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
await tx.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: {
|
||||
status: VersionStatus.VEROEFFENTLICHT,
|
||||
veroeffentlichtAm: new Date(),
|
||||
// Bleibt leer: es gibt keinen angemeldeten Menschen, der
|
||||
// veroeffentlicht haette. Die Migration laesst das ausdruecklich zu.
|
||||
veroeffentlichtVonId: null,
|
||||
aenderungsnotiz: "Erste Fassung aus dem Seed.",
|
||||
},
|
||||
});
|
||||
|
||||
return { vorlageId: vorlage.id, versionId: version.id };
|
||||
});
|
||||
|
||||
ergebnis.push({ name: dokument.name, ...angelegt, neu: true });
|
||||
}
|
||||
|
||||
if (uebersprungen > 0) {
|
||||
console.log(` ${uebersprungen} Vorlage(n) übersprungen: vorhanden, aber ohne veröffentlichte Version`);
|
||||
}
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Welche Vorlage auf der Geraeteseite als Schnellstart vorgeschlagen wird.
|
||||
*
|
||||
* Die Zuordnung steht im Auftrag und haengt am Geraetetyp, nicht am Kunden —
|
||||
* deshalb traegt `GeraetetypVorlage` keine `kundeId`. Verwiesen wird auf die
|
||||
* **Vorlage**, nicht auf eine Version: ein Schnellstart soll die jeweils aktuelle
|
||||
* veroeffentlichte Fassung starten und nicht die von damals.
|
||||
*/
|
||||
export const TYP_VORGABEN: readonly { readonly typ: GeraeteTyp; readonly vorlagen: readonly string[] }[] = [
|
||||
{ typ: GeraeteTyp.PC, vorlagen: ["PC Neuinstallation"] },
|
||||
{ typ: GeraeteTyp.NOTEBOOK, vorlagen: ["PC Neuinstallation"] },
|
||||
{
|
||||
typ: GeraeteTyp.SERVER,
|
||||
vorlagen: ["Server-Inbetriebnahme Windows-VM", "Backup-Restore-Test"],
|
||||
},
|
||||
{ typ: GeraeteTyp.FIREWALL, vorlagen: ["Firewall-Rollout OPNsense"] },
|
||||
];
|
||||
|
||||
/**
|
||||
* Legt die Schnellstart-Vorgaben an.
|
||||
*
|
||||
* Verlangt, dass es die genannten Vorlagen gibt: ein Tippfehler im Namen waere
|
||||
* sonst eine Geraeteseite ohne Schnellstart — und niemand wuesste, warum.
|
||||
*/
|
||||
export async function typVorgabenSaeen(
|
||||
db: PrismaClient,
|
||||
vorlagen: readonly GesaeteVorlage[],
|
||||
): Promise<number> {
|
||||
const nachName = new Map(vorlagen.map((eintrag) => [eintrag.name, eintrag.vorlageId]));
|
||||
let angelegt = 0;
|
||||
|
||||
for (const vorgabe of TYP_VORGABEN) {
|
||||
for (const [position, name] of vorgabe.vorlagen.entries()) {
|
||||
const vorlageId = nachName.get(name);
|
||||
if (!vorlageId) {
|
||||
throw new Error(
|
||||
`Für den Gerätetyp ${vorgabe.typ} ist die Vorlage „${name}“ vorgesehen, ` +
|
||||
"aber es gibt sie nicht. Stimmt der Name in der JSON-Datei?",
|
||||
);
|
||||
}
|
||||
const vorhanden = await db.geraetetypVorlage.findUnique({
|
||||
where: { geraeteTyp_vorlageId: { geraeteTyp: vorgabe.typ, vorlageId } },
|
||||
});
|
||||
if (vorhanden) continue;
|
||||
await db.geraetetypVorlage.create({
|
||||
data: { geraeteTyp: vorgabe.typ, vorlageId, position },
|
||||
});
|
||||
angelegt += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return angelegt;
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
{
|
||||
"format": 1,
|
||||
"name": "PC Neuinstallation",
|
||||
"beschreibung": "Vollständige Neuinstallation eines Arbeitsplatzrechners: von der Datensicherung des alten Systems über Installation, Einbindung und Verschlüsselung bis zur Übergabe an den Benutzer.",
|
||||
"kategorie": "Client",
|
||||
"schlagworte": ["windows", "client", "neuinstallation", "bitlocker"],
|
||||
"struktur": {
|
||||
"variablen": [
|
||||
{
|
||||
"schluessel": "os_version",
|
||||
"beschriftung": "Windows-Version",
|
||||
"typ": "AUSWAHL",
|
||||
"optionen": ["Windows 11 Pro", "Windows 11 Enterprise"],
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"schluessel": "join_typ",
|
||||
"beschriftung": "Art der Einbindung",
|
||||
"typ": "AUSWAHL",
|
||||
"optionen": ["Active Directory", "Entra ID", "Hybrid", "Workgroup"],
|
||||
"pflicht": true
|
||||
}
|
||||
],
|
||||
"abschnitte": [
|
||||
{
|
||||
"titel": "Vorbereitung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Ticket und Endbenutzer im Lauf erfasst",
|
||||
"hilfetext": "Ohne Ticketnummer und zugeordnete Person lässt sich später keine Rückfrage zuordnen. Beides gehört in die Kopfdaten des Laufs, nicht in einen Kommentar.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Datensicherung des alten Systems geprüft (Profil, Desktop, Dokumente, lokale PST, Browser-Favoriten)",
|
||||
"hilfetext": "Auch nach lokal gespeicherten Daten außerhalb des Profils fragen, z. B. `C:\\Daten`.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Sondersoftware und Lizenzen des Benutzers notiert",
|
||||
"hilfetext": "Vor dem Löschen erfassen, welche Programme und Lizenzschlüssel auf dem alten System liegen — hinterher ist die Information weg. Dongles und an die Hardware gebundene Aktivierungen mit aufführen.",
|
||||
"typ": "TEXT",
|
||||
"pflicht": true,
|
||||
"konfig": { "mehrzeilig": true }
|
||||
},
|
||||
{
|
||||
"titel": "BIOS/UEFI auf aktuellen Stand gebracht",
|
||||
"hilfetext": "Firmware vor der Installation aktualisieren, nicht danach: ein Update setzt Secure-Boot- und TPM-Einstellungen gelegentlich zurück."
|
||||
},
|
||||
{
|
||||
"titel": "Secure Boot und TPM 2.0 aktiv",
|
||||
"hilfetext": "In der Firmware prüfen, nicht nur in Windows. Ohne TPM 2.0 lässt sich BitLocker später nur mit Startschlüssel auf einem USB-Stick betreiben.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Installation",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "{{os_version}} installiert",
|
||||
"hilfetext": "Edition und Sprache müssen zur Lizenz passen. Saubere Installation auf gelöschter Systempartition, keine Aktualisierung des alten Stands.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Hostname gesetzt",
|
||||
"hilfetext": "Der Name muss dem Namensschema folgen; der Befehl startet den Rechner neu. Danach prüfen, ob der Name auch im DNS angekommen ist.",
|
||||
"typ": "BEFEHL",
|
||||
"pflicht": true,
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "Rename-Computer -NewName \"{{hostname}}\" -Restart"
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Treiber vollständig, keine unbekannten Geräte im Geräte-Manager",
|
||||
"hilfetext": "Im Geräte-Manager auch „Ausgeblendete Geräte anzeigen“ einschalten. Kein gelbes Ausrufezeichen, kein unbekanntes Gerät — fehlt etwas, ist es meist der Chipsatz-Treiber.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Windows Updates vollständig eingespielt",
|
||||
"hilfetext": "Mehrfach nach Updates suchen, bis nichts mehr kommt: Kumulativ- und Treiber-Updates erscheinen oft erst nach einem Neustart.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Installierte Version dokumentieren",
|
||||
"hilfetext": "Die Ausgabe in den nächsten Schritt übertragen. Supportfälle beziehen sich auf die Build-Nummer, nicht auf „Windows 11“.",
|
||||
"typ": "BEFEHL",
|
||||
"pflicht": true,
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "Get-ComputerInfo | Select-Object OsName, OsVersion, OsBuildNumber"
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Build-Nummer",
|
||||
"hilfetext": "Die Build aus der Ausgabe des vorigen Schritts, etwa 26100. Ohne sie ist später nicht nachvollziehbar, auf welchem Stand das Gerät ausgeliefert wurde.",
|
||||
"typ": "TEXT",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Einbindung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Einbindung durchgeführt: {{join_typ}}",
|
||||
"hilfetext": "Die Art der Einbindung entscheidet über Anmeldung und Richtlinien. Ein Gerät gehört in genau eine Domäne bzw. einen Tenant — nicht zusätzlich woanders anmelden.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Entra-Status prüfen",
|
||||
"hilfetext": "Unter „Device State“ muss `AzureAdJoined` bzw. `DomainJoined` auf YES stehen; unter „SSO State“ zeigt sich, ob ein gültiges Token vorliegt.",
|
||||
"typ": "BEFEHL",
|
||||
"konfig": { "shell": "cmd", "befehl": "dsregcmd /status" }
|
||||
},
|
||||
{
|
||||
"titel": "Gruppenrichtlinien prüfen",
|
||||
"hilfetext": "Zeigt die angewendeten Richtlinien und die Gruppenzugehörigkeit des Rechners. Fehlt eine erwartete Richtlinie, stimmt meist die OU nicht.",
|
||||
"typ": "BEFEHL",
|
||||
"konfig": { "shell": "cmd", "befehl": "gpresult /r" }
|
||||
},
|
||||
{
|
||||
"titel": "Gerät in richtiger OU bzw. Gerätegruppe",
|
||||
"hilfetext": "Die OU bestimmt, welche Richtlinien greifen. Nach dem Verschieben `gpupdate /force` und einen Neustart, sonst gilt weiter der alte Stand.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Software",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "RMM-Agent installiert und meldet sich",
|
||||
"hilfetext": "Nicht nur installieren, sondern in der Konsole nachsehen, ob das Gerät online erscheint und der richtigen Gruppe zugeordnet ist.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Virenschutz/EDR installiert und aktiv",
|
||||
"hilfetext": "Echtzeitschutz aktiv, Signaturen aktuell, Gerät im Portal sichtbar. Zwei Scanner parallel behindern sich gegenseitig — der ersetzte muss weg.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Office installiert und aktiviert",
|
||||
"hilfetext": "Eine Anwendung öffnen und unter „Konto“ prüfen, dass die richtige Lizenz und das richtige Konto hinterlegt sind.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Standardsoftware laut Firmenstandard installiert",
|
||||
"hilfetext": "Der Firmenstandard steht in der Dokumentation, nicht im Kopf. Abweichungen im nächsten Schritt festhalten.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Zusätzlich installierte Sondersoftware",
|
||||
"hilfetext": "Alles, was über den Standard hinausgeht, mit Version und Lizenzherkunft notieren — beim nächsten Gerätetausch ist das die Vorlage.",
|
||||
"typ": "TEXT",
|
||||
"konfig": { "mehrzeilig": true }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Sicherheit",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "BitLocker auf allen festen Laufwerken aktiv",
|
||||
"hilfetext": "Datenpartitionen mit verschlüsseln, nicht nur C:. Wechseldatenträger nur, wenn die Richtlinie es vorsieht.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "BitLocker-Status prüfen",
|
||||
"hilfetext": "Erwartet werden „Schutzstatus: Der Schutz ist aktiviert“ und „Verschlüsselt: 100 %“. Solange die Verschlüsselung noch läuft, ist das Laufwerk nicht geschützt.",
|
||||
"typ": "BEFEHL",
|
||||
"pflicht": true,
|
||||
"konfig": { "shell": "cmd", "befehl": "manage-bde -status C:" }
|
||||
},
|
||||
{
|
||||
"titel": "Recovery-Key in AD/Entra bzw. Passwortmanager hinterlegt",
|
||||
"hilfetext": "Den Schlüssel niemals in diese Checkliste schreiben.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Lokales Adminkonto nach Standard (LAPS aktiv oder Passwort im Passwortmanager)",
|
||||
"hilfetext": "Kein gemeinsames Kennwort über mehrere Geräte. Mit LAPS prüfen, ob das Kennwort tatsächlich im Verzeichnis ankommt.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Windows-Firewall in allen Profilen aktiv",
|
||||
"hilfetext": "Domäne, Privat und Öffentlich einzeln prüfen. Manche Installationspakete schalten ein einzelnes Profil ab und lassen es so.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Benutzer",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Daten zurückgespielt und vom Benutzer stichprobenartig geprüft",
|
||||
"hilfetext": "Die Person öffnet selbst zwei oder drei Dateien und ihr Mailarchiv. Erst danach gilt die Sicherung als brauchbar — und erst danach darf das Altgerät gelöscht werden.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Drucker und Netzlaufwerke eingerichtet",
|
||||
"hilfetext": "Einmal probedrucken und in jedem Laufwerk eine Datei speichern. Ein verbundenes Laufwerk heißt nicht, dass Schreibrechte bestehen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Mailprofil eingerichtet, Senden/Empfangen getestet",
|
||||
"hilfetext": "Testmail nach außen und zurück. Signatur, Freigabekalender und zusätzliche Postfächer nicht vergessen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Funktionstest gemeinsam mit {{benutzer}} durchgeführt",
|
||||
"hilfetext": "Die Programme durchgehen, die die Person täglich braucht. Was hier auffällt, kostet zehn Minuten; was später auffällt, einen zweiten Termin.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Abschluss",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Inventar und Dokumentation aktualisiert",
|
||||
"hilfetext": "Seriennummer, Standort, Besitzer und Kaufdatum nachziehen, damit ein Garantiefall später zuzuordnen ist.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Gerät im Monitoring sichtbar",
|
||||
"hilfetext": "Im Monitoring nachsehen, ob das Gerät Werte liefert und die Schwellwerte gesetzt sind. Ein installierter Agent allein sagt nichts.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Verbleib Altgerät bzw. alter Datenträger",
|
||||
"hilfetext": "Erst eintragen, wenn die Daten nachweislich zurückgespielt und geprüft sind. „Sicher gelöscht“ heißt überschrieben, nicht formatiert.",
|
||||
"typ": "AUSWAHL",
|
||||
"pflicht": true,
|
||||
"konfig": {
|
||||
"optionen": ["Sicher gelöscht", "Datenträger vernichtet", "Eingelagert", "Kein Altgerät"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Gerät an {{benutzer}} übergeben",
|
||||
"hilfetext": "Übergabe mit Datum festhalten. Bei Notebooks Netzteil, Tasche und Dockingstation mitzählen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Ticket {{ticket}} geschlossen",
|
||||
"hilfetext": "Im Ticket kurz festhalten, was getauscht wurde und wo die Dokumentation liegt. Der Laufzettel ersetzt den Abschlusskommentar nicht.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
{
|
||||
"format": 1,
|
||||
"name": "Mitarbeiter-Onboarding",
|
||||
"beschreibung": "Neues Benutzerkonto im Active Directory oder in Entra ID anlegen, Hardware bereitstellen und den ersten Arbeitstag begleiten.",
|
||||
"kategorie": "Benutzer",
|
||||
"schlagworte": ["onboarding", "eintritt", "benutzerkonto", "active directory", "entra id"],
|
||||
"struktur": {
|
||||
"variablen": [
|
||||
{
|
||||
"schluessel": "konto_typ",
|
||||
"beschriftung": "Kontotyp",
|
||||
"typ": "AUSWAHL",
|
||||
"optionen": ["Active Directory", "Entra ID"],
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"schluessel": "vorname",
|
||||
"beschriftung": "Vorname",
|
||||
"typ": "TEXT",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"schluessel": "nachname",
|
||||
"beschriftung": "Nachname",
|
||||
"typ": "TEXT",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"schluessel": "upn",
|
||||
"beschriftung": "UPN (User Principal Name)",
|
||||
"typ": "TEXT",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"schluessel": "sam",
|
||||
"beschriftung": "Anmeldename (sAMAccountName)",
|
||||
"typ": "TEXT"
|
||||
},
|
||||
{
|
||||
"schluessel": "mail",
|
||||
"beschriftung": "Mail-Adresse",
|
||||
"typ": "TEXT"
|
||||
},
|
||||
{
|
||||
"schluessel": "abteilung",
|
||||
"beschriftung": "Abteilung",
|
||||
"typ": "TEXT",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"schluessel": "ou",
|
||||
"beschriftung": "Ziel-OU (leer lassen: nimmt die Ziel-OU der Abteilung)",
|
||||
"typ": "TEXT"
|
||||
},
|
||||
{
|
||||
"schluessel": "gruppen",
|
||||
"beschriftung": "Gruppen, kommagetrennt (leer lassen: nimmt die Standardgruppen der Abteilung)",
|
||||
"typ": "TEXT"
|
||||
},
|
||||
{
|
||||
"schluessel": "eintrittsdatum",
|
||||
"beschriftung": "Eintrittsdatum",
|
||||
"typ": "DATUM",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"schluessel": "vorgesetzter",
|
||||
"beschriftung": "Vorgesetzter",
|
||||
"typ": "TEXT"
|
||||
}
|
||||
],
|
||||
"abschnitte": [
|
||||
{
|
||||
"titel": "Vorbereitung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Anforderung vollständig: Abteilung, Vorgesetzter, Eintrittsdatum, benötigte Zugriffe, Hardware",
|
||||
"hilfetext": "Eintritt am {{eintrittsdatum}} — bis dahin müssen Konto, Postfach und Gerät stehen. Fehlt eine dieser Angaben, steht der Lauf später fest. Einmal beim Vorgesetzten nachfragen ist billiger, als Konto, OU und Gruppen hinterher zu korrigieren.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Referenzbenutzer für Berechtigungen (falls genannt)",
|
||||
"hilfetext": "Ein bestehendes Konto derselben Rolle als Vorbild für Gruppen und Ablagerechte. Nicht blind kopieren: gewachsene Sonderrechte wandern sonst mit. Ist keiner genannt, das hier vermerken.",
|
||||
"typ": "TEXT",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Namensschema geprüft: UPN {{upn}}, Anmeldename {{sam}}, Mail {{mail}}",
|
||||
"hilfetext": "Vorschläge kommen aus den Namensregeln in den Einstellungen, OU und Gruppen aus der Abteilung. Vor dem Anlegen im Verzeichnis prüfen, ob der Name schon vergeben ist.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Konto anlegen (Active Directory)",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Konto anlegen",
|
||||
"hilfetext": "Dieser Abschnitt gilt nur für den Kontotyp „Active Directory“ (gewählt: {{konto_typ}}). Trifft er nicht zu, wird er als Ganzes über „Abschnitt trifft nicht zu“ erledigt — nicht Schritt für Schritt abgehakt.\n\nDas Passwort im Befehl entsteht bei jeder Anzeige neu und wird nirgends gespeichert — weder im Schrittergebnis noch im Protokoll oder im Ausdruck. Deshalb: Befehl ausführen und das Passwort im selben Zug in den Passwortmanager übertragen.",
|
||||
"typ": "BEFEHL",
|
||||
"pflicht": true,
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "New-ADUser -Name \"{{vorname}} {{nachname}}\" -GivenName \"{{vorname}}\" -Surname \"{{nachname}}\" `\n -DisplayName \"{{vorname}} {{nachname}}\" -SamAccountName \"{{sam}}\" -UserPrincipalName \"{{upn}}\" `\n -EmailAddress \"{{mail}}\" -Department \"{{abteilung}}\" -Path \"{{ou}}\" `\n -AccountPassword (ConvertTo-SecureString \"{{generiertes_passwort}}\" -AsPlainText -Force) `\n -ChangePasswordAtLogon $true -Enabled $true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Initialpasswort im Passwortmanager hinterlegt und sicher an den Benutzer bzw. Vorgesetzten übermittelt",
|
||||
"hilfetext": "Das Passwort aus dem Befehl oben wird nicht gespeichert.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Gruppen zuweisen",
|
||||
"hilfetext": "Setzt die Standardgruppen der Abteilung aus {{gruppen}}. Leerzeichen um die Kommata sind unschädlich, `Trim()` entfernt sie; die Gruppen müssen im Verzeichnis aber genau so heißen.",
|
||||
"typ": "BEFEHL",
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "\"{{gruppen}}\".Split(\",\") | ForEach-Object { Add-ADGroupMember -Identity $_.Trim() -Members \"{{sam}}\" }"
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Vorgesetzten setzen",
|
||||
"hilfetext": "`{{vorgesetzter_sam}}` ist ein eingebauter Platzhalter und kommt aus dem Mitarbeiter am Lauf. Solange die Verknüpfung fehlt, den Anmeldenamen von {{vorgesetzter}} von Hand einsetzen.",
|
||||
"typ": "BEFEHL",
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "Set-ADUser -Identity \"{{sam}}\" -Manager \"{{vorgesetzter_sam}}\""
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Kontrolle",
|
||||
"hilfetext": "Zeigt Name, UPN, Abteilung, Vorgesetzten und Gruppen in einer Liste. Die Ausgabe wirklich lesen: eine fehlende Gruppe fällt sonst erst als fehlender Zugriff auf.",
|
||||
"typ": "BEFEHL",
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "Get-ADUser -Identity \"{{sam}}\" -Properties MemberOf, Department, Manager | Format-List Name, UserPrincipalName, Department, Manager, MemberOf"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Konto anlegen (Entra ID)",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Konto anlegen",
|
||||
"hilfetext": "Dieser Abschnitt gilt nur für den Kontotyp „Entra ID“ (gewählt: {{konto_typ}}). Trifft er nicht zu, wird er als Ganzes über „Abschnitt trifft nicht zu“ erledigt — nicht Schritt für Schritt abgehakt.\n\n`Connect-MgGraph` fragt einmal nach Anmeldung und Zustimmung. Das Passwort im Befehl entsteht bei jeder Anzeige neu und wird nirgends gespeichert, also sofort weiterverarbeiten.",
|
||||
"typ": "BEFEHL",
|
||||
"pflicht": true,
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "Connect-MgGraph -Scopes \"User.ReadWrite.All\",\"Group.ReadWrite.All\"\nNew-MgUser -DisplayName \"{{vorname}} {{nachname}}\" -GivenName \"{{vorname}}\" -Surname \"{{nachname}}\" `\n -UserPrincipalName \"{{upn}}\" -MailNickname \"{{sam}}\" -Department \"{{abteilung}}\" -UsageLocation \"DE\" `\n -AccountEnabled -PasswordProfile @{ Password = \"{{generiertes_passwort}}\"; ForceChangePasswordNextSignIn = $true }"
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Initialpasswort im Passwortmanager hinterlegt und sicher übermittelt",
|
||||
"hilfetext": "Das Passwort aus dem Befehl oben wird nicht gespeichert und ist nach dem Verlassen der Ansicht nicht mehr abrufbar. Nie zusammen mit dem UPN im selben Kanal versenden.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Gruppen zuweisen",
|
||||
"hilfetext": "Sucht jede Gruppe aus {{gruppen}} über ihren Anzeigenamen. Findet der Filter nichts, bleibt `$g` leer und der folgende Aufruf schlägt fehl — dann die Schreibweise prüfen.",
|
||||
"typ": "BEFEHL",
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "$u = Get-MgUser -UserId \"{{upn}}\"\n\"{{gruppen}}\".Split(\",\") | ForEach-Object { $g = Get-MgGroup -Filter \"displayName eq '$($_.Trim())'\"; New-MgGroupMember -GroupId $g.Id -DirectoryObjectId $u.Id }"
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Zugewiesene Lizenz",
|
||||
"hilfetext": "Lizenz per Gruppe (gruppenbasierte Lizenzierung) oder `Set-MgUserLicense`.",
|
||||
"typ": "TEXT",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Konto, gemeinsam",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "MFA-Registrierung vorbereitet bzw. durchgeführt",
|
||||
"hilfetext": "Entweder ein befristeter Zugangspass für die Selbstregistrierung oder die gemeinsame Einrichtung am ersten Tag. Ohne MFA kommt der Benutzer bei bedingtem Zugriff nicht an sein Postfach.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Postfach erreichbar, Signatur eingerichtet",
|
||||
"hilfetext": "Einmal anmelden und eine Testmail nach außen senden. Signatur nach der Vorgabe des Hauses, mit richtiger Schreibweise von Name und Funktion.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Zugänge zu Fachanwendungen angelegt",
|
||||
"hilfetext": "Nur die Anwendungen, die die Rolle braucht — nicht alles, was der Referenzbenutzer im Laufe der Jahre angesammelt hat."
|
||||
},
|
||||
{
|
||||
"titel": "Angelegte Fachanwendungs-Zugänge",
|
||||
"hilfetext": "Je Zeile eine Anwendung mit Benutzername oder Rolle. Beim Offboarding wird genau diese Liste wieder abgearbeitet.",
|
||||
"typ": "TEXT",
|
||||
"konfig": {
|
||||
"mehrzeilig": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Hardware",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Arbeitsplatzgerät bereitgestellt (eigener Lauf „PC Neuinstallation“ falls nötig)",
|
||||
"hilfetext": "Gerät, Tag-Code und Inventarnummer gehören zusammen. Die Installation selbst läuft als eigener Lauf; hier wird nur festgehalten, dass das Gerät bereitsteht.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Telefon / Softphone eingerichtet",
|
||||
"hilfetext": "Durchwahl vergeben, im Telefonbuch eingetragen und einmal in beide Richtungen getestet."
|
||||
},
|
||||
{
|
||||
"titel": "Peripherie vorhanden (Monitor, Dockingstation, Headset)",
|
||||
"hilfetext": "Am Arbeitsplatz aufgebaut und angeschlossen, nicht nur aus dem Lager geholt."
|
||||
},
|
||||
{
|
||||
"titel": "Zutrittsmedium / Schlüssel beantragt",
|
||||
"hilfetext": "Ausgabe und Rückgabe werden dokumentiert — beim Offboarding wird danach gefragt."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Erster Tag",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Anmeldung gemeinsam mit dem Benutzer getestet",
|
||||
"hilfetext": "Erstanmeldung mit Passwortwechsel und MFA am eigenen Gerät. Erst danach gilt das Konto als übergeben.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Kurzeinweisung: Passwortregeln, MFA, Datenablage, Ansprechpartner IT",
|
||||
"hilfetext": "Kurz und mündlich, mit dem Hinweis, wo Daten hingehören und wie man die IT erreicht.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Dokumentation und Inventar aktualisiert",
|
||||
"hilfetext": "Mitarbeiter, Gerät, Rufnummer und Zutrittsmedium eingetragen. Was hier fehlt, fehlt beim Offboarding auch.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
{
|
||||
"format": 1,
|
||||
"name": "Mitarbeiter-Offboarding",
|
||||
"beschreibung": "Konto sperren, Postfach und Daten übergeben, Hardware und Zugänge einsammeln, wenn jemand das Haus verlässt.",
|
||||
"kategorie": "Benutzer",
|
||||
"schlagworte": ["offboarding", "austritt", "benutzerkonto", "sperrung"],
|
||||
"struktur": {
|
||||
"variablen": [
|
||||
{
|
||||
"schluessel": "austrittsdatum",
|
||||
"beschriftung": "Austrittsdatum",
|
||||
"typ": "DATUM",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"schluessel": "ou_gesperrt",
|
||||
"beschriftung": "OU für deaktivierte Konten",
|
||||
"typ": "TEXT"
|
||||
},
|
||||
{
|
||||
"schluessel": "postfach_verbleib",
|
||||
"beschriftung": "Verbleib des Postfachs",
|
||||
"typ": "AUSWAHL",
|
||||
"optionen": ["In Shared Mailbox umwandeln", "Weiterleitung", "Export und löschen"],
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"schluessel": "vertreter",
|
||||
"beschriftung": "Vertreter",
|
||||
"typ": "TEXT"
|
||||
}
|
||||
],
|
||||
"abschnitte": [
|
||||
{
|
||||
"titel": "Klärung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Austrittsdatum und Zeitpunkt der Sperrung mit Vorgesetztem/HR abgestimmt",
|
||||
"hilfetext": "{{austrittsdatum}} nennt nur den Tag. Die Sperrung gehört üblicherweise auf den letzten Arbeitstag nach Dienstschluss — die genaue Uhrzeit hier schriftlich festhalten.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Verbleib von Postfach und Daten geklärt: {{postfach_verbleib}}",
|
||||
"hilfetext": "Wer bekommt Zugriff, wie lange, und was geschieht danach? Ohne diese Festlegung wird am Postfach nichts verändert.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Sperrung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Konto sperren (AD)",
|
||||
"hilfetext": "Deaktivieren, Passwort zurücksetzen und nach {{ou_gesperrt}} verschieben — in dieser Reihenfolge. Das neue Passwort wird nirgends gespeichert und soll auch von niemandem mehr benutzt werden; es verhindert eine erneute Anmeldung. Bestehende Sitzungen beendet es nicht — die Kerberos-Tickets laufen erst aus, siehe nächster Schritt.",
|
||||
"typ": "BEFEHL",
|
||||
"pflicht": true,
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "Disable-ADAccount -Identity \"{{sam}}\"\nSet-ADAccountPassword -Identity \"{{sam}}\" -Reset -NewPassword (ConvertTo-SecureString \"{{generiertes_passwort}}\" -AsPlainText -Force)\nMove-ADObject -Identity (Get-ADUser \"{{sam}}\").DistinguishedName -TargetPath \"{{ou_gesperrt}}\""
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Konto sperren (Entra ID)",
|
||||
"hilfetext": "Deaktiviert das Konto und widerruft die Aktualisierungstoken. Bereits ausgegebene Zugriffstoken können noch bis zu einer Stunde gelten — bei Eile zusätzlich das Passwort ändern.",
|
||||
"typ": "BEFEHL",
|
||||
"pflicht": true,
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "Update-MgUser -UserId \"{{upn}}\" -AccountEnabled:$false\nRevoke-MgUserSignInSession -UserId \"{{upn}}\""
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Aktive Sitzungen und Token widerrufen (AD: Kerberos-Tickets laufen aus, bei Bedarf `Set-ADUser -Replace @{msDS-KeyVersionNumber=...}` nicht nötig; Hinweis auf Entra-Befehl oben)",
|
||||
"hilfetext": "Im AD genügt das Zurücksetzen des Passworts, die Kerberos-Tickets laufen von selbst aus. In Entra ID erledigt das `Revoke-MgUserSignInSession` aus dem Schritt oben. Offene Sitzungen an Terminalservern trennt man zusätzlich von Hand.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "MFA-Methoden und App-Passwörter entfernt",
|
||||
"hilfetext": "Sonst bleibt eine zweite Tür offen: registrierte Authenticator-Apps und Telefonnummern liegen beim Benutzer, nicht bei der IT.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "VPN-, WLAN- und Remotezugänge entfernt",
|
||||
"hilfetext": "Auch Zertifikate, hinterlegte Profile und geteilte Zugangsdaten. Ein gesperrtes Konto hilft nicht, wenn ein VPN-Zertifikat weiter gültig ist.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Mobile Geräte aus MDM entfernt bzw. Firmendaten gelöscht",
|
||||
"hilfetext": "Firmengeräte vollständig zurücksetzen, private Geräte nur um die Firmendaten bereinigen (selektives Löschen).",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Daten",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Abwesenheitsnotiz mit Verweis auf {{vertreter}} gesetzt",
|
||||
"hilfetext": "Intern und extern getrennt formulieren und den Austritt nur so weit erwähnen wie nötig. Die Notiz wirkt auch dann noch, wenn das Postfach eine Shared Mailbox geworden ist.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Postfach umwandeln",
|
||||
"hilfetext": "Nur wenn {{postfach_verbleib}} die Umwandlung vorsieht. Eine Shared Mailbox braucht keine eigene Lizenz — deshalb steht dieser Schritt vor der Lizenzfreigabe.",
|
||||
"typ": "BEFEHL",
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "Set-Mailbox -Identity \"{{upn}}\" -Type Shared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Zugriff auf Postfach und OneDrive/Home-Laufwerk an {{vertreter}} übergeben oder Daten gesichert",
|
||||
"hilfetext": "Zugriff befristet vergeben und die Frist notieren. Wird nichts übergeben, gehört eine Sicherung an einen Ort, der die Aufbewahrungsfrist übersteht.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Aus Gruppen und Verteilern entfernt",
|
||||
"hilfetext": "Auch aus Verteilern, Teams und geteilten Ablagen. Was hier stehen bleibt, taucht Monate später als Mail an eine gesperrte Adresse wieder auf.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Lizenz freigegeben",
|
||||
"hilfetext": "Erst nach der Umwandlung in eine Shared Mailbox, sonst gehen Daten verloren.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Zugänge zu Fachanwendungen und externen Portalen gesperrt",
|
||||
"hilfetext": "Die im Onboarding festgehaltene Liste ist der Ausgangspunkt. Externe Portale werden gern vergessen, weil sie nicht am Verzeichnis hängen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Gesperrte externe Zugänge",
|
||||
"hilfetext": "Je Zeile ein Portal mit dem Datum der Sperrung. Das ist der Nachweis, wenn später jemand danach fragt.",
|
||||
"typ": "TEXT",
|
||||
"konfig": {
|
||||
"mehrzeilig": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Hardware",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Geräte zurückerhalten (PC/Notebook, Telefon, Token, Zutrittsmedium)",
|
||||
"hilfetext": "Gegen die Inventarliste prüfen und die Rückgabe quittieren lassen. Datenträger erst danach löschen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Fehlende oder beschädigte Gegenstände",
|
||||
"hilfetext": "Fehlendes hier festhalten und dem Vorgesetzten melden — mündlich ist es nach zwei Wochen nicht mehr belegbar.",
|
||||
"typ": "TEXT",
|
||||
"konfig": {
|
||||
"mehrzeilig": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Inventar aktualisiert",
|
||||
"hilfetext": "Geräte vom Mitarbeiter lösen und ihren neuen Zustand eintragen (Lager, Nachfolger oder Ausmusterung). Der Tag-Code bleibt am Gerät und wird nie neu vergeben.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Abschluss",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Termin für endgültige Kontolöschung",
|
||||
"hilfetext": "Üblich sind drei bis sechs Monate nach dem Austritt, abgestimmt mit der Aufbewahrungsfrist. Bis dahin bleibt das Konto deaktiviert stehen.",
|
||||
"typ": "DATUM",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Dokumentation aktualisiert, Ticket geschlossen",
|
||||
"hilfetext": "Im Ticket festhalten, was mit Postfach, Daten und Geräten geschehen ist. Der Lauf selbst bleibt als Nachweis im Archiv.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"format": 1,
|
||||
"name": "Server-Inbetriebnahme Windows-VM",
|
||||
"beschreibung": "Inbetriebnahme einer neuen Windows-Server-VM: Planung und Adressvergabe, Installation, Domänenbeitritt, Absicherung sowie Aufnahme in Backup und Monitoring.",
|
||||
"kategorie": "Server",
|
||||
"schlagworte": ["windows", "server", "vm", "inbetriebnahme"],
|
||||
"struktur": {
|
||||
"variablen": [
|
||||
{ "schluessel": "vmid", "beschriftung": "VM-Kennung im Hypervisor", "typ": "TEXT" },
|
||||
{ "schluessel": "ip_adresse", "beschriftung": "IP-Adresse", "typ": "TEXT", "pflicht": true },
|
||||
{ "schluessel": "vlan", "beschriftung": "VLAN", "typ": "TEXT" },
|
||||
{
|
||||
"schluessel": "rolle",
|
||||
"beschriftung": "Rolle (z. B. Fileserver)",
|
||||
"typ": "TEXT",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"schluessel": "os_version",
|
||||
"beschriftung": "Windows-Server-Version",
|
||||
"typ": "AUSWAHL",
|
||||
"optionen": ["Windows Server 2022", "Windows Server 2025"]
|
||||
}
|
||||
],
|
||||
"abschnitte": [
|
||||
{
|
||||
"titel": "Planung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Ressourcen festgelegt (vCPU, RAM, Datenträger) und dokumentiert",
|
||||
"hilfetext": "Knapp dimensionieren und später erweitern ist einfacher als der umgekehrte Weg: RAM und Datenträger lassen sich vergrößern, verkleinern praktisch nicht.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "IP {{ip_adresse}} reserviert und in IP-Dokumentation eingetragen, VLAN {{vlan}}",
|
||||
"hilfetext": "Die Adresse vor dem Anlegen der VM reservieren, sonst vergibt der DHCP-Bereich sie womöglich ein zweites Mal. VLAN und Verwendungszweck gleich mit eintragen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "DNS-Name nach Namensschema: {{hostname}}",
|
||||
"hilfetext": "A-Record und PTR anlegen, nicht nur den A-Record. Ohne Rückwärtsauflösung melden sich Backup- und Monitoring-Agenten mit der IP statt mit dem Namen.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "VM und Betriebssystem",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "VM angelegt (VirtIO SCSI, VirtIO-Netzwerk, QEMU Guest Agent aktiviert, UEFI + TPM)",
|
||||
"hilfetext": "Die VirtIO-Treiber-ISO schon beim Setup einbinden, sonst findet die Installation keinen Datenträger. UEFI und TPM lassen sich nachträglich nur mit Aufwand ergänzen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "{{os_version}} installiert, VirtIO-Treiber und Guest Agent installiert",
|
||||
"hilfetext": "Der Guest Agent ist erst dann wirklich aktiv, wenn der Hypervisor die IP-Adresse der VM anzeigt. Dann funktionieren auch geordnetes Herunterfahren und konsistente Sicherungen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Statische IP, DNS-Server und Gateway gesetzt",
|
||||
"hilfetext": "Ein Server holt seine Adresse nicht per DHCP. Danach Namensauflösung in beide Richtungen prüfen und das Gateway anpingen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Hostname gesetzt",
|
||||
"hilfetext": "Vor dem Domänenbeitritt umbenennen, nicht danach — sonst steht das Computerkonto im Verzeichnis unter dem alten Namen.",
|
||||
"typ": "BEFEHL",
|
||||
"pflicht": true,
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "Rename-Computer -NewName \"{{hostname}}\" -Restart"
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Windows Updates vollständig",
|
||||
"hilfetext": "Mehrfach durchlaufen lassen, bis nichts mehr kommt, und erst danach die Rolle installieren. Das künftige Wartungsfenster gleich festlegen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Zeitzone korrekt, Zeitsynchronisation prüfen",
|
||||
"hilfetext": "Mehr als fünf Minuten Zeitversatz lassen Kerberos-Anmeldungen scheitern. In der Ausgabe auf „Source“ achten: ein Domänenmitglied synchronisiert gegen den Domänencontroller, nicht gegen einen Internetserver.",
|
||||
"typ": "BEFEHL",
|
||||
"pflicht": true,
|
||||
"konfig": { "shell": "cmd", "befehl": "w32tm /query /status" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Einbindung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Domänenbeitritt, richtige OU",
|
||||
"hilfetext": "Das Computerkonto gehört in die vorgesehene Server-OU, nicht in den Standard-Container „Computers“ — dort greifen die Richtlinien nicht.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Vertrauensstellung prüfen",
|
||||
"hilfetext": "Muss `True` liefern. Ein `False` deutet auf ein zurückgesetztes Computerkonto hin, etwa nach dem Zurückspielen eines älteren Snapshots.",
|
||||
"typ": "BEFEHL",
|
||||
"pflicht": true,
|
||||
"konfig": { "shell": "powershell", "befehl": "Test-ComputerSecureChannel -Verbose" }
|
||||
},
|
||||
{
|
||||
"titel": "Rolle eingerichtet: {{rolle}}",
|
||||
"hilfetext": "Nur die Rolle installieren, die der Server tragen soll. Jede zusätzliche Rolle vergrößert Angriffsfläche und Abhängigkeiten bei Neustarts.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Lizenz aktiviert",
|
||||
"hilfetext": "Den Aktivierungsstatus prüfen, nicht nur den Schlüssel eintragen. Bei KMS und AVMA kommt die Aktivierung erst nach dem Domänenbeitritt bzw. über den Host zustande.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Sicherheit",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Lokales Administratorpasswort im Passwortmanager bzw. LAPS aktiv",
|
||||
"hilfetext": "Für jeden Server ein eigenes Kennwort. Ein gemeinsames lokales Adminkennwort ist der kürzeste Weg von einem übernommenen Server zu allen anderen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "RDP nur für berechtigte Gruppe, NLA aktiv",
|
||||
"hilfetext": "RDP nicht für „Jeder“ öffnen und nie direkt aus dem Internet erreichbar machen. Mit aktiver NLA scheitert ein Anmeldeversuch, bevor überhaupt eine Sitzung aufgebaut wird.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Virenschutz/EDR aktiv, Ausnahmen laut Rolle gesetzt",
|
||||
"hilfetext": "Ausnahmen nur für die Pfade und Prozesse der Rolle setzen und begründet dokumentieren. Eine pauschale Ausnahme für ein ganzes Laufwerk hebt den Schutz auf.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Firewall-Regeln auf das Nötige beschränkt",
|
||||
"hilfetext": "Nur die Ports der Rolle, und nur aus den Netzen, die sie brauchen. Nach dem Einrichten von außen nachmessen, statt der Regelliste zu vertrauen.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Betrieb",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "In Backup aufgenommen, erster Sicherungslauf erfolgreich",
|
||||
"hilfetext": "Es zählt der erfolgreiche erste Lauf, nicht der angelegte Auftrag. Aufbewahrungsdauer und Sicherungsziel gleich mitprüfen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Testwiederherstellung einer Datei durchgeführt",
|
||||
"hilfetext": "Eine Sicherung, aus der noch nie etwas zurückgeholt wurde, ist keine. Eine einzelne Datei an einen anderen Ort wiederherstellen und den Inhalt ansehen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Monitoring eingerichtet (Agent, Host, Trigger)",
|
||||
"hilfetext": "Der Agent allein genügt nicht: Trigger für Datenträgerfüllstand, die Dienste der Rolle und Erreichbarkeit setzen und einen Alarm testweise auslösen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "RMM-Agent installiert",
|
||||
"hilfetext": "Im Portal prüfen, ob der Server online erscheint und der richtigen Gruppe zugeordnet ist. Wartungsfenster hinterlegen, damit Patches nicht mitten am Tag laufen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Dokumentation erstellt (Zweck, Rolle, IP, Abhängigkeiten, Verantwortlicher)",
|
||||
"hilfetext": "Wichtig ist, was ein Kollege um drei Uhr nachts braucht: Zweck, Abhängigkeiten, Verantwortlicher und wo die Zugangsdaten liegen.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"format": 1,
|
||||
"name": "Firewall-Rollout OPNsense",
|
||||
"beschreibung": "Austausch oder Neuaufbau einer OPNsense-Firewall: von den Providerdaten über Regelwerk und VPN bis zur Abnahme und zur außer Betrieb genommenen Altfirewall.",
|
||||
"kategorie": "Netzwerk",
|
||||
"schlagworte": ["firewall", "opnsense", "netzwerk", "vpn", "rollout"],
|
||||
"struktur": {
|
||||
"variablen": [
|
||||
{
|
||||
"schluessel": "wan_typ",
|
||||
"beschriftung": "WAN-Typ",
|
||||
"typ": "AUSWAHL",
|
||||
"optionen": ["DHCP", "Statisch", "PPPoE"],
|
||||
"pflicht": true
|
||||
},
|
||||
{ "schluessel": "lan_netz", "beschriftung": "LAN-Netz", "typ": "TEXT", "pflicht": true },
|
||||
{
|
||||
"schluessel": "vpn_typ",
|
||||
"beschriftung": "VPN-Typ",
|
||||
"typ": "AUSWAHL",
|
||||
"optionen": ["WireGuard", "OpenVPN", "IPsec", "Keines"]
|
||||
}
|
||||
],
|
||||
"abschnitte": [
|
||||
{
|
||||
"titel": "Vorbereitung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Zugangsdaten des Providers vorhanden, WAN-Typ: {{wan_typ}}",
|
||||
"hilfetext": "Bei PPPoE Benutzername und Passwort, bei einer statischen Leitung Adresse, Netzmaske und Gateway. Fehlt eine Angabe, steht der Umbau mitten im Wartungsfenster still.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Netzplan mit VLANs und Adressbereichen abgestimmt, LAN: {{lan_netz}}",
|
||||
"hilfetext": "Jedes VLAN braucht ID, Adressbereich und Zweck. Überschneidungen mit den Netzen der Gegenstellen fallen sonst erst beim ersten VPN-Tunnel auf.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Wartungsfenster mit den betroffenen Abteilungen vereinbart",
|
||||
"hilfetext": "Der Umbau trennt Internet und alle Dienste hinter der Firewall. Telefonie, Filialanbindung und externe Dienstleister ausdrücklich mitnennen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Konfiguration der Altfirewall gesichert",
|
||||
"hilfetext": "Vollständiger Konfigurationsexport, dazu Ausdruck oder Screenshots des Regelwerks. Das ist der einzige Rückweg, wenn später eine Regel fehlt.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Grundkonfiguration",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Aktuelle Firmware installiert",
|
||||
"hilfetext": "Vor dem Einrichten aktualisieren, nicht danach: ein Update startet die Firewall neu und setzt Dienste neu auf.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Firmware-Version",
|
||||
"hilfetext": "Genau so eintragen, wie das Dashboard sie anzeigt. Damit lässt sich ein späteres Fehlverhalten einer Version zuordnen.",
|
||||
"typ": "TEXT",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Hostname, Domain, Zeitzone, NTP gesetzt",
|
||||
"hilfetext": "Eine falsche Uhr macht Zertifikate, VPN-Aushandlung und Logauswertung unbrauchbar. NTP-Quelle nicht nur eintragen, sondern die Synchronisation prüfen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Admin-Zugang: eigenes Konto, Passwort im Passwortmanager, Root-Login per Passwort eingeschränkt",
|
||||
"hilfetext": "Persönliches Konto je Techniker, damit das Log zeigt, wer etwas geändert hat. root bleibt für den Notfall, möglichst nur per Schlüssel.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "WebGUI nur aus Management-Netz erreichbar, HTTPS",
|
||||
"hilfetext": "Von außen gegenproben: die Oberfläche darf auf der WAN-Seite nicht antworten. Ein selbstsigniertes Zertifikat ist in Ordnung, unverschlüsseltes HTTP nicht.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "WAN eingerichtet und Internetzugang getestet",
|
||||
"hilfetext": "Nicht nur den Link prüfen, sondern Namensauflösung und einen echten Abruf. Bei PPPoE auf die MTU achten, sonst brechen einzelne Seiten ab.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "LAN und VLANs mit DHCP eingerichtet",
|
||||
"hilfetext": "Je Netz Bereich, Gateway, DNS und Leasedauer. Feste Reservierungen für Drucker und Server aus der Altkonfiguration übernehmen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "DNS-Resolver konfiguriert",
|
||||
"hilfetext": "Interne Zonen an den Domänencontroller weiterleiten, alles andere nach außen auflösen. Sonst findet kein Client mehr seine Freigaben.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Regelwerk",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Regeln pro Netz nach Konzept, keine Any-Any-Regeln zwischen den VLANs",
|
||||
"hilfetext": "Jede Regel mit Beschreibung anlegen. Die Trennung der Netze ist der ganze Sinn der Übung, und eine einzige Any-Any-Regel hebt sie wieder auf.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Gäste- und IoT-Netz vom internen Netz getrennt",
|
||||
"hilfetext": "Aus dem Gastnetz gegenproben: Ping und Zugriff auf eine interne Freigabe müssen scheitern. Auch den Weg über DNS und Multicast mitprüfen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Portweiterleitungen nur soweit nötig und dokumentiert",
|
||||
"hilfetext": "Jede Weiterleitung ist ein Loch nach innen. Quelladressen einschränken, wo es geht, und RDP niemals offen ins Internet stellen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Eingerichtete Portweiterleitungen",
|
||||
"hilfetext": "Je Zeile externer Port, Ziel und Zweck. Wer das nicht notiert, sucht in einem Jahr, wozu Port 8443 einmal gehörte.",
|
||||
"typ": "TEXT",
|
||||
"konfig": { "mehrzeilig": true }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "VPN und Dienste",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "VPN eingerichtet: {{vpn_typ}}",
|
||||
"hilfetext": "Der Adressbereich des Tunnels darf sich mit keinem Netz auf beiden Seiten überschneiden. Zugriff auf das Nötige begrenzen, nicht auf alles."
|
||||
},
|
||||
{
|
||||
"titel": "VPN-Zugriff von extern getestet",
|
||||
"hilfetext": "Aus einem fremden Netz testen, etwa über das Mobilfunknetz. Ein Test aus dem eigenen LAN sagt über den Zugang von draußen nichts."
|
||||
},
|
||||
{
|
||||
"titel": "Dynamisches DNS eingerichtet (falls keine feste IP)",
|
||||
"hilfetext": "Nach dem Einrichten eine Aktualisierung erzwingen und den Namen auflösen. Ohne feste Adresse hängt der ganze VPN-Zugang daran."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Betrieb",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Automatische Konfigurationssicherung eingerichtet",
|
||||
"hilfetext": "Ziel außerhalb der Firewall wählen, sonst ist die Sicherung mit dem Gerät verloren. Einmal eine Sicherung herunterladen und hineinsehen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Syslog an zentralen Logserver",
|
||||
"hilfetext": "Lokal hält die Firewall nur wenige Tage vor. Erst ein externer Empfänger macht Regelfehler und Angriffe im Nachhinein nachvollziehbar.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Monitoring eingerichtet (SNMP/Agent, WAN-Verfügbarkeit)",
|
||||
"hilfetext": "Neben der Erreichbarkeit auch Durchsatz, Auslastung und Zustand des WAN-Gateways erfassen. Einen Alarm einmal auslösen und die Benachrichtigung abwarten.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Benachrichtigungen bei Firmware-Updates geklärt",
|
||||
"hilfetext": "Festlegen, wer von neuen Versionen erfährt und wer über den Einbau entscheidet. Sicherheitsupdates einer Firewall dulden keine Wochen.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Abnahme",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Funktionstest vor Ort: Internet, interne Dienste, Drucker, Telefonie, VPN",
|
||||
"hilfetext": "Mit einem echten Arbeitsplatz durchgehen, nicht vom Technikerlaptop aus. Telefonie zuletzt prüfen, denn SIP scheitert am ehesten an NAT-Einstellungen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Altfirewall außer Betrieb, zurückgesetzt bzw. sicher gelöscht",
|
||||
"hilfetext": "Bis zur Abnahme nur vom Netz nehmen, erst danach zurücksetzen. Auf dem Gerät stehen Zugangsdaten, Zertifikate und VPN-Schlüssel.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Netzwerkdokumentation aktualisiert",
|
||||
"hilfetext": "VLANs, Adressbereiche, Regelwerk, Weiterleitungen und Zugänge nachziehen. Die Dokumentation von gestern beschreibt noch die Altfirewall.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Abnahme durch den Verantwortlichen",
|
||||
"hilfetext": "Mit Namen und Datum festhalten. Was danach kommt, ist eine Änderung und kein Teil des Rollouts mehr.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"format": 1,
|
||||
"name": "Monatlicher Patchday",
|
||||
"beschreibung": "Der wiederkehrende Ablauf für Updates an Servern, Hypervisor, Netzgeräten und Clients: ankündigen, einspielen, kontrollieren und berichten.",
|
||||
"kategorie": "Wartung",
|
||||
"schlagworte": ["patchday", "updates", "wartung", "monatlich"],
|
||||
"struktur": {
|
||||
"variablen": [
|
||||
{
|
||||
"schluessel": "monat",
|
||||
"beschriftung": "Monat (z. B. 2026-10)",
|
||||
"typ": "TEXT",
|
||||
"pflicht": true
|
||||
}
|
||||
],
|
||||
"abschnitte": [
|
||||
{
|
||||
"titel": "Vorbereitung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Wartungsfenster angekündigt",
|
||||
"hilfetext": "Mindestens einen Werktag vorher, mit Zeitraum und betroffenen Diensten. Neustarts von Terminalservern und Domänencontrollern ausdrücklich nennen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Bekannte Probleme der aktuellen Updates geprüft",
|
||||
"hilfetext": "Herstellerhinweise und einschlägige Foren durchsehen. Ein zurückgezogenes Update erspart einen Abend Fehlersuche.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Letzte Sicherung aller betroffenen Systeme erfolgreich",
|
||||
"hilfetext": "Nicht nur den Jobstatus ansehen, sondern das Datum des letzten fehlerfreien Wiederherstellungspunkts. Ohne Sicherung kein Patch.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "VM-Snapshot vor kritischen Updates",
|
||||
"hilfetext": "`<vmid>` durch die Nummer der VM ersetzen. Ein Snapshot ist kein Backup und kostet Speicher, also nach der Kontrolle wieder entfernen.",
|
||||
"typ": "BEFEHL",
|
||||
"konfig": { "shell": "bash", "befehl": "qm snapshot <vmid> prepatch" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Durchführung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Server gepatcht und neu gestartet, Reihenfolge beachtet (Domänencontroller nicht gleichzeitig)",
|
||||
"hilfetext": "Einen Domänencontroller nach dem anderen und die Replikation zwischendurch prüfen. Anwendungsserver erst, wenn ihre Datenbank wieder läuft.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Hypervisor-Updates geprüft bzw. eingespielt",
|
||||
"hilfetext": "Gäste vorher verschieben oder sauber herunterfahren. Knoten eines Clusters auf gleichem Stand halten, Gasterweiterungen mitziehen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Firewall-, Switch- und AP-Firmware geprüft",
|
||||
"hilfetext": "Netzgeräte zuletzt und einzeln, damit der eigene Zugriff nicht abreißt. Vorher die Konfiguration sichern.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Clients: Update-Status im RMM geprüft",
|
||||
"hilfetext": "Nach ausstehenden Neustarts und nach Geräten sehen, die seit Wochen nichts melden. Ein Client, der stumm bleibt, ist das eigentliche Problem.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Anzahl Clients mit ausstehenden Updates",
|
||||
"hilfetext": "Der Wert aus dem RMM nach dem Durchlauf. Über Monate verglichen zeigt er, ob die Verteilung wirklich greift.",
|
||||
"typ": "ZAHL",
|
||||
"pflicht": true,
|
||||
"konfig": { "min": 0 }
|
||||
},
|
||||
{
|
||||
"titel": "Drittanbieter-Software aktualisiert (Browser, PDF, Java, Laufzeitumgebungen)",
|
||||
"hilfetext": "Über diese Programme kommen Angriffe herein, seltener über das Betriebssystem. Altlasten, die niemand mehr braucht, besser deinstallieren.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Kontrolle",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Alle Dienste nach Neustart gestartet, Monitoring ohne neue Alarme",
|
||||
"hilfetext": "Besonders auf Dienste achten, die verzögert starten oder an einem Dienstkonto hängen. Das Monitoring einige Minuten beobachten, nicht nur einmal ansehen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Anmeldung und Kernanwendungen stichprobenartig getestet",
|
||||
"hilfetext": "Mit einem normalen Benutzerkonto anmelden, nicht als Administrator. ERP, Mail und Drucken kurz antesten.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Sicherung nach dem Patchday erfolgreich gelaufen",
|
||||
"hilfetext": "Der erste Lauf nach den Updates ist der wichtigste: geänderte Agenten und Treiber lassen Jobs gern scheitern.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Snapshots wieder entfernt",
|
||||
"hilfetext": "Liegengebliebene Snapshots füllen den Speicher und bremsen die VM. Erst löschen, wenn die Kontrolle abgeschlossen ist.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Auffälligkeiten und zurückgestellte Updates",
|
||||
"hilfetext": "Je Zeile System, Update und Grund der Zurückstellung. Das ist die Arbeitsliste für den nächsten Patchday.",
|
||||
"typ": "TEXT",
|
||||
"konfig": { "mehrzeilig": true }
|
||||
},
|
||||
{
|
||||
"titel": "Wartungsbericht {{monat}} abgelegt und an die IT-Leitung gemeldet",
|
||||
"hilfetext": "Kurz halten: was eingespielt wurde, was offen blieb, welcher Aufwand entstanden ist.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"format": 1,
|
||||
"name": "Backup-Restore-Test",
|
||||
"beschreibung": "Der regelmäßige Nachweis, dass sich eine Sicherung wirklich zurückholen lässt: Wiederherstellungspunkt prüfen, in einer isolierten Umgebung zurückspielen, Funktion und Dauer festhalten.",
|
||||
"kategorie": "Wartung",
|
||||
"schlagworte": ["backup", "restore", "wiederherstellung", "test", "wartung"],
|
||||
"struktur": {
|
||||
"variablen": [
|
||||
{ "schluessel": "system", "beschriftung": "System", "typ": "TEXT", "pflicht": true },
|
||||
{
|
||||
"schluessel": "restore_typ",
|
||||
"beschriftung": "Art der Wiederherstellung",
|
||||
"typ": "AUSWAHL",
|
||||
"optionen": ["Einzeldatei", "Komplette VM", "Datenbank", "Postfach"],
|
||||
"pflicht": true
|
||||
}
|
||||
],
|
||||
"abschnitte": [
|
||||
{
|
||||
"titel": "Vorbereitung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Testumfang festgelegt: {{restore_typ}} von {{system}}",
|
||||
"hilfetext": "Vorher festlegen, was als Erfolg gilt: welche Datei, welcher Dienst, welche Anmeldung. Sonst wird aus dem Test ein Herumprobieren.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Isolierte Zielumgebung vorhanden (kein Konflikt mit dem Produktivsystem)",
|
||||
"hilfetext": "Eigenes Netz oder getrennte Netzwerkkarte, geänderter Hostname. Ein wiederhergestellter Domänencontroller im Produktivnetz richtet echten Schaden an.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Prüfung der Sicherungen",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Sicherungsjobs der letzten 30 Tage ohne ungeklärte Fehler",
|
||||
"hilfetext": "Auch Warnungen ansehen: übersprungene Dateien und abgebrochene Teiljobs sind die häufigste Ursache für eine unbrauchbare Wiederherstellung.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Datum des verwendeten Wiederherstellungspunkts",
|
||||
"hilfetext": "Nicht ungeprüft den neuesten Punkt nehmen. Ein etwas älterer zeigt eher, ob die Aufbewahrungskette hält.",
|
||||
"typ": "DATUM",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Externe bzw. unveränderliche Kopie vorhanden und aktuell",
|
||||
"hilfetext": "Die Kopie außer Haus oder mit Schreibschutz ist die, die einen Verschlüsselungsangriff übersteht. Ihr Alter prüfen, nicht nur ihre Existenz.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Integritätsprüfung (Verify) des Wiederherstellungspunkts erfolgreich",
|
||||
"hilfetext": "Die Prüfung liest die Sicherung wirklich durch. Ein stiller Lesefehler auf dem Sicherungsmedium fällt anders nicht auf.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Wiederherstellung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Wiederherstellung durchgeführt",
|
||||
"hilfetext": "In die Testumgebung zurückspielen, nie über das Original. Auffälligkeiten während des Laufs gleich notieren.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Dauer der Wiederherstellung",
|
||||
"hilfetext": "Von Start bis benutzbar messen, nicht nur die Kopierzeit. Die Zahl ist die Grundlage für die zugesagte Wiederanlaufzeit.",
|
||||
"typ": "ZAHL",
|
||||
"pflicht": true,
|
||||
"konfig": { "min": 0, "einheit": "Minuten" }
|
||||
},
|
||||
{
|
||||
"titel": "Wiederhergestellte Daten bzw. System auf Funktion geprüft (Dateien öffnen, Dienst startet, Anmeldung möglich)",
|
||||
"hilfetext": "Eine Datei zählt erst, wenn sie sich öffnen lässt. Bei Datenbanken den Dienst starten und eine Abfrage ausführen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Ergebnis",
|
||||
"hilfetext": "Alles außer „Erfolgreich“ braucht eine Erklärung im Textfeld am Ende des Laufzettels.",
|
||||
"typ": "AUSWAHL",
|
||||
"pflicht": true,
|
||||
"konfig": { "optionen": ["Erfolgreich", "Mit Einschränkungen", "Fehlgeschlagen"] }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Abschluss",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Testumgebung wieder entfernt",
|
||||
"hilfetext": "Wiederhergestellte Systeme und Daten löschen, nicht nur abschalten. Eine vergessene Kopie ist ein Datenschutzproblem.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Festgestellte Mängel und Maßnahmen",
|
||||
"hilfetext": "Je Zeile Mangel, geplante Maßnahme und Verantwortlicher. Ein Test ohne Folgerung ändert nichts.",
|
||||
"typ": "TEXT",
|
||||
"konfig": { "mehrzeilig": true }
|
||||
},
|
||||
{
|
||||
"titel": "Ergebnis dokumentiert und der IT-Leitung mitgeteilt",
|
||||
"hilfetext": "Mit Datum, Wiederherstellungspunkt, Dauer und Ergebnis. Dieser Nachweis wird in Audits und von Versicherern verlangt.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+73
-81
@@ -1,19 +1,37 @@
|
||||
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";
|
||||
import { Betriebsmodus } from "../src/generated/prisma/enums.js";
|
||||
import { demoKundenSaeen } from "./seed-daten/dienstleistung.js";
|
||||
import {
|
||||
adminAnlegen,
|
||||
eigeneOrganisationAnlegen,
|
||||
einstellungAnlegen,
|
||||
modusLesen,
|
||||
} from "./seed-daten/grundbestand.js";
|
||||
import { beispielQuelleSaeen, stammdatenSaeen } from "./seed-daten/stammdaten.js";
|
||||
import { typVorgabenSaeen, vorlagenSaeen } from "./seed-daten/vorlagen.js";
|
||||
|
||||
/**
|
||||
* Grundbestand, ohne den die Anwendung nicht bedienbar waere: der Betriebsmodus,
|
||||
* die eigene Organisation und ein Administrator.
|
||||
* Der Seed: was eine frische Installation braucht, um bedienbar zu sein (#017).
|
||||
*
|
||||
* 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.
|
||||
* Angelegt werden: Betriebsmodus samt Namensregeln, die eigene Organisation, ein
|
||||
* Administrator, die sieben Vorlagen als veroeffentlichte Version 1 und ein
|
||||
* Beispielbestand aus Abteilungen, Personen, Geraeten und Tags. Im Modus INTERN
|
||||
* haengt der an der eigenen Organisation und bekommt eine **deaktivierte**
|
||||
* Beispiel-Verzeichnisquelle dazu; im Modus DIENSTLEISTUNG haengt er an den
|
||||
* Demo-Kunden — „erzeugt stattdessen“, so steht es im Auftrag.
|
||||
*
|
||||
* Die einzelnen Schritte liegen unter `prisma/seed-daten/`, damit Tests sie
|
||||
* einzeln aufrufen koennen: diese Datei fuehrt beim Import den ganzen Seed aus.
|
||||
*
|
||||
* Was schon da ist, bleibt, wie es ist. Der Seed richtet ein; er raeumt nicht auf
|
||||
* und stellt nichts zurueck. Wer eine Vorlage bearbeitet oder eine Ziel-OU
|
||||
* geaendert hat, bekommt sie beim naechsten Lauf nicht ueberschrieben.
|
||||
*/
|
||||
|
||||
const url = process.env["DATABASE_URL"];
|
||||
@@ -23,84 +41,58 @@ if (!url) {
|
||||
|
||||
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<void> {
|
||||
// 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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const modus = modusLesen();
|
||||
console.log("Seed laeuft:");
|
||||
await einstellungAnlegen(modus);
|
||||
await eigeneOrganisationAnlegen();
|
||||
await adminAnlegen();
|
||||
await einstellungAnlegen(prisma, modus);
|
||||
const eigeneId = await eigeneOrganisationAnlegen(prisma);
|
||||
await adminAnlegen(prisma);
|
||||
|
||||
const vorlagen = await vorlagenSaeen(prisma);
|
||||
const neueVorlagen = vorlagen.filter((eintrag) => eintrag.neu).length;
|
||||
console.log(` Vorlagen: ${vorlagen.length} vorhanden, davon ${neueVorlagen} neu angelegt`);
|
||||
|
||||
const typVorgaben = await typVorgabenSaeen(prisma, vorlagen);
|
||||
console.log(` Schnellstart je Geraetetyp: ${typVorgaben} neu`);
|
||||
|
||||
/*
|
||||
* Der Beispielbestand haengt an dem, der ihn im jeweiligen Modus braucht:
|
||||
*
|
||||
* - INTERN: an der eigenen Organisation. Sie ist das Haus.
|
||||
* - DIENSTLEISTUNG: an der Musterfirma. Der Auftrag verlangt, dass `LZ-7K3F9Q`
|
||||
* dort auf `MUS-PC-001` fuehrt — „erzeugt **stattdessen**“. Beides zugleich
|
||||
* ginge ohnehin nicht: ein Tag-Code gehoert genau einem Geraet.
|
||||
*/
|
||||
if (modus === Betriebsmodus.INTERN) {
|
||||
const bestand = await stammdatenSaeen(prisma, eigeneId);
|
||||
console.log(
|
||||
` Beispielbestand: ${bestand.abteilungen} Abteilungen, ${bestand.personen} Personen, ` +
|
||||
`${bestand.geraete} Geraete, ${bestand.offeneTags} freie Tags (jeweils neu)`,
|
||||
);
|
||||
|
||||
// Das Verzeichnis gibt es nur im Modus INTERN (#008) – im Modus
|
||||
// DIENSTLEISTUNG waere die Beispielquelle eine Zeile, die niemand sieht.
|
||||
const quelle = await beispielQuelleSaeen(prisma, eigeneId);
|
||||
console.log(
|
||||
quelle
|
||||
? " Verzeichnisquelle: Beispiel angelegt (deaktiviert, Adresse und Passwort ersetzen)"
|
||||
: " Verzeichnisquelle: vorhanden",
|
||||
);
|
||||
} else {
|
||||
const kunden = await demoKundenSaeen(prisma);
|
||||
for (const kunde of kunden) {
|
||||
const bestand = kunde.bestand;
|
||||
const zahlen = bestand
|
||||
? `${bestand.abteilungen} Abteilungen, ${bestand.personen} Personen, ` +
|
||||
`${bestand.geraete} Geraete, ${bestand.offeneTags} freie Tags (jeweils neu)`
|
||||
: "kein Bestand";
|
||||
console.log(
|
||||
` Kunde ${kunde.kuerzel}: ${kunde.neu ? "angelegt" : "vorhanden"} ` +
|
||||
`(${kunde.kunde}, ${kunde.hostname}) – ${zahlen}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Seed fertig.");
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,16 @@
|
||||
* angenommen, schlaegt es fehl, statt eine Lage herzustellen, die es in der
|
||||
* Anwendung nie gibt.
|
||||
*
|
||||
* Idempotent. Mit #017 wird das Skript geloescht.
|
||||
* Idempotent.
|
||||
*
|
||||
* **Bleibt auch nach #017.** Der Kopf sagte frueher „Mit #017 wird das Skript
|
||||
* geloescht“ — das war eine Verwechslung zweier Dinge. Der Seed richtet eine
|
||||
* **Installation** ein: Vorlagen, Abteilungen, Personen, Geraete. Was
|
||||
* Screenshots und Ende-zu-Ende-Abläufe brauchen, ist etwas anderes: ein offener
|
||||
* Entwurf, ein halb abgearbeiteter Lauf, eine Person aus dem Verzeichnis. Das in
|
||||
* den Seed zu legen hiesse, jeder frischen Installation Demo-Laeufe mitzugeben,
|
||||
* die dort niemand bestellt hat. Die Zusammenfuehrung dieser Fixtures gehoert zu
|
||||
* #018.
|
||||
*
|
||||
* pnpm beispiel:lauf
|
||||
*/
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
/**
|
||||
* Beispiel-Stammdaten fuer die Screenshots (#011).
|
||||
*
|
||||
* Personen, Abteilungen und Geraete lassen sich nur mit Inhalt zeigen, und die
|
||||
* echten Beispieldaten kommen erst mit #017. Bis dahin legt dieses Skript einen
|
||||
* Personen, Abteilungen und Geraete lassen sich nur mit Inhalt zeigen, und der Seed legt
|
||||
* keine Person aus dem Verzeichnis an. Dieses Skript legt einen
|
||||
* kleinen, glaubwuerdigen Bestand an — darunter eine Person mit
|
||||
* `herkunft = ABGLEICH`, damit die gesperrten Stammdaten sichtbar werden.
|
||||
*
|
||||
@@ -12,7 +12,16 @@
|
||||
* der Anwendung nie gibt. Der abgeglichene Fall entsteht direkt ueber Prisma —
|
||||
* den Weg dorthin baut erst der Verzeichnisabgleich (#016).
|
||||
*
|
||||
* Idempotent. Mit #017 wird das Skript geloescht.
|
||||
* Idempotent.
|
||||
*
|
||||
* **Bleibt auch nach #017.** Der Kopf sagte frueher „Mit #017 wird das Skript
|
||||
* geloescht“ — das war eine Verwechslung zweier Dinge. Der Seed richtet eine
|
||||
* **Installation** ein: Vorlagen, Abteilungen, Personen, Geraete. Was
|
||||
* Screenshots und Ende-zu-Ende-Abläufe brauchen, ist etwas anderes: ein offener
|
||||
* Entwurf, ein halb abgearbeiteter Lauf, eine Person aus dem Verzeichnis. Das in
|
||||
* den Seed zu legen hiesse, jeder frischen Installation Demo-Laeufe mitzugeben,
|
||||
* die dort niemand bestellt hat. Die Zusammenfuehrung dieser Fixtures gehoert zu
|
||||
* #018.
|
||||
*
|
||||
* pnpm beispiel:stammdaten
|
||||
*/
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
/**
|
||||
* Eine Beispielvorlage fuer die Screenshots des Editors (#010).
|
||||
*
|
||||
* Der Editor laesst sich nur mit Inhalt zeigen, und die sieben echten Vorlagen
|
||||
* kommen erst mit #017. Bis dahin legt dieses Skript eine Vorlage mit
|
||||
* Der Editor laesst sich nur mit Inhalt zeigen, und die sieben Vorlagen des Seeds
|
||||
* haben keinen offenen Entwurf. Dieses Skript legt eine Vorlage mit
|
||||
* veroeffentlichter Version 1 **und** einem offenen Entwurf an — also genau die
|
||||
* Lage, die die Oberflaeche unterscheiden muss.
|
||||
*
|
||||
@@ -15,7 +15,14 @@
|
||||
*
|
||||
* pnpm beispiel:vorlage
|
||||
*
|
||||
* Mit #017 wird das Skript geloescht.
|
||||
* **Bleibt auch nach #017.** Der Kopf sagte frueher „Mit #017 wird das Skript
|
||||
* geloescht“ — das war eine Verwechslung zweier Dinge. Der Seed richtet eine
|
||||
* **Installation** ein: Vorlagen, Abteilungen, Personen, Geraete. Was
|
||||
* Screenshots und Ende-zu-Ende-Abläufe brauchen, ist etwas anderes: ein offener
|
||||
* Entwurf, ein halb abgearbeiteter Lauf, eine Person aus dem Verzeichnis. Das in
|
||||
* den Seed zu legen hiesse, jeder frischen Installation Demo-Laeufe mitzugeben,
|
||||
* die dort niemand bestellt hat. Die Zusammenfuehrung dieser Fixtures gehoert zu
|
||||
* #018.
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { prisma } from "../src/lib/db.js";
|
||||
|
||||
@@ -356,6 +356,7 @@ const BEISPIELWERTE: Record<string, string> = {
|
||||
dn: "CN=Anna Beispiel,OU=Vertrieb,DC=muster,DC=example",
|
||||
ou: "OU=Vertrieb,DC=muster,DC=example",
|
||||
gruppen: "G-Vertrieb, G-Drucker-EG",
|
||||
ou_gesperrt: "OU=Gesperrt,OU=Benutzer,DC=muster,DC=example",
|
||||
generiertes_passwort: BEISPIEL_PASSWORT,
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* zweite Aufzaehlung an anderer Stelle wuerde unweigerlich auseinanderlaufen.
|
||||
*/
|
||||
|
||||
export type PlatzhalterQuelle = "GERAET_LAUF" | "MITARBEITER" | "ABTEILUNG" | "BESONDERS";
|
||||
export type PlatzhalterQuelle = "GERAET_LAUF" | "MITARBEITER" | "ABTEILUNG" | "EINSTELLUNG" | "BESONDERS";
|
||||
|
||||
export type EingebauterPlatzhalter = {
|
||||
readonly schluessel: string;
|
||||
@@ -65,6 +65,13 @@ export const EINGEBAUTE_PLATZHALTER: readonly EingebauterPlatzhalter[] = [
|
||||
},
|
||||
{ schluessel: "gruppen", quelle: "ABTEILUNG", erklaerung: "Standardgruppen, kommagetrennt" },
|
||||
|
||||
// Aus den Einstellungen
|
||||
{
|
||||
schluessel: "ou_gesperrt",
|
||||
quelle: "EINSTELLUNG",
|
||||
erklaerung: "Ziel-OU für deaktivierte Konten, aus Admin → Einstellungen",
|
||||
},
|
||||
|
||||
// Besonders
|
||||
{
|
||||
schluessel: "generiertes_passwort",
|
||||
@@ -78,6 +85,7 @@ export const QUELLEN_TEXT: Record<PlatzhalterQuelle, string> = {
|
||||
GERAET_LAUF: "Gerät und Lauf",
|
||||
MITARBEITER: "Mitarbeiter",
|
||||
ABTEILUNG: "Abteilung",
|
||||
EINSTELLUNG: "Einstellungen",
|
||||
BESONDERS: "Besonders",
|
||||
};
|
||||
|
||||
|
||||
@@ -316,8 +316,18 @@ export async function laufZumAbarbeiten(
|
||||
schrittId: schritt.id,
|
||||
ergebnisId: ergebnis?.id ?? null,
|
||||
nummer,
|
||||
titel: platzhalterErsetzen(schritt.titel, werte).text,
|
||||
hilfetext: schritt.hilfetext ? platzhalterErsetzen(schritt.hilfetext, werte).text : null,
|
||||
/*
|
||||
* **Dasselbe Passwort wie im Befehl.** Titel und Hilfetext bekommen das
|
||||
* Passwort dieses Seitenaufbaus mit, nicht jeweils ein eigenes. Ohne das
|
||||
* `passwort`-Argument erzeugt jede Aufloesung ein neues: im Befehl stuende
|
||||
* A, im Hilfetext daneben B — das Konto bekaeme A, der Benutzer erfuehre
|
||||
* B, und A waere danach weg. Genau davor warnt `platzhalter-werte.ts` in
|
||||
* Regel 4 seiner eigenen Kopfdokumentation (Reviewbefund zu #017).
|
||||
*/
|
||||
titel: platzhalterErsetzen(schritt.titel, werte, { art: "anzeige" }, passwort).text,
|
||||
hilfetext: schritt.hilfetext
|
||||
? platzhalterErsetzen(schritt.hilfetext, werte, { art: "anzeige" }, passwort).text
|
||||
: null,
|
||||
typ: schritt.typ,
|
||||
pflicht: schritt.pflicht,
|
||||
dokuUrl: schritt.dokuUrl,
|
||||
|
||||
@@ -171,6 +171,22 @@ export async function platzhalterWerteDesLaufs(kontext: LaufKontext): Promise<Pl
|
||||
if (werte["ou"] === undefined) setzen(werte, "ou", alsText(lauf.abteilung.zielOu));
|
||||
}
|
||||
|
||||
/*
|
||||
* --- Aus den Einstellungen ---
|
||||
*
|
||||
* Die Ziel-OU fuer deaktivierte Konten steht global in `Einstellung` und wird
|
||||
* im Offboarding gebraucht. Sie stand dort schon, wurde aber von keinem
|
||||
* Platzhalter gelesen: `Move-ADObject -TargetPath "{{ou_gesperrt}}"` blieb leer,
|
||||
* obwohl die Einstellungsseite „Wird beim Offboarding vorbelegt“ verspricht
|
||||
* (Reviewbefund zu #017). Eine Variable gleichen Namens gewinnt weiter — sie
|
||||
* steht weiter unten.
|
||||
*/
|
||||
const einstellung = await prisma.einstellung.findUnique({
|
||||
where: { id: "global" },
|
||||
select: { gesperrteOu: true },
|
||||
});
|
||||
setzen(werte, "ou_gesperrt", alsText(einstellung?.gesperrteOu));
|
||||
|
||||
/*
|
||||
* --- Vorlagenvariablen ---
|
||||
*
|
||||
|
||||
@@ -27,10 +27,33 @@ async function anmelden(seite: Page) {
|
||||
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
|
||||
}
|
||||
|
||||
/** Die Vorlage aus `pnpm beispiel:vorlage` – diese Ablaeufe arbeiten mit ihr. */
|
||||
const BEISPIEL_VORLAGE = "PC-Neuinstallation (Beispiel)";
|
||||
|
||||
/** Legt einen Lauf an und gibt seine Adresse zurueck. */
|
||||
async function laufStarten(seite: Page, titel: string): Promise<string> {
|
||||
await seite.goto("/laeufe/starten");
|
||||
await seite.selectOption('select[name="vorlagenVersionId"]', { index: 1 });
|
||||
|
||||
/*
|
||||
* Die Vorlage wird **am Namen** gewaehlt und nicht ueber `index: 1`. Seit dem
|
||||
* Seed der sieben Vorlagen (#017) steht dort an erster Stelle, was die
|
||||
* Sortierung gerade nach vorn stellt — bei einer frisch eingerichteten
|
||||
* Installation etwa „Mitarbeiter-Offboarding“ mit Pflichtangaben, die dieser
|
||||
* Helfer nicht kennt. Der Ablauf lief dann in den Zeitablauf, und die Meldung
|
||||
* sagte nichts ueber die Ursache.
|
||||
*/
|
||||
const vorlagen = await seite
|
||||
.locator('select[name="vorlagenVersionId"] option')
|
||||
.evaluateAll((liste) =>
|
||||
liste
|
||||
.filter((option): option is HTMLOptionElement => option instanceof HTMLOptionElement)
|
||||
.map((option) => ({ wert: option.value, text: option.textContent })),
|
||||
);
|
||||
const beispiel = vorlagen.find((eintrag) => (eintrag.text ?? "").includes(BEISPIEL_VORLAGE));
|
||||
if (!beispiel) {
|
||||
throw new Error(`Die Vorlage „${BEISPIEL_VORLAGE}“ fehlt. Erst „pnpm beispiel:vorlage“ laufen lassen.`);
|
||||
}
|
||||
await seite.selectOption('select[name="vorlagenVersionId"]', beispiel.wert);
|
||||
await seite.selectOption('select[name="geraetId"]', { index: 1 });
|
||||
await seite.locator("details summary", { hasText: "Titel, Ticket" }).click();
|
||||
await seite.getByLabel("Titel", { exact: true }).fill(titel);
|
||||
@@ -42,6 +65,13 @@ async function laufStarten(seite: Page, titel: string): Promise<string> {
|
||||
for (const feld of await seite.locator('fieldset input[type="number"]').all()) {
|
||||
await feld.fill("16");
|
||||
}
|
||||
for (const feld of await seite.locator('fieldset input[type="date"]').all()) {
|
||||
await feld.fill("2026-10-01");
|
||||
}
|
||||
for (const feld of await seite.locator('fieldset input[type="text"]').all()) {
|
||||
// Nur leere Felder: die Namensvorschlaege sollen stehen bleiben.
|
||||
if ((await feld.inputValue()) === "") await feld.fill("E2E");
|
||||
}
|
||||
|
||||
await seite.getByRole("button", { name: "Lauf anlegen" }).click();
|
||||
await seite.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 30_000 });
|
||||
|
||||
@@ -0,0 +1,694 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Betriebsmodus, GeraeteTyp, Herkunft, VersionStatus } from "@/generated/prisma/enums";
|
||||
import { entschluesseln } from "@/lib/geheimnisse";
|
||||
import { istTagCode } from "@/lib/tagcode";
|
||||
import { platzhalterDerStruktur } from "@/lib/vorlagen-format";
|
||||
import { unbekanntePlatzhalter, unbenutzteVariablen } from "@/lib/platzhalter";
|
||||
import { demoKundenSaeen, DEMO_KUNDEN } from "../prisma/seed-daten/dienstleistung";
|
||||
import {
|
||||
adminAnlegen,
|
||||
einstellungAnlegen,
|
||||
GESPERRTE_OU,
|
||||
modusLesen,
|
||||
namensregelnFuerModus,
|
||||
} from "../prisma/seed-daten/grundbestand";
|
||||
import {
|
||||
ABTEILUNGEN,
|
||||
beispielQuelleSaeen,
|
||||
NB_TAG,
|
||||
OFFENE_TAGS,
|
||||
PC_TAG,
|
||||
PERSONEN,
|
||||
stammdatenSaeen,
|
||||
} from "../prisma/seed-daten/stammdaten";
|
||||
import { TYP_VORGABEN, typVorgabenSaeen, vorlagenLesen, vorlagenSaeen } from "../prisma/seed-daten/vorlagen";
|
||||
import { befehlFuellen } from "@/server/services/befehle";
|
||||
import { platzhalterWerteDesLaufs } from "@/server/services/platzhalter";
|
||||
import { namenVorschlagen } from "@/server/services/befehle";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Der Seed (#017).
|
||||
*
|
||||
* Zwei Arten von Pruefungen, und die Trennung ist Absicht:
|
||||
*
|
||||
* 1. **Ohne Datenbank**: die sieben Vorlagendateien selbst. Sie sind der
|
||||
* fachliche Kern des Auftrags — dass jeder Platzhalter eine Variable hat, dass
|
||||
* der `New-ADUser`-Befehl vollstaendig ist, dass nichts doppelt heisst.
|
||||
* 2. **Mit Datenbank**: dass ein zweiter Seed-Lauf nichts aendert. Das ist die
|
||||
* Zusage, die am leichtesten kaputtgeht — ein `create` ohne vorherige Pruefung
|
||||
* genuegt, und beim dritten Lauf stehen einundzwanzig Vorlagen in der Liste.
|
||||
*/
|
||||
|
||||
const VORLAGEN = vorlagenLesen();
|
||||
|
||||
/** Jede Zeichenkette eines Dokuments mit ihrem Pfad – fuer Pruefungen ueber alle Texte. */
|
||||
function* texteDurchlaufen(wert: unknown, pfad = ""): Generator<readonly [string, string]> {
|
||||
if (typeof wert === "string") {
|
||||
yield [pfad, wert];
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(wert)) {
|
||||
for (const [index, eintrag] of wert.entries()) yield* texteDurchlaufen(eintrag, `${pfad}[${index}]`);
|
||||
return;
|
||||
}
|
||||
if (typeof wert === "object" && wert !== null) {
|
||||
for (const [name, eintrag] of Object.entries(wert)) {
|
||||
yield* texteDurchlaufen(eintrag, pfad ? `${pfad}.${name}` : name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("Die sieben Vorlagen", () => {
|
||||
it("sind vollzaehlig und heissen jede anders", () => {
|
||||
expect(VORLAGEN).toHaveLength(7);
|
||||
const namen = VORLAGEN.map((eintrag) => eintrag.dokument.name);
|
||||
expect(new Set(namen).size).toBe(7);
|
||||
expect(namen).toEqual([
|
||||
"PC Neuinstallation",
|
||||
"Mitarbeiter-Onboarding",
|
||||
"Mitarbeiter-Offboarding",
|
||||
"Server-Inbetriebnahme Windows-VM",
|
||||
"Firewall-Rollout OPNsense",
|
||||
"Monatlicher Patchday",
|
||||
"Backup-Restore-Test",
|
||||
]);
|
||||
});
|
||||
|
||||
it("haben Abschnitte und Schritte, und kein Abschnitt ist leer", () => {
|
||||
for (const { datei, dokument } of VORLAGEN) {
|
||||
expect(dokument.struktur.abschnitte.length, datei).toBeGreaterThan(0);
|
||||
for (const abschnitt of dokument.struktur.abschnitte) {
|
||||
expect(abschnitt.schritte.length, `${datei} / ${abschnitt.titel}`).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("tragen keine Nummer im Abschnittstitel", () => {
|
||||
// Die Nummer kommt aus der Position und wird nie gespeichert; stuende sie im
|
||||
// Titel, hiesse der erste Abschnitt nach dem Umsortieren „01 · 02 · …“.
|
||||
for (const { datei, dokument } of VORLAGEN) {
|
||||
for (const abschnitt of dokument.struktur.abschnitte) {
|
||||
expect(abschnitt.titel, datei).not.toMatch(/^\d+\s*·/u);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("benutzen nur Platzhalter, die es gibt", () => {
|
||||
for (const { datei, dokument } of VORLAGEN) {
|
||||
const verwendet = platzhalterDerStruktur(dokument.struktur);
|
||||
const schluessel = dokument.struktur.variablen.map((variable) => variable.schluessel);
|
||||
expect(unbekanntePlatzhalter(verwendet, schluessel), datei).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("nennt jede unbenutzte Variable – damit sie eine Entscheidung ist", () => {
|
||||
/*
|
||||
* Eine Variable, die in keinem Schritt vorkommt, wird beim Start abgefragt
|
||||
* und verschwindet dann. Genau eine bleibt so stehen: `vmid` in der
|
||||
* Server-Vorlage, weil der Auftrag sie nennt und in keinem Schritt braucht —
|
||||
* die Kennung der VM im Hypervisor ist beim Anlegen nun einmal bekannt und
|
||||
* taucht in keinem Befehl auf. Der Test haelt fest, dass es **diese eine**
|
||||
* bleibt; eine weitere waere ein Versehen (`eintrittsdatum` war eines und
|
||||
* steht jetzt im Hilfetext des ersten Schritts).
|
||||
*/
|
||||
const unbenutzt = VORLAGEN.flatMap(({ dokument }) =>
|
||||
unbenutzteVariablen(
|
||||
platzhalterDerStruktur(dokument.struktur),
|
||||
dokument.struktur.variablen.map((variable) => variable.schluessel),
|
||||
).map((schluessel) => `${dokument.name}: ${schluessel}`),
|
||||
);
|
||||
expect(unbenutzt).toEqual(["Server-Inbetriebnahme Windows-VM: vmid"]);
|
||||
});
|
||||
|
||||
it("hat im Onboarding den vollstaendigen New-ADUser-Befehl", () => {
|
||||
// Wortgetreu aus dem Auftrag und ein Abnahmekriterium: ohne `-Path` legt der
|
||||
// Befehl das Konto im Standard-Container an, nicht in der Abteilungs-OU.
|
||||
const onboarding = VORLAGEN.find((eintrag) => eintrag.dokument.name === "Mitarbeiter-Onboarding");
|
||||
const befehle = (onboarding?.dokument.struktur.abschnitte ?? [])
|
||||
.flatMap((abschnitt) => abschnitt.schritte)
|
||||
.filter((schritt) => schritt.typ === "BEFEHL")
|
||||
.map((schritt) => String(schritt.konfig["befehl"] ?? ""));
|
||||
|
||||
const anlegen = befehle.find((befehl) => befehl.includes("New-ADUser"));
|
||||
expect(anlegen).toBeDefined();
|
||||
for (const teil of [
|
||||
'-Name "{{vorname}} {{nachname}}"',
|
||||
'-SamAccountName "{{sam}}"',
|
||||
'-UserPrincipalName "{{upn}}"',
|
||||
'-EmailAddress "{{mail}}"',
|
||||
'-Department "{{abteilung}}"',
|
||||
'-Path "{{ou}}"',
|
||||
"{{generiertes_passwort}}",
|
||||
"-ChangePasswordAtLogon $true",
|
||||
]) {
|
||||
expect(anlegen, teil).toContain(teil);
|
||||
}
|
||||
|
||||
const gruppen = befehle.find((befehl) => befehl.includes("Add-ADGroupMember"));
|
||||
expect(gruppen).toContain('"{{gruppen}}".Split(",")');
|
||||
expect(gruppen).toContain('-Members "{{sam}}"');
|
||||
});
|
||||
|
||||
it("enthaelt beide Konto-Abschnitte des Onboardings", () => {
|
||||
// Bedingte Abschnitte kommen erst in Phase 3; bis dahin wird der nicht
|
||||
// zutreffende als Ganzes auf „nicht zutreffend“ gesetzt.
|
||||
const onboarding = VORLAGEN.find((eintrag) => eintrag.dokument.name === "Mitarbeiter-Onboarding");
|
||||
const titel = (onboarding?.dokument.struktur.abschnitte ?? []).map((abschnitt) => abschnitt.titel);
|
||||
expect(titel).toContain("Konto anlegen (Active Directory)");
|
||||
expect(titel).toContain("Konto anlegen (Entra ID)");
|
||||
});
|
||||
|
||||
it("speichert nirgends ein Passwort im Klartext", () => {
|
||||
/*
|
||||
* `{{generiertes_passwort}}` ist erlaubt – es ist ein Platzhalter, der je
|
||||
* Anzeige neu entsteht. Ein ausgeschriebenes Passwort waere es nicht.
|
||||
*
|
||||
* Geprueft wird jede **Zeichenkette einzeln** und nicht das JSON am Stueck:
|
||||
* in `JSON.stringify` steht vor jedem Anfuehrungszeichen ein Backslash, und
|
||||
* der Ausdruck lief daran vorbei. Ein eingebautes Testpasswort blieb
|
||||
* unbemerkt — die Zusicherung konnte gar nicht scheitern.
|
||||
*/
|
||||
// Auch ohne Anfuehrungszeichen: „Das Initialpasswort: Sommer2026! eintragen“
|
||||
// waere genauso ein Klartextpasswort wie eines in Gaensefuesschen.
|
||||
const verdaechtig = /passwort[a-zäöüß]*\s*[:=]\s*\\?["'„]?\S{6,}/iu;
|
||||
for (const { datei, dokument } of VORLAGEN) {
|
||||
for (const [pfad, wert] of texteDurchlaufen(dokument)) {
|
||||
const ohnePlatzhalter = wert.replaceAll("{{generiertes_passwort}}", "");
|
||||
expect(ohnePlatzhalter, `${datei} / ${pfad}`).not.toMatch(verdaechtig);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("nennt zu jedem Schritt einen Hilfetext", () => {
|
||||
// Der Auftrag verlangt die genannten Hilfetexte und kurze eigene fuer die
|
||||
// uebrigen. Ein Schritt ohne Hilfe ist im Zweifel der, bei dem jemand raet.
|
||||
for (const { datei, dokument } of VORLAGEN) {
|
||||
for (const abschnitt of dokument.struktur.abschnitte) {
|
||||
for (const schritt of abschnitt.schritte) {
|
||||
expect(schritt.hilfetext?.trim(), `${datei} / ${schritt.titel}`).toBeTruthy();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("gibt jedem Befehlsschritt eine Shell und einen Befehl", () => {
|
||||
for (const { datei, dokument } of VORLAGEN) {
|
||||
for (const abschnitt of dokument.struktur.abschnitte) {
|
||||
for (const schritt of abschnitt.schritte) {
|
||||
if (schritt.typ !== "BEFEHL") continue;
|
||||
expect(schritt.konfig["shell"], `${datei} / ${schritt.titel}`).toBeTruthy();
|
||||
expect(String(schritt.konfig["befehl"] ?? "").trim(), datei).not.toBe("");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("gibt jedem Auswahlschritt Optionen", () => {
|
||||
for (const { datei, dokument } of VORLAGEN) {
|
||||
for (const abschnitt of dokument.struktur.abschnitte) {
|
||||
for (const schritt of abschnitt.schritte) {
|
||||
if (schritt.typ !== "AUSWAHL") continue;
|
||||
const optionen = schritt.konfig["optionen"];
|
||||
expect(Array.isArray(optionen) && optionen.length > 0, `${datei} / ${schritt.titel}`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Die Tag-Codes des Seeds", () => {
|
||||
it("haben die richtige Form und kommen nur einmal vor", () => {
|
||||
const alle = [
|
||||
PC_TAG,
|
||||
NB_TAG,
|
||||
...OFFENE_TAGS,
|
||||
// Der Vollbestands-Kunde benutzt PC_TAG und NB_TAG; nur der kleine Satz
|
||||
// bringt einen eigenen Code mit.
|
||||
...DEMO_KUNDEN.flatMap((kunde) => (kunde.pcTag === undefined ? [] : [kunde.pcTag])),
|
||||
];
|
||||
for (const code of alle) expect(istTagCode(code), code).toBe(true);
|
||||
expect(new Set(alle).size).toBe(alle.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Der Betriebsmodus aus der Umgebung", () => {
|
||||
const vorher = process.env["SEED_MODE"];
|
||||
afterEach(() => {
|
||||
if (vorher === undefined) delete process.env["SEED_MODE"];
|
||||
else process.env["SEED_MODE"] = vorher;
|
||||
});
|
||||
|
||||
it("nimmt INTERN als Vorgabe", () => {
|
||||
delete process.env["SEED_MODE"];
|
||||
expect(modusLesen()).toBe(Betriebsmodus.INTERN);
|
||||
});
|
||||
|
||||
it("kennt SERVICE als Alias für DIENSTLEISTUNG", () => {
|
||||
// Der Auftrag dokumentiert `SEED_MODE=SERVICE`; der Alias steht auch in
|
||||
// CLAUDE.md. Ohne ihn liefe der dort genannte Befehl ins Leere.
|
||||
for (const wert of ["SERVICE", "service", " Dienstleistung "]) {
|
||||
process.env["SEED_MODE"] = wert;
|
||||
expect(modusLesen(), wert).toBe(Betriebsmodus.DIENSTLEISTUNG);
|
||||
}
|
||||
});
|
||||
|
||||
it("weist einen unbekannten Wert ab, statt still INTERN zu nehmen", () => {
|
||||
process.env["SEED_MODE"] = "KUNDE";
|
||||
expect(() => modusLesen()).toThrow(/INTERN/u);
|
||||
});
|
||||
|
||||
it("gibt dem Modus DIENSTLEISTUNG die Hostname-Regel mit Kundenkürzel", () => {
|
||||
expect(namensregelnFuerModus(Betriebsmodus.INTERN).hostname).toBe("PC-{nnn}");
|
||||
expect(namensregelnFuerModus(Betriebsmodus.DIENSTLEISTUNG).hostname).toBe("{kundenkuerzel}-PC-{nnn}");
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Seed gegen die Datenbank", () => {
|
||||
beforeEach(datenbankLeeren);
|
||||
|
||||
/** Die Einstellung samt Namensregeln, so wie der Seed sie anlegt. */
|
||||
async function einstellungSetzen() {
|
||||
await testPrisma.einstellung.deleteMany({});
|
||||
await einstellungAnlegen(testPrisma, Betriebsmodus.INTERN);
|
||||
const zeile = await testPrisma.einstellung.findUniqueOrThrow({ where: { id: "global" } });
|
||||
expect(zeile.gesperrteOu).toBe(GESPERRTE_OU);
|
||||
return zeile;
|
||||
}
|
||||
|
||||
async function eigeneOrganisation() {
|
||||
const kunde = await testPrisma.kunde.create({
|
||||
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||||
});
|
||||
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
|
||||
return kunde;
|
||||
}
|
||||
|
||||
it("legt alle sieben als veroeffentlichte Version 1 an", async () => {
|
||||
const gesaet = await vorlagenSaeen(testPrisma);
|
||||
expect(gesaet).toHaveLength(7);
|
||||
expect(gesaet.every((eintrag) => eintrag.neu)).toBe(true);
|
||||
|
||||
const versionen = await testPrisma.vorlagenVersion.findMany();
|
||||
expect(versionen).toHaveLength(7);
|
||||
for (const version of versionen) {
|
||||
expect(version.version).toBe(1);
|
||||
expect(version.status).toBe(VersionStatus.VEROEFFENTLICHT);
|
||||
expect(version.veroeffentlichtAm).toBeInstanceOf(Date);
|
||||
// Kein Mensch hat veroeffentlicht – und keine erfundene Kennung.
|
||||
expect(version.veroeffentlichtVonId).toBeNull();
|
||||
}
|
||||
expect(await testPrisma.vorlagenAbschnitt.count()).toBe(
|
||||
VORLAGEN.reduce((summe, eintrag) => summe + eintrag.dokument.struktur.abschnitte.length, 0),
|
||||
);
|
||||
expect(await testPrisma.vorlagenSchritt.count()).toBe(
|
||||
VORLAGEN.reduce(
|
||||
(summe, eintrag) =>
|
||||
summe +
|
||||
eintrag.dokument.struktur.abschnitte.reduce((s, abschnitt) => s + abschnitt.schritte.length, 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("aendert beim zweiten Lauf nichts", async () => {
|
||||
const erst = await vorlagenSaeen(testPrisma);
|
||||
const zweit = await vorlagenSaeen(testPrisma);
|
||||
expect(zweit.every((eintrag) => !eintrag.neu)).toBe(true);
|
||||
// Dieselben Zeilen, nicht nur dieselbe Anzahl.
|
||||
expect(zweit.map((eintrag) => eintrag.versionId)).toEqual(erst.map((eintrag) => eintrag.versionId));
|
||||
expect(await testPrisma.vorlage.count()).toBe(7);
|
||||
expect(await testPrisma.vorlagenVersion.count()).toBe(7);
|
||||
});
|
||||
|
||||
it("laesst eine bearbeitete Vorlage in Ruhe", async () => {
|
||||
// Der Fall, der den Unterschied macht: jemand hat die Vorlage im Betrieb
|
||||
// bearbeitet und eine zweite Version veroeffentlicht. Ein Seed-Lauf danach
|
||||
// darf weder eine dritte anlegen noch die Bearbeitung zuruecknehmen.
|
||||
const [erste] = await vorlagenSaeen(testPrisma);
|
||||
if (!erste) throw new Error("Der Seed hat keine Vorlage angelegt.");
|
||||
await testPrisma.vorlagenVersion.create({
|
||||
data: {
|
||||
vorlageId: erste.vorlageId,
|
||||
version: 2,
|
||||
status: VersionStatus.VEROEFFENTLICHT,
|
||||
veroeffentlichtAm: new Date(),
|
||||
aenderungsnotiz: "Von Hand angepasst",
|
||||
},
|
||||
});
|
||||
|
||||
await vorlagenSaeen(testPrisma);
|
||||
expect(await testPrisma.vorlage.count()).toBe(7);
|
||||
expect(await testPrisma.vorlagenVersion.count({ where: { vorlageId: erste.vorlageId } })).toBe(2);
|
||||
});
|
||||
|
||||
it("verknuepft die Schnellstart-Vorgaben mit den richtigen Vorlagen", async () => {
|
||||
const gesaet = await vorlagenSaeen(testPrisma);
|
||||
const angelegt = await typVorgabenSaeen(testPrisma, gesaet);
|
||||
expect(angelegt).toBe(TYP_VORGABEN.reduce((summe, eintrag) => summe + eintrag.vorlagen.length, 0));
|
||||
|
||||
const fuerServer = await testPrisma.geraetetypVorlage.findMany({
|
||||
where: { geraeteTyp: GeraeteTyp.SERVER },
|
||||
include: { vorlage: true },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
expect(fuerServer.map((eintrag) => eintrag.vorlage.name)).toEqual([
|
||||
"Server-Inbetriebnahme Windows-VM",
|
||||
"Backup-Restore-Test",
|
||||
]);
|
||||
|
||||
// Zweiter Lauf: keine Dubletten.
|
||||
expect(await typVorgabenSaeen(testPrisma, gesaet)).toBe(0);
|
||||
});
|
||||
|
||||
it("legt Abteilungen, Personen, Geraete und freie Tags an", async () => {
|
||||
const eigene = await eigeneOrganisation();
|
||||
const bestand = await stammdatenSaeen(testPrisma, eigene.id);
|
||||
expect(bestand).toEqual({
|
||||
abteilungen: ABTEILUNGEN.length,
|
||||
personen: PERSONEN.length,
|
||||
geraete: 2,
|
||||
offeneTags: OFFENE_TAGS.length,
|
||||
});
|
||||
|
||||
const buchhaltung = await testPrisma.abteilung.findFirstOrThrow({ where: { name: "Buchhaltung" } });
|
||||
expect(buchhaltung.zielOu).toBe("OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local");
|
||||
expect(buchhaltung.standardGruppen).toEqual(["G-Buchhaltung", "G-Alle"]);
|
||||
|
||||
const huber = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "mhuber" } });
|
||||
expect(huber.upn).toBe("m.huber@firma.example");
|
||||
expect(huber.abteilung).toBe("Buchhaltung");
|
||||
expect(huber.herkunft).toBe(Herkunft.MANUELL);
|
||||
// Von Hand angelegt heisst: keine Quelle und keine Verzeichniskennung.
|
||||
expect(huber.quelleId).toBeNull();
|
||||
expect(huber.externeId).toBeNull();
|
||||
|
||||
const pc = await testPrisma.geraet.findUniqueOrThrow({ where: { tagCode: PC_TAG } });
|
||||
expect(pc.hostname).toBe("PC-001");
|
||||
expect(pc.typ).toBe(GeraeteTyp.PC);
|
||||
expect(pc.mitarbeiterId).toBe(huber.id);
|
||||
expect(pc.registriertAm).toBeInstanceOf(Date);
|
||||
|
||||
// Die freien Tags sind Aufkleber ohne Geraet: kein Name, kein Datum.
|
||||
const frei = await testPrisma.geraet.findMany({ where: { registriertAm: null } });
|
||||
expect(frei).toHaveLength(OFFENE_TAGS.length);
|
||||
expect(frei.every((tag) => tag.hostname === null && tag.bezeichnung === null)).toBe(true);
|
||||
// Jeder vergebene Code steht im Vergabebuch – auch der eines freien Tags.
|
||||
expect(await testPrisma.tagCodeVergabe.count()).toBe(OFFENE_TAGS.length + 2);
|
||||
});
|
||||
|
||||
it("legt den Bestand beim zweiten Lauf nicht noch einmal an", async () => {
|
||||
const eigene = await eigeneOrganisation();
|
||||
await stammdatenSaeen(testPrisma, eigene.id);
|
||||
const zweit = await stammdatenSaeen(testPrisma, eigene.id);
|
||||
expect(zweit).toEqual({ abteilungen: 0, personen: 0, geraete: 0, offeneTags: 0 });
|
||||
expect(await testPrisma.mitarbeiter.count()).toBe(PERSONEN.length);
|
||||
expect(await testPrisma.geraet.count()).toBe(OFFENE_TAGS.length + 2);
|
||||
expect(await testPrisma.tagCodeVergabe.count()).toBe(OFFENE_TAGS.length + 2);
|
||||
});
|
||||
|
||||
it("legt die Beispielquelle deaktiviert und mit verschluesseltem Passwort an", async () => {
|
||||
const eigene = await eigeneOrganisation();
|
||||
expect(await beispielQuelleSaeen(testPrisma, eigene.id)).toBe(true);
|
||||
expect(await beispielQuelleSaeen(testPrisma, eigene.id)).toBe(false);
|
||||
|
||||
const quelle = await testPrisma.verzeichnisquelle.findFirstOrThrow();
|
||||
expect(quelle.aktiv).toBe(false);
|
||||
expect(quelle.url.startsWith("ldaps://")).toBe(true);
|
||||
// Das Versionspraefix prueft auch die Datenbank; hier zaehlt, dass sich das
|
||||
// Feld ueberhaupt entschluesseln laesst und nichts Lesbares enthaelt.
|
||||
expect(quelle.bindPasswortVerschluesselt).toMatch(/^v[0-9]+\./u);
|
||||
expect(entschluesseln(quelle.bindPasswortVerschluesselt).length).toBeGreaterThan(16);
|
||||
});
|
||||
|
||||
it("erzeugt im Modus Dienstleistung die beiden Demo-Kunden mit MUS-PC-001", async () => {
|
||||
await eigeneOrganisation();
|
||||
const kunden = await demoKundenSaeen(testPrisma);
|
||||
expect(kunden.map((kunde) => kunde.kuerzel)).toEqual(["MUS", "REI"]);
|
||||
expect(kunden[0]?.hostname).toBe("MUS-PC-001");
|
||||
|
||||
/*
|
||||
* **Stattdessen, nicht zusätzlich.** Der Beispielaufkleber `LZ-7K3F9Q` führt
|
||||
* in diesem Modus auf `MUS-PC-001` — genau das verlangt das
|
||||
* Abnahmekriterium des Auftrags. Läge er an der eigenen Organisation, liefe
|
||||
* der Hauptablauf gegen die falsche Zeile (Reviewbefund zu #017).
|
||||
*/
|
||||
const beispiel = await testPrisma.geraet.findUniqueOrThrow({
|
||||
where: { tagCode: PC_TAG },
|
||||
include: { kunde: true, mitarbeiter: true },
|
||||
});
|
||||
expect(beispiel.hostname).toBe("MUS-PC-001");
|
||||
expect(beispiel.kunde.kuerzel).toBe("MUS");
|
||||
expect(beispiel.kunde.istEigeneOrganisation).toBe(false);
|
||||
expect(beispiel.mitarbeiter?.sam).toBe("mhuber");
|
||||
|
||||
// Die Bäckerei bekommt den kleinen Satz und einen eigenen Code.
|
||||
const reiter = await testPrisma.geraet.findFirstOrThrow({ where: { hostname: "REI-PC-001" } });
|
||||
expect(reiter.tagCode).not.toBe(PC_TAG);
|
||||
expect(await testPrisma.mitarbeiter.count({ where: { kundeId: reiter.kundeId } })).toBe(1);
|
||||
|
||||
// Die eigene Organisation bleibt in diesem Modus ohne Beispielbestand.
|
||||
const eigene = await testPrisma.kunde.findFirstOrThrow({ where: { istEigeneOrganisation: true } });
|
||||
expect(await testPrisma.geraet.count({ where: { kundeId: eigene.id } })).toBe(0);
|
||||
expect(await testPrisma.mitarbeiter.count({ where: { kundeId: eigene.id } })).toBe(0);
|
||||
|
||||
const zweit = await demoKundenSaeen(testPrisma);
|
||||
expect(zweit.every((eintrag) => !eintrag.neu)).toBe(true);
|
||||
expect(await testPrisma.kunde.count()).toBe(3);
|
||||
// Zwei Beispielgeräte und fünf freie Tags bei MUS, eines bei REI.
|
||||
expect(await testPrisma.geraet.count()).toBe(OFFENE_TAGS.length + 3);
|
||||
});
|
||||
|
||||
it("lässt einen echten Kunden mit demselben Kürzel in Ruhe", async () => {
|
||||
/*
|
||||
* Ein Haus, das schon einen Kunden „Muster und Söhne KG“ mit dem Kürzel MUS
|
||||
* betreut, bekommt keine erfundene Person, keine erfundene Ziel-OU und kein
|
||||
* Gerät in seinen Bestand geschrieben (Reviewbefund zu #017).
|
||||
*/
|
||||
await eigeneOrganisation();
|
||||
const echt = await testPrisma.kunde.create({
|
||||
data: { name: "Muster und Söhne KG", kuerzel: "MUS", istEigeneOrganisation: false },
|
||||
});
|
||||
|
||||
const kunden = await demoKundenSaeen(testPrisma);
|
||||
expect(kunden.map((kunde) => kunde.kuerzel)).toEqual(["REI"]);
|
||||
expect(await testPrisma.mitarbeiter.count({ where: { kundeId: echt.id } })).toBe(0);
|
||||
expect(await testPrisma.geraet.count({ where: { kundeId: echt.id } })).toBe(0);
|
||||
expect(await testPrisma.abteilung.count({ where: { kundeId: echt.id } })).toBe(0);
|
||||
});
|
||||
|
||||
it("löst den New-ADUser-Befehl mit echten Werten auf", async () => {
|
||||
/*
|
||||
* Der eine Test, der dem Abnahmekriterium wirklich entspricht: nicht der
|
||||
* Rohtext mit `-Path "{{ou}}"`, sondern der Befehl, den der Techniker
|
||||
* kopiert. Er hätte zwei Befunde des Reviews zu #017 sofort gezeigt — das
|
||||
* zweite, abweichende Passwort im Hilfetext und die Ziel-OU, die niemand
|
||||
* vorbelegt.
|
||||
*/
|
||||
const eigene = await eigeneOrganisation();
|
||||
await stammdatenSaeen(testPrisma, eigene.id);
|
||||
const vorlagen = await vorlagenSaeen(testPrisma);
|
||||
const onboarding = vorlagen.find((eintrag) => eintrag.name === "Mitarbeiter-Onboarding");
|
||||
if (!onboarding) throw new Error("Die Onboarding-Vorlage fehlt.");
|
||||
|
||||
const huber = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "mhuber" } });
|
||||
const abteilung = await testPrisma.abteilung.findFirstOrThrow({ where: { name: "Buchhaltung" } });
|
||||
const lauf = await testPrisma.lauf.create({
|
||||
data: {
|
||||
vorlagenVersionId: onboarding.versionId,
|
||||
kundeId: eigene.id,
|
||||
titel: "Onboarding Lisa Bauer",
|
||||
mitarbeiterId: huber.id,
|
||||
abteilungId: abteilung.id,
|
||||
// Die Ziel-OU bleibt **leer** – so, wie der Start-Dialog sie abschickt.
|
||||
// Sie kommt dann aus der Abteilung.
|
||||
variablen: {
|
||||
konto_typ: "Active Directory",
|
||||
vorname: "Lisa",
|
||||
nachname: "Bauer",
|
||||
upn: "l.bauer@firma.example",
|
||||
sam: "lbauer",
|
||||
mail: "l.bauer@firma.example",
|
||||
abteilung: "Buchhaltung",
|
||||
eintrittsdatum: "2026-10-01",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const werte = await platzhalterWerteDesLaufs({ laufId: lauf.id });
|
||||
const schritt = await testPrisma.vorlagenSchritt.findFirstOrThrow({
|
||||
where: { abschnitt: { versionId: onboarding.versionId }, titel: "Konto anlegen", typ: "BEFEHL" },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
const konfig = schritt.konfig as { shell: "powershell"; befehl: string };
|
||||
const passwort = "PROBEPASSWORT-1234";
|
||||
const fertig = befehlFuellen(
|
||||
{ titel: schritt.titel, shell: konfig.shell, befehl: konfig.befehl },
|
||||
werte,
|
||||
passwort,
|
||||
);
|
||||
|
||||
expect(fertig.fehler).toBeUndefined();
|
||||
expect(fertig.befehl).toContain('-Name "Lisa Bauer"');
|
||||
expect(fertig.befehl).toContain('-SamAccountName "lbauer"');
|
||||
expect(fertig.befehl).toContain('-UserPrincipalName "l.bauer@firma.example"');
|
||||
// Die Ziel-OU kommt aus der Abteilung – genau der Wert aus dem Auftrag.
|
||||
expect(fertig.befehl).toContain('-Path "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local"');
|
||||
expect(fertig.befehl).toContain(passwort);
|
||||
expect(fertig.befehl).not.toContain("{{");
|
||||
|
||||
// Und der Hilfetext daneben nennt **kein** zweites Passwort.
|
||||
const hilfe = schritt.hilfetext ?? "";
|
||||
expect(hilfe).not.toContain("{{generiertes_passwort}}");
|
||||
expect(hilfe).toContain("Abschnitt trifft nicht zu");
|
||||
|
||||
// Der Gruppen-Befehl trägt die Standardgruppen der Abteilung.
|
||||
const gruppen = await testPrisma.vorlagenSchritt.findFirstOrThrow({
|
||||
where: { abschnitt: { versionId: onboarding.versionId }, titel: "Gruppen zuweisen", typ: "BEFEHL" },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
const gruppenKonfig = gruppen.konfig as { shell: "powershell"; befehl: string };
|
||||
const gefuellt = befehlFuellen(
|
||||
{ titel: gruppen.titel, shell: gruppenKonfig.shell, befehl: gruppenKonfig.befehl },
|
||||
werte,
|
||||
passwort,
|
||||
);
|
||||
expect(gefuellt.befehl).toContain("G-Buchhaltung, G-Alle");
|
||||
});
|
||||
|
||||
it("belegt die Sperr-OU im Offboarding aus den Einstellungen vor", async () => {
|
||||
/*
|
||||
* Die Einstellungsseite verspricht „Wird beim Offboarding vorbelegt“. Bis zum
|
||||
* Review zu #017 las das niemand: `Move-ADObject -TargetPath "{{ou_gesperrt}}"`
|
||||
* blieb leer, und ein deaktiviertes Konto wäre liegen geblieben, wo es war.
|
||||
*/
|
||||
const eigene = await eigeneOrganisation();
|
||||
await einstellungSetzen();
|
||||
const vorlagen = await vorlagenSaeen(testPrisma);
|
||||
const offboarding = vorlagen.find((eintrag) => eintrag.name === "Mitarbeiter-Offboarding");
|
||||
if (!offboarding) throw new Error("Die Offboarding-Vorlage fehlt.");
|
||||
|
||||
const lauf = await testPrisma.lauf.create({
|
||||
data: {
|
||||
vorlagenVersionId: offboarding.versionId,
|
||||
kundeId: eigene.id,
|
||||
titel: "Offboarding Probe",
|
||||
mitarbeiterName: "Alte Kollegin",
|
||||
variablen: { austrittsdatum: "2026-10-31", postfach_verbleib: "Weiterleitung" },
|
||||
},
|
||||
});
|
||||
|
||||
const werte = await platzhalterWerteDesLaufs({ laufId: lauf.id });
|
||||
expect(werte["ou_gesperrt"]).toBe(GESPERRTE_OU);
|
||||
|
||||
const schritt = await testPrisma.vorlagenSchritt.findFirstOrThrow({
|
||||
where: { abschnitt: { versionId: offboarding.versionId }, titel: "Konto sperren (AD)" },
|
||||
});
|
||||
const konfig = schritt.konfig as { shell: "powershell"; befehl: string };
|
||||
const fertig = befehlFuellen({ titel: schritt.titel, shell: konfig.shell, befehl: konfig.befehl }, werte);
|
||||
expect(fertig.befehl).toContain(`-TargetPath "${GESPERRTE_OU}"`);
|
||||
|
||||
// Eine ausdrückliche Angabe im Lauf gewinnt trotzdem.
|
||||
await testPrisma.lauf.update({
|
||||
where: { id: lauf.id },
|
||||
data: { variablen: { ou_gesperrt: "OU=Ausgeschieden,DC=firma,DC=local" } },
|
||||
});
|
||||
const eigene2 = await platzhalterWerteDesLaufs({ laufId: lauf.id });
|
||||
expect(eigene2["ou_gesperrt"]).toBe("OU=Ausgeschieden,DC=firma,DC=local");
|
||||
});
|
||||
|
||||
it("schlägt für Maria Huber genau ihre Kennungen vor", async () => {
|
||||
// Die Namensregeln des Seeds müssen zu seinen eigenen Daten passen — sonst
|
||||
// schlägt die Anwendung beim ersten Onboarding etwas anderes vor, als in der
|
||||
// Personenliste steht.
|
||||
const eigene = await eigeneOrganisation();
|
||||
await einstellungSetzen();
|
||||
const admin = await testPrisma.benutzer.create({
|
||||
data: { email: "a@firma.example", name: "A", rolle: "ADMIN" },
|
||||
});
|
||||
const vorschlaege = await namenVorschlagen(
|
||||
eigene.id,
|
||||
{ vorname: "Maria", nachname: "Huber" },
|
||||
{ id: admin.id, rolle: "ADMIN" },
|
||||
);
|
||||
expect(vorschlaege.upn).toBe("m.huber@firma.example");
|
||||
expect(vorschlaege.sam).toBe("mhuber");
|
||||
expect(vorschlaege.mail).toBe("m.huber@firma.example");
|
||||
expect(vorschlaege.hostname).toBe("PC-001");
|
||||
});
|
||||
|
||||
it("legt den Administrator nur als argon2-Hash an", async () => {
|
||||
// Das Abnahmekriterium ausdrücklich: kein Seed-Datensatz trägt ein
|
||||
// Klartext-Passwort, auch nicht das Admin-Passwort aus der Umgebung.
|
||||
const vorherEmail = process.env["SEED_ADMIN_EMAIL"];
|
||||
const vorherPasswort = process.env["SEED_ADMIN_PASSWORD"];
|
||||
process.env["SEED_ADMIN_EMAIL"] = "seed-admin@firma.example";
|
||||
process.env["SEED_ADMIN_PASSWORD"] = "ein-langes-probe-passwort";
|
||||
try {
|
||||
await adminAnlegen(testPrisma);
|
||||
const admin = await testPrisma.benutzer.findUniqueOrThrow({
|
||||
where: { email: "seed-admin@firma.example" },
|
||||
});
|
||||
expect(admin.rolle).toBe("ADMIN");
|
||||
expect(admin.passwortHash).toMatch(/^\$argon2id\$/u);
|
||||
expect(admin.passwortHash).not.toContain("ein-langes-probe-passwort");
|
||||
|
||||
// Zweiter Lauf: kein zweiter Administrator.
|
||||
await adminAnlegen(testPrisma);
|
||||
expect(await testPrisma.benutzer.count()).toBe(1);
|
||||
} finally {
|
||||
if (vorherEmail === undefined) delete process.env["SEED_ADMIN_EMAIL"];
|
||||
else process.env["SEED_ADMIN_EMAIL"] = vorherEmail;
|
||||
if (vorherPasswort === undefined) delete process.env["SEED_ADMIN_PASSWORD"];
|
||||
else process.env["SEED_ADMIN_PASSWORD"] = vorherPasswort;
|
||||
}
|
||||
});
|
||||
|
||||
it("vergibt einen verbrannten Tag-Code nicht neu", async () => {
|
||||
/*
|
||||
* Der Beispiel-PC lässt sich endgültig löschen — das ist die eine Ausnahme,
|
||||
* die es gibt. Der Code bleibt trotzdem verbrannt: ein alter Aufkleber im
|
||||
* Regal darf nie auf ein anderes Gerät zeigen (CLAUDE.md).
|
||||
*/
|
||||
const eigene = await eigeneOrganisation();
|
||||
await stammdatenSaeen(testPrisma, eigene.id);
|
||||
await testPrisma.geraet.delete({ where: { tagCode: PC_TAG } });
|
||||
|
||||
const zweit = await stammdatenSaeen(testPrisma, eigene.id);
|
||||
expect(zweit.geraete).toBe(0);
|
||||
expect(await testPrisma.geraet.findUnique({ where: { tagCode: PC_TAG } })).toBeNull();
|
||||
// Im Vergabebuch steht er weiter – genau darauf beruht die Prüfung.
|
||||
expect(await testPrisma.tagCodeVergabe.findUnique({ where: { tagCode: PC_TAG } })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("lässt eine Vorlage in Ruhe, die nur als Entwurf existiert", async () => {
|
||||
/*
|
||||
* Der Import legt absichtlich einen Entwurf an — wer eine der sieben Dateien
|
||||
* einspielt und noch nicht veröffentlicht hat, darf den Seed nicht sprengen.
|
||||
* Vorher brach er hier mit einem rohen Prisma-Fehler ab, und zwar **vor**
|
||||
* Stammdaten und Demo-Kunden (Reviewbefund zu #017).
|
||||
*/
|
||||
const vorlage = await testPrisma.vorlage.create({
|
||||
data: { name: "Monatlicher Patchday", kategorie: "Wartung" },
|
||||
});
|
||||
await testPrisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
|
||||
const gesaet = await vorlagenSaeen(testPrisma);
|
||||
expect(gesaet.map((eintrag) => eintrag.name)).not.toContain("Monatlicher Patchday");
|
||||
expect(gesaet).toHaveLength(6);
|
||||
expect(await testPrisma.vorlage.count({ where: { name: "Monatlicher Patchday" } })).toBe(1);
|
||||
expect(await testPrisma.vorlagenVersion.count({ where: { vorlageId: vorlage.id } })).toBe(1);
|
||||
});
|
||||
|
||||
it("gibt keinem Demo-Kunden eine Verzeichnisquelle", async () => {
|
||||
// An fremde Active Directories bindet sich diese Anwendung nicht (#008).
|
||||
await eigeneOrganisation();
|
||||
await demoKundenSaeen(testPrisma);
|
||||
const kunden = await testPrisma.kunde.findMany({ where: { istEigeneOrganisation: false } });
|
||||
for (const kunde of kunden) {
|
||||
expect(await testPrisma.verzeichnisquelle.count({ where: { kundeId: kunde.id } })).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user