diff --git a/.env.example b/.env.example
index 6fb7932..cda3587 100644
--- a/.env.example
+++ b/.env.example
@@ -70,6 +70,14 @@ SEED_ORG_NAME=Eigene Organisation
# DIENSTLEISTUNG steht das Kuerzel auf Etiketten und in der Hostname-Regel.
SEED_ORG_KUERZEL=EIGEN
+# ---------- Verzeichnis-Abgleich ----------
+
+# Der Zeitplan laeuft im selben Prozess wie die Anwendung und sieht jede Minute
+# nach, welche Verzeichnisquelle faellig ist; das Intervall steht je Quelle in
+# der Datenbank. `false` schaltet ihn ab — fuer Einmal-Container, die nur
+# Migrationen fahren, und fuer Testlaeufe.
+ZEITPLAN_AKTIV=true
+
# ---------- Tests ----------
# Datenbank fuer Vitest. Ohne diese Variable ueberspringen sich die Datenbanktests.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 12be833..a4c6a40 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -136,6 +136,28 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
- Druckansicht eines Laufs: hell, ohne Navigation, auf A4, mit allen Schritten, Werten, Notizen,
Namen und Zeitstempeln. Sie ersetzt im MVP den PDF-Export (#015)
+- Verzeichnis-Abgleich über LDAP: Quelle anlegen, Verbindung testen, nach Zeitplan oder auf Knopfdruck
+ abgleichen. Wer im Verzeichnis verschwindet oder deaktiviert wird, wird hier inaktiv — gelöscht wird
+ nie, sonst verlöre die Historie ihre Namen (#016)
+- „Verbindung testen“ zeigt die ersten Einträge **mit den gemappten Feldern** und schreibt nichts: erst
+ daran sieht ein Administrator, ob die Zuordnung stimmt (#016)
+- Der Zeitplan läuft im selben Prozess wie die Anwendung, sieht jede Minute nach, welche Quelle fällig
+ ist, und verlässt sich auf eine Sperre in der Datenbank — zwei Instanzen kommen sich nicht in die
+ Quere (#016)
+- Personen aus einer CSV-Datei einlesen: je Zeile ein Ergebnis, eine fehlerhafte Zeile bricht den
+ Import nicht ab. Eine leere Zelle löscht nichts, und wer schon aus dem Abgleich stammt, wird nicht
+ überschrieben (#016)
+- Abteilungsvorschläge aus den Verzeichnis-Werten: ein Klick macht daraus eine gepflegte Abteilung
+ (#016)
+- Die Attributzuordnung ist in der Oberfläche bearbeitbar: zehn Felder mit den AD-Vorgaben, damit ein
+ OpenLDAP nicht in der Datenbank nachgepflegt werden muss (#016)
+- Abgleich-Log je Quelle: die letzten zehn Läufe mit gelesen, angelegt, übernommen, aktualisiert,
+ deaktiviert und übersprungen — und wer sie angestoßen hat, Mensch oder Zeitplan (#016)
+- Wer schon von Hand angelegt wurde und dann im Verzeichnis auftaucht, wird **übernommen** statt
+ 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)
+
### Sicherheit
- Der Compose-Stand bricht ab, wenn `APP_SECRET`, `POSTGRES_PASSWORD` oder `APP_URL` fehlen,
@@ -239,3 +261,18 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
desselben Laufs sind deshalb Zeichen für Zeichen gleich — der Test prüft genau das (#015)
- Die Archivsuche setzt jeden Wert als Parameter und maskiert `%`, `_` und `\` für `ILIKE`: die
Suche nach „50 %“ trifft nicht „5099“ (#015)
+- Das Bind-Passwort einer Verzeichnisquelle wird mit AES-256-GCM verschlüsselt, erreicht keine
+ Antwort und kein Log: Fehlermeldungen der LDAP-Bibliothek werden gefiltert, bevor sie gespeichert
+ werden, und die Ausnahme trägt keine `cause` — sonst stünde die ganze Kette im Server-Log (#016)
+- Gespeichert werden nur die zugeordneten Attribute: keine Passwörter, keine
+ Gruppenmitgliedschaften (#016)
+- Die Hostnamen-Prüfung von TLS lässt sich nicht abschalten; ein Test hält fest, dass weder
+ `rejectUnauthorized` noch `checkServerIdentity` gesetzt werden (#016)
+- Das Verzeichnis gibt es nur im Modus Intern — Reiter, Seite und Dienst prüfen das je selbst (#016)
+- Der Abgleich deaktiviert niemanden, wenn sich **kein einziger** gelesener Eintrag zuordnen ließ:
+ ein falsch gesetztes Kennungsattribut hätte sonst die gesamte Belegschaft abgeschaltet und dabei
+ Erfolg gemeldet (#016)
+- Übersprungene Einträge werden nicht deaktiviert: sie standen im Ergebnis, nur ohne brauchbare
+ 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)
diff --git a/README.md b/README.md
index c826f6c..b756fce 100644
--- a/README.md
+++ b/README.md
@@ -130,6 +130,29 @@ zugeordnet. Unbekannte Benutzer werden beim ersten Login angelegt.
Die Anbindung an authentik ist in [`docs/betrieb/authentik.md`](docs/betrieb/authentik.md)
Schritt für Schritt beschrieben.
+## Verzeichnis-Abgleich (nur Modus `INTERN`)
+
+Unter **Admin → Verzeichnis** wird eine LDAP-Quelle angelegt: Adresse (`ldaps://` empfohlen,
+`ldap+starttls://` und `ldap://` gehen auch), ein Dienstkonto mit **Leserecht**, der Basis-DN und
+optional ein eigener Filter. Ein Dienstkonto genügt — Laufzettel schreibt nie ins Verzeichnis.
+
+- **„Verbindung testen“** liest die ersten Einträge und zeigt sie **mit den gemappten Feldern**,
+ ohne etwas zu speichern. Erst daran sieht man, ob die Zuordnung stimmt.
+- **Die Attributzuordnung** ist einklappbar im Formular und trägt die AD-Vorgaben; ein OpenLDAP
+ bekommt hier etwa `entryUUID` als Kennung.
+- **Der Zeitplan** läuft im selben Prozess wie die Anwendung und sieht jede Minute nach, welche
+ Quelle fällig ist; das Intervall steht je Quelle. `ZEITPLAN_AKTIV=false` schaltet ihn ab — für
+ Einmal-Container, die nur Migrationen fahren.
+- **Gelöscht wird nie.** Wer im Verzeichnis verschwindet oder deaktiviert wird, wird hier inaktiv.
+ Lässt sich **kein einziger** Eintrag zuordnen, deaktiviert der Abgleich niemanden und meldet
+ stattdessen einen Fehler — ein falsch gesetztes Kennungsattribut soll nicht die halbe Firma
+ abschalten.
+- **Das Bind-Passwort** wird verschlüsselt gespeichert und nie wieder angezeigt; ein leeres Feld
+ beim Bearbeiten heißt „unverändert“.
+
+Ohne Verzeichnis geht es auch: Personen lassen sich von Hand anlegen oder aus einer CSV-Datei
+einlesen.
+
## Umgebungsvariablen
Alle Variablen mit Erklärung stehen in [`.env.example`](.env.example). Pflicht sind `APP_URL`,
diff --git a/docker-compose.test.yml b/docker-compose.test.yml
index 315914d..1c4c8e3 100644
--- a/docker-compose.test.yml
+++ b/docker-compose.test.yml
@@ -1,6 +1,4 @@
# Dienste fuer die Testsuite. Nicht fuer den Betrieb.
-# Die LDAP-Quelle fuer den Verzeichnis-Abgleich kommt in #016 dazu, dort wird sie
-# auch tatsaechlich benutzt und geprueft.
name: laufzettel-test
@@ -18,3 +16,48 @@ services:
interval: 5s
timeout: 5s
retries: 10
+
+ # Das Verzeichnis fuer den Abgleich (#016). OpenLDAP mit einem AD-aehnlichen
+ # Schema: sAMAccountName, userPrincipalName, binaeres objectGUID,
+ # userAccountControl, objectCategory und department bringt OpenLDAP nicht mit,
+ # sie stehen deshalb in tests/fixtures/ldap/schema/ad-fixture.ldif.
+ #
+ # LDAP_TLS ist aus, und das ist eine Einschraenkung mit Ansage: das Zertifikat,
+ # das osixia sonst selbst erzeugt, laeutet auf den Containernamen, waehrend der
+ # Test von aussen 127.0.0.1 anspricht -- die Pruefung des Hostnamens schlaege
+ # fehl. Ein Schalter, der sie abstellt, waere ein Schalter zu viel im
+ # Produktionscode. Der Container wird deshalb im Klartext angesprochen; die
+ # TLS-Optionen (ldaps, StartTLS, eigenes CA-Zertifikat, minVersion) sind als
+ # reine Funktion `tlsOptionenBauen` in tests/verzeichnis.test.ts geprueft.
+ ldap:
+ image: osixia/openldap:1.5.0
+ # Ohne `--copy-service` spielt der Startskript die Bootstrap-Dateien nicht
+ # ein, wenn sie nur eingehaengt sind.
+ command: ["--copy-service"]
+ environment:
+ LDAP_ORGANISATION: Firma Test
+ LDAP_DOMAIN: firma.test
+ LDAP_ADMIN_PASSWORD: ldap-test-geheim
+ LDAP_TLS: "false"
+ ports:
+ - "127.0.0.1:1389:389"
+ volumes:
+ # Schema und Daten liegen in getrennten Verzeichnissen, weil osixia die
+ # Daten-LDIFs mit `sed -i` nach Platzhaltern durchsucht. Eingehaengt wird
+ # deshalb das Verzeichnis und nicht die Datei: `sed -i` schreibt ueber eine
+ # Umbenennung, und die scheitert auf einer einzeln eingehaengten Datei --
+ # das Startskript laeuft mit `bash -e` und der Container waere tot.
+ - ./tests/fixtures/ldap/schema:/container/service/slapd/assets/config/bootstrap/schema/custom:ro
+ - ./tests/fixtures/ldap/daten:/container/service/slapd/assets/config/bootstrap/ldif/custom
+ healthcheck:
+ # Die Suche laeuft ueber den authentifizierten Bind, weil anonymes Lesen
+ # abgeschaltet ist. Gesund ist der Dienst erst, wenn die Fixture-Personen
+ # wirklich da sind -- sonst liest der Test gegen ein leeres Verzeichnis.
+ test:
+ [
+ "CMD-SHELL",
+ "ldapsearch -x -H ldap://localhost:389 -D cn=admin,dc=firma,dc=test -w ldap-test-geheim -b ou=Benutzer,dc=firma,dc=test -s sub '(sAMAccountName=mhuber)' dn | grep -q 'cn=Maria Huber'",
+ ]
+ interval: 5s
+ timeout: 5s
+ retries: 20
diff --git a/docker-compose.yml b/docker-compose.yml
index 925d1b4..e5996ac 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -68,6 +68,9 @@ services:
OIDC_GROUP_TECHNICIAN: ${OIDC_GROUP_TECHNICIAN:-}
OIDC_GROUP_VIEWER: ${OIDC_GROUP_VIEWER:-}
OIDC_CUSTOMER_GROUP_PREFIX: ${OIDC_CUSTOMER_GROUP_PREFIX:-}
+ # Der Verzeichnis-Abgleich laeuft im selben Prozess (#016). Hier steht er
+ # an, weil dieser Dienst der einzige ist, der dauerhaft laeuft.
+ ZEITPLAN_AKTIV: ${ZEITPLAN_AKTIV:-true}
# Standardmaessig nur auf localhost; davor gehoert ein Reverse Proxy.
ports:
- "${BIND_ADDR:-127.0.0.1}:${APP_PORT:-3000}:3000"
diff --git a/docs/architektur.md b/docs/architektur.md
index f188e99..fa30299 100644
--- a/docs/architektur.md
+++ b/docs/architektur.md
@@ -276,6 +276,61 @@ Vom Mitarbeiter holt dieses Lesemodell **nur Name, UPN und Abteilung** — nicht
alles und dann gefiltert. Ein Ausdruck liegt herum, wird kopiert und weitergegeben;
was darauf steht, ist aus der Hand gegeben.
+## 5g. Der Verzeichnis-Abgleich: vier Schichten und eine Notbremse
+
+Das LDAP-Modul liegt unter `src/server/ldap/` und ist absichtlich in vier Dateien
+geteilt, von denen nur eine das Netz anfasst:
+
+| Datei | Was darin steht |
+|---|---|
+| `fehler.ts` | `VerzeichnisFehler` — die Basis, unterhalb der Dienstschicht, damit `verbindung.ts` von ihr erben kann, ohne die Schichtung umzudrehen |
+| `verbindung.ts` | Adressformen (`ldaps://`, `ldap+starttls://`, `ldap://`), TLS-Optionen, `mitVerbindung()` und das Saeubern fremder Fehlermeldungen |
+| `zuordnung.ts` | reine Funktionen: aus einem `Entry` wird ein Datensatz in unseren Feldern, DNs werden vergleichbar, Vorgesetzte aufgeloest |
+| `leser.ts` | `searchPaginated` in 1000er-Seiten, Eintrag fuer Eintrag weitergegeben |
+
+Die **Attributzuordnung** und die Grenzen des Intervalls stehen in
+`src/lib/verzeichnis.ts` und nicht bei den LDAP-Modulen: das Formular braucht
+dieselbe Liste und dieselben Zahlen, und eine Client-Komponente importiert nichts
+aus `src/server/*`. Als beides zweimal geschrieben war, lief es auseinander — die
+Oberflaeche liess hoechstens 1440 Minuten zu, der Dienst 10080.
+
+**Der Abgleich laeuft nicht in einer Transaktion.** Das ist eine Entscheidung mit
+Folgen: bricht er in der Mitte ab, bleiben die bis dahin geschriebenen Personen
+stehen. Eine Transaktion ueber das gesamte Verzeichnis haette die Tabelle so lange
+gesperrt, wie das Lesen dauert. **Deaktiviert wird dagegen nur nach einem
+vollstaendigen Durchlauf** — sonst haette ein Netzwerkfehler nach der halben Liste
+die andere Haelfte der Firma abgeschaltet.
+
+Darum herum liegen drei Regeln, die alle denselben Fehler verhindern — dass eine
+kaputte Eingabe wie „alle sind gegangen“ aussieht:
+
+1. **Kein einziger Eintrag zuordenbar ⇒ Fehler, kein Abschalten.** Steht in der
+ Zuordnung ein Kennungsattribut, das es am Server nicht gibt, wird jeder Eintrag
+ uebersprungen: das Lesen hat geklappt, niemand wurde wiedergesehen, und die
+ Abschaltung traefe die gesamte Belegschaft — mit Status `ERFOLG`. Danach sucht
+ niemand mehr nach dem Grund.
+2. **Uebersprungene Eintraege sind nicht verschwunden.** Sie werden am `dn` erkannt
+ und aus der Abschaltung herausgenommen.
+3. **Von Hand angelegte Personen werden uebernommen, nicht verdoppelt.** Beim
+ Onboarding entsteht die Person hier und das AD-Konto danach; der Abgleich findet
+ sie ueber `upn` oder `sam` (ohne Rucksicht auf Gross- und Kleinschreibung) und
+ haengt Quelle und Kennung an dieselbe Zeile. Eine Person, die schon eine andere
+ Quelle fuehrt, wird nie uebernommen.
+
+Uebersprungene Eintraege kommen als `hinweis` zurueck und **nicht** als `fehler`:
+`Verzeichnisquelle.letzterAbgleichFehler` soll heissen, dass etwas schiefging, und
+nicht „ein Konto war krumm“.
+
+Der **Zeitplan** ist `node-cron` im selben Prozess wie die Anwendung, gestartet aus
+`src/instrumentation.ts` — nur in der Node-Laufzeit und nur, wenn `ZEITPLAN_AKTIV`
+nicht `false` ist. Zwei Gruende fuer „im selben Prozess“: der nativ ausgerollte LXC
+braucht keine zweite systemd-Unit, und der Abgleich benutzt denselben Dienst wie
+„Jetzt abgleichen“, also gibt es keinen zweiten Weg mit eigenen Regeln. Die Sperre
+liegt in der Datenbank (`AbgleichSperre`) und **nicht** im Prozess: zwei Instanzen
+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.
+
## 6. Betrieb: zwei Wege, eine Regel
Die Datenbank ist auf beiden Wegen von außen nicht erreichbar:
diff --git a/docs/devlog.md b/docs/devlog.md
index 87b73f2..701331a 100644
--- a/docs/devlog.md
+++ b/docs/devlog.md
@@ -18,11 +18,12 @@ als Unterpunkte mit Aufgabe und Dauer.
| #013 | M8 – Läufe: Service und Abarbeitung | 3 h 05 min |
| #014 | M9 – QR-Tags, Geräteseite, Etiketten, Scanner | 3 h 35 min |
| #015 | M10 – Dashboard und Archiv | 2 h 20 min |
-| | **Summe** | **22 h 45 min** |
+| #016 | M11 – Verzeichnis und LDAP-Abgleich | 3 h 25 min |
+| | **Summe** | **26 h 10 min** |
| Meilenstein | Tatsächlich | Status |
|---|---|---|
-| v0.1.0 | 22 h 45 min | in Arbeit |
+| v0.1.0 | 26 h 10 min | in Arbeit |
---
@@ -680,3 +681,74 @@ als Unterpunkte mit Aufgabe und Dauer.
- #016 Verzeichnis und LDAP-Abgleich — der größte verbleibende Brocken: `ldapts`, Attribut-Mapping,
Zeitplan, verschlüsseltes Bind-Passwort und ein Integrationstest gegen einen OpenLDAP-Container.
+
+---
+
+## 2026-09-26 · Sitzung 12 · 23:05–02:30
+
+**Issue:** #016 (M11)
+**Version:** 0.1.0
+
+### Erledigt
+
+- **Vier Teile parallel**: LDAP-Schicht und Abgleich (Sub-Agent), Oberfläche Admin → Verzeichnis
+ (Sub-Agent), CSV-Import und Abteilungsvorschläge (Sub-Agent), Zeitplan und Server Actions (selbst)
+- `ldapts` mit `ldaps://`, `ldap+starttls://` und `ldap://`, eigenem CA-Zertifikat, TLS ab 1.2 und
+ `searchPaginated` in 1000er-Seiten — Eintrag für Eintrag verarbeitet, nicht erst gesammelt
+- `objectGUID` als Puffer gelesen und gemischt-endian zur GUID umsortiert; ein Puffer, der keine
+ GUID ist, wird nur dann als Text genommen, wenn dabei lesbarer Text herauskommt
+- Abgleich mit Anlegen, Aktualisieren, Übernehmen und Deaktivieren, Vorgesetzten-Auflösung in einem
+ zweiten Durchgang und Ergebnis in `Verzeichnisquelle` **und** `Protokoll`
+- Zeitplan mit `node-cron` im selben Prozess, gestartet aus `src/instrumentation.ts`, Sperre in der
+ Datenbank; „Jetzt abgleichen“ nimmt denselben Dienst
+- Admin → Verzeichnis mit Quelle anlegen und bearbeiten, Verbindungstest samt gemappter Felder,
+ bearbeitbarer Attributzuordnung, CA-Zertifikat und Abgleich-Log
+- Personen aus CSV einlesen, Abteilungsvorschläge aus den `department`-Werten
+- Unabhängiger Review: **ein Blocker**, acht Befunde, sieben Anmerkungen — alle abgearbeitet
+- **1098 Tests** (vorher 980) und **71 Ende-zu-Ende-Tests** (vorher 67) grün, Screenshots in
+ `docs/screenshots/m11/`
+
+### Entscheidungen
+
+- **Der Zeitplan nimmt keine eigene Sperre.** Der Dienst sperrt selbst, in der Datenbank. Eine
+ zweite Sperre im Zeitplan trüge einen anderen Schlüssel, liefe an der ersten vorbei — und ein von
+ Hand angestoßener Abgleich sähe für den Zeitplan wie ein unerwarteter Fehler aus.
+- **Übersprungene Einträge sind ein Hinweis, kein Fehler.** `letzterAbgleichFehler` soll heißen,
+ dass etwas schiefging, und nicht „ein Konto war krumm“. Der Hinweis steht in der Erfolgsmeldung
+ und im Log; der Lauf gilt als gelungen.
+- **Von Hand angelegte Personen werden übernommen, nicht verdoppelt.** Beim Onboarding entsteht die
+ Person hier und das AD-Konto danach. Gesucht wird über `upn`, dann `sam`, ohne Rücksicht auf
+ Groß- und Kleinschreibung — und nur unter Zeilen ohne Quelle: wer schon einer anderen Quelle
+ gehört, wandert nicht zwischen zwei Verzeichnissen hin und her.
+- **Attributzuordnung und Intervallgrenzen liegen in `src/lib/verzeichnis.ts`.** Das Formular
+ braucht dieselbe Liste wie der Dienst, und eine Client-Komponente importiert nichts aus
+ `src/server/*`.
+
+### Probleme
+
+- **Der Blocker hätte die ganze Firma gekostet.** Steht in der Attributzuordnung ein
+ Kennungsattribut, das es am Server nicht gibt, wird **jeder** Eintrag übersprungen: das Lesen
+ klappt, niemand wird wiedergesehen, und die Abschaltung trifft die gesamte Belegschaft — mit
+ Status `ERFOLG`. Das Abgleich-Log hat den Fall danach selbst dokumentiert: der Lauf um 01:20 zeigt
+ „4 gelesen, 0 angelegt, 3 deaktiviert, 4 übersprungen“, die Läufe danach nie wieder.
+- **Der Code versprach mehr als die Oberfläche hielt.** Ein Kommentar sagte „wer ein OpenLDAP
+ anbindet, ändert einzelne Zeilen“ — über die Oberfläche ging genau das nicht. Achter Meilenstein
+ in Folge mit einer Zusage ohne Deckung; diesmal war es die Zusage einer Bedienbarkeit.
+- **Ein hinterlegtes CA-Zertifikat war unsichtbar und unlöschbar.** Das Feld stand beim Bearbeiten
+ leer da, der Hinweis darunter behauptete „ohne Eintrag gelten die Systemwurzeln“ — und ein
+ leeres Feld hieß „unverändert“. Das Zertifikat entschied also unsichtbar weiter mit.
+- **Ein Test, der sich selbst aus dem Weg ging:** die Zusicherung im Nebenläufigkeitstest der
+ Vorlagen stand in einem `if`. Gewann das Speichern das Rennen, lief der Test durch, ohne etwas zu
+ belegen. Jetzt werden beide Ausgänge geprüft.
+- **Eine Stunde gegen einen alten Server geprüft.** Der gebaute Stand lief, sah richtig aus und
+ zeigte trotzdem keine der neuen Funktionen — auf Port 3300 hing noch ein `next start` aus einer
+ früheren Sitzung, und der neue Start scheiterte still an `EADDRINUSE`. Seitdem: erst
+ nachsehen, wer auf dem Port sitzt, dann glauben, was die Seite zeigt.
+- **Das Screenshot-Skript hat bei jedem Lauf zwei PNG-Dateien angefasst.** `kachelnErzeugen`
+ schrieb `public/icon-*.png` immer neu; `git status` zeigte Änderungen, die niemand gemacht hatte.
+ Jetzt wird verglichen und nur bei echtem Unterschied geschrieben.
+
+### Nächster Schritt
+
+- #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“.
diff --git a/docs/issues/016-verzeichnis-ldap.md b/docs/issues/016-verzeichnis-ldap.md
index 9817d58..c245f09 100644
--- a/docs/issues/016-verzeichnis-ldap.md
+++ b/docs/issues/016-verzeichnis-ldap.md
@@ -1,9 +1,9 @@
# #016 M11 – Verzeichnis und LDAP-Abgleich
-**Status:** offen
+**Status:** erledigt
**Meilenstein:** v0.1.0
**Labels:** phase-1, backend, frontend, security
-**Geschätzt:** 12 h **Tatsächlich:** –
+**Geschätzt:** 12 h **Tatsächlich:** 3 h 25 min
## Ziel
@@ -11,29 +11,61 @@ Die Mitarbeiter der eigenen Firma kommen aus dem Active Directory, damit beim St
## Aufgaben
-- [ ] `ldapts` 9.x: `ldaps` bevorzugt, StartTLS optional, eigenes CA-Zertifikat hinterlegbar (`caCertPem`), `minVersion: TLSv1.2`
-- [ ] `searchPaginated` mit `pageSize: 1000` — **nicht** `search` mit `paged`, das puffert alles im Speicher
-- [ ] `explicitBufferAttributes: ['objectGUID']` und korrekte Umsortierung der gemischt-endianen Bytes zur GUID-Zeichenkette als `externeId`
-- [ ] Standardfilter `(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))`
-- [ ] Attribut-Mapping mit AD-Vorgaben: `givenName`, `sn`, `displayName`, `mail`, `userPrincipalName` → `upn`, `sAMAccountName` → `sam`, `department`, `title`, `manager` → `vorgesetzterExterneId`, `distinguishedName` → `dn`
-- [ ] „Verbindung testen“: zeigt die ersten 10 Treffer mit gemappten Feldern, ohne etwas zu schreiben
-- [ ] `abgleichService`: anlegen, aktualisieren, verschwundene oder deaktivierte Konten auf `aktiv = false` — **nie löschen**; Ergebnis (angelegt/aktualisiert/deaktiviert/Fehler) in `letzterAbgleichStatus`, `letzterAbgleichFehler`, `letzterAbgleichAm` und im `Protokoll`
-- [ ] Zeitplan mit `node-cron` im selben Prozess, Intervall je Quelle (Standard 60 min), Sperre über `AbgleichSperre` in der Datenbank, damit nur eine Instanz läuft; zusätzlich „Jetzt abgleichen“
-- [ ] Bind-Passwort verschlüsselt über `src/lib/geheimnisse.ts`, Serializer entfernt `bindPasswortVerschluesselt` aus jeder Antwort
-- [ ] Admin → Verzeichnis: Quelle anlegen und bearbeiten, Verbindung testen, Abgleich-Status und -Log, „Jetzt abgleichen“
-- [ ] Manuelle Mitarbeiter und CSV-Import (`givenName`, `sn`, `displayName`, `mail`, `upn`, `sam`, `department`, `title`)
-- [ ] Abteilungsvorschläge aus den `department`-Werten des Abgleichs, vom Admin in `Abteilung`-Einträge übernehmbar
-- [ ] Nur die gemappten Attribute speichern: keine Passwörter, keine Gruppenmitgliedschaften des Mitarbeiters
-- [ ] `docker-compose.test.yml` um OpenLDAP erweitern, mit AD-ähnlichem Fixture-Schema (`sAMAccountName`, `userPrincipalName`, binäres `objectGUID`) — aus #005 hierher verschoben, weil der Dienst erst hier benutzt wird
-- [ ] Screenshots Verzeichnis, Verbindungstest, Abgleich-Log, CSV-Import in `docs/screenshots/m11/`
+- [x] `ldapts` 9.x: `ldaps` bevorzugt, StartTLS optional, eigenes CA-Zertifikat hinterlegbar (`caCertPem`), `minVersion: TLSv1.2`
+- [x] `searchPaginated` mit `pageSize: 1000` — **nicht** `search` mit `paged`, das puffert alles im Speicher
+- [x] `explicitBufferAttributes: ['objectGUID']` und korrekte Umsortierung der gemischt-endianen Bytes zur GUID-Zeichenkette als `externeId`
+- [x] Standardfilter `(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))`
+- [x] Attribut-Mapping mit AD-Vorgaben: `givenName`, `sn`, `displayName`, `mail`, `userPrincipalName` → `upn`, `sAMAccountName` → `sam`, `department`, `title`, `manager` → `vorgesetzterExterneId`, `distinguishedName` → `dn`
+- [x] „Verbindung testen“: zeigt die ersten 10 Treffer mit gemappten Feldern, ohne etwas zu schreiben
+- [x] `abgleichService`: anlegen, aktualisieren, verschwundene oder deaktivierte Konten auf `aktiv = false` — **nie löschen**; Ergebnis (angelegt/aktualisiert/deaktiviert/Fehler) in `letzterAbgleichStatus`, `letzterAbgleichFehler`, `letzterAbgleichAm` und im `Protokoll`
+- [x] Zeitplan mit `node-cron` im selben Prozess, Intervall je Quelle (Standard 60 min), Sperre über `AbgleichSperre` in der Datenbank, damit nur eine Instanz läuft; zusätzlich „Jetzt abgleichen“
+- [x] Bind-Passwort verschlüsselt über `src/lib/geheimnisse.ts`, Serializer entfernt `bindPasswortVerschluesselt` aus jeder Antwort
+- [x] Admin → Verzeichnis: Quelle anlegen und bearbeiten, Verbindung testen, Abgleich-Status und -Log, „Jetzt abgleichen“
+- [x] Manuelle Mitarbeiter und CSV-Import (`givenName`, `sn`, `displayName`, `mail`, `upn`, `sam`, `department`, `title`)
+- [x] Abteilungsvorschläge aus den `department`-Werten des Abgleichs, vom Admin in `Abteilung`-Einträge übernehmbar
+- [x] Nur die gemappten Attribute speichern: keine Passwörter, keine Gruppenmitgliedschaften des Mitarbeiters
+- [x] `docker-compose.test.yml` um OpenLDAP erweitern, mit AD-ähnlichem Fixture-Schema (`sAMAccountName`, `userPrincipalName`, binäres `objectGUID`) — aus #005 hierher verschoben, weil der Dienst erst hier benutzt wird
+- [x] Screenshots Verzeichnis, Verbindungstest, Abgleich-Log, CSV-Import in `docs/screenshots/m11/`
## Akzeptanzkriterien
-- [ ] Integrationstest gegen den Container: erster Lauf legt Mitarbeiter an, zweiter Lauf aktualisiert sie ohne Duplikate
-- [ ] Integrationstest: ein aus dem Verzeichnis entferntes Konto wird `aktiv = false` und bleibt in der Datenbank
-- [ ] Integrationstest: ein per `userAccountControl` deaktiviertes Konto wird `aktiv = false`
-- [ ] Test: `{{ou}}` wird korrekt aus dem `dn` abgeleitet
-- [ ] Test: `objectGUID` als Buffer ergibt reproduzierbar dieselbe GUID-Zeichenkette (bekannter Byte-Vektor als Fixture)
-- [ ] Test: keine API-Antwort und kein Server-Log enthält das Bind-Passwort im Klartext
-- [ ] Test: zwei gleichzeitig gestartete Abgleiche derselben Quelle — der zweite wird von der Sperre abgewiesen
-- [ ] Im Modus `DIENSTLEISTUNG` ist Admin → Verzeichnis nicht erreichbar
+- [x] Integrationstest gegen den Container: erster Lauf legt Mitarbeiter an, zweiter Lauf aktualisiert sie ohne Duplikate
+- [x] Integrationstest: ein aus dem Verzeichnis entferntes Konto wird `aktiv = false` und bleibt in der Datenbank
+- [x] Integrationstest: ein per `userAccountControl` deaktiviertes Konto wird `aktiv = false`
+- [x] Test: `{{ou}}` wird korrekt aus dem `dn` abgeleitet
+- [x] Test: `objectGUID` als Buffer ergibt reproduzierbar dieselbe GUID-Zeichenkette (bekannter Byte-Vektor als Fixture)
+- [x] Test: keine API-Antwort und kein Server-Log enthält das Bind-Passwort im Klartext
+- [x] Test: zwei gleichzeitig gestartete Abgleiche derselben Quelle — der zweite wird von der Sperre abgewiesen
+- [x] Im Modus `DIENSTLEISTUNG` ist Admin → Verzeichnis nicht erreichbar
+
+## Ergebnis
+
+Erledigt am 2026-09-26. **1098 Tests** (vorher 980) und **71 Ende-zu-Ende-Tests** (vorher 67) grün,
+Screenshots in `docs/screenshots/m11/`.
+
+Über die Vorgabe hinaus entstanden drei Dinge, die der Review verlangt hat und die zum Kern der
+Sache gehören:
+
+- **Eine Notbremse gegen den teuersten Fehler.** Lässt sich kein einziger gelesener Eintrag
+ zuordnen, wird niemand deaktiviert — der Lauf meldet einen Fehler mit Namen. Ein Tippfehler im
+ Kennungsattribut hätte sonst die gesamte Belegschaft abgeschaltet und dabei `ERFOLG` gemeldet.
+- **Die Attributzuordnung ist in der Oberfläche bearbeitbar.** Vorher stand sie nur in der
+ Datenbank, obwohl Code und Hilfetext etwas anderes versprachen.
+- **Ein Abgleich-Log je Quelle** mit den letzten zehn Läufen: gelesen, angelegt, übernommen,
+ aktualisiert, deaktiviert, übersprungen — und wer sie angestoßen hat, Mensch oder Zeitplan.
+
+### Der unabhängige Review
+
+Ein Blocker, acht Befunde, sieben Anmerkungen:
+
+- **Blocker: Abgleich ohne zuordenbare Einträge schaltete alle ab.** Siehe oben. Das Log hat den
+ Fall danach selbst dokumentiert — der Lauf um 01:20 zeigt „3 deaktiviert, 4 übersprungen“.
+- Übersprungene Einträge wurden mitdeaktiviert, obwohl sie im Ergebnis standen.
+- `VerbindungsFehler` erbte nicht von `VerzeichnisFehler`: falsches Passwort, falscher Basis-DN und
+ ein toter Server sahen in der Oberfläche identisch aus.
+- Ergebniscodes wie `0x31` standen roh in der Meldung.
+- Bei einem Abbruch mitten im Lesen stand „angelegt: 1500, gelesen: 0“.
+- `faelligeQuellen` fragte ohne Mandantengrenze.
+- Eine von Hand angelegte Person mit demselben `sam` wurde vom Abgleich verdoppelt.
+- Ein hinterlegtes CA-Zertifikat war unsichtbar und nicht entfernbar.
+- Die Intervallgrenzen liefen auseinander: 1440 Minuten in der Oberfläche, 10080 im Dienst.
diff --git a/docs/issues/README.md b/docs/issues/README.md
index 767ae1b..cb7af6e 100644
--- a/docs/issues/README.md
+++ b/docs/issues/README.md
@@ -20,8 +20,8 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #013 | [M8 – Läufe: Service und Abarbeitung](013-laeufe-abarbeitung.md) | erledigt | v0.1.0 | 14 h | 3 h 05 min |
| #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) | offen | v0.1.0 | 12 h | – |
+| #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 | – |
| #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** | **22 h 45 min** |
+| | **Summe** | | | **136 h** | **26 h 10 min** |
diff --git a/docs/screenshots/m11/abgleich-log-desktop.png b/docs/screenshots/m11/abgleich-log-desktop.png
new file mode 100644
index 0000000..3b33229
Binary files /dev/null and b/docs/screenshots/m11/abgleich-log-desktop.png differ
diff --git a/docs/screenshots/m11/abgleich-log-handy.png b/docs/screenshots/m11/abgleich-log-handy.png
new file mode 100644
index 0000000..9604c61
Binary files /dev/null and b/docs/screenshots/m11/abgleich-log-handy.png differ
diff --git a/docs/screenshots/m11/abteilungen-vorschlaege-desktop.png b/docs/screenshots/m11/abteilungen-vorschlaege-desktop.png
new file mode 100644
index 0000000..1f9f8b9
Binary files /dev/null and b/docs/screenshots/m11/abteilungen-vorschlaege-desktop.png differ
diff --git a/docs/screenshots/m11/abteilungen-vorschlaege-handy.png b/docs/screenshots/m11/abteilungen-vorschlaege-handy.png
new file mode 100644
index 0000000..4a43e5f
Binary files /dev/null and b/docs/screenshots/m11/abteilungen-vorschlaege-handy.png differ
diff --git a/docs/screenshots/m11/personen-import-desktop.png b/docs/screenshots/m11/personen-import-desktop.png
new file mode 100644
index 0000000..3ffdb4e
Binary files /dev/null and b/docs/screenshots/m11/personen-import-desktop.png differ
diff --git a/docs/screenshots/m11/personen-import-handy.png b/docs/screenshots/m11/personen-import-handy.png
new file mode 100644
index 0000000..e8f916d
Binary files /dev/null and b/docs/screenshots/m11/personen-import-handy.png differ
diff --git a/docs/screenshots/m11/verbindungstest-desktop.png b/docs/screenshots/m11/verbindungstest-desktop.png
new file mode 100644
index 0000000..239f620
Binary files /dev/null and b/docs/screenshots/m11/verbindungstest-desktop.png differ
diff --git a/docs/screenshots/m11/verbindungstest-handy.png b/docs/screenshots/m11/verbindungstest-handy.png
new file mode 100644
index 0000000..ddd574f
Binary files /dev/null and b/docs/screenshots/m11/verbindungstest-handy.png differ
diff --git a/docs/screenshots/m11/verzeichnis-desktop.png b/docs/screenshots/m11/verzeichnis-desktop.png
new file mode 100644
index 0000000..c13dcd6
Binary files /dev/null and b/docs/screenshots/m11/verzeichnis-desktop.png differ
diff --git a/docs/screenshots/m11/verzeichnis-handy.png b/docs/screenshots/m11/verzeichnis-handy.png
new file mode 100644
index 0000000..01dfc0b
Binary files /dev/null and b/docs/screenshots/m11/verzeichnis-handy.png differ
diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts
index f42a613..70f48bd 100644
--- a/scripts/screenshots.ts
+++ b/scripts/screenshots.ts
@@ -18,7 +18,7 @@
* Bild sieht dann richtig aus, zeigt aber eine Seite, auf der kein Knopf etwas
* tut. Siehe CLAUDE.md, Abschnitt „Oberflaeche pruefen“.
*/
-import { mkdir } from "node:fs/promises";
+import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { chromium, type Browser, type Cookie, type Locator, type Page } from "@playwright/test";
@@ -191,6 +191,56 @@ const SEITEN: readonly Seite[] = [
},
},
{ name: "archiv", pfad: "/archiv?suche=Inventar" },
+ { name: "verzeichnis", pfad: "/admin/verzeichnis", nurModus: "INTERN" },
+ {
+ /*
+ * Der Verbindungstest zeigt seinen Wert erst mit Ergebnis: die Tabelle der
+ * gemappten Felder. Also wird der Knopf gedrueckt und gewartet, bis der
+ * Server geantwortet hat — ohne Container steht dort eine Fehlermeldung,
+ * und auch die ist ein ehrliches Bild.
+ */
+ name: "verbindungstest",
+ pfad: "/admin/verzeichnis",
+ nurModus: "INTERN",
+ vorbereiten: async (tab) => {
+ const knopf = tab.getByRole("button", { name: "Verbindung testen" }).first();
+ if ((await knopf.count()) === 0) return;
+ await knopf.click();
+ await tab.waitForTimeout(4000);
+ },
+ },
+ {
+ /*
+ * Das Abgleich-Log braucht einen Lauf, damit ueberhaupt etwas darin steht.
+ * Also erst „Jetzt abgleichen“ druecken und dann aufklappen — ob der Lauf
+ * gelingt, haengt am Testcontainer, und beide Ausgaenge sind ein ehrliches
+ * Bild: die Zeile zeigt Zahlen oder den Grund.
+ */
+ name: "abgleich-log",
+ pfad: "/admin/verzeichnis",
+ nurModus: "INTERN",
+ vorbereiten: async (tab) => {
+ const knopf = tab.getByRole("button", { name: "Jetzt abgleichen" }).first();
+ if ((await knopf.count()) > 0) {
+ await knopf.click();
+ await tab.waitForTimeout(5000);
+ await tab.reload({ waitUntil: "load" });
+ }
+ const aufklappen = tab.locator("summary", { hasText: "Abgleich-Log" }).first();
+ if ((await aufklappen.count()) === 0) return;
+ await aufklappen.click();
+ await tab.waitForTimeout(150);
+ },
+ },
+ {
+ name: "personen-import",
+ pfad: "/mitarbeiter",
+ vorbereiten: async (tab) => {
+ await tab.locator("summary", { hasText: "CSV" }).first().click();
+ await tab.waitForTimeout(150);
+ },
+ },
+ { name: "abteilungen-vorschlaege", pfad: "/admin/abteilungen" },
{
/*
* Die Druckansicht braucht einen Lauf, und welcher das ist, weiss nur die
@@ -263,15 +313,30 @@ async function anmelden(browser: Browser): Promise {
return kekse;
}
-/** Erzeugt die PWA-Kacheln aus dem SVG, damit keine Bilddatei von Hand entsteht. */
+/**
+ * Erzeugt die PWA-Kacheln aus dem SVG, damit keine Bilddatei von Hand entsteht.
+ *
+ * Geschrieben wird nur, wenn sich die Bytes wirklich unterscheiden. Vorher legte
+ * jeder Screenshot-Lauf zwei geaenderte PNG-Dateien in den Arbeitsbaum, obwohl das
+ * Bild dasselbe war — und `git status` zeigte Aenderungen, die niemand gemacht
+ * hatte (Reviewbefund zu #016).
+ */
async function kachelnErzeugen(browser: Browser): Promise {
const svg = new URL(`${APP_URL}/icon-kachel.svg`).toString();
for (const kante of [192, 512]) {
const seite = await browser.newPage({ viewport: { width: kante, height: kante } });
await seite.goto(svg, { waitUntil: "load" });
- await seite.screenshot({ path: join("public", `icon-${kante}.png`), omitBackground: false });
+ const neu = await seite.screenshot({ omitBackground: false });
await seite.close();
- console.log(` public/icon-${kante}.png`);
+
+ const datei = join("public", `icon-${kante}.png`);
+ const alt = await readFile(datei).catch(() => undefined);
+ if (alt && alt.equals(neu)) {
+ console.log(` ${datei} (unverändert)`);
+ continue;
+ }
+ await writeFile(datei, neu);
+ console.log(` ${datei}`);
}
}
diff --git a/src/app/(app)/admin/abteilungen/page.tsx b/src/app/(app)/admin/abteilungen/page.tsx
index c71597f..6cc4db9 100644
--- a/src/app/(app)/admin/abteilungen/page.tsx
+++ b/src/app/(app)/admin/abteilungen/page.tsx
@@ -1,6 +1,7 @@
import { Building, Plus } from "lucide-react";
import { AbteilungArchivieren } from "@/components/abteilung-archivieren";
import { AbteilungFormular } from "@/components/abteilung-formular";
+import { AbteilungVorschlaege } from "@/components/abteilung-vorschlaege";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundenListe } from "@/server/queries/kunden";
@@ -44,19 +45,16 @@ export default async function AbteilungenSeite() {
Standardgruppen bleiben die Platzhalter {"{{ou}}"} und{" "}
{"{{gruppen}}"} im Lauf leer.
-
- {offene.map((eintrag) => (
-
- {eintrag.name}{" "}
-
- ({eintrag.anzahl} {eintrag.anzahl === 1 ? "Person" : "Personen"})
-
-
- ))}
-
+ {/* Der Knopf je Vorschlag ruft dieselbe Server Action wie das Formular
+ unten (#016) — eine zweite Regel zum Anlegen gibt es nicht. */}
+ ({
+ ...eintrag,
+ kundeName: kundenEbene
+ ? (kunden.find((kunde) => kunde.id === eintrag.kundeId)?.name ?? null)
+ : null,
+ }))}
+ />
) : null}
diff --git a/src/app/(app)/admin/layout.tsx b/src/app/(app)/admin/layout.tsx
index a4053b3..42e77a2 100644
--- a/src/app/(app)/admin/layout.tsx
+++ b/src/app/(app)/admin/layout.tsx
@@ -1,6 +1,8 @@
import Link from "next/link";
import { headers } from "next/headers";
+import { Betriebsmodus } from "@/generated/prisma/enums";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
+import { betriebsmodus } from "@/server/mandant";
/**
* Der Verwaltungsbereich. Die Rechtepruefung steht hier **und** in jeder Seite
@@ -11,21 +13,31 @@ const REITER = [
{ pfad: "/admin", beschriftung: "Einstellungen" },
{ pfad: "/admin/benutzer", beschriftung: "Benutzer" },
{ pfad: "/admin/abteilungen", beschriftung: "Abteilungen" },
+ /*
+ * Das Verzeichnis gibt es nur im Modus INTERN: dort hat das Haus **ein** AD,
+ * aus dem seine Leute kommen. Im Modus DIENSTLEISTUNG waere es ein LDAP zu
+ * fremden Verzeichnissen — genau das ist ausgeschlossen (#008). Der Reiter
+ * verschwindet deshalb mit dem Modus, und die Seite selbst leitet ebenfalls
+ * um: ein Reiter ist eine Bequemlichkeit, keine Sperre (#016).
+ */
+ { pfad: "/admin/verzeichnis", beschriftung: "Verzeichnis", nurIntern: true },
] as const;
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
await sitzungErforderlich("verwaltung", "/admin");
const pfad = (await headers()).get("x-pfad") ?? "/admin";
+ const intern = (await betriebsmodus()) === Betriebsmodus.INTERN;
+ const reiter = REITER.filter((eintrag) => intern || !("nurIntern" in eintrag && eintrag.nurIntern));
return (
<>
- {REITER.map((reiter) => {
- const aktiv = pfad === reiter.pfad;
+ {reiter.map((eintrag) => {
+ const aktiv = pfad === eintrag.pfad;
return (
- {reiter.beschriftung}
+ {eintrag.beschriftung}
);
})}
diff --git a/src/app/(app)/admin/verzeichnis/page.tsx b/src/app/(app)/admin/verzeichnis/page.tsx
new file mode 100644
index 0000000..f5af6d1
--- /dev/null
+++ b/src/app/(app)/admin/verzeichnis/page.tsx
@@ -0,0 +1,208 @@
+import { redirect } from "next/navigation";
+import { AlertTriangle, CheckCircle2, Clock, Server } from "lucide-react";
+import { Betriebsmodus } from "@/generated/prisma/enums";
+import { JetztAbgleichen } from "@/components/verzeichnis/jetzt-abgleichen";
+import { QuelleFormular } from "@/components/verzeichnis/quelle-formular";
+import { VerbindungTesten } from "@/components/verzeichnis/verbindung-testen";
+import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
+import { betriebsmodus } from "@/server/mandant";
+import { abgleichLog, type AbgleichLogZeile, quellenListe } from "@/server/queries/verzeichnis";
+import type { QuellenSicht } from "@/server/services/verzeichnis";
+
+/**
+ * Admin → Verzeichnis (#016).
+ *
+ * Nur im Modus INTERN: dort hat das Haus **ein** Active Directory, aus dem seine
+ * Leute kommen. Im Modus DIENSTLEISTUNG gäbe es Verzeichnisse fremder Kunden,
+ * und die bindet diese Anwendung ausdrücklich nicht an (#008) — die Seite leitet
+ * dann um, nicht nur der Reiter verschwindet.
+ *
+ * Die Reihenfolge auf der Seite ist die Reihenfolge der Arbeit: erst sehen, was
+ * da ist und wie der letzte Abgleich lief, dann prüfen, dann anlegen.
+ */
+
+const ZEITPUNKT = new Intl.DateTimeFormat("de-DE", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+});
+
+function Status({ quelle }: { quelle: QuellenSicht }) {
+ if (quelle.letzterAbgleichAm === null) {
+ return (
+
+ Noch nie abgeglichen. „Verbindung testen“ zeigt, was ankäme, ohne etwas zu schreiben.
+
+ );
+ }
+
+ const gescheitert = quelle.letzterAbgleichStatus === "FEHLER";
+ return (
+
+
+ {gescheitert ? (
+
+ ) : (
+
+ )}
+
+ {gescheitert ? "Letzter Abgleich gescheitert" : "Zuletzt abgeglichen"} am{" "}
+ {ZEITPUNKT.format(quelle.letzterAbgleichAm)}
+
+
+ {quelle.letzterAbgleichFehler ? (
+
{quelle.letzterAbgleichFehler}
+ ) : null}
+
+ );
+}
+
+/**
+ * Das Abgleich-Log: was die letzten Läufe getan haben.
+ *
+ * Keine Tabelle, sondern eine Liste mit umbrechenden Zeilen — bei 390 px hätte
+ * eine Tabelle mit sechs Zahlenspalten quer geschoben werden müssen.
+ *
+ * Gezeigt werden auch Nullen: „0 angelegt, 0 aktualisiert“ ist die Auskunft, dass
+ * der Abgleich lief und das Verzeichnis sich nicht geändert hat. Fehlte die Zeile,
+ * sähe ein erfolgreicher Lauf ohne Änderung aus wie ein Lauf, der nie stattfand.
+ */
+function Log({ zeilen }: { zeilen: readonly AbgleichLogZeile[] }) {
+ if (zeilen.length === 0) {
+ return (
+
+ Noch kein Lauf. Hier steht danach, was jeder Abgleich getan hat.
+
+ );
+ }
+
+ return (
+
+ {zeilen.map((zeile) => (
+
+
+ {zeile.gescheitert ? (
+
+ ) : (
+
+ )}
+ {ZEITPUNKT.format(zeile.zeitpunkt)}
+
+ {/* Ohne Menschen war es der Zeitplan – das ist der Regelfall und keine Lücke. */}
+ {zeile.durch === null ? "Zeitplan" : zeile.durch}
+
+
+
+ {zeile.gelesen} gelesen
+ {zeile.angelegt} angelegt
+ {zeile.uebernommen > 0 ? {zeile.uebernommen} übernommen : null}
+ {zeile.aktualisiert} aktualisiert
+ {zeile.deaktiviert} deaktiviert
+ {zeile.uebersprungen > 0 ? (
+ {zeile.uebersprungen} übersprungen
+ ) : null}
+
+ {zeile.fehler ? (
+ {zeile.fehler}
+ ) : null}
+
+ ))}
+
+ );
+}
+
+export default async function VerzeichnisSeite() {
+ const { benutzer } = await sitzungErforderlich("verwaltung", "/admin/verzeichnis");
+ if ((await betriebsmodus()) !== Betriebsmodus.INTERN) {
+ redirect("/admin");
+ }
+
+ // Der angemeldete Benutzer, nicht ein erfundener: die Mandantensicht haengt
+ // daran, und ein hart gesetzter „ADMIN“ waere eine Hintertuer.
+ const handelnder = { id: benutzer.id, rolle: benutzer.rolle };
+ const quellen = await quellenListe(handelnder);
+ // Je Quelle ein Log. Die Abfragen laufen parallel; es sind so viele wie Quellen,
+ // und das sind im Modus INTERN eine oder zwei.
+ const logs = new Map(
+ await Promise.all(
+ quellen.map(async (quelle) => [quelle.id, await abgleichLog(quelle.id, handelnder)] as const),
+ ),
+ );
+
+ return (
+ <>
+ Verzeichnis
+
+ Die Personen der eigenen Firma kommen aus dem Active Directory, damit beim Start eines Laufs nichts
+ abgetippt werden muss. Wer dort verschwindet oder deaktiviert wird, wird hier inaktiv {" "}
+ — gelöscht wird nie, sonst verlöre die Historie ihre Namen.
+
+
+ {quellen.length === 0 ? (
+
+ Es ist keine Quelle eingerichtet. Personen lassen sich auch von Hand anlegen oder aus einer
+ CSV-Datei einlesen — ein Verzeichnis nimmt nur die Arbeit ab.
+
+ ) : (
+
+ )}
+
+
+ Quelle anlegen
+
+ Ein Dienstkonto mit Leserechten genügt. Laufzettel schreibt nie ins Verzeichnis und speichert nur
+ die zugeordneten Felder — keine Passwörter, keine Gruppenmitgliedschaften.
+
+
+
+ >
+ );
+}
diff --git a/src/app/(app)/mitarbeiter/page.tsx b/src/app/(app)/mitarbeiter/page.tsx
index 0145ba0..83854aa 100644
--- a/src/app/(app)/mitarbeiter/page.tsx
+++ b/src/app/(app)/mitarbeiter/page.tsx
@@ -1,6 +1,7 @@
import Link from "next/link";
-import { Plus, User } from "lucide-react";
+import { Plus, Upload, User } from "lucide-react";
import { MitarbeiterAnlegen } from "@/components/mitarbeiter-anlegen";
+import { MitarbeiterImportieren } from "@/components/mitarbeiter-importieren";
import { MitarbeiterSuche } from "@/components/mitarbeiter-suche";
import { StammdatenReiter } from "@/components/stammdaten-reiter";
import { Herkunft } from "@/generated/prisma/enums";
@@ -9,6 +10,7 @@ import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundenListe } from "@/server/queries/kunden";
import { abteilungsNamen, mitarbeiterListe, type MitarbeiterZeile } from "@/server/queries/mitarbeiter";
+import { MITARBEITER_IMPORT_HOECHSTZEILEN, MITARBEITER_IMPORT_SPALTEN } from "@/server/services/mitarbeiter";
/**
* Mitarbeiterliste (#011).
@@ -138,6 +140,27 @@ export default async function MitarbeiterSeite({
) : null}
+
+ {darfBearbeiten ? (
+ /*
+ * Der CSV-Import steht neben dem Formular und verlangt dasselbe Recht
+ * (#016): wer eine Person eintragen darf, darf auch zwanzig auf einmal
+ * eintragen. Zugeklappt, weil die tägliche Arbeit die Liste ist.
+ */
+
+
+
+ Personen aus einer CSV-Datei einlesen
+
+
+ namen[0])}
+ kunden={kunden.map(({ id, name }) => ({ id, name }))}
+ />
+
+
+ ) : null}
>
);
}
diff --git a/src/components/abteilung-vorschlaege.tsx b/src/components/abteilung-vorschlaege.tsx
new file mode 100644
index 0000000..fe12979
--- /dev/null
+++ b/src/components/abteilung-vorschlaege.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import { useActionState } from "react";
+import { Plus } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { abteilungAnlegenAktion, type AktionsErgebnis } from "@/server/actions/abteilungen";
+
+/**
+ * Abteilungsvorschlaege aus den `department`-Werten der Personen (#016).
+ *
+ * Die Namen stehen schon in den Daten — aus dem Verzeichnisabgleich, aus dem
+ * CSV-Import oder von Hand eingetragen —, es fehlt nur die gepflegte Abteilung
+ * dazu. Ein Klick legt sie an, und zwar ueber `abteilungAnlegenAktion`: dieselbe
+ * Server Action wie das Formular darunter, dieselbe Rechtepruefung, dieselbe
+ * Regel im Dienst. Hier entsteht **keine** zweite.
+ *
+ * Angelegt wird dabei nur der Name. Das sagt der Text auch: Ziel-OU und
+ * Standardgruppen sind danach leer, und ein Knopf, der so aussieht, als waere
+ * damit alles erledigt, waere eine Zusage, die dieser Code nicht haelt.
+ */
+export type AbteilungsVorschlag = {
+ readonly name: string;
+ readonly anzahl: number;
+ readonly kundeId: string;
+ /** Nur im Modus Dienstleistung gesetzt — sonst gibt es keine Kundenebene. */
+ readonly kundeName: string | null;
+};
+
+export function AbteilungVorschlaege({ vorschlaege }: { vorschlaege: readonly AbteilungsVorschlag[] }) {
+ return (
+ <>
+
+ Ein Klick legt die Abteilung mit diesem Namen an — und nur den Namen. Ziel-OU, Standardgruppen und
+ Mail-Domain sind danach noch leer und werden in der Liste darunter nachgetragen.
+
+
+ {vorschlaege.map((vorschlag) => (
+ // Der Name allein taugt nicht als Schluessel: bei zwei Kunden kann
+ // „Vertrieb“ zweimal offen sein, und das sind zwei Vorgaenge.
+
+ ))}
+
+ >
+ );
+}
+
+function Vorschlag({ vorschlag }: { vorschlag: AbteilungsVorschlag }) {
+ const [ergebnis, aktion, laeuft] = useActionState(abteilungAnlegenAktion, {});
+
+ return (
+
+
+
+ {ergebnis.fehler ? (
+
+ {ergebnis.fehler}
+
+ ) : null}
+ {ergebnis.erfolg ? (
+
+ {ergebnis.erfolg}
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/mitarbeiter-importieren.tsx b/src/components/mitarbeiter-importieren.tsx
new file mode 100644
index 0000000..f544ba0
--- /dev/null
+++ b/src/components/mitarbeiter-importieren.tsx
@@ -0,0 +1,158 @@
+"use client";
+
+import { useActionState, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { FELD } from "@/lib/formular";
+import { mitarbeiterImportierenAktion, type AktionsErgebnis } from "@/server/actions/mitarbeiter";
+
+/**
+ * Personen aus einer CSV-Datei einspielen (#016).
+ *
+ * Die Datei wird **im Browser** gelesen und ihr Inhalt als Text mitgeschickt —
+ * derselbe Weg wie beim Geraete- und Vorlagenimport, und dieselbe Begruendung:
+ * das Format ist Text, die Dateien sind klein, und so gibt es einen Weg statt
+ * zwei.
+ *
+ * Was ueber dem Feld steht, muss der Dienst auch halten. Deshalb stehen dort nur
+ * Zusagen, die in `mitarbeiterImportieren` als Code nachzulesen sind: die
+ * Obergrenze, die Spalten, die Wiedererkennung, und dass eine leere Zelle nichts
+ * loescht.
+ */
+export function MitarbeiterImportieren({
+ hoechstZeilen,
+ spalten,
+ kunden,
+}: {
+ hoechstZeilen: number;
+ spalten: readonly string[];
+ kunden: readonly { readonly id: string; readonly name: string }[];
+}) {
+ const [ergebnis, aktion, laeuft] = useActionState(
+ mitarbeiterImportierenAktion,
+ {},
+ );
+ const [inhalt, setInhalt] = useState("");
+ const [leseFehler, setLeseFehler] = useState(null);
+
+ async function dateiLesen(datei: File | undefined) {
+ if (!datei) return;
+ setLeseFehler(null);
+ try {
+ setInhalt(await datei.text());
+ } catch {
+ setLeseFehler("Die Datei ließ sich nicht lesen.");
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/verzeichnis/jetzt-abgleichen.tsx b/src/components/verzeichnis/jetzt-abgleichen.tsx
new file mode 100644
index 0000000..dcb4cfe
--- /dev/null
+++ b/src/components/verzeichnis/jetzt-abgleichen.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { useActionState } from "react";
+import { RefreshCw } from "lucide-react";
+import { abgleichJetztAktion, type AbgleichAktionsErgebnis } from "@/server/actions/verzeichnis";
+
+/**
+ * „Jetzt abgleichen“ (#016).
+ *
+ * Derselbe Dienst, den auch der Zeitplan ruft — kein zweiter Weg mit eigenen
+ * Regeln. Der Knopf ist für die Ungeduld da: nach einer Änderung am Filter will
+ * niemand bis zur nächsten vollen Stunde warten.
+ *
+ * Läuft die Quelle gerade schon (die Sperre liegt in der Datenbank, nicht im
+ * Prozess), sagt das der Dienst; hier steht dann seine Meldung.
+ */
+export function JetztAbgleichen({ quelleId }: { quelleId: string }) {
+ const [ergebnis, aktion, laeuft] = useActionState(
+ abgleichJetztAktion,
+ {},
+ );
+
+ return (
+
+
+
+
+
+ {laeuft ? "Wird abgeglichen …" : "Jetzt abgleichen"}
+
+
+
+ {ergebnis.fehler ? (
+
+ {ergebnis.fehler}
+
+ ) : null}
+ {ergebnis.erfolg ? (
+
+ {ergebnis.erfolg}
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/verzeichnis/quelle-formular.tsx b/src/components/verzeichnis/quelle-formular.tsx
new file mode 100644
index 0000000..76e0b01
--- /dev/null
+++ b/src/components/verzeichnis/quelle-formular.tsx
@@ -0,0 +1,283 @@
+"use client";
+
+import { useActionState, useState } from "react";
+import { KeyRound, Save, Server, ShieldAlert, ShieldCheck } from "lucide-react";
+import { FELD, KAESTCHEN } from "@/lib/formular";
+import {
+ AD_ZUORDNUNG,
+ INTERVALL_HOECHSTENS,
+ INTERVALL_MINDESTENS,
+ ZUORDNUNGS_BESCHRIFTUNG,
+ ZUORDNUNGS_FELDER,
+} from "@/lib/verzeichnis";
+import {
+ quelleAnlegenAktion,
+ quelleBearbeitenAktion,
+ type AktionsErgebnis,
+} from "@/server/actions/verzeichnis";
+import type { QuellenSicht } from "@/server/services/verzeichnis";
+
+/**
+ * Anlegen und Bearbeiten einer Verzeichnisquelle (#016).
+ *
+ * Ein Formular für beides, weil es dieselben Felder sind — mit einem
+ * Unterschied, der zählt: **beim Bearbeiten bleibt das Passwortfeld leer, und
+ * leer heißt „unverändert“.** Die Anwendung gibt das gespeicherte Passwort
+ * nirgends heraus; würde ein leeres Feld als Löschung verstanden, nähme jede
+ * Namensänderung der Quelle ihre Anmeldung.
+ */
+export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
+ const bearbeiten = quelle !== undefined;
+ const [ergebnis, aktion, laeuft] = useActionState(
+ bearbeiten ? quelleBearbeitenAktion : quelleAnlegenAktion,
+ {},
+ );
+ const [zertifikatOffen, setzeZertifikatOffen] = useState(false);
+ const [zuordnungOffen, setzeZuordnungOffen] = useState(false);
+ const [adresse, setzeAdresse] = useState(quelle?.url ?? "");
+ const klartext = adresse.trim().startsWith("ldap://");
+
+ return (
+
+ {bearbeiten ? : null}
+
+
+ Name
+
+
+
+ {/*
+ * Die Erklärung steht **außerhalb** des `label` und hängt über
+ * `aria-describedby` am Feld. Im `label` wäre sie Teil des Feldnamens:
+ * ein Screenreader läse „Adresse Drei Formen: ldaps:// …“ als Bezeichnung
+ * des Eingabefelds, und kein Werkzeug fände das Feld noch über seinen
+ * Namen.
+ */}
+
+
+ Adresse
+
+ setzeAdresse(ereignis.target.value)}
+ required
+ aria-describedby="quelle-url-hinweis"
+ placeholder="ldaps://dc01.firma.local:636"
+ className={`${FELD} mono`}
+ />
+
+ Drei Formen: ldaps:// (verschlüsselt ab dem ersten Byte, die richtige
+ Wahl), ldap+starttls:// (unverschlüsselt verbunden und dann
+ hochgestuft) und ldap:// .
+
+ {klartext ? (
+ /*
+ * Die Warnung erscheint erst, wenn sie zutrifft — eine, die immer
+ * dasteht, liest nach dem dritten Mal niemand mehr.
+ */
+
+
+
+ Mit ldap:// geht auch das Bind-Passwort im Klartext über das Netz.
+ Für ein Dienstkonto im Active Directory ist das keine gute Idee.
+
+
+ ) : null}
+
+
+
+
+
+ Basis-DN
+
+
+
+
+
+ Filter (optional)
+
+
+
+ Leer lassen nimmt den Standard: aktive Personenkonten. Er schließt deaktivierte Konten aus — wer aus
+ dem Ergebnis verschwindet, wird hier deaktiviert und nie gelöscht.
+
+
+
+
+
+ Abgleich alle … Minuten
+
+
+
+
+ Nach Zeitplan abgleichen
+
+
+
+ {/*
+ * Die Attributzuordnung ist einklappbar, aber vollständig da. Vorher stand
+ * sie nur in der Datenbank: der Kommentar im Code versprach „wer ein
+ * OpenLDAP anbindet, ändert einzelne Zeilen“, und genau das ging über die
+ * Oberfläche nicht (Reviewbefund zu #016).
+ */}
+ setzeZuordnungOffen(e.currentTarget.open)}>
+
+ Attributzuordnung anpassen
+
+
+ Die Vorgaben sind die Attributnamen eines Active Directory. Ein OpenLDAP heißt an einigen Stellen
+ anders — dort ist die dauerhafte Kennung etwa entryUUID .
+
+
+ {ZUORDNUNGS_FELDER.map((feld) => (
+
+ {ZUORDNUNGS_BESCHRIFTUNG[feld]}
+
+
+ ))}
+
+
+ Die Kennung ist das eine Feld, das später nicht mehr geändert werden sollte: an ihr erkennt der
+ Abgleich eine Person wieder. Ein anderer Wert legt beim nächsten Lauf alle neu an.
+
+
+
+ setzeZertifikatOffen(e.currentTarget.open)}>
+
+ Eigenes CA-Zertifikat hinterlegen
+
+ {/*
+ * Der Zertifikatstext selbst kommt nicht zurück in die Oberfläche — nur
+ * die Auskunft, dass eines hinterlegt ist. Ohne diese Zeile sah das
+ * leere Feld beim Bearbeiten aus wie „kein Zertifikat“, und der Hinweis
+ * darunter behauptete es auch (Reviewbefund zu #016).
+ */}
+ {quelle?.caZertifikatVorhanden ? (
+
+
+
+ Ein CA-Zertifikat ist hinterlegt. Das Feld unten bleibt leer — Text eintragen ersetzt es, leer
+ lassen behält es.
+
+
+ ) : null}
+
+ PEM
+
+
+ Nur nötig, wenn der Domänencontroller ein Zertifikat aus einer eigenen CA trägt.
+ {quelle?.caZertifikatVorhanden
+ ? " Ohne hinterlegtes Zertifikat gelten die Wurzelzertifikate des Systems."
+ : " Ohne Eintrag gelten die Wurzelzertifikate des Systems."}
+
+
+ {quelle?.caZertifikatVorhanden ? (
+
+
+ Hinterlegtes Zertifikat entfernen
+
+ ) : null}
+
+
+ {ergebnis.fehler ? (
+
+ {ergebnis.fehler}
+
+ ) : null}
+ {ergebnis.erfolg ? (
+
+ {ergebnis.erfolg}
+
+ ) : null}
+
+
+ {bearbeiten ? (
+
+ ) : (
+
+ )}
+ {laeuft ? "Wird gespeichert …" : bearbeiten ? "Speichern" : "Quelle anlegen"}
+
+
+ );
+}
diff --git a/src/components/verzeichnis/verbindung-testen.tsx b/src/components/verzeichnis/verbindung-testen.tsx
new file mode 100644
index 0000000..ef83197
--- /dev/null
+++ b/src/components/verzeichnis/verbindung-testen.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import { useActionState } from "react";
+import { PlugZap } from "lucide-react";
+import { verbindungTestenAktion, type TestErgebnis } from "@/server/actions/verzeichnis";
+
+/**
+ * „Verbindung testen“ (#016).
+ *
+ * Der Wert steckt nicht im „geht“, sondern in der Tabelle darunter: erst an den
+ * **gemappten** Feldern sieht ein Administrator, ob die Zuordnung stimmt. Ein
+ * grünes „Verbindung erfolgreich“ sähe genauso aus, wenn `sam` und `upn`
+ * vertauscht wären — und der Fehler fiele erst auf, wenn ein PowerShell-Befehl
+ * den falschen Anmeldenamen trägt.
+ *
+ * Geschrieben wird dabei nichts; das steht im Dienst und nicht hier.
+ */
+export function VerbindungTesten({ quelleId }: { quelleId: string }) {
+ const [ergebnis, aktion, laeuft] = useActionState(verbindungTestenAktion, {});
+ const treffer = ergebnis.treffer ?? [];
+
+ return (
+
+
+
+
+
+ {laeuft ? "Wird geprüft …" : "Verbindung testen"}
+
+
+
+ {ergebnis.fehler ? (
+
+ {ergebnis.fehler}
+
+ ) : null}
+ {ergebnis.erfolg ? (
+
+ {ergebnis.erfolg}
+
+ ) : null}
+
+ {treffer.length > 0 ? (
+ <>
+
+ Die ersten {treffer.length} Einträge, so wie sie ankommen würden. Es wurde nichts gespeichert. Ein
+ Vorgesetzter erscheint hier nur, wenn er zufällig unter diesen Einträgen ist — beim vollständigen
+ Abgleich wird er aufgelöst.
+
+ {/*
+ * Am Handy eine Karte je Person statt einer Tabelle: eine Tabelle mit
+ * sieben Spalten zwingt bei 390 px zum Seitwärtsscrollen, und genau
+ * das ist ausgeschlossen.
+ */}
+
+ {treffer.map((eintrag) => (
+
+ {eintrag.anzeigename}
+
+ {(
+ [
+ ["UPN", eintrag.upn],
+ ["sam", eintrag.sam],
+ ["Mail", eintrag.mail],
+ ["Abteilung", eintrag.abteilung],
+ ["Titel", eintrag.titel],
+ ["DN", eintrag.dn],
+ ] as const
+ )
+ .filter(([, wert]) => Boolean(wert))
+ .map(([name, wert]) => (
+
+
{name}
+ {wert}
+
+ ))}
+
+
+ ))}
+
+ >
+ ) : null}
+
+ );
+}
diff --git a/src/instrumentation.ts b/src/instrumentation.ts
new file mode 100644
index 0000000..e211304
--- /dev/null
+++ b/src/instrumentation.ts
@@ -0,0 +1,18 @@
+/**
+ * Der eine Einstiegspunkt, den Next je Server-Prozess genau einmal ruft (#016).
+ *
+ * Hier startet der Zeitplan des Verzeichnis-Abgleichs. Zwei Wachen davor:
+ *
+ * - **Nur in der Node-Laufzeit.** `register` laeuft auch fuer die Edge-Laufzeit,
+ * und dort gibt es weder Datenbank noch `node-cron`.
+ * - **Abschaltbar.** Ein Einmal-Container, der nur Migrationen fahren soll, und
+ * die Testlaeufe brauchen keinen tickenden Zeitplan. `ZEITPLAN_AKTIV=false`
+ * schaltet ihn ab, ohne dass jemand Code aendern muss.
+ */
+export async function register(): Promise {
+ if (process.env["NEXT_RUNTIME"] !== "nodejs") return;
+ if (process.env["ZEITPLAN_AKTIV"]?.trim().toLowerCase() === "false") return;
+
+ const { zeitplanStarten } = await import("@/server/zeitplan/abgleich-plan");
+ zeitplanStarten();
+}
diff --git a/src/lib/ldap-guid.ts b/src/lib/ldap-guid.ts
new file mode 100644
index 0000000..4aae4b8
--- /dev/null
+++ b/src/lib/ldap-guid.ts
@@ -0,0 +1,58 @@
+/**
+ * `objectGUID` aus dem Active Directory in eine GUID-Zeichenkette (#016).
+ *
+ * Das AD liefert `objectGUID` als 16 rohe Bytes, und diese Bytes sind **gemischt
+ * endian**: die ersten drei Gruppen der GUID stehen als little-endian-Zahlen im
+ * Puffer, die letzten beiden byteweise in Leserichtung. Genau so macht es auch
+ * .NET (`new Guid(byte[])`) und Pythons `uuid.UUID(bytes_le=…)`; wer die Bytes
+ * stattdessen einfach der Reihe nach in Hexzeichen uebersetzt, bekommt eine
+ * Zeichenkette, die keiner der ueblichen Werkzeuge wiedererkennt — und der
+ * Abgleich haette dann eine Kennung, die niemand gegen `Get-ADUser` gegenpruefen
+ * kann.
+ *
+ * Framework- und datenbankfrei, weil die Umsortierung genau eine Sache tut und
+ * sich so mit einem bekannten Byte-Vektor festnageln laesst.
+ */
+
+export class GuidFehler extends Error {
+ constructor(nachricht: string) {
+ super(nachricht);
+ this.name = "GuidFehler";
+ }
+}
+
+/** So viele Bytes hat eine GUID, immer. */
+const GUID_BYTES = 16;
+
+/**
+ * Die Gruppen der GUID-Zeichenkette: Byte-Indizes in der Reihenfolge, in der sie
+ * ausgegeben werden. Die ersten drei Gruppen laufen rueckwaerts durch den Puffer
+ * (little-endian), die letzten beiden vorwaerts.
+ */
+const GRUPPEN: readonly (readonly number[])[] = [
+ [3, 2, 1, 0],
+ [5, 4],
+ [7, 6],
+ [8, 9],
+ [10, 11, 12, 13, 14, 15],
+];
+
+/**
+ * Wandelt die 16 Bytes eines `objectGUID` in die uebliche Schreibweise
+ * `5f074310-3c35-4a1d-836f-a50f515e7a35` (Kleinbuchstaben, mit Bindestrichen).
+ *
+ * Wirft bei falscher Laenge statt eine halbe Kennung zu liefern: eine
+ * verstuemmelte `externeId` wuerde beim naechsten Abgleich als neue Person
+ * erscheinen und ein Duplikat anlegen.
+ */
+export function guidAusBytes(bytes: Uint8Array): string {
+ if (bytes.length !== GUID_BYTES) {
+ throw new GuidFehler(
+ `Ein objectGUID hat ${GUID_BYTES} Bytes, hier kamen ${bytes.length}. ` +
+ "Wird das Attribut als Text statt als Puffer gelesen, fehlt explicitBufferAttributes.",
+ );
+ }
+ return GRUPPEN.map((gruppe) =>
+ gruppe.map((index) => (bytes[index] as number).toString(16).padStart(2, "0")).join(""),
+ ).join("-");
+}
diff --git a/src/lib/satzzeichen.ts b/src/lib/satzzeichen.ts
new file mode 100644
index 0000000..6c1d189
--- /dev/null
+++ b/src/lib/satzzeichen.ts
@@ -0,0 +1,19 @@
+/**
+ * Satzzeichen beim Zusammensetzen von Meldungen (#016).
+ *
+ * Meldungen entstehen hier oft aus zwei Teilen: einem eigenen Rahmen und einem
+ * fremden Grund („Ohne objectGUID gibt es keine dauerhafte Kennung.“). Ob der
+ * fremde Teil schon einen Punkt tragt, weiss der Rahmen nicht — und „Kennung..“
+ * liest sich wie ein Tippfehler in unserer Anwendung, obwohl beide Teile fuer
+ * sich richtig sind.
+ */
+
+/**
+ * Haengt einen Punkt an, wo keiner steht. Leerer Text wird zu `"unbekannt."`,
+ * damit ein fehlender Grund nicht als Luecke mitten im Satz erscheint.
+ */
+export function satzEnde(text: string | null | undefined): string {
+ const roh = (text ?? "").trim();
+ if (!roh) return "unbekannt.";
+ return /[.!?…:]$/u.test(roh) ? roh : `${roh}.`;
+}
diff --git a/src/lib/verzeichnis.ts b/src/lib/verzeichnis.ts
new file mode 100644
index 0000000..82dffe9
--- /dev/null
+++ b/src/lib/verzeichnis.ts
@@ -0,0 +1,154 @@
+/**
+ * Was ueber eine Verzeichnisquelle auf beiden Seiten gilt (#016).
+ *
+ * Hier stehen die Attributzuordnung und die Grenzen des Abgleich-Intervalls: Zahlen
+ * und Namen, die das Formular genauso braucht wie der Dienst. Sie ein zweites Mal
+ * im Formular zu schreiben ging eine Weile gut und lief dann auseinander — die
+ * Oberflaeche liess hoechstens 1440 Minuten zu, der Dienst 10080, und wer das
+ * Gegenteil glaubte, stand vor einer Grenze, die nirgends stand (Reviewbefund zu
+ * #016).
+ *
+ * Die Zuordnung steht je Quelle in der Datenbank (`Verzeichnisquelle.attributZuordnung`,
+ * ein Json-Feld), weil nicht jedes Verzeichnis ein Active Directory ist. Die
+ * Vorgaben sind die AD-Namen; wer ein OpenLDAP anbindet, aendert einzelne Zeilen.
+ *
+ * Diese Datei liegt in `src/lib` und nicht bei den uebrigen LDAP-Modulen, weil das
+ * Formular dieselben Feldnamen und dieselben Vorgaben braucht — und eine
+ * Client-Komponente nichts aus `src/server/*` importieren darf. Hier steht
+ * deshalb nur, was ohne Netz und ohne Datenbank gilt.
+ */
+
+/**
+ * Die Grenzen des Abgleich-Intervalls, in Minuten.
+ *
+ * Unten fuenf Minuten: haeufiger ist kein Abgleich, sondern Last auf dem
+ * Domaenencontroller. Oben eine Woche: seltener gelesen heisst, dass ein
+ * ausgetretener Mitarbeiter tagelang aktiv bleibt.
+ */
+export const INTERVALL_MINDESTENS = 5;
+export const INTERVALL_HOECHSTENS = 7 * 24 * 60;
+
+/** Dieselbe Regel als Satz, damit Dienst und Formular dasselbe sagen. */
+export function intervallMeldung(): string {
+ return `Das Abgleich-Intervall muss zwischen ${INTERVALL_MINDESTENS} und ${INTERVALL_HOECHSTENS} Minuten liegen (fünf Minuten bis eine Woche).`;
+}
+
+export type AttributZuordnung = {
+ vorname: string;
+ nachname: string;
+ anzeigename: string;
+ mail: string;
+ upn: string;
+ sam: string;
+ abteilung: string;
+ titel: string;
+ vorgesetzter: string;
+ externeId: string;
+};
+
+/** Die Vorgaben aus dem Auftrag: die Attributnamen eines Active Directory. */
+export const AD_ZUORDNUNG: AttributZuordnung = {
+ vorname: "givenName",
+ nachname: "sn",
+ anzeigename: "displayName",
+ mail: "mail",
+ upn: "userPrincipalName",
+ sam: "sAMAccountName",
+ abteilung: "department",
+ titel: "title",
+ vorgesetzter: "manager",
+ externeId: "objectGUID",
+};
+
+/** Die Felder in der Reihenfolge, in der das Formular sie zeigt. */
+export const ZUORDNUNGS_FELDER = Object.keys(AD_ZUORDNUNG) as (keyof AttributZuordnung)[];
+
+/** Wie das Feld in der Oberflaeche heisst. Menschenlesbar, also mit Umlauten. */
+export const ZUORDNUNGS_BESCHRIFTUNG: Record = {
+ vorname: "Vorname",
+ nachname: "Nachname",
+ anzeigename: "Anzeigename",
+ mail: "E-Mail",
+ upn: "UPN",
+ sam: "Anmeldename (sam)",
+ abteilung: "Abteilung",
+ titel: "Titel",
+ vorgesetzter: "Vorgesetzter",
+ externeId: "Kennung (dauerhaft)",
+};
+
+/**
+ * Ein Attributname, wie ein Verzeichnis ihn kennt: Buchstabe voran, dann
+ * Buchstaben, Ziffern und Bindestriche. Mehr braucht es nicht, und mehr
+ * zuzulassen hiesse, Klammern und Sternchen in eine LDAP-Anfrage zu lassen.
+ */
+export const ATTRIBUT_MUSTER = /^[A-Za-z][A-Za-z0-9-]*$/u;
+
+/**
+ * Liest eine gespeicherte Zuordnung aus dem Json-Feld.
+ *
+ * Fehlende oder leere Felder bekommen die AD-Vorgabe. Das ist Absicht: eine
+ * Quelle, die nur `anzeigename` abweichend belegt, soll nicht alle zehn Zeilen
+ * mitschreiben muessen, und eine von Hand geleerte Zeile darf kein Attribut mit
+ * leerem Namen anfragen.
+ */
+export function zuordnungLesen(roh: unknown): AttributZuordnung {
+ const felder = (typeof roh === "object" && roh !== null ? roh : {}) as Record;
+ const gelesen = { ...AD_ZUORDNUNG };
+ for (const schluessel of ZUORDNUNGS_FELDER) {
+ const wert = felder[schluessel];
+ if (typeof wert === "string" && wert.trim()) gelesen[schluessel] = wert.trim();
+ }
+ return gelesen;
+}
+
+/**
+ * Die Attributzuordnung aus den Formularfeldern, geprueft.
+ *
+ * Steht hier und nicht in der Server Action, weil es reine Textarbeit ist und
+ * jeden Sonderfall einzeln geprueft gehoert: ein leeres Feld faellt heraus (der
+ * Dienst legt dann die Vorgabe darueber), und ein Name, der kein Attributname
+ * ist, wird abgewiesen statt in eine LDAP-Anfrage geschrieben.
+ */
+export function zuordnungAusEingabe(
+ werte: Partial>,
+): { readonly zuordnung: Partial } | { readonly fehler: string } {
+ const zuordnung: Partial = {};
+ for (const feld of ZUORDNUNGS_FELDER) {
+ const wert = werte[feld]?.trim();
+ if (!wert) continue;
+ if (!ATTRIBUT_MUSTER.test(wert)) {
+ return {
+ fehler:
+ `„${wert}“ ist kein Attributname (${ZUORDNUNGS_BESCHRIFTUNG[feld]}). ` +
+ "Erwartet werden Buchstaben, Ziffern und Bindestriche, beginnend mit einem Buchstaben.",
+ };
+ }
+ zuordnung[feld] = wert;
+ }
+ return { zuordnung };
+}
+
+/**
+ * Was mit dem CA-Zertifikat geschehen soll: drei Faelle aus zwei Feldern.
+ *
+ * `undefined` heisst **unveraendert** — das hinterlegte bleibt. `""` heisst
+ * entfernen. Alles andere ist der neue Text. Beides zugleich ist keine Eingabe,
+ * die sich ausfuehren liesse, sondern ein Missverstaendnis, und wird als solches
+ * gemeldet.
+ */
+export function zertifikatWahl(
+ text: string,
+ entfernen: boolean,
+): { readonly wert: string | undefined } | { readonly fehler: string } {
+ const neu = text.trim();
+ if (neu && entfernen) {
+ return {
+ fehler:
+ "Entweder ein neues CA-Zertifikat eintragen oder das hinterlegte entfernen — beides zugleich " +
+ "ergibt keinen Sinn.",
+ };
+ }
+ if (entfernen) return { wert: "" };
+ return { wert: neu || undefined };
+}
diff --git a/src/server/actions/mitarbeiter.ts b/src/server/actions/mitarbeiter.ts
index fb01247..c8666ef 100644
--- a/src/server/actions/mitarbeiter.ts
+++ b/src/server/actions/mitarbeiter.ts
@@ -2,13 +2,15 @@
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
-import { MandantFehler } from "@/server/mandant";
+import { MandantFehler, kundenEbeneSichtbar } from "@/server/mandant";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
MitarbeiterFehler,
mitarbeiterAktivSetzen,
mitarbeiterAnlegen,
mitarbeiterBearbeiten,
+ mitarbeiterImportieren,
+ type MitarbeiterImportAbweisung,
} from "@/server/services/mitarbeiter";
import type { HandelnderBenutzer } from "@/server/mandant";
@@ -21,7 +23,12 @@ import type { HandelnderBenutzer } from "@/server/mandant";
* Verzeichnisabgleich gelaufen ist.
*/
-export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
+export type AktionsErgebnis = {
+ readonly fehler?: string;
+ readonly erfolg?: string;
+ /** Nur beim CSV-Import: je abgewiesene Zeile eine Nummer und ein Grund (#016). */
+ readonly abgewiesen?: readonly MitarbeiterImportAbweisung[];
+};
const RECHTE_MELDUNG =
"Deine Anmeldung gilt nicht mehr. Melde dich in einem zweiten Tab neu an und klicke dann hier " +
@@ -153,3 +160,51 @@ export async function mitarbeiterAktivSetzenAktion(
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
+
+/**
+ * Personen aus einer CSV-Datei einspielen (#016).
+ *
+ * Dasselbe Recht wie beim Anlegen von Hand: wer eine Person eintragen darf, darf
+ * auch zwanzig auf einmal eintragen. Der Inhalt kommt als Text im Formular — die
+ * Datei liest der Browser, genau wie beim Geraeteimport.
+ */
+export async function mitarbeiterImportierenAktion(
+ _vorher: AktionsErgebnis,
+ formular: FormData,
+): Promise {
+ const zugang = await angemeldetOderMeldung();
+ if ("fehler" in zugang) return zugang;
+
+ const eingabe = z
+ .object({
+ inhalt: z.string().min(1, "Bitte eine CSV-Datei auswählen oder den Inhalt einfügen."),
+ kundeId: z.string().trim().optional(),
+ })
+ .safeParse({ inhalt: formular.get("inhalt"), kundeId: formular.get("kundeId") ?? undefined });
+ if (!eingabe.success) {
+ return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
+ }
+
+ // Im Modus INTERN gibt es keine Kundenwahl; `kundeErmitteln` verwirft eine
+ // mitgeschickte Kennung ohnehin, hier bleibt sie schon gar nicht liegen.
+ const kundeId = (await kundenEbeneSichtbar()) ? eingabe.data.kundeId || null : null;
+
+ try {
+ const ergebnis = await mitarbeiterImportieren(eingabe.data.inhalt, zugang.benutzer, kundeId);
+ revalidatePath("/mitarbeiter");
+ // Neue Abteilungsnamen aus der Datei sind sofort ein Vorschlag auf der
+ // Abteilungsseite — ohne diese Zeile erst nach dem naechsten Neuladen.
+ revalidatePath("/admin/abteilungen");
+
+ const teile = [`${ergebnis.angelegt} angelegt`];
+ if (ergebnis.aktualisiert > 0) teile.push(`${ergebnis.aktualisiert} aktualisiert`);
+ if (ergebnis.unveraendert > 0) teile.push(`${ergebnis.unveraendert} unverändert`);
+ if (ergebnis.abgewiesen.length > 0) teile.push(`${ergebnis.abgewiesen.length} abgewiesen`);
+ return {
+ erfolg: `${ergebnis.gesamt} ${ergebnis.gesamt === 1 ? "Zeile" : "Zeilen"} gelesen: ${teile.join(", ")}.`,
+ abgewiesen: ergebnis.abgewiesen,
+ };
+ } catch (fehler) {
+ return alsErgebnis(fehler, "Die Datei konnte nicht eingelesen werden.");
+ }
+}
diff --git a/src/server/actions/verzeichnis.ts b/src/server/actions/verzeichnis.ts
new file mode 100644
index 0000000..94a9afa
--- /dev/null
+++ b/src/server/actions/verzeichnis.ts
@@ -0,0 +1,324 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { satzEnde } from "@/lib/satzzeichen";
+import {
+ INTERVALL_HOECHSTENS,
+ INTERVALL_MINDESTENS,
+ intervallMeldung,
+ zertifikatWahl,
+ zuordnungAusEingabe,
+ ZUORDNUNGS_FELDER,
+} from "@/lib/verzeichnis";
+import { z } from "@/lib/validierung";
+import { MandantFehler, type HandelnderBenutzer } from "@/server/mandant";
+import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
+import { abgleichAusfuehren, type AbgleichErgebnis } from "@/server/services/abgleich";
+import {
+ VerzeichnisFehler,
+ quelleAnlegen,
+ quelleBearbeiten,
+ quelleLoeschen,
+ verbindungTesten,
+ type GemappterEintrag,
+} from "@/server/services/verzeichnis";
+
+/**
+ * Server Actions des Verzeichnisses (#016).
+ *
+ * Duenn wie ueberall: Recht pruefen, Zod anwenden, Dienst rufen. Eine Regel
+ * steht hier nicht — dass nur der Modus INTERN ein Verzeichnis hat, dass das
+ * Bind-Passwort verschluesselt wird und dass eine Fehlermeldung der Bibliothek
+ * gefiltert gehoert, entscheidet alles der Dienst.
+ *
+ * **Das Bind-Passwort kommt durch diese Datei und geht nie zurueck.** Keine der
+ * Antworten hier traegt es, und keine Meldung gibt eine rohe Ausnahme der
+ * LDAP-Bibliothek weiter: die kann es enthalten.
+ */
+
+export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
+
+const RECHTE_MELDUNG =
+ "Deine Anmeldung gilt nicht mehr. Melde dich in einem zweiten Tab neu an und klicke dann hier " +
+ "noch einmal — was du eingegeben hast, steht weiterhin auf dieser Seite.";
+
+async function angemeldetOderMeldung(): Promise<
+ { readonly benutzer: HandelnderBenutzer } | { readonly fehler: string }
+> {
+ try {
+ const { benutzer } = await rechtErforderlich("verwaltung");
+ return { benutzer: { id: benutzer.id, rolle: benutzer.rolle } };
+ } catch (fehler) {
+ if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
+ throw fehler;
+ }
+}
+
+/**
+ * Uebersetzt die Fehler der Dienste in eine Meldung, alles andere in eine
+ * neutrale — **und zwar hier**, nicht in der Komponente.
+ *
+ * Eine unbekannte Ausnahme wird nicht weitergereicht: sie kann aus `ldapts`
+ * stammen und den Bind-Versuch samt Zugangsdaten enthalten. Ins Protokoll des
+ * Prozesses kommt nur Name und Meldung, nicht das Objekt.
+ */
+function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
+ if (fehler instanceof VerzeichnisFehler || fehler instanceof MandantFehler) {
+ return { fehler: fehler.message };
+ }
+ console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : neutral);
+ return { fehler: neutral };
+}
+
+function text(formular: FormData, name: string): string {
+ const wert = formular.get(name);
+ return typeof wert === "string" ? wert.trim() : "";
+}
+
+/**
+ * Die Attributzuordnung aus dem Formular. Die Felder heissen `attr.`, damit
+ * sie nicht mit den Kopfdaten kollidieren; geprueft wird in `src/lib/verzeichnis.ts`.
+ */
+function zuordnungAusFormular(formular: FormData) {
+ return zuordnungAusEingabe(
+ Object.fromEntries(ZUORDNUNGS_FELDER.map((feld) => [feld, text(formular, `attr.${feld}`)])),
+ );
+}
+
+const QuelleSchema = z.object({
+ name: z.string().trim().min(1, "Bitte einen Namen angeben."),
+ url: z
+ .string()
+ .trim()
+ .min(1, "Bitte die Adresse angeben, etwa ldaps://dc01.firma.local:636.")
+ .refine(
+ (wert) =>
+ wert.startsWith("ldaps://") || wert.startsWith("ldap+starttls://") || wert.startsWith("ldap://"),
+ "Die Adresse beginnt mit ldaps://, ldap+starttls:// oder ldap://.",
+ ),
+ bindDn: z.string().trim().min(1, "Bitte den Benutzer für die Anmeldung angeben."),
+ basisDn: z.string().trim().min(1, "Bitte angeben, ab welchem Knoten gesucht wird."),
+ filter: z.string().trim().optional(),
+ caZertifikatPem: z.string().trim().optional(),
+ // Dieselben Grenzen wie im Dienst, aus derselben Quelle.
+ abgleichIntervallMinuten: z.coerce
+ .number()
+ .int()
+ .min(INTERVALL_MINDESTENS, intervallMeldung())
+ .max(INTERVALL_HOECHSTENS, intervallMeldung()),
+ aktiv: z.boolean(),
+});
+
+export async function quelleAnlegenAktion(
+ _vorher: AktionsErgebnis,
+ formular: FormData,
+): Promise {
+ const zugang = await angemeldetOderMeldung();
+ if ("fehler" in zugang) return zugang;
+
+ const eingabe = QuelleSchema.safeParse({
+ name: text(formular, "name"),
+ url: text(formular, "url"),
+ bindDn: text(formular, "bindDn"),
+ basisDn: text(formular, "basisDn"),
+ filter: text(formular, "filter") || undefined,
+ caZertifikatPem: text(formular, "caZertifikatPem") || undefined,
+ abgleichIntervallMinuten: text(formular, "abgleichIntervallMinuten") || "60",
+ aktiv: formular.get("aktiv") === "1",
+ });
+ if (!eingabe.success) {
+ return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
+ }
+
+ const bindPasswort = text(formular, "bindPasswort");
+ if (!bindPasswort) return { fehler: "Ohne Passwort kommt keine Anmeldung am Verzeichnis zustande." };
+
+ const zuordnung = zuordnungAusFormular(formular);
+ if ("fehler" in zuordnung) return zuordnung;
+
+ try {
+ const quelle = await quelleAnlegen(
+ { ...eingabe.data, bindPasswort, attributZuordnung: zuordnung.zuordnung },
+ zugang.benutzer,
+ );
+ revalidatePath("/admin/verzeichnis");
+ return { erfolg: `„${quelle.name}“ ist angelegt. Prüf als Nächstes die Verbindung.` };
+ } catch (fehler) {
+ return alsErgebnis(fehler, "Die Quelle konnte nicht angelegt werden.");
+ }
+}
+
+export async function quelleBearbeitenAktion(
+ _vorher: AktionsErgebnis,
+ formular: FormData,
+): Promise {
+ const zugang = await angemeldetOderMeldung();
+ if ("fehler" in zugang) return zugang;
+
+ const id = text(formular, "id");
+ if (!id) return { fehler: "Es fehlt die Quelle." };
+
+ const eingabe = QuelleSchema.safeParse({
+ name: text(formular, "name"),
+ url: text(formular, "url"),
+ bindDn: text(formular, "bindDn"),
+ basisDn: text(formular, "basisDn"),
+ filter: text(formular, "filter") || undefined,
+ // Das Zertifikat entscheidet sich weiter unten: es kennt drei Faelle, das
+ // Schema nur zwei.
+ caZertifikatPem: undefined,
+ abgleichIntervallMinuten: text(formular, "abgleichIntervallMinuten") || "60",
+ aktiv: formular.get("aktiv") === "1",
+ });
+ if (!eingabe.success) {
+ return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
+ }
+
+ /*
+ * Drei Faelle, und das Formular kann sie nicht auf ein Feld abbilden: ein neues
+ * Zertifikat eintragen, das hinterlegte behalten (Feld leer) und das hinterlegte
+ * entfernen (Kaestchen). Ohne das Kaestchen liess sich ein einmal hinterlegtes
+ * Zertifikat nie wieder loswerden — es stand unsichtbar in der Datenbank und
+ * entschied weiter, welcher CA vertraut wird (Reviewbefund zu #016).
+ */
+ const zertifikat = zertifikatWahl(
+ text(formular, "caZertifikatPem"),
+ formular.get("caZertifikatEntfernen") === "1",
+ );
+ if ("fehler" in zertifikat) return zertifikat;
+ const caZertifikatPem = zertifikat.wert;
+
+ /*
+ * Ein leeres Passwortfeld heisst „unveraendert“ und nicht „leer“. Das Feld
+ * steht beim Bearbeiten leer da — es gibt das gespeicherte nicht her, und ein
+ * Formular, das es als Loeschung verstuende, naehme der Quelle bei jeder
+ * Namensaenderung die Anmeldung.
+ */
+ const bindPasswort = text(formular, "bindPasswort");
+
+ const zuordnung = zuordnungAusFormular(formular);
+ if ("fehler" in zuordnung) return zuordnung;
+
+ try {
+ const quelle = await quelleBearbeiten(
+ id,
+ {
+ ...eingabe.data,
+ ...(bindPasswort ? { bindPasswort } : {}),
+ ...(caZertifikatPem !== undefined ? { caZertifikatPem } : {}),
+ attributZuordnung: zuordnung.zuordnung,
+ },
+ zugang.benutzer,
+ );
+ revalidatePath("/admin/verzeichnis");
+ return { erfolg: `„${quelle.name}“ ist gespeichert.` };
+ } catch (fehler) {
+ return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
+ }
+}
+
+export async function quelleLoeschenAktion(
+ _vorher: AktionsErgebnis,
+ formular: FormData,
+): Promise {
+ const zugang = await angemeldetOderMeldung();
+ if ("fehler" in zugang) return zugang;
+ const id = text(formular, "id");
+ if (!id) return { fehler: "Es fehlt die Quelle." };
+
+ try {
+ await quelleLoeschen(id, zugang.benutzer);
+ revalidatePath("/admin/verzeichnis");
+ return { erfolg: "Die Quelle ist entfernt." };
+ } catch (fehler) {
+ return alsErgebnis(fehler, "Die Quelle konnte nicht entfernt werden.");
+ }
+}
+
+export type TestErgebnis = AktionsErgebnis & {
+ readonly treffer?: readonly GemappterEintrag[];
+ readonly gesamtGelesen?: number;
+};
+
+/**
+ * „Verbindung testen“ — liest die ersten Treffer und **schreibt nichts**.
+ *
+ * Der Wert liegt nicht im „geht/geht nicht“, sondern in der Tabelle darunter:
+ * erst an den gemappten Feldern sieht ein Administrator, ob die Zuordnung
+ * stimmt. Ein „Verbindung erfolgreich“ allein saehe genauso aus, wenn `sam` und
+ * `upn` vertauscht waeren.
+ */
+export async function verbindungTestenAktion(
+ _vorher: TestErgebnis,
+ formular: FormData,
+): Promise {
+ const zugang = await angemeldetOderMeldung();
+ if ("fehler" in zugang) return zugang;
+ const id = text(formular, "id");
+ if (!id) return { fehler: "Es fehlt die Quelle." };
+
+ try {
+ const ergebnis = await verbindungTesten(id, zugang.benutzer);
+ return {
+ erfolg:
+ ergebnis.treffer.length === 0
+ ? "Die Verbindung steht, aber der Filter findet niemanden."
+ : ergebnis.treffer.length === 0
+ ? /*
+ * Gelesen, aber nichts zuordenbar: das ist fast immer ein falsch
+ * gesetztes Kennungsattribut und **nicht** ein zu enger Filter. Die
+ * beiden Faelle denselben Satz sagen zu lassen hiesse, den
+ * Administrator an der falschen Stelle suchen zu schicken.
+ */
+ `Die Verbindung steht und der Filter trifft ${ergebnis.gesamtGelesen} Einträge — ` +
+ `zuordnen ließ sich keiner davon. Grund des ersten: ` +
+ `${satzEnde(ergebnis.uebersprungen[0]?.grund)} Prüf die Attributzuordnung, ` +
+ "besonders das Kennungsattribut."
+ : ergebnis.uebersprungen.length > 0
+ ? `Die Verbindung steht. ${ergebnis.gesamtGelesen} Einträge gelesen, ` +
+ `${ergebnis.uebersprungen.length} übersprungen — Grund des ersten: ` +
+ satzEnde(ergebnis.uebersprungen[0]?.grund)
+ : `Die Verbindung steht. ${ergebnis.gesamtGelesen} Einträge gelesen.`,
+ treffer: ergebnis.treffer,
+ gesamtGelesen: ergebnis.gesamtGelesen,
+ };
+ } catch (fehler) {
+ return alsErgebnis(fehler, "Die Verbindung kam nicht zustande.");
+ }
+}
+
+export type AbgleichAktionsErgebnis = AktionsErgebnis & { readonly ergebnis?: AbgleichErgebnis };
+
+export async function abgleichJetztAktion(
+ _vorher: AbgleichAktionsErgebnis,
+ formular: FormData,
+): Promise {
+ const zugang = await angemeldetOderMeldung();
+ if ("fehler" in zugang) return zugang;
+ const id = text(formular, "id");
+ if (!id) return { fehler: "Es fehlt die Quelle." };
+
+ try {
+ const ergebnis = await abgleichAusfuehren(id, zugang.benutzer);
+ revalidatePath("/admin/verzeichnis");
+ revalidatePath("/mitarbeiter");
+ return ergebnis.status === "FEHLER"
+ ? { fehler: ergebnis.fehler ?? "Der Abgleich ist gescheitert.", ergebnis }
+ : {
+ erfolg:
+ `${ergebnis.angelegt} angelegt, ${ergebnis.aktualisiert} aktualisiert, ` +
+ `${ergebnis.deaktiviert} deaktiviert.` +
+ // Die Uebernahme steht nur da, wenn es eine gab: eine Zeile „0
+ // übernommen“ bei jedem Lauf erklaert nichts und verdeckt den Rest.
+ (ergebnis.uebernommen > 0
+ ? ` ${ergebnis.uebernommen} von Hand angelegte Person${ergebnis.uebernommen === 1 ? "" : "en"} übernommen.`
+ : "") +
+ // Der Hinweis haengt an derselben Zeile, weil ein zweiter Absatz in
+ // gruener Schrift wie eine zweite Erfolgsmeldung liest.
+ (ergebnis.hinweis ? ` ${ergebnis.hinweis}` : ""),
+ ergebnis,
+ };
+ } catch (fehler) {
+ return alsErgebnis(fehler, "Der Abgleich ist gescheitert.");
+ }
+}
diff --git a/src/server/ldap/fehler.ts b/src/server/ldap/fehler.ts
new file mode 100644
index 0000000..e3d50c8
--- /dev/null
+++ b/src/server/ldap/fehler.ts
@@ -0,0 +1,19 @@
+/**
+ * Die gemeinsame Fehlerklasse des Verzeichnisses (#016).
+ *
+ * Sie steht **unterhalb** der Dienste, weil `src/server/ldap/*` von ihnen
+ * benutzt wird und nicht umgekehrt: eine Fehlerklasse in `services/verzeichnis`
+ * koennte `verbindung.ts` nicht erweitern, ohne die Schichtung umzudrehen.
+ *
+ * Alles, was daran haengt, kommt in der Oberflaeche als **lesbare Meldung** an;
+ * die Server Actions unterscheiden genau daran (`instanceof`), ob sie den Text
+ * zeigen duerfen oder einen neutralen Satz nehmen muessen. Was nicht davon
+ * erbt, gilt als unerwartet — und unerwartete Ausnahmen koennen aus einer
+ * fremden Bibliothek stammen und Zugangsdaten enthalten.
+ */
+export class VerzeichnisFehler extends Error {
+ constructor(nachricht: string, optionen?: ErrorOptions) {
+ super(nachricht, optionen);
+ this.name = "VerzeichnisFehler";
+ }
+}
diff --git a/src/server/ldap/leser.ts b/src/server/ldap/leser.ts
new file mode 100644
index 0000000..3abb39d
--- /dev/null
+++ b/src/server/ldap/leser.ts
@@ -0,0 +1,101 @@
+import {
+ type AttributZuordnung,
+ type RoherEintrag,
+ type Uebersprungen,
+ angeforderteAttribute,
+ eintragZuordnen,
+} from "@/server/ldap/zuordnung";
+import { type LdapZugang, mitVerbindung } from "@/server/ldap/verbindung";
+
+/**
+ * Das Verzeichnis lesen (#016).
+ *
+ * Gelesen wird mit `searchPaginated` und nicht mit `search({ paged: … })`. Der
+ * Unterschied ist nicht kosmetisch: `search` sammelt alle Seiten und gibt sie am
+ * Ende als ein Array zurueck — bei einem Verzeichnis mit einigen Tausend Konten
+ * steht damit der gesamte Bestand im Speicher, bevor die erste Zeile geschrieben
+ * ist. `searchPaginated` liefert Seite fuer Seite, und der Abgleich schreibt
+ * mit. Die Seitengroesse geht trotzdem durch `paged.pageSize`; das ist der Weg,
+ * auf dem ldapts 9 sie erwartet (`Client.searchPaginated` liest sie von dort,
+ * sonst nimmt es 100).
+ */
+
+/** Die Seitengroesse aus dem Auftrag. */
+export const SEITENGROESSE = 1000;
+
+export type LeseVorgabe = {
+ readonly basisDn: string;
+ readonly filter: string;
+ readonly zuordnung: AttributZuordnung;
+};
+
+export type LeseBilanz = {
+ /** Wie viele Eintraege der Server geliefert hat — samt der uebersprungenen. */
+ readonly gesamtGelesen: number;
+ readonly uebersprungen: readonly Uebersprungen[];
+};
+
+/**
+ * Laeuft ueber die Treffer und gibt jeden einzeln weiter.
+ *
+ * `grenze` zaehlt die **zugeordneten** Eintraege, nicht die gelesenen: „die
+ * ersten zehn Treffer zeigen“ meint zehn Zeilen in der Tabelle. Ist die Grenze
+ * erreicht, wird die Suche abgebrochen und keine weitere Seite geholt;
+ * `gesamtGelesen` kann dann um die uebersprungenen Eintraege hoeher liegen.
+ */
+export async function eintraegeStroemen(
+ zugang: LdapZugang,
+ vorgabe: LeseVorgabe,
+ verarbeiten: (eintrag: RoherEintrag) => Promise | void,
+ grenze?: number,
+): Promise {
+ let gesamtGelesen = 0;
+ let zugeordnet = 0;
+ const uebersprungen: Uebersprungen[] = [];
+
+ await mitVerbindung(zugang, async (client) => {
+ const seiten = client.searchPaginated(vorgabe.basisDn, {
+ scope: "sub",
+ filter: vorgabe.filter,
+ // Nur die zugeordneten Attribute. Gruppenmitgliedschaften und was sonst am
+ // Konto haengt werden nicht einmal angefragt.
+ attributes: angeforderteAttribute(vorgabe.zuordnung),
+ // objectGUID ist binaer. Ohne diese Zeile macht ldapts daraus eine
+ // Zeichenkette, und 16 rohe Bytes als UTF-8 zu lesen zerstoert sie.
+ explicitBufferAttributes: [vorgabe.zuordnung.externeId],
+ paged: { pageSize: SEITENGROESSE },
+ });
+
+ for await (const seite of seiten) {
+ for (const roh of seite.searchEntries) {
+ gesamtGelesen += 1;
+ const ergebnis = eintragZuordnen(roh, vorgabe.zuordnung);
+ if ("uebersprungen" in ergebnis) {
+ uebersprungen.push(ergebnis.uebersprungen);
+ continue;
+ }
+ await verarbeiten(ergebnis.eintrag);
+ zugeordnet += 1;
+ // Das Verlassen der Schleife schliesst den Generator; die naechste Seite
+ // wird nicht mehr geholt.
+ if (grenze !== undefined && zugeordnet >= grenze) return;
+ }
+ }
+ });
+
+ return { gesamtGelesen, uebersprungen };
+}
+
+/**
+ * Dieselbe Suche, aber alles im Speicher — fuer „Verbindung testen“ mit einer
+ * kleinen Grenze. Fuer den Abgleich ist `eintraegeStroemen` der richtige Weg.
+ */
+export async function eintraegeLesen(
+ zugang: LdapZugang,
+ vorgabe: LeseVorgabe,
+ grenze: number,
+): Promise {
+ const eintraege: RoherEintrag[] = [];
+ const bilanz = await eintraegeStroemen(zugang, vorgabe, (eintrag) => void eintraege.push(eintrag), grenze);
+ return { ...bilanz, eintraege };
+}
diff --git a/src/server/ldap/verbindung.ts b/src/server/ldap/verbindung.ts
new file mode 100644
index 0000000..17fb771
--- /dev/null
+++ b/src/server/ldap/verbindung.ts
@@ -0,0 +1,212 @@
+import type { ConnectionOptions } from "node:tls";
+import { Client } from "ldapts";
+import { VerzeichnisFehler } from "@/server/ldap/fehler";
+
+/**
+ * Die Verbindung zum Verzeichnis (#016).
+ *
+ * Hier stehen drei Dinge, die auseinandergehalten werden muessen: welche
+ * Adressen erlaubt sind, wie die TLS-Optionen aussehen, und wie eine Meldung der
+ * Bibliothek aussieht, nachdem das Bind-Passwort aus ihr entfernt wurde. Die
+ * ersten beiden sind reine Funktionen und einzeln geprueft; nur `mitVerbindung`
+ * spricht wirklich mit dem Netz.
+ */
+
+/**
+ * Ein Fehler beim Zugriff auf das Verzeichnis.
+ *
+ * Erbt von `VerzeichnisFehler`, damit die Meldung in der Oberflaeche ankommt.
+ * Vorher war es ein blosser `Error`, und die Server Action fiel fuer **jeden**
+ * LDAP-Fehler auf den neutralen Satz „Die Verbindung kam nicht zustande“ zurueck
+ * — falsches Passwort, falscher Basis-DN und ein toter Server sahen identisch
+ * aus, obwohl die Meldung hier eigens dafuer gesaeubert wird (Reviewbefund zu
+ * #016).
+ */
+export class VerbindungsFehler extends VerzeichnisFehler {
+ constructor(nachricht: string, optionen?: ErrorOptions) {
+ super(nachricht, optionen);
+ this.name = "VerbindungsFehler";
+ }
+}
+
+/**
+ * Die drei Adressformen.
+ *
+ * `ldaps` ist der Regelfall und stand deshalb zuerst in der Liste. `ldap+starttls`
+ * ist unsere Schreibweise fuer „auf 389 verbinden und die Verbindung danach mit
+ * StartTLS hochziehen“ — es gibt in der Datenbank kein zweites Feld dafuer, und
+ * ein verstecktes Kaestchen waere schlechter als ein sichtbares Schema in der
+ * Adresse. `ldap` ist Klartext: der Bind geht dann unverschluesselt ueber das
+ * Netz, inklusive Passwort. Erlaubt bleibt es, weil es Verzeichnisse ohne TLS
+ * gibt und weil der Testcontainer genau so laeuft.
+ */
+const SCHEMATA = ["ldaps://", "ldap+starttls://", "ldap://"] as const;
+
+export type LdapZugang = {
+ readonly url: string;
+ readonly bindDn: string;
+ readonly bindPasswort: string;
+ readonly caZertifikatPem: string | null;
+};
+
+/**
+ * Prueft die Adresse und trennt das StartTLS-Schema ab.
+ *
+ * `ldapts` kennt nur `ldap://` und `ldaps://`; die Umschaltung auf StartTLS
+ * geschieht bei uns nach dem Verbinden. Zurueck kommt deshalb die Adresse, die
+ * die Bibliothek bekommt, und die Frage, ob danach `startTLS()` faellig ist.
+ */
+export function urlPruefen(roh: string): { url: string; startTls: boolean; verschluesselt: boolean } {
+ const url = roh.trim();
+ const schema = SCHEMATA.find((kandidat) => url.toLowerCase().startsWith(kandidat));
+ if (!schema) {
+ throw new VerbindungsFehler(
+ `„${url}“ ist keine LDAP-Adresse. Erwartet wird ldaps://host:636, ` +
+ "ldap+starttls://host:389 oder ldap://host:389.",
+ );
+ }
+ const rest = url.slice(schema.length);
+ if (!rest || rest.startsWith("/")) {
+ throw new VerbindungsFehler(`In „${url}“ fehlt der Rechnername.`);
+ }
+ // Ein DN oder ein Pfad hinter dem Port wird von ldapts nicht ausgewertet. Ihn
+ // stillschweigend zu schlucken hiesse, einen Basis-DN anzunehmen, der dann doch
+ // aus dem eigenen Feld kaeme.
+ if (rest.includes("/")) {
+ throw new VerbindungsFehler(
+ `„${url}“ enthält einen Pfad. Die Adresse besteht nur aus Schema, Rechner und Port; ` +
+ "der Basis-DN steht in seinem eigenen Feld.",
+ );
+ }
+ // Zugangsdaten in der Adresse wuerden das Passwort im Klartext in die
+ // Datenbank schreiben — verschluesselt wird nur das Feld dafuer.
+ if (rest.includes("@")) {
+ throw new VerbindungsFehler(
+ `„${url}“ enthält Zugangsdaten. Bind-DN und Passwort stehen in ihren eigenen Feldern.`,
+ );
+ }
+
+ const startTls = schema === "ldap+starttls://";
+ return {
+ url: startTls ? `ldap://${rest}` : url,
+ startTls,
+ verschluesselt: startTls || schema === "ldaps://",
+ };
+}
+
+/**
+ * Die TLS-Optionen.
+ *
+ * `minVersion: "TLSv1.2"` ist gesetzt, weil Node sonst auch TLS 1.0 und 1.1
+ * annimmt, sobald ein alter Domaenencontroller sie anbietet. Ein eigenes
+ * CA-Zertifikat **ersetzt** die mitgelieferten Wurzeln nicht zufaellig, sondern
+ * absichtlich: wer ein internes Zertifikat hinterlegt, will genau dieser CA
+ * trauen und keiner oeffentlichen. Die Pruefung des Hostnamens bleibt in jedem
+ * Fall an — dafuer gibt es hier bewusst keinen Schalter.
+ */
+export function tlsOptionenBauen(caZertifikatPem: string | null | undefined): ConnectionOptions {
+ const ca = caZertifikatPem?.trim();
+ return {
+ minVersion: "TLSv1.2",
+ ...(ca ? { ca: [ca] } : {}),
+ };
+}
+
+/** Wie viel von einer fremden Meldung wir uebernehmen. Der Rest waere Rauschen im Feld. */
+const MELDUNG_GRENZE = 400;
+
+/**
+ * Eine Meldung, die gespeichert und angezeigt werden darf.
+ *
+ * Rohe Fehler der Bibliothek koennen das Bind-Passwort enthalten — etwa wenn es
+ * in einer Adresse steckt oder eine Ausnahme die Anfrage mitschreibt. In
+ * `Verzeichnisquelle.letzterAbgleichFehler` landet deshalb nur, was hier
+ * durchkommt: ohne die uebergebenen Geheimnisse, einzeilig und gekuerzt. Ein
+ * Stapelverlauf gehoert ohnehin nicht in eine Oberflaeche.
+ *
+ * Die Geheimnisse werden vollstaendig ersetzt, nicht gekuerzt angezeigt: schon
+ * die Laenge eines Passworts ist eine Auskunft, die niemand braucht.
+ */
+/**
+ * Die drei Ergebniscodes, die im Alltag wirklich vorkommen.
+ *
+ * `ldapts` gibt sie als „Code: 0x31“ weiter — richtig, aber fuer den Menschen
+ * vor dem Bildschirm wertlos: er sieht dieselbe Zeichenfolge bei falschem
+ * Passwort wie bei fehlendem Leserecht. Der Rohtext bleibt trotzdem stehen,
+ * denn die uebrigen fuenfzig Codes uebersetzt hier niemand (Reviewbefund zu
+ * #016).
+ */
+const CODE_TEXTE: ReadonlyArray = [
+ [/0x31|invalid ?credentials/iu, "Anmeldung abgelehnt: Anmelde-DN oder Passwort stimmen nicht."],
+ [
+ /0x32|insufficient ?access/iu,
+ "Das Dienstkonto darf hier nicht lesen — es fehlt die Leseberechtigung auf dem Basis-DN.",
+ ],
+ [/0x20|no ?such ?object/iu, "Den Basis-DN gibt es auf diesem Server nicht."],
+];
+
+export function fehlerMeldung(fehler: unknown, geheimnisse: readonly string[]): string {
+ const roh = fehler instanceof Error ? fehler.message : String(fehler);
+ let text = roh.replace(/\s+/gu, " ").trim();
+
+ // Die Erklaerung **vor** den Rohtext, nicht statt seiner: wer den Server
+ // kennt, liest den Code, alle anderen den Satz.
+ const treffer = CODE_TEXTE.find(([muster]) => muster.test(text));
+ if (treffer) text = `${treffer[1]} (${text})`;
+ for (const geheimnis of geheimnisse) {
+ // Kurze Zeichenketten koennten in jeder Meldung stecken; das Geheimnis
+ // dennoch zu ersetzen ist harmlos, es auszulassen waere es nicht.
+ if (!geheimnis) continue;
+ text = text.split(geheimnis).join("***");
+ }
+ if (!text) return "Unbekannter Fehler beim Zugriff auf das Verzeichnis.";
+ return text.length > MELDUNG_GRENZE ? `${text.slice(0, MELDUNG_GRENZE)}…` : text;
+}
+
+/**
+ * Baut die Verbindung, bindet, fuehrt `was` aus und trennt in jedem Fall wieder.
+ *
+ * `unbind` steht im `finally`, weil eine offene Verbindung den Zeitplan ueber
+ * Stunden am Leben halten wuerde: der Abgleich laeuft im selben Prozess wie die
+ * Anwendung. Fehler werden hier gesaeubert, damit kein Aufrufer es vergessen
+ * kann — weiter aussen liegt nur noch die Stelle, die die Meldung speichert.
+ */
+export async function mitVerbindung(zugang: LdapZugang, was: (client: Client) => Promise): Promise {
+ const { url, startTls, verschluesselt } = urlPruefen(zugang.url);
+ const tlsOptionen = tlsOptionenBauen(zugang.caZertifikatPem);
+ const client = new Client({
+ url,
+ /*
+ * Die Optionen kommen nur mit, wenn die Verbindung von Anfang an
+ * verschluesselt ist — also bei `ldaps`.
+ *
+ * ldapts 9 entscheidet `secure` aus dem Schema **oder** daraus, dass
+ * `tlsOptions` ueberhaupt ein gesetztes Feld hat (Client.ts: `this.secure =
+ * isSecureProtocol || hasTlsOptions`). Sie immer mitzugeben liesse also auch
+ * `ldap://` als TLS starten, und ein Server im Klartext legt dann sofort auf:
+ * „Client network socket disconnected before secure TLS connection was
+ * established“. Bei StartTLS bekommt `startTLS()` sie stattdessen.
+ */
+ ...(verschluesselt && !startTls ? { tlsOptions: tlsOptionen } : {}),
+ // Ohne Zeitgrenzen haengt ein Abgleich an einem stummen Server, bis jemand
+ // den Prozess neu startet — und die Sperre laeuft mit.
+ connectTimeout: 10_000,
+ timeout: 60_000,
+ });
+
+ try {
+ if (startTls) await client.startTLS(tlsOptionen);
+ await client.bind(zugang.bindDn, zugang.bindPasswort);
+ return await was(client);
+ } catch (fehler) {
+ // Ohne `cause`, und das ist der Punkt: Next schreibt bei einem Fehler die
+ // ganze Kette ins Server-Log. Der rohe Fehler bliebe darin ungefiltert
+ // stehen, und die gesaeuberte Meldung waere eine Zusage, die nur fuer die
+ // Oberflaeche gilt.
+ throw new VerbindungsFehler(fehlerMeldung(fehler, [zugang.bindPasswort]));
+ } finally {
+ // Auch das Trennen kann werfen (abgebrochene Verbindung). Das ist dann nicht
+ // der Fehler, der den Aufrufer interessiert.
+ await client.unbind().catch(() => undefined);
+ }
+}
diff --git a/src/server/ldap/zuordnung.ts b/src/server/ldap/zuordnung.ts
new file mode 100644
index 0000000..7afa223
--- /dev/null
+++ b/src/server/ldap/zuordnung.ts
@@ -0,0 +1,234 @@
+import type { Entry } from "ldapts";
+import { GuidFehler, guidAusBytes } from "@/lib/ldap-guid";
+import { AD_ZUORDNUNG, type AttributZuordnung, ZUORDNUNGS_FELDER, zuordnungLesen } from "@/lib/verzeichnis";
+
+/*
+ * Die Liste der Felder liegt in `src/lib/verzeichnis.ts`, weil das
+ * Formular sie auch braucht. Sie wird hier weiter mitexportiert, damit die
+ * LDAP-Module eine Anlaufstelle behalten und ein Aufrufer nicht wissen muss,
+ * welcher Teil der Zuordnung framework-frei ist.
+ */
+export { AD_ZUORDNUNG, ZUORDNUNGS_FELDER, zuordnungLesen };
+export type { AttributZuordnung };
+
+/**
+ * Welches LDAP-Attribut wird welches Feld (#016).
+ *
+ * Die Zuordnung steht je Quelle in der Datenbank (`Verzeichnisquelle.attributZuordnung`,
+ * ein Json-Feld), weil nicht jedes Verzeichnis ein Active Directory ist. Die
+ * Vorgaben sind die AD-Namen; wer ein OpenLDAP anbindet, aendert einzelne Zeilen.
+ *
+ * Hier stehen deshalb nur reine Funktionen: aus einem `Entry` der Bibliothek
+ * wird ein Datensatz in unseren Feldern. Kein Netz, keine Datenbank — dadurch
+ * laesst sich die Zuordnung mit erfundenen Eintraegen pruefen, und der
+ * Integrationstest muss nicht jeden Sonderfall des Mappings nachbauen.
+ */
+
+/**
+ * Der Standardfilter: Personenkonten, die **nicht** deaktiviert sind.
+ *
+ * `userAccountControl:1.2.840.113556.1.4.803:=2` ist die Bit-UND-Regel des AD;
+ * Bit 2 (`ACCOUNTDISABLE`) gesetzt heisst deaktiviert. Das Ausschliessen hier ist
+ * kein Komfort, sondern die halbe Aufgabe: ein deaktiviertes Konto verschwindet
+ * damit aus dem Ergebnis, und was aus dem Ergebnis verschwindet, setzt der
+ * Abgleich auf `aktiv = false`.
+ */
+export const STANDARD_FILTER =
+ "(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))";
+
+/** Ein Eintrag in unseren Feldern, so wie ihn „Verbindung testen“ zeigt. */
+export type GemappterEintrag = {
+ externeId: string;
+ vorname: string;
+ nachname: string;
+ anzeigename: string;
+ mail: string | null;
+ upn: string | null;
+ sam: string | null;
+ dn: string;
+ abteilung: string | null;
+ titel: string | null;
+ vorgesetzterExterneId: string | null;
+};
+
+/**
+ * Derselbe Eintrag, bevor der Vorgesetzte aufgeloest ist.
+ *
+ * `manager` ist im Verzeichnis ein **DN**, gespeichert wird aber die
+ * `externeId` des Vorgesetzten — sonst zeigt `{{vorgesetzter_sam}}` ins Leere,
+ * sobald jemand umgezogen ist und sein DN sich geaendert hat. Die Aufloesung
+ * braucht die anderen Eintraege und kann deshalb nicht beim Lesen eines
+ * einzelnen Eintrags geschehen.
+ */
+export type RoherEintrag = Omit & {
+ vorgesetzterDn: string | null;
+};
+
+/**
+ * Die Attribute, die angefragt werden — genau die zugeordneten, nichts sonst.
+ *
+ * Ohne diese Liste liefert der Server alle Attribute, also auch
+ * Gruppenmitgliedschaften und was sonst am Konto haengt. Gespeichert wird davon
+ * nichts, aber gelesen eben doch: es stuende im Speicher, in einer
+ * Fehlerausgabe, in einem Netzwerkmitschnitt. Wir fragen deshalb nur, was wir
+ * brauchen.
+ */
+export function angeforderteAttribute(zuordnung: AttributZuordnung): string[] {
+ // Set, weil zwei Felder dasselbe Attribut benutzen duerfen (etwa upn = mail).
+ return [...new Set(Object.values(zuordnung))];
+}
+
+/** Ein einzelner Attributwert als Text, oder `null`. */
+function textWert(eintrag: Entry, attribut: string): string | null {
+ const wert = eintrag[attribut];
+ if (wert === undefined) return null;
+ // Mehrwertige Attribute kommen als Array. Der erste Wert gewinnt; ein Konto mit
+ // zwei Vornamen ist ein Datenfehler im Verzeichnis, kein Fall fuer Rateraten.
+ const erster = Array.isArray(wert) ? wert[0] : wert;
+ if (erster === undefined) return null;
+ const text = (typeof erster === "string" ? erster : erster.toString("utf8")).trim();
+ return text ? text : null;
+}
+
+/**
+ * Ist das lesbarer Text – oder sind es rohe Bytes, die nur so aussehen?
+ *
+ * Steuerzeichen sind das Unterscheidungsmerkmal. Ein verstuemmelter GUID-Puffer
+ * enthaelt praktisch immer welche; eine Kennung wie `entryUUID` nie.
+ */
+function istLesbar(text: string): boolean {
+ return text.length > 0 && !/[\u0000-\u001f\u007f]/u.test(text);
+}
+
+/**
+ * Die stabile Kennung als Text.
+ *
+ * `objectGUID` kommt dank `explicitBufferAttributes` als Puffer. Sind es genau 16
+ * Bytes, ist es eine GUID und wird gemischt-endian umsortiert. Ist es etwas
+ * anderes — wer `externeId` auf `entryUUID` eines OpenLDAP legt, bekommt 36
+ * Textbytes —, wird der Puffer als UTF-8 gelesen, **aber nur wenn dabei lesbarer
+ * Text herauskommt**.
+ *
+ * Diese Einschraenkung ist der Punkt: ein Puffer mit 7 Bytes ist eine
+ * verstuemmelte GUID und keine Kennung. Wuerde er als Text durchgehen, staende
+ * eine Zeichenfolge aus Steuerzeichen als `externeId` in der Datenbank — stabil
+ * genug, um kein Duplikat zu erzeugen, und falsch genug, dass sie zu keinem Konto
+ * im Verzeichnis passt. Lieber gar keine Kennung: dann wird der Eintrag
+ * uebersprungen und das faellt auf.
+ */
+export function externeIdLesen(eintrag: Entry, attribut: string): string | undefined {
+ const wert = eintrag[attribut];
+ const erster = Array.isArray(wert) ? wert[0] : wert;
+ if (erster === undefined) return undefined;
+ if (typeof erster !== "string") {
+ if (erster.length === 0) return undefined;
+ if (erster.length === 16) return guidAusBytes(erster);
+ const text = erster.toString("utf8").trim();
+ return istLesbar(text) ? text : undefined;
+ }
+ const text = erster.trim();
+ return istLesbar(text) ? text : undefined;
+}
+
+/**
+ * Warum ein Eintrag uebersprungen wurde. Nur `externeId` fehlt — alles andere
+ * darf fehlen.
+ */
+export type Uebersprungen = { dn: string; grund: string };
+
+/**
+ * Ein `Entry` wird ein `RoherEintrag`, oder er wird uebersprungen.
+ *
+ * Uebersprungen wird genau ein Fall: ohne stabile Kennung gibt es nichts, woran
+ * ein zweiter Abgleich den Eintrag wiedererkennen koennte — er wuerde bei jedem
+ * Lauf erneut angelegt. Ein Abbruch waere die falsche Antwort: ein einzelnes
+ * krummes Konto darf nicht den Abgleich aller anderen verhindern.
+ */
+export function eintragZuordnen(
+ eintrag: Entry,
+ zuordnung: AttributZuordnung,
+): { eintrag: RoherEintrag } | { uebersprungen: Uebersprungen } {
+ const dn = eintrag.dn.trim();
+ let externeId: string | undefined;
+ try {
+ externeId = externeIdLesen(eintrag, zuordnung.externeId);
+ } catch (fehler) {
+ if (!(fehler instanceof GuidFehler)) throw fehler;
+ return { uebersprungen: { dn, grund: fehler.message } };
+ }
+ if (!externeId) {
+ return {
+ uebersprungen: {
+ dn,
+ grund: `Ohne ${zuordnung.externeId} gibt es keine dauerhafte Kennung.`,
+ },
+ };
+ }
+
+ const vorname = textWert(eintrag, zuordnung.vorname);
+ const nachname = textWert(eintrag, zuordnung.nachname);
+ return {
+ eintrag: {
+ externeId,
+ vorname: vorname ?? "",
+ nachname: nachname ?? "",
+ anzeigename: textWert(eintrag, zuordnung.anzeigename) ?? ersatzName(vorname, nachname, dn),
+ mail: textWert(eintrag, zuordnung.mail),
+ upn: textWert(eintrag, zuordnung.upn),
+ sam: textWert(eintrag, zuordnung.sam),
+ dn,
+ abteilung: textWert(eintrag, zuordnung.abteilung),
+ titel: textWert(eintrag, zuordnung.titel),
+ vorgesetzterDn: textWert(eintrag, zuordnung.vorgesetzter),
+ },
+ };
+}
+
+/**
+ * Ein Anzeigename muss stehen, das Feld ist nicht optional — und eine leere Zeile
+ * in einer Auswahlliste waere fuer den Techniker unbrauchbar. Gibt es keinen,
+ * wird er aus Nachname und Vorname gebaut; bleibt auch das leer, tritt der erste
+ * Teil des DN ein.
+ */
+function ersatzName(vorname: string | null, nachname: string | null, dn: string): string {
+ const gebaut = [nachname, vorname].filter((teil) => teil).join(", ");
+ if (gebaut) return gebaut;
+ const ersterTeil = dn.split(",")[0]?.split("=")[1]?.trim();
+ return ersterTeil ?? dn;
+}
+
+/**
+ * Vergleichbare Form eines DN.
+ *
+ * Verglichen wird als Text, klein geschrieben und ohne Leerzeichen um die
+ * Kommas. Das deckt, was ein AD ausgibt. Es ist **keine** Normalisierung nach
+ * RFC 4514: ein anders maskierter oder anders geschriebener DN bleibt
+ * unaufgeloest, und der Vorgesetzte fehlt dann — er wird nicht falsch geraten.
+ */
+export function dnSchluessel(dn: string): string {
+ return dn
+ .trim()
+ .toLowerCase()
+ .replace(/\s*,\s*/gu, ",");
+}
+
+/**
+ * Setzt `vorgesetzterExterneId` aus den DNs.
+ *
+ * Aufgeloest wird nur gegen die Kennungen, die dieser Lauf gesehen hat. Steht der
+ * Vorgesetzte ausserhalb des Suchbereichs oder ist er deaktiviert, bleibt das
+ * Feld leer — lieber kein Vorgesetzter als ein falscher.
+ */
+export function vorgesetzteAufloesen(
+ eintraege: readonly RoherEintrag[],
+ zusaetzlicheKennungen?: ReadonlyMap,
+): GemappterEintrag[] {
+ const nachDn = new Map(zusaetzlicheKennungen ?? []);
+ for (const eintrag of eintraege) {
+ nachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
+ }
+ return eintraege.map(({ vorgesetzterDn, ...rest }) => ({
+ ...rest,
+ vorgesetzterExterneId: vorgesetzterDn ? (nachDn.get(dnSchluessel(vorgesetzterDn)) ?? null) : null,
+ }));
+}
diff --git a/src/server/queries/abteilungen.ts b/src/server/queries/abteilungen.ts
index 6cd6653..2b6863e 100644
--- a/src/server/queries/abteilungen.ts
+++ b/src/server/queries/abteilungen.ts
@@ -80,6 +80,15 @@ export async function abteilungsListe(
* Lauf leer, ohne dass diese Seite es je sagte. Genau so stand es hier, bis der
* Review es gefunden hat; `abteilungsListe` darueber macht es seit jeher
* richtig.
+ *
+ * Eine **archivierte** Abteilung gilt als gepflegt: der Name ist je Kunde
+ * eindeutig, ein zweites Anlegen scheitert. Wer sie zurueck will, holt sie in der
+ * Liste darunter wieder hervor.
+ *
+ * Kunden, die archiviert sind, kommen nicht vor (#016). Die Oberflaeche macht aus
+ * jedem Eintrag hier einen Knopf, der die Abteilung anlegt — und `kundeErmitteln`
+ * weist einen archivierten Kunden ab. Ein Vorschlag, der beim Anklicken nur eine
+ * Fehlermeldung ergibt, ist ein Versprechen, das der Code nicht haelt.
*/
export async function ungepflegteAbteilungen(
benutzer: HandelnderBenutzer,
@@ -89,7 +98,9 @@ export async function ungepflegteAbteilungen(
prisma.abteilung.findMany({ where: { AND: mandant }, select: { name: true, kundeId: true } }),
prisma.mitarbeiter.groupBy({
by: ["abteilung", "kundeId"],
- where: { AND: [{ aktiv: true }, { abteilung: { not: null } }, ...mandant] },
+ where: {
+ AND: [{ aktiv: true }, { abteilung: { not: null } }, { kunde: { archiviert: false } }, ...mandant],
+ },
_count: { _all: true },
}),
]);
diff --git a/src/server/queries/verzeichnis.ts b/src/server/queries/verzeichnis.ts
new file mode 100644
index 0000000..8891ce8
--- /dev/null
+++ b/src/server/queries/verzeichnis.ts
@@ -0,0 +1,131 @@
+import { Betriebsmodus } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/db";
+import { betriebsmodus, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
+import { type QuellenSicht, quellenSichtBauen } from "@/server/services/verzeichnis";
+
+/**
+ * Lesemodelle der Verzeichnisquellen (#016).
+ *
+ * Zwei Dinge wendet dieses Modul selbst an, damit kein Aufrufer sie vergessen
+ * kann:
+ *
+ * 1. **Im Modus DIENSTLEISTUNG gibt es keine Quellen.** Nicht „keine
+ * sichtbaren“, sondern keine: der Abgleich gilt nur fuer die eigene
+ * Organisation. Eine Quelle, die aus einer frueheren Zeit im Modus INTERN
+ * noch in der Tabelle steht, wird deshalb hier nicht gezeigt — genauso wenig,
+ * wie sich der Abgleich dann starten liesse.
+ * 2. **Das Bind-Passwort kommt nicht mit.** Die Zeile wird ueber
+ * `quellenSichtBauen` in `QuellenSicht` uebersetzt, die kein Feld dafuer hat.
+ * Ein `select` mit einer Feldliste waere die zweite Stelle, an der jemand ein
+ * Feld vergessen oder zu viel aufnehmen kann.
+ */
+
+export type { QuellenSicht };
+
+export async function quellenListe(benutzer: HandelnderBenutzer): Promise {
+ if ((await betriebsmodus()) !== Betriebsmodus.INTERN) return [];
+ // Im Modus INTERN gibt es genau eine Organisation, und `sichtbareKundenIds`
+ // liefert dort `undefined`. Die Bedingung steht trotzdem hier: sie ist die
+ // Mandantensicht dieses Lesemodells, und sie wuerde fehlen, sobald sich die
+ // Regel oben einmal aendert.
+ const sichtbar = await sichtbareKundenIds(benutzer);
+ const zeilen = await prisma.verzeichnisquelle.findMany({
+ where: sichtbar === undefined ? {} : { kundeId: { in: [...sichtbar] } },
+ orderBy: [{ name: "asc" }],
+ });
+ return zeilen.map(quellenSichtBauen);
+}
+
+export async function quelleHolen(
+ id: string,
+ benutzer: HandelnderBenutzer,
+): Promise {
+ if ((await betriebsmodus()) !== Betriebsmodus.INTERN) return undefined;
+ const zeile = await prisma.verzeichnisquelle.findUnique({ where: { id } });
+ if (!zeile) return undefined;
+ const sichtbar = await sichtbareKundenIds(benutzer);
+ // Eine fremde Quelle ist „nicht vorhanden“ und nicht „verboten“: die Auskunft,
+ // dass es sie gibt, ist selbst schon eine.
+ if (sichtbar !== undefined && !sichtbar.includes(zeile.kundeId)) return undefined;
+ return quellenSichtBauen(zeile);
+}
+
+/**
+ * Eine Zeile des Abgleich-Logs.
+ *
+ * Die Zahlen stehen im `Protokoll` und nicht in einer eigenen Tabelle: jeder Lauf
+ * schreibt dort ohnehin einen Eintrag, und ein zweiter Ort fuer dieselbe Auskunft
+ * liefe auseinander. `durch` ist `null`, wenn der Zeitplan den Lauf angestossen
+ * hat — dann gibt es keinen Menschen, der es getan hat.
+ */
+export type AbgleichLogZeile = {
+ readonly id: string;
+ readonly zeitpunkt: Date;
+ readonly gescheitert: boolean;
+ readonly gelesen: number;
+ readonly angelegt: number;
+ readonly uebernommen: number;
+ readonly aktualisiert: number;
+ readonly deaktiviert: number;
+ readonly uebersprungen: number;
+ readonly fehler: string | null;
+ readonly durch: string | null;
+};
+
+/** Wie viele Laeufe die Seite zeigt. Mehr liest niemand, und alle waeren unbegrenzt viele. */
+export const LOG_ZEILEN = 10;
+
+/** Eine Zahl aus dem Json-Feld, oder 0 — aeltere Eintraege kennen neue Spalten nicht. */
+function zahl(nachher: unknown, name: string): number {
+ if (typeof nachher !== "object" || nachher === null) return 0;
+ const wert = (nachher as Record)[name];
+ return typeof wert === "number" && Number.isFinite(wert) ? wert : 0;
+}
+
+function textOderNull(nachher: unknown, name: string): string | null {
+ if (typeof nachher !== "object" || nachher === null) return null;
+ const wert = (nachher as Record)[name];
+ return typeof wert === "string" && wert.trim() ? wert : null;
+}
+
+/**
+ * Die letzten Abgleich-Laeufe einer Quelle.
+ *
+ * Geht ueber `quelleHolen` und damit ueber dieselbe Mandanten- und Modussicht:
+ * eine fremde oder im Modus DIENSTLEISTUNG liegende Quelle hat kein Log, und die
+ * Antwort ist leer und nicht verboten.
+ */
+export async function abgleichLog(
+ quelleId: string,
+ benutzer: HandelnderBenutzer,
+ grenze: number = LOG_ZEILEN,
+): Promise {
+ const quelle = await quelleHolen(quelleId, benutzer);
+ if (!quelle) return [];
+
+ const zeilen = await prisma.protokoll.findMany({
+ where: {
+ entitaet: "Verzeichnisquelle",
+ entitaetId: quelleId,
+ aktion: { in: ["Verzeichnis abgeglichen", "Verzeichnisabgleich fehlgeschlagen"] },
+ },
+ orderBy: { erstelltAm: "desc" },
+ take: Math.max(1, Math.trunc(grenze)),
+ include: { benutzer: { select: { name: true, email: true } } },
+ });
+
+ return zeilen.map((zeile) => ({
+ id: zeile.id,
+ zeitpunkt: zeile.erstelltAm,
+ gescheitert: zeile.aktion === "Verzeichnisabgleich fehlgeschlagen",
+ gelesen: zahl(zeile.nachher, "gelesen"),
+ angelegt: zahl(zeile.nachher, "angelegt"),
+ uebernommen: zahl(zeile.nachher, "uebernommen"),
+ aktualisiert: zahl(zeile.nachher, "aktualisiert"),
+ deaktiviert: zahl(zeile.nachher, "deaktiviert"),
+ uebersprungen: zahl(zeile.nachher, "uebersprungen"),
+ fehler: textOderNull(zeile.nachher, "fehler"),
+ // Der Name, sonst die Mailadresse: ein Benutzer ohne Namen ist moeglich.
+ durch: zeile.benutzer ? (zeile.benutzer.name ?? zeile.benutzer.email) : null,
+ }));
+}
diff --git a/src/server/services/abgleich.ts b/src/server/services/abgleich.ts
new file mode 100644
index 0000000..42753e9
--- /dev/null
+++ b/src/server/services/abgleich.ts
@@ -0,0 +1,574 @@
+import { hostname } from "node:os";
+import { randomUUID } from "node:crypto";
+import type { Mitarbeiter, Verzeichnisquelle } from "@/generated/prisma/client";
+import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/db";
+import { satzEnde } from "@/lib/satzzeichen";
+import { darf } from "@/server/auth/rollen";
+import { betriebsmodus, eigeneOrganisation, type HandelnderBenutzer } from "@/server/mandant";
+import { eintraegeStroemen } from "@/server/ldap/leser";
+import { fehlerMeldung } from "@/server/ldap/verbindung";
+import { dnSchluessel, type RoherEintrag, zuordnungLesen } from "@/server/ldap/zuordnung";
+import { VerzeichnisFehler, quelleZeileHolen, zugangAus } from "@/server/services/verzeichnis";
+
+/**
+ * Der Abgleich (#016).
+ *
+ * Die eine Regel, die alles andere traegt: **es wird nichts geloescht.** Wer aus
+ * dem Suchergebnis verschwindet — gekuendigt, deaktiviert, in eine andere OU
+ * gezogen —, wird `aktiv = false` und bleibt stehen. Ein archivierter Lauf muss
+ * auch in zehn Jahren noch sagen koennen, fuer wen er lief; ein Loeschen waere
+ * gegen den Zweck des Archivs, und der Fremdschluessel steht ohnehin auf
+ * `Restrict`.
+ *
+ * Der Abgleich laeuft **nicht** in einer Transaktion. Das ist eine Entscheidung
+ * mit Folgen, und sie steht hier, damit niemand etwas anderes annimmt: bricht der
+ * Lauf in der Mitte ab, bleiben die bis dahin geschriebenen Personen so stehen,
+ * wie sie geschrieben wurden. Deaktiviert wird dagegen **nur nach einem
+ * vollstaendigen Durchlauf** — sonst haette ein Netzwerkfehler nach der halben
+ * Liste die andere Haelfte der Firma abgeschaltet. Eine Transaktion ueber das
+ * gesamte Verzeichnis haette die Tabelle solange gesperrt, wie das Lesen dauert.
+ */
+
+export type AbgleichErgebnis = {
+ readonly angelegt: number;
+ /**
+ * Personen, die es schon von Hand gab und die jetzt das Verzeichnis fuehrt.
+ *
+ * Eigene Spalte und nicht in `angelegt` oder `aktualisiert` mitgezaehlt: es ist
+ * die einzige Zahl, die etwas ueber die Datenqualitaet sagt. Steht sie bei jedem
+ * Lauf hoch, legt jemand Personen an, die das Verzeichnis kurz danach liefert.
+ */
+ readonly uebernommen: number;
+ readonly aktualisiert: number;
+ readonly deaktiviert: number;
+ readonly gelesen: number;
+ readonly status: "ERFOLG" | "FEHLER";
+ readonly fehler: string | null;
+ /**
+ * Etwas, das der Administrator wissen sollte, ohne dass der Lauf gescheitert
+ * waere — etwa uebersprungene Eintraege. Steht **nicht** in
+ * `letzterAbgleichFehler`: das Feld heisst, dass etwas schiefging.
+ */
+ readonly hinweis: string | null;
+};
+
+/**
+ * Der zweite gleichzeitige Abgleich derselben Quelle wird **abgewiesen**, nicht
+ * eingereiht: eine Warteschlange waere ein zweiter Lauf auf denselben Daten,
+ * Minuten spaeter, ohne neuen Erkenntniswert. Eine eigene Klasse, damit die
+ * Oberflaeche „laeuft schon“ von „ist schiefgegangen“ unterscheiden kann.
+ */
+export class AbgleichLaeuftBereitsFehler extends VerzeichnisFehler {
+ constructor(nachricht: string) {
+ super(nachricht);
+ this.name = "AbgleichLaeuftBereitsFehler";
+ }
+}
+
+/**
+ * Wie lange die Sperre hoechstens gilt.
+ *
+ * Sie muss ablaufen: stuerzt der Prozess mitten im Abgleich ab, bleibt die Zeile
+ * stehen, und ohne Ablauf waere die Quelle fuer immer blockiert — ein
+ * Datenbankeintrag, den niemand mehr findet und den ein Neustart nicht raeumt.
+ * Zwei Stunden sind reichlich fuer ein grosses Verzeichnis und kurz genug, dass
+ * ein Absturz am Morgen den Abgleich am Mittag nicht verhindert.
+ */
+const SPERRE_MINUTEN = 120;
+
+function sperrSchluessel(quelleId: string): string {
+ return `abgleich:${quelleId}`;
+}
+
+/**
+ * Wer die Sperre haelt: Rechner, Prozess und ein Zufallsanteil.
+ *
+ * Der Zufallsanteil ist nicht Zierde. Ohne ihn koennten zwei Abgleiche im
+ * **selben** Prozess (ein Klick und der Zeitplan im selben Moment) einander die
+ * Sperre wegnehmen und der zweite die des ersten beim Aufraeumen loeschen.
+ */
+function haltername(): string {
+ return `${hostname()}/${process.pid}/${randomUUID().slice(0, 8)}`;
+}
+
+/**
+ * Fuehrt `was` unter der Sperre aus; `undefined`, wenn sie belegt ist.
+ *
+ * Das Belegen ist **eine** Anweisung, und das ist der Punkt: `SELECT` und danach
+ * `INSERT` waeren zwei, und zwischen ihnen passt genau der zweite Abgleich, den
+ * die Sperre verhindern soll. Das `WHERE` am `ON CONFLICT` uebernimmt eine
+ * abgelaufene Sperre und laesst eine lebende in Ruhe, sodass die Zeile nie
+ * dauerhaft blockiert.
+ */
+export async function mitAbgleichSperre(
+ schluessel: string,
+ dauerMinuten: number,
+ was: () => Promise,
+): Promise {
+ const halter = haltername();
+ const bekommen = await prisma.$executeRaw`
+ INSERT INTO "AbgleichSperre" ("schluessel", "gehaltenVon", "gehaltenSeit", "laeuftAbAm")
+ VALUES (${schluessel}, ${halter}, now(), now() + make_interval(mins => ${dauerMinuten}))
+ ON CONFLICT ("schluessel") DO UPDATE
+ SET "gehaltenVon" = EXCLUDED."gehaltenVon",
+ "gehaltenSeit" = EXCLUDED."gehaltenSeit",
+ "laeuftAbAm" = EXCLUDED."laeuftAbAm"
+ WHERE "AbgleichSperre"."laeuftAbAm" <= now()
+ `;
+ if (bekommen === 0) return undefined;
+
+ try {
+ return await was();
+ } finally {
+ // Nur die eigene Sperre freigeben. Ist sie zwischenzeitlich abgelaufen und
+ // von einem anderen Lauf uebernommen worden, gehoert die Zeile diesem — sie
+ // hier zu loeschen hiesse, ihm die Sperre unter den Fuessen wegzuziehen.
+ await prisma.$executeRaw`
+ DELETE FROM "AbgleichSperre" WHERE "schluessel" = ${schluessel} AND "gehaltenVon" = ${halter}
+ `;
+ }
+}
+
+/**
+ * Quellen, deren Intervall abgelaufen ist – fuer den Zeitplan.
+ *
+ * Gerechnet wird in der Datenbank, weil das Intervall je Quelle in der Zeile
+ * steht: `letzterAbgleichAm + Intervall <= jetzt`. Eine Quelle, die noch nie
+ * gelaufen ist, ist sofort faellig. Sortiert wird nach dem aeltesten Abgleich,
+ * damit bei vielen faelligen Quellen keine dauerhaft hinten steht.
+ *
+ * Im Modus DIENSTLEISTUNG ist die Liste leer: dort gibt es kein Verzeichnis.
+ */
+export async function faelligeQuellen(
+ jetzt: Date = new Date(),
+): Promise {
+ if ((await betriebsmodus()) !== Betriebsmodus.INTERN) return [];
+ /*
+ * Nur die eigene Organisation. Eine Quelle, die an einem anderen Kunden
+ * haengt, weist `quelleZeileHolen` beim Abgleich ohnehin ab — aber erst
+ * **nachdem** der Zeitplan sie geholt hat. Ohne diese Bedingung stuende sie
+ * jede Minute neu in der Liste, schriebe jede Minute „unerwartet gescheitert“
+ * ins Protokoll des Prozesses und bliebe fuer immer faellig (Reviewbefund zu
+ * #016). Anlegen laesst sich so eine Quelle ueber die Anwendung nicht; ueber
+ * `psql` und eine Migration schon.
+ */
+ const eigene = await eigeneOrganisation();
+ return prisma.$queryRaw<{ id: string; name: string }[]>`
+ SELECT "id", "name"
+ FROM "Verzeichnisquelle"
+ WHERE "aktiv" = true
+ AND "kundeId" = ${eigene.id}
+ AND ("letzterAbgleichAm" IS NULL
+ OR "letzterAbgleichAm" + make_interval(mins => "abgleichIntervallMinuten") <= ${jetzt})
+ ORDER BY "letzterAbgleichAm" ASC NULLS FIRST, "name" ASC
+ `;
+}
+
+/**
+ * Die von Hand angelegte Person zu einem Verzeichniseintrag – oder `null`.
+ *
+ * Gesucht wird nur im eigenen Kunden und nur unter Zeilen **ohne** Quelle: eine
+ * Person, die schon eine andere Quelle fuehrt, gehoert der und wird nicht
+ * uebernommen. Verglichen wird ohne Rucksicht auf Gross- und Kleinschreibung,
+ * weil `MHuber` und `mhuber` derselbe Anmeldename sind.
+ *
+ * `upn` zaehlt vor `sam`: der UPN traegt die Domaene und ist damit die genauere
+ * Aussage. Ein blosser Namensvergleich waere hier falsch — zwei Menschen koennen
+ * „Maria Huber“ heissen, aber nicht denselben Anmeldenamen haben.
+ */
+async function vonHandAngelegteFinden(kundeId: string, eintrag: RoherEintrag): Promise {
+ const wege = [
+ ...(eintrag.upn ? [{ upn: { equals: eintrag.upn, mode: "insensitive" as const } }] : []),
+ ...(eintrag.sam ? [{ sam: { equals: eintrag.sam, mode: "insensitive" as const } }] : []),
+ ];
+ for (const weg of wege) {
+ const treffer = await prisma.mitarbeiter.findFirst({
+ where: { kundeId, quelleId: null, externeId: null, ...weg },
+ // Die aelteste zuerst: sie hat die Laeufe und Geraete an sich.
+ orderBy: { erstelltAm: "asc" },
+ });
+ if (treffer) return treffer;
+ }
+ return null;
+}
+
+/** Was wir zwischen den beiden Durchgaengen ueber eine Person behalten muessen. */
+type OffenerVorgesetzter = {
+ readonly externeId: string;
+ readonly vorgesetzterDn: string;
+ readonly vorherigeKennung: string | null;
+ readonly neuAngelegt: boolean;
+};
+
+export async function abgleichAusfuehren(
+ quelleId: string,
+ benutzer: HandelnderBenutzer,
+): Promise {
+ if ((await betriebsmodus()) !== Betriebsmodus.INTERN) {
+ throw new VerzeichnisFehler(
+ "Der Verzeichnisabgleich gilt nur für die eigene Organisation und ist im Modus " +
+ "Dienstleistung abgeschaltet.",
+ );
+ }
+ if (!darf(benutzer.rolle, "verwaltung")) {
+ throw new VerzeichnisFehler(
+ `Einen Abgleich zu starten verlangt das Recht "verwaltung"; die Rolle ${benutzer.rolle} hat es nicht.`,
+ );
+ }
+ const quelle = await quelleZeileHolen(quelleId);
+
+ const ergebnis = await mitAbgleichSperre(sperrSchluessel(quelleId), SPERRE_MINUTEN, () =>
+ lauf(quelle, benutzer),
+ );
+ if (ergebnis === undefined) {
+ throw new AbgleichLaeuftBereitsFehler(
+ `Für „${quelle.name}“ läuft gerade schon ein Abgleich. Bitte das Ergebnis abwarten.`,
+ );
+ }
+ return ergebnis;
+}
+
+/** Der eigentliche Lauf, immer unter der Sperre. */
+async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Promise {
+ const quelleId = quelle.id;
+ // Ein Zeitpunkt fuer den ganzen Lauf. Daran haengt, wer als "gesehen" gilt;
+ // mit einem Zeitstempel je Person waere die Grenze unscharf.
+ const beginn = new Date();
+
+ let angelegt = 0;
+ let uebernommen = 0;
+ let gelesen = 0;
+ /**
+ * Wie viele Eintraege bis hierher wirklich durch die Hand gegangen sind.
+ *
+ * Bricht das Lesen mittendrin ab, gibt es keine Bilanz — und `gelesen` bliebe
+ * null, waehrend `angelegt` schon bei 1500 stuende. Eine Zahl, die nicht
+ * aufgeht, ist schlimmer als ein sichtbarer Fehler (Reviewbefund zu #016).
+ */
+ let verarbeitet = 0;
+ /**
+ * Wer sich wirklich geaendert hat — als Menge und nicht als Zaehler.
+ *
+ * Eine Person, an der sich Nachname **und** Vorgesetzter geaendert haben, ist
+ * eine aktualisierte Person und nicht zwei. Der zweite Durchgang traegt in
+ * dieselbe Menge ein.
+ */
+ const geaendert = new Set();
+ let bindPasswort: string | undefined;
+
+ try {
+ const zugang = zugangAus(quelle);
+ bindPasswort = zugang.bindPasswort;
+ const kennungNachDn = new Map();
+ const offene: OffenerVorgesetzter[] = [];
+
+ const bilanz = await eintraegeStroemen(
+ zugang,
+ {
+ basisDn: quelle.basisDn,
+ filter: quelle.filter,
+ zuordnung: zuordnungLesen(quelle.attributZuordnung),
+ },
+ async (eintrag) => {
+ verarbeitet += 1;
+ kennungNachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
+ let vorhanden = await prisma.mitarbeiter.findFirst({
+ where: { quelleId, externeId: eintrag.externeId },
+ });
+ /*
+ * Kennt die Quelle die Person noch nicht, kann sie trotzdem schon in der
+ * Datenbank stehen: beim Onboarding legt jemand sie von Hand an, das
+ * AD-Konto entsteht danach, und der naechste Abgleich findet sie.
+ *
+ * Ohne diese Uebernahme entstuenden zwei Maria Huber — eine mit Laeufen
+ * und Geraeten, eine mit der Kennung. Die von Hand angelegte waere die mit
+ * den Daten und die falsche in jeder Auswahlliste (Reviewbefund zu #016).
+ */
+ let uebernommenJetzt = false;
+ if (!vorhanden) {
+ const vonHand = await vonHandAngelegteFinden(quelle.kundeId, eintrag);
+ if (vonHand) {
+ vorhanden = vonHand;
+ uebernommenJetzt = true;
+ uebernommen += 1;
+ }
+ }
+
+ const felder = {
+ vorname: eintrag.vorname,
+ nachname: eintrag.nachname,
+ anzeigename: eintrag.anzeigename,
+ mail: eintrag.mail,
+ upn: eintrag.upn,
+ sam: eintrag.sam,
+ dn: eintrag.dn,
+ abteilung: eintrag.abteilung,
+ titel: eintrag.titel,
+ };
+
+ if (!vorhanden) {
+ await prisma.mitarbeiter.create({
+ data: {
+ kundeId: quelle.kundeId,
+ quelleId,
+ externeId: eintrag.externeId,
+ ...felder,
+ // Der Vorgesetzte kommt im zweiten Durchgang: sein DN laesst sich
+ // erst aufloesen, wenn alle Kennungen bekannt sind.
+ vorgesetzterExterneId: null,
+ aktiv: true,
+ herkunft: Herkunft.ABGLEICH,
+ zuletztGesehenAm: beginn,
+ },
+ });
+ angelegt += 1;
+ if (eintrag.vorgesetzterDn) {
+ offene.push({
+ externeId: eintrag.externeId,
+ vorgesetzterDn: eintrag.vorgesetzterDn,
+ vorherigeKennung: null,
+ neuAngelegt: true,
+ });
+ }
+ return;
+ }
+
+ // Steht im Verzeichnis kein Vorgesetzter mehr, wird ein alter Wert
+ // geleert. Steht einer, entscheidet der zweite Durchgang.
+ const vorgesetztenLeeren = eintrag.vorgesetzterDn === null;
+ if (
+ // Eine Uebernahme zaehlt in ihrer eigenen Spalte und nicht zusaetzlich
+ // als Aktualisierung; sonst gingen die Zahlen nicht mehr auf.
+ !uebernommenJetzt &&
+ (vorhanden.vorname !== felder.vorname ||
+ vorhanden.nachname !== felder.nachname ||
+ vorhanden.anzeigename !== felder.anzeigename ||
+ vorhanden.mail !== felder.mail ||
+ vorhanden.upn !== felder.upn ||
+ vorhanden.sam !== felder.sam ||
+ vorhanden.dn !== felder.dn ||
+ vorhanden.abteilung !== felder.abteilung ||
+ vorhanden.titel !== felder.titel ||
+ !vorhanden.aktiv ||
+ vorhanden.herkunft !== Herkunft.ABGLEICH ||
+ (vorgesetztenLeeren && vorhanden.vorgesetzterExterneId !== null))
+ ) {
+ geaendert.add(eintrag.externeId);
+ }
+
+ await prisma.mitarbeiter.update({
+ where: { id: vorhanden.id },
+ data: {
+ ...felder,
+ // Bei einer Uebernahme kommt die Herkunft dazu: ab jetzt fuehrt das
+ // Verzeichnis diese Person, und der naechste Lauf findet sie ueber die
+ // Kennung.
+ ...(uebernommenJetzt ? { quelleId, externeId: eintrag.externeId } : {}),
+ ...(vorgesetztenLeeren ? { vorgesetzterExterneId: null } : {}),
+ // Wer wieder im Ergebnis steht, ist wieder aktiv — eine
+ // wiedereingestellte Person behaelt so ihre Laeufe.
+ aktiv: true,
+ herkunft: Herkunft.ABGLEICH,
+ zuletztGesehenAm: beginn,
+ },
+ });
+ if (!vorgesetztenLeeren) {
+ offene.push({
+ externeId: eintrag.externeId,
+ vorgesetzterDn: eintrag.vorgesetzterDn as string,
+ vorherigeKennung: vorhanden.vorgesetzterExterneId,
+ neuAngelegt: false,
+ });
+ }
+ },
+ );
+ gelesen = bilanz.gesamtGelesen;
+
+ // --- Zweiter Durchgang: Vorgesetzte --------------------------------------
+ for (const offen of offene) {
+ const kennung = kennungNachDn.get(dnSchluessel(offen.vorgesetzterDn)) ?? null;
+ if (kennung === offen.vorherigeKennung) continue;
+ await prisma.mitarbeiter.updateMany({
+ where: { quelleId, externeId: offen.externeId },
+ data: { vorgesetzterExterneId: kennung },
+ });
+ // Eine gerade angelegte Person ist angelegt, nicht zusaetzlich
+ // aktualisiert.
+ if (!offen.neuAngelegt) geaendert.add(offen.externeId);
+ }
+
+ /*
+ * --- Konnte ueberhaupt etwas zugeordnet werden? -------------------------
+ *
+ * Der Fall, den der Review zu #016 gefunden hat und der die ganze Firma
+ * kostet: steht in der Attributzuordnung ein Kennungsattribut, das es am
+ * Server nicht gibt — ein Tippfehler, oder ein OpenLDAP ohne `objectGUID` —,
+ * dann wird **jeder** Eintrag uebersprungen. Der Durchlauf ist vollstaendig,
+ * das Lesen hat geklappt, niemand wurde wiedergesehen: die Abschaltung
+ * unten deaktivierte die gesamte Belegschaft und meldete `ERFOLG`. Danach
+ * sucht niemand mehr nach dem Grund, weil alles gruen aussieht.
+ *
+ * Deshalb: nichts gelesen, was sich zuordnen liess, obwohl es Eintraege gab
+ * **und** wir bisher aktive Personen aus dieser Quelle kennen ⇒ kein
+ * Abschalten, sondern ein Fehler mit Namen.
+ */
+ const zugeordnet = gelesen - bilanz.uebersprungen.length;
+ if (zugeordnet === 0 && gelesen > 0) {
+ const bisherAktiv = await prisma.mitarbeiter.count({
+ where: { quelleId, herkunft: Herkunft.ABGLEICH, aktiv: true },
+ });
+ if (bisherAktiv > 0) {
+ const ergebnis: AbgleichErgebnis = {
+ angelegt: 0,
+ uebernommen: 0,
+ aktualisiert: 0,
+ deaktiviert: 0,
+ gelesen,
+ status: "FEHLER",
+ fehler:
+ `Kein einziger der ${gelesen} gelesenen Einträge ließ sich zuordnen — ` +
+ `Grund des ersten: ${satzEnde(bilanz.uebersprungen[0]?.grund)} ` +
+ "Niemand wurde deaktiviert. Prüf die Attributzuordnung, besonders das Kennungsattribut.",
+ hinweis: null,
+ };
+ await abschlussSchreiben(quelleId, benutzer, ergebnis, bilanz.uebersprungen.length);
+ return ergebnis;
+ }
+ }
+
+ /*
+ * --- Verschwundene abschalten -------------------------------------------
+ *
+ * Nur, was aus dem Abgleich stammt: eine von Hand angelegte Person steht
+ * nicht im Verzeichnis und darf davon nicht abgeschaltet werden.
+ *
+ * **Uebersprungene Eintraege sind nicht verschwunden.** Sie standen im
+ * Ergebnis, nur ohne brauchbare Kennung — wer sie mit abschaltete, naehme
+ * einem anwesenden Menschen seinen Zugang. Erkannt werden sie am `dn`, den
+ * der Leser mitliefert.
+ */
+ const uebersprungeneDns = bilanz.uebersprungen
+ .map((eintrag) => eintrag.dn)
+ .filter((dn): dn is string => Boolean(dn));
+
+ const { count: deaktiviert } = await prisma.mitarbeiter.updateMany({
+ where: {
+ quelleId,
+ herkunft: Herkunft.ABGLEICH,
+ aktiv: true,
+ OR: [{ zuletztGesehenAm: null }, { zuletztGesehenAm: { lt: beginn } }],
+ ...(uebersprungeneDns.length > 0 ? { NOT: { dn: { in: uebersprungeneDns } } } : {}),
+ },
+ data: { aktiv: false },
+ });
+
+ const ergebnis: AbgleichErgebnis = {
+ angelegt,
+ uebernommen,
+ aktualisiert: geaendert.size,
+ deaktiviert,
+ gelesen,
+ status: "ERFOLG",
+ fehler: null,
+ /*
+ * Kein Fehlschlag, aber auch keine Nebensache: uebersprungene Eintraege
+ * stehen im Verzeichnis und fehlen hier. Sie kommen deshalb als **Hinweis**
+ * zurueck und nicht als Fehler — `letzterAbgleichFehler` soll heissen, dass
+ * etwas schiefging, und nicht „ein Konto war krumm“.
+ */
+ hinweis:
+ bilanz.uebersprungen.length === 0
+ ? null
+ : `${bilanz.uebersprungen.length} von ${gelesen} Einträgen übersprungen — ` +
+ `Grund des ersten: ${satzEnde(bilanz.uebersprungen[0]?.grund)}`,
+ };
+ await abschlussSchreiben(quelleId, benutzer, ergebnis, bilanz.uebersprungen.length);
+ return ergebnis;
+ } catch (fehler) {
+ // Gesaeubert, weil die Meldung gespeichert und angezeigt wird. `mitVerbindung`
+ // tut das fuer LDAP-Fehler schon; hier faengt es alles andere ab, damit kein
+ // Weg daran vorbeifuehrt.
+ const ergebnis: AbgleichErgebnis = {
+ angelegt,
+ uebernommen,
+ aktualisiert: geaendert.size,
+ // Bei einem Fehler wird niemand abgeschaltet. Siehe oben: eine halbe Liste
+ // saehe aus wie eine halbe Firma, die gegangen ist.
+ deaktiviert: 0,
+ /*
+ * `gelesen` kommt aus dem Zaehler, der **waehrend** des Stroemens
+ * mitlaeuft, nicht aus der Bilanz am Ende: bricht das Lesen nach der
+ * Haelfte ab, gibt es keine Bilanz, und „angelegt: 1500, gelesen: 0“
+ * waere eine Zahl, die nicht aufgeht (Reviewbefund zu #016). Was der
+ * Server insgesamt gehabt haette, weiss hier niemand — gezaehlt wird, was
+ * wirklich durch die Hand ging.
+ */
+ gelesen: gelesen > 0 ? gelesen : verarbeitet,
+ status: "FEHLER",
+ fehler: fehlerMeldung(fehler, bindPasswort === undefined ? [] : [bindPasswort]),
+ hinweis: null,
+ };
+ await abschlussSchreiben(quelleId, benutzer, ergebnis, 0);
+ return ergebnis;
+ }
+}
+
+/**
+ * Die Kennung fuer das Protokoll – oder `null` fuer „das System selbst“.
+ *
+ * Der Zeitplan hat keinen Benutzer und schickt eine leere Kennung.
+ * `Protokoll.benutzerId` ist genau dafuer optional (CLAUDE.md,
+ * „Erweiterbarkeit“: ein Systemlauf ist darstellbar). Eine leere Zeichenkette
+ * durchzureichen waere kein Systemlauf, sondern ein Verstoss gegen den
+ * Fremdschluessel — der Abgleich wuerde erst beim Schreiben des Protokolls
+ * abbrechen, also nachdem er die Mitarbeiter schon geaendert hat.
+ */
+function urheber(benutzer: HandelnderBenutzer): string | null {
+ const id = benutzer.id.trim();
+ return id ? id : null;
+}
+
+/**
+ * Ergebnis an die Quelle und ins Protokoll.
+ *
+ * `letzterAbgleichAm` wird auch bei einem Fehler gesetzt: es beantwortet „wann
+ * wurde es zuletzt versucht“, und daran haengt der Zeitplan. Stuende dort nur der
+ * letzte Erfolg, liefe eine kaputte Quelle im Minutentakt gegen die Wand.
+ */
+async function abschlussSchreiben(
+ quelleId: string,
+ benutzer: HandelnderBenutzer,
+ ergebnis: AbgleichErgebnis,
+ uebersprungen: number,
+): Promise {
+ await prisma.$transaction(async (tx) => {
+ await tx.verzeichnisquelle.update({
+ where: { id: quelleId },
+ data: {
+ letzterAbgleichAm: new Date(),
+ letzterAbgleichStatus: ergebnis.status,
+ letzterAbgleichFehler: ergebnis.fehler,
+ },
+ });
+ await tx.protokoll.create({
+ data: {
+ entitaet: "Verzeichnisquelle",
+ entitaetId: quelleId,
+ aktion:
+ ergebnis.status === "ERFOLG" ? "Verzeichnis abgeglichen" : "Verzeichnisabgleich fehlgeschlagen",
+ benutzerId: urheber(benutzer),
+ nachher: {
+ angelegt: ergebnis.angelegt,
+ uebernommen: ergebnis.uebernommen,
+ aktualisiert: ergebnis.aktualisiert,
+ deaktiviert: ergebnis.deaktiviert,
+ gelesen: ergebnis.gelesen,
+ uebersprungen,
+ status: ergebnis.status,
+ ...(ergebnis.fehler === null ? {} : { fehler: ergebnis.fehler }),
+ },
+ },
+ });
+ });
+}
diff --git a/src/server/services/mitarbeiter.ts b/src/server/services/mitarbeiter.ts
index 71bf623..cb713a2 100644
--- a/src/server/services/mitarbeiter.ts
+++ b/src/server/services/mitarbeiter.ts
@@ -1,5 +1,6 @@
import type { Mitarbeiter, Prisma } from "@/generated/prisma/client";
import { Herkunft } from "@/generated/prisma/enums";
+import { CsvFehler, csvZerlegen, hatSpalte, wertAus, type CsvZeile } from "@/lib/csv";
import { prisma } from "@/lib/db";
import { kundeErmitteln, kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant";
@@ -91,10 +92,23 @@ function anzeigenameBilden(eingabe: MitarbeiterEingabe, vorname: string, nachnam
return gewuenscht ?? `${vorname} ${nachname}`.trim();
}
+/**
+ * Woher diese Zeile kam — nur fuers Protokoll (#016).
+ *
+ * Der CSV-Import legt ueber **denselben** Weg an wie das Formular; unterscheiden
+ * soll sich nur der Eintrag im Protokoll. Ohne die Zeilennummer laesst sich ein
+ * halb misslungener Import spaeter nicht mehr auf die Datei zurueckfuehren.
+ */
+export type ProtokollVermerk = {
+ readonly aktion: string;
+ readonly zeile?: number;
+};
+
/** Legt einen Mitarbeiter von Hand an — `herkunft = MANUELL`. */
export async function mitarbeiterAnlegen(
eingabe: MitarbeiterEingabe,
benutzer: HandelnderBenutzer,
+ vermerk?: ProtokollVermerk,
): Promise {
const vorname = pflicht(eingabe.vorname, "Der Vorname");
const nachname = pflicht(eingabe.nachname, "Der Nachname");
@@ -119,9 +133,13 @@ export async function mitarbeiterAnlegen(
data: {
entitaet: "Mitarbeiter",
entitaetId: mitarbeiter.id,
- aktion: "Mitarbeiter angelegt",
+ aktion: vermerk?.aktion ?? "Mitarbeiter angelegt",
benutzerId: benutzer.id,
- nachher: { anzeigename: mitarbeiter.anzeigename, herkunft: mitarbeiter.herkunft },
+ nachher: {
+ anzeigename: mitarbeiter.anzeigename,
+ herkunft: mitarbeiter.herkunft,
+ ...(vermerk?.zeile === undefined ? {} : { zeile: vermerk.zeile }),
+ },
},
});
return mitarbeiter;
@@ -250,3 +268,336 @@ function auszug(zeile: Mitarbeiter, felder: readonly string[]): Prisma.InputJson
}
return ausschnitt;
}
+
+// ---- CSV-Import (#016) -----------------------------------------------------
+
+/**
+ * Obergrenze je Datei.
+ *
+ * Dieselbe Zahl und derselbe Grund wie beim Geraeteimport: ohne Grenze laeuft
+ * eine versehentlich exportierte Riesentabelle minutenlang durch, und die
+ * Oberflaeche nennt die Zahl, **bevor** jemand dagegen laeuft.
+ */
+export const MITARBEITER_IMPORT_HOECHSTZEILEN = 500;
+
+/**
+ * Die Spalten aus dem Auftrag – englisch, wie das Verzeichnis sie nennt – und
+ * jeweils die deutsche Entsprechung.
+ *
+ * Gross- und Kleinschreibung, Leerraum, Unterstriche und Bindestriche sind
+ * unerheblich, dafuer sorgt `spaltenName` in src/lib/csv.ts. Die Reihenfolge der
+ * Schluessel ist die Reihenfolge, in der die Oberflaeche die Spalten nennt.
+ */
+export const MITARBEITER_IMPORT_SPALTEN = {
+ vorname: ["givenName", "vorname"],
+ nachname: ["sn", "surname", "nachname"],
+ anzeigename: ["displayName", "anzeigename"],
+ mail: ["mail", "email", "mailadresse"],
+ upn: ["upn", "userPrincipalName"],
+ sam: ["sam", "sAMAccountName"],
+ abteilung: ["department", "abteilung"],
+ titel: ["title", "titel"],
+} as const;
+
+type ImportFeld = keyof typeof MITARBEITER_IMPORT_SPALTEN;
+
+const IMPORT_FELDER = Object.keys(MITARBEITER_IMPORT_SPALTEN) as readonly ImportFeld[];
+
+/**
+ * Die Werte einer Zeile – und zwar **nur** die, die in der Datei gefuellt sind.
+ *
+ * Eine leere Zelle ist keine Loeschung: in einer Onboarding-Liste steht die
+ * Abteilung, aber selten der Titel, und ein Import, der den vorhandenen Titel
+ * dabei leert, richtet mehr Schaden an als er Nutzen bringt. Deshalb fehlt der
+ * Schluessel hier ganz, statt `null` zu tragen.
+ */
+type ImportFelder = {
+ readonly vorname: string;
+ readonly nachname: string;
+ readonly anzeigename?: string;
+ readonly mail?: string;
+ readonly upn?: string;
+ readonly sam?: string;
+ readonly abteilung?: string;
+ readonly titel?: string;
+};
+
+type Aenderung = { -readonly [K in keyof ImportFelder]?: string };
+
+export type MitarbeiterImportAbweisung = {
+ /** Zeilennummer in der Datei, wie sie ein Editor anzeigt. */
+ readonly zeile: number;
+ readonly grund: string;
+};
+
+/**
+ * Je Zeile genau ein Fach.
+ *
+ * `angelegt + aktualisiert + unveraendert + abgewiesen === gesamt` gilt immer,
+ * und darauf prueft ein Test. Im Review von #011 hatte der Geraeteimport „3 von 3
+ * angelegt“ gemeldet, obwohl Zeilen verschluckt worden waren — eine Zahl, die
+ * nicht aufgeht, ist schlimmer als ein sichtbarer Fehler.
+ */
+export type MitarbeiterImportErgebnis = {
+ /** Datenzeilen in der Datei, ohne Kopfzeile und ohne Leerzeilen. */
+ readonly gesamt: number;
+ readonly angelegt: number;
+ readonly aktualisiert: number;
+ /** Erkannt, aber es stand nichts Neues drin. */
+ readonly unveraendert: number;
+ readonly abgewiesen: readonly MitarbeiterImportAbweisung[];
+};
+
+/**
+ * Eine Zeile zu Feldern. Wirft bei einem Zeilenfehler.
+ *
+ * Fehlen `givenName` und `sn`, wird `displayName` am **letzten** Leerzeichen
+ * geteilt. Das ist eine Annahme und keine Wahrheit — bei „Anna van der Berg“
+ * landet „van der“ beim Vornamen. Deshalb bleibt `displayName` als Anzeigename
+ * genau so stehen, wie er in der Datei steht: was ein Mensch zu lesen bekommt,
+ * ist dann auf jeden Fall richtig, und die Teilung betrifft nur die beiden Felder,
+ * die ein Techniker beim Nachpflegen ohnehin sieht.
+ */
+function zeileLesen(zeile: CsvZeile): { readonly felder: ImportFelder; readonly anzeigename: string } {
+ const spalten = MITARBEITER_IMPORT_SPALTEN;
+ const angezeigt = wertAus(zeile, ...spalten.anzeigename);
+ let vorname = wertAus(zeile, ...spalten.vorname);
+ let nachname = wertAus(zeile, ...spalten.nachname);
+
+ if (!vorname && !nachname) {
+ const trennstelle = angezeigt.lastIndexOf(" ");
+ if (trennstelle > 0) {
+ vorname = angezeigt.slice(0, trennstelle).trim();
+ nachname = angezeigt.slice(trennstelle + 1).trim();
+ }
+ }
+ if (!vorname || !nachname) {
+ throw new MitarbeiterFehler(
+ "In dieser Zeile fehlt ein Name. Gebraucht werden „givenName“ und „sn“ – oder ein " +
+ "„displayName“ aus mindestens zwei Teilen, der dann am letzten Leerzeichen geteilt wird.",
+ );
+ }
+
+ const weitere: Aenderung = {};
+ for (const feld of IMPORT_FELDER) {
+ if (feld === "vorname" || feld === "nachname") continue;
+ const wert = wertAus(zeile, ...spalten[feld]);
+ if (wert) weitere[feld] = wert;
+ }
+
+ return {
+ felder: { vorname, nachname, ...weitere },
+ anzeigename: angezeigt || `${vorname} ${nachname}`,
+ };
+}
+
+/**
+ * Die Kennungen, unter denen diese Zeile wiedererkannt wird.
+ *
+ * `sam` und `upn` sind die Felder, die das Verzeichnis eindeutig haelt und die der
+ * Techniker vor sich hat; `mail` steht bewusst **nicht** dabei — eine
+ * Sammeladresse trifft mehrere Personen, und eine Adresse wechselt bei einer
+ * Heirat, ohne dass es eine andere Person wird.
+ *
+ * Steht keine der beiden in der Zeile, bleibt der Anzeigename. Das ist der
+ * schwaechere Schluessel, aber der einzige, der bleibt — und ohne ihn legte
+ * dieselbe Onboarding-Liste beim zweiten Einlesen jede Person ein zweites Mal an.
+ * Genau diese Sorte stiller Verdopplung war der Blocker in #011.
+ */
+function kennungen(felder: ImportFelder, anzeigename: string): readonly string[] {
+ const liste: string[] = [];
+ if (felder.sam) liste.push(`sam:${felder.sam.toLowerCase()}`);
+ if (felder.upn) liste.push(`upn:${felder.upn.toLowerCase()}`);
+ if (liste.length === 0) liste.push(`name:${anzeigename.toLowerCase()}`);
+ return liste;
+}
+
+/** Die Felder, die der Import vergleicht – mehr braucht der Abgleich mit der Zeile nicht. */
+const VORHANDEN_AUSWAHL = {
+ id: true,
+ anzeigename: true,
+ herkunft: true,
+ vorname: true,
+ nachname: true,
+ mail: true,
+ upn: true,
+ sam: true,
+ abteilung: true,
+ titel: true,
+} satisfies Prisma.MitarbeiterSelect;
+
+type VorhandenePerson = {
+ readonly id: string;
+ readonly herkunft: Herkunft;
+} & { readonly [K in keyof ImportFelder]-?: string | null } & { readonly anzeigename: string };
+
+/**
+ * Sucht die Person, die zu dieser Zeile gehoert – innerhalb **desselben** Kunden.
+ *
+ * Passen zwei Personen, wird die Zeile abgewiesen statt eine davon zu nehmen: hier
+ * zu raten heisst, die Stammdaten der falschen Person zu ueberschreiben.
+ */
+async function vorhandenePersonSuchen(
+ kundeId: string,
+ felder: ImportFelder,
+ anzeigename: string,
+): Promise {
+ const bedingungen: Prisma.MitarbeiterWhereInput[] = [];
+ if (felder.sam) bedingungen.push({ sam: { equals: felder.sam, mode: "insensitive" } });
+ if (felder.upn) bedingungen.push({ upn: { equals: felder.upn, mode: "insensitive" } });
+ if (bedingungen.length === 0) {
+ bedingungen.push({ anzeigename: { equals: anzeigename, mode: "insensitive" } });
+ }
+
+ const treffer = await prisma.mitarbeiter.findMany({
+ where: { kundeId, OR: bedingungen },
+ select: VORHANDEN_AUSWAHL,
+ orderBy: { erstelltAm: "asc" },
+ take: 2,
+ });
+ if (treffer.length > 1) {
+ throw new MitarbeiterFehler(
+ `Diese Zeile passt auf „${treffer[0]?.anzeigename}“ und „${treffer[1]?.anzeigename}“. ` +
+ "Welche der beiden gemeint ist, steht nicht in der Datei — bitte sAMAccountName oder UPN eintragen.",
+ );
+ }
+ return treffer[0] ?? null;
+}
+
+/**
+ * Spielt eine CSV-Datei mit Personen ein.
+ *
+ * **Je Zeile ein Ergebnis.** Eine fehlerhafte Zeile bricht den Import nicht ab;
+ * sie wird mit Zeilennummer und Grund gemeldet, die uebrigen werden verarbeitet.
+ * Deshalb schreibt jede Zeile in ihrer eigenen Transaktion — eine gemeinsame
+ * haette am Ende alles zurueckgerollt, was vor dem Fehler gelungen war.
+ *
+ * Geschrieben wird ueber `mitarbeiterAnlegen` und `mitarbeiterBearbeiten`, nicht
+ * an ihnen vorbei: die Namensregeln, die 20-Zeichen-Grenze des `sam` und die
+ * Sperre der abgeglichenen Felder gelten damit hier genauso wie im Formular.
+ *
+ * Eine Person mit `herkunft = ABGLEICH` wird **nicht** angetastet und die Zeile
+ * mit Begruendung gemeldet — der naechste Abgleich drehte die Aenderung ohnehin
+ * zurueck, und stillschweigend zu ueberspringen hiesse, einen Import zu melden,
+ * der nicht stattgefunden hat.
+ */
+export async function mitarbeiterImportieren(
+ csvText: string,
+ benutzer: HandelnderBenutzer,
+ kundeIdAusEingabe?: string | null,
+): Promise {
+ const kundeId = await kundeErmitteln(kundeIdAusEingabe, benutzer);
+
+ let tabelle;
+ try {
+ tabelle = csvZerlegen(csvText, { hoechstZeilen: MITARBEITER_IMPORT_HOECHSTZEILEN });
+ } catch (fehler) {
+ if (fehler instanceof CsvFehler) throw new MitarbeiterFehler(fehler.message);
+ throw fehler;
+ }
+
+ const spalten = MITARBEITER_IMPORT_SPALTEN;
+ // Ohne eine Namensspalte koennte keine einzige Zeile gelingen. Das als 500
+ // gleichlautende Zeilenfehler zu melden waere richtig, aber nutzlos.
+ if (!hatSpalte(tabelle, ...spalten.vorname, ...spalten.nachname, ...spalten.anzeigename)) {
+ throw new MitarbeiterFehler(
+ "In der Kopfzeile fehlt der Name — „givenName“ und „sn“ oder wenigstens „displayName“. " +
+ `Gelesen werden diese Spalten: ${Object.values(spalten)
+ .map((namen) => namen[0])
+ .join(", ")}.`,
+ );
+ }
+
+ const abgewiesen: MitarbeiterImportAbweisung[] = [];
+ let angelegt = 0;
+ let aktualisiert = 0;
+ let unveraendert = 0;
+
+ /** Kennung zu Zeilennummer – damit die Meldung sagen kann, *wo* es schon stand. */
+ const gesehen = new Map();
+
+ for (const zeile of tabelle.zeilen) {
+ if (zeile.fehler !== undefined) {
+ abgewiesen.push({ zeile: zeile.nummer, grund: zeile.fehler });
+ continue;
+ }
+ try {
+ const { felder, anzeigename } = zeileLesen(zeile);
+
+ const schluessel = kennungen(felder, anzeigename);
+ const doppelt = schluessel.find((eintrag) => gesehen.has(eintrag));
+ if (doppelt !== undefined) {
+ abgewiesen.push({
+ zeile: zeile.nummer,
+ grund: `„${anzeigename}“ steht in dieser Datei schon in Zeile ${gesehen.get(doppelt)}.`,
+ });
+ continue;
+ }
+
+ const vorhanden = await vorhandenePersonSuchen(kundeId, felder, anzeigename);
+
+ if (vorhanden === null) {
+ await mitarbeiterAnlegen({ ...felder, kundeId }, benutzer, {
+ aktion: "Mitarbeiter aus CSV angelegt",
+ zeile: zeile.nummer,
+ });
+ angelegt += 1;
+ for (const eintrag of schluessel) gesehen.set(eintrag, zeile.nummer);
+ continue;
+ }
+
+ if (vorhanden.herkunft === Herkunft.ABGLEICH) {
+ abgewiesen.push({
+ zeile: zeile.nummer,
+ grund:
+ `„${vorhanden.anzeigename}“ kommt aus dem Verzeichnis. Von Hand eingelesene Werte würde ` +
+ "der nächste Abgleich zurücksetzen — geändert wird diese Person im Verzeichnis.",
+ });
+ continue;
+ }
+
+ const aenderung: Aenderung = {};
+ for (const feld of IMPORT_FELDER) {
+ const wert = felder[feld];
+ if (wert === undefined) continue;
+ const alt = vorhanden[feld];
+ /*
+ * `sam` und `upn` sind die Kennungen, ueber die diese Zeile gefunden
+ * wurde — und gefunden wird ohne Ruecksicht auf Gross- und
+ * Kleinschreibung. Dann darf ein „AMUSTER“ in der Datei den vorhandenen
+ * Wert auch nicht umschreiben: es ist dieselbe Kennung, und sonst
+ * schriebe jeder Import aus einem Werkzeug, das anders schreibt, einen
+ * Protokolleintrag ueber einen Unterschied, der keiner ist. Bei den
+ * uebrigen Feldern zaehlt die Schreibweise: „vertrieb“ und „Vertrieb“
+ * sind zwei Abteilungsnamen, und nur einer davon trifft eine `Abteilung`.
+ */
+ const gleich =
+ feld === "sam" || feld === "upn"
+ ? alt !== null && alt.toLowerCase() === wert.toLowerCase()
+ : alt === wert;
+ if (!gleich) aenderung[feld] = wert;
+ }
+ if (Object.keys(aenderung).length === 0) {
+ unveraendert += 1;
+ } else {
+ // Der Protokolleintrag entsteht in `mitarbeiterBearbeiten` und nennt die
+ // geaenderten Felder. Die Zeilennummer steht dort nicht — sie ist beim
+ // Anlegen die einzige Spur zur Datei, beim Aendern sagt das Protokoll
+ // ohnehin, was vorher und was nachher drin stand.
+ await mitarbeiterBearbeiten(vorhanden.id, aenderung, benutzer);
+ aktualisiert += 1;
+ }
+ for (const eintrag of schluessel) gesehen.set(eintrag, zeile.nummer);
+ } catch (fehler) {
+ if (fehler instanceof MitarbeiterFehler) {
+ abgewiesen.push({ zeile: zeile.nummer, grund: fehler.message });
+ continue;
+ }
+ // Alles Uebrige – ein Verbindungsabbruch etwa – ist kein Zeilenproblem und
+ // wuerde in den folgenden Zeilen genauso auftreten.
+ throw fehler;
+ }
+ }
+
+ return { gesamt: tabelle.zeilen.length, angelegt, aktualisiert, unveraendert, abgewiesen };
+}
diff --git a/src/server/services/verzeichnis.ts b/src/server/services/verzeichnis.ts
new file mode 100644
index 0000000..608a0ca
--- /dev/null
+++ b/src/server/services/verzeichnis.ts
@@ -0,0 +1,442 @@
+import type { Prisma, Verzeichnisquelle } from "@/generated/prisma/client";
+import { Betriebsmodus } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/db";
+import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
+import { darf } from "@/server/auth/rollen";
+import { betriebsmodus, eigeneOrganisation, kundeErmitteln, type HandelnderBenutzer } from "@/server/mandant";
+import { VerzeichnisFehler } from "@/server/ldap/fehler";
+import { INTERVALL_HOECHSTENS, INTERVALL_MINDESTENS, intervallMeldung } from "@/lib/verzeichnis";
+import { eintraegeLesen } from "@/server/ldap/leser";
+import { type LdapZugang, urlPruefen } from "@/server/ldap/verbindung";
+import {
+ AD_ZUORDNUNG,
+ type AttributZuordnung,
+ type GemappterEintrag,
+ STANDARD_FILTER,
+ type Uebersprungen,
+ vorgesetzteAufloesen,
+ zuordnungLesen,
+} from "@/server/ldap/zuordnung";
+
+/**
+ * Verzeichnisquellen verwalten (#016).
+ *
+ * Drei Grenzen liegen hier, und alle drei sind absichtlich in der Service-Schicht
+ * und nicht nur in der Oberflaeche:
+ *
+ * 1. **Nur im Modus INTERN.** Das Verzeichnis ist das der eigenen Firma. Im Modus
+ * DIENSTLEISTUNG gibt es keinen Abgleich — dort stehen die Mitarbeiter der
+ * Kunden, und die kommen nicht aus unserem AD. Das Ausblenden in der
+ * Oberflaeche allein waere eine Zusage, die eine Server Action von Hand
+ * umgehen koennte.
+ * 2. **Nur `verwaltung`.** An einer Quelle haengen Zugangsdaten zum
+ * Domaenencontroller. Die Aktion prueft das Recht ebenfalls; hier steht es
+ * noch einmal, damit auch der Zeitplan und ein Skript daran vorbeikommen.
+ * 3. **Das Bind-Passwort verlaesst die Schicht nicht.** Es wird mit
+ * `src/lib/geheimnisse.ts` verschluesselt gespeichert, `QuellenSicht` hat kein
+ * Feld dafuer, und jede Meldung der LDAP-Bibliothek geht durch
+ * `fehlerMeldung`, bevor sie gespeichert wird.
+ */
+
+export type { AttributZuordnung, GemappterEintrag };
+export { AD_ZUORDNUNG, STANDARD_FILTER };
+
+/*
+ * Die Klasse selbst liegt in `src/server/ldap/fehler.ts` — unterhalb dieser
+ * Schicht, damit auch `server/ldap/verbindung.ts` von ihr erben kann, ohne die
+ * Schichtung umzudrehen. Hier steht sie weiter zur Verfuegung, weil jeder
+ * Aufrufer sie von hier kennt.
+ */
+export { VerzeichnisFehler };
+
+/** Ohne Passwort – diese Form geht an die Oberflaeche. */
+export type QuellenSicht = {
+ readonly id: string;
+ readonly name: string;
+ readonly aktiv: boolean;
+ readonly url: string;
+ readonly bindDn: string;
+ readonly basisDn: string;
+ readonly filter: string;
+ readonly attributZuordnung: AttributZuordnung;
+ readonly caZertifikatVorhanden: boolean;
+ readonly abgleichIntervallMinuten: number;
+ readonly letzterAbgleichAm: Date | null;
+ readonly letzterAbgleichStatus: string | null;
+ readonly letzterAbgleichFehler: string | null;
+};
+
+export type NeueQuelle = {
+ name: string;
+ url: string;
+ bindDn: string;
+ bindPasswort: string;
+ basisDn: string;
+ filter?: string;
+ attributZuordnung?: Partial;
+ caZertifikatPem?: string | null;
+ abgleichIntervallMinuten?: number;
+ aktiv?: boolean;
+};
+
+/**
+ * Die Grenzen des Intervalls.
+ *
+ * Nach unten fuenf Minuten: ein Abgleich laeuft im selben Prozess wie die
+ * Anwendung und liest das ganze Verzeichnis: alle dreissig Sekunden waere eine
+ * Last, die niemand gewollt hat. Nach oben eine Woche, damit ein Tippfehler mit
+ * einer Null zu viel nicht zu einer Quelle fuehrt, die nie wieder laeuft.
+ */
+
+/** „Verbindung testen“ zeigt die ersten zehn Treffer. Mehr braucht keine Probe. */
+const PROBE_GRENZE = 10;
+const PROBE_GRENZE_HOECHSTENS = 50;
+
+/**
+ * Das Modul ist im Modus DIENSTLEISTUNG nicht bloss unsichtbar, sondern
+ * abgeschaltet. Gilt fuer jeden Schreibweg, auch fuer den Zeitplan.
+ */
+async function nurIntern(): Promise {
+ if ((await betriebsmodus()) !== Betriebsmodus.INTERN) {
+ throw new VerzeichnisFehler(
+ "Der Verzeichnisabgleich gilt nur für die eigene Organisation und ist im Modus " +
+ "Dienstleistung abgeschaltet.",
+ );
+ }
+}
+
+function rechtPruefen(benutzer: HandelnderBenutzer): void {
+ if (!darf(benutzer.rolle, "verwaltung")) {
+ throw new VerzeichnisFehler(
+ `Verzeichnisquellen zu pflegen verlangt das Recht "verwaltung"; die Rolle ${benutzer.rolle} hat es nicht.`,
+ );
+ }
+}
+
+function pflichtText(wert: string | undefined, feld: string): string {
+ const text = wert?.trim();
+ if (!text) throw new VerzeichnisFehler(`${feld} fehlt.`);
+ return text;
+}
+
+function intervallPruefen(minuten: number | undefined): number | undefined {
+ if (minuten === undefined) return undefined;
+ if (!Number.isInteger(minuten) || minuten < INTERVALL_MINDESTENS || minuten > INTERVALL_HOECHSTENS) {
+ throw new VerzeichnisFehler(intervallMeldung());
+ }
+ return minuten;
+}
+
+/**
+ * Ein Zertifikat, oder gar keines.
+ *
+ * Geprueft wird nur die Huelle — dass es ein PEM-Block ist. Ob das Zertifikat
+ * passt, sagt erst der Verbindungstest, und das ist die richtige Stelle dafuer:
+ * eine Pruefung hier koennte nur behaupten, was sie nicht weiss.
+ */
+function zertifikatPruefen(pem: string | null | undefined): string | null {
+ const wert = pem?.trim();
+ if (!wert) return null;
+ if (!wert.includes("-----BEGIN CERTIFICATE-----") || !wert.includes("-----END CERTIFICATE-----")) {
+ throw new VerzeichnisFehler(
+ "Das CA-Zertifikat muss im PEM-Format vorliegen, also mit „-----BEGIN CERTIFICATE-----“ beginnen.",
+ );
+ }
+ return wert;
+}
+
+function alsSicht(zeile: Verzeichnisquelle): QuellenSicht {
+ return {
+ id: zeile.id,
+ name: zeile.name,
+ aktiv: zeile.aktiv,
+ url: zeile.url,
+ bindDn: zeile.bindDn,
+ basisDn: zeile.basisDn,
+ filter: zeile.filter,
+ attributZuordnung: zuordnungLesen(zeile.attributZuordnung),
+ // Der Inhalt bleibt hier: die Oberflaeche muss nur wissen, ob eines
+ // hinterlegt ist, und braucht dafuer keinen Zertifikatstext.
+ caZertifikatVorhanden: Boolean(zeile.caZertifikatPem?.trim()),
+ abgleichIntervallMinuten: zeile.abgleichIntervallMinuten,
+ letzterAbgleichAm: zeile.letzterAbgleichAm,
+ letzterAbgleichStatus: zeile.letzterAbgleichStatus,
+ letzterAbgleichFehler: zeile.letzterAbgleichFehler,
+ };
+}
+
+/** Auch fuer die Lesemodelle: die eine Stelle, die aus einer Zeile eine Sicht macht. */
+export { alsSicht as quellenSichtBauen };
+
+export async function quelleAnlegen(
+ eingabe: NeueQuelle,
+ benutzer: HandelnderBenutzer,
+): Promise {
+ await nurIntern();
+ rechtPruefen(benutzer);
+ const kundeId = await kundeErmitteln(null, benutzer);
+
+ const name = pflichtText(eingabe.name, "Der Name der Quelle");
+ const url = pflichtText(eingabe.url, "Die Adresse des Verzeichnisses");
+ urlPruefen(url);
+ const bindDn = pflichtText(eingabe.bindDn, "Der Bind-DN");
+ const bindPasswort = eingabe.bindPasswort?.trim();
+ if (!bindPasswort) {
+ throw new VerzeichnisFehler("Das Bind-Passwort fehlt. Ein anonymer Bind wird nicht unterstützt.");
+ }
+ const basisDn = pflichtText(eingabe.basisDn, "Der Basis-DN");
+ const intervall = intervallPruefen(eingabe.abgleichIntervallMinuten);
+
+ const zeile = await prisma.$transaction(async (tx) => {
+ const angelegt = await tx.verzeichnisquelle.create({
+ data: {
+ kundeId,
+ name,
+ // Gespeichert wird die getippte Adresse samt Schema. `ldap+starttls://`
+ // ist Teil der Angabe und wird erst beim Verbinden abgetrennt — sonst
+ // waere nach dem Speichern nicht mehr zu erkennen, dass StartTLS
+ // gewuenscht war.
+ url,
+ bindDn,
+ bindPasswortVerschluesselt: verschluesseln(bindPasswort),
+ basisDn,
+ filter: eingabe.filter?.trim() || STANDARD_FILTER,
+ attributZuordnung: zuordnungLesen({ ...AD_ZUORDNUNG, ...eingabe.attributZuordnung }),
+ caZertifikatPem: zertifikatPruefen(eingabe.caZertifikatPem),
+ // Ohne Angabe bleibt der Standardwert des Schemas (60 Minuten) stehen.
+ ...(intervall === undefined ? {} : { abgleichIntervallMinuten: intervall }),
+ aktiv: eingabe.aktiv ?? false,
+ },
+ });
+ await tx.protokoll.create({
+ data: {
+ entitaet: "Verzeichnisquelle",
+ entitaetId: angelegt.id,
+ aktion: "Verzeichnisquelle angelegt",
+ benutzerId: benutzer.id,
+ // Kein Passwort, auch nicht verschluesselt: das Protokoll wird gelesen.
+ nachher: { name: angelegt.name, url: angelegt.url, basisDn: angelegt.basisDn },
+ },
+ });
+ return angelegt;
+ });
+ return alsSicht(zeile);
+}
+
+export async function quelleBearbeiten(
+ id: string,
+ aenderung: Partial,
+ benutzer: HandelnderBenutzer,
+): Promise {
+ await nurIntern();
+ rechtPruefen(benutzer);
+ const vorher = await quelleZeileHolen(id);
+
+ const daten: Prisma.VerzeichnisquelleUpdateInput = {};
+ const geaendert: string[] = [];
+
+ if (aenderung.name !== undefined) {
+ const name = pflichtText(aenderung.name, "Der Name der Quelle");
+ if (name !== vorher.name) {
+ daten.name = name;
+ geaendert.push("Name");
+ }
+ }
+ if (aenderung.url !== undefined) {
+ const url = pflichtText(aenderung.url, "Die Adresse des Verzeichnisses");
+ urlPruefen(url);
+ if (url !== vorher.url) {
+ daten.url = url;
+ geaendert.push("Adresse");
+ }
+ }
+ if (aenderung.bindDn !== undefined) {
+ const bindDn = pflichtText(aenderung.bindDn, "Der Bind-DN");
+ if (bindDn !== vorher.bindDn) {
+ daten.bindDn = bindDn;
+ geaendert.push("Bind-DN");
+ }
+ }
+ if (aenderung.bindPasswort !== undefined) {
+ // Ein leeres Feld heisst „unveraendert“. Ein Formular, das das Passwort nicht
+ // anzeigen kann (und das darf es nicht), schickt sonst bei jedem Speichern
+ // eine leere Zeichenkette und loeschte das Geheimnis.
+ const neu = aenderung.bindPasswort.trim();
+ if (neu) {
+ daten.bindPasswortVerschluesselt = verschluesseln(neu);
+ geaendert.push("Bind-Passwort");
+ }
+ }
+ if (aenderung.basisDn !== undefined) {
+ const basisDn = pflichtText(aenderung.basisDn, "Der Basis-DN");
+ if (basisDn !== vorher.basisDn) {
+ daten.basisDn = basisDn;
+ geaendert.push("Basis-DN");
+ }
+ }
+ if (aenderung.filter !== undefined) {
+ const filter = aenderung.filter.trim() || STANDARD_FILTER;
+ if (filter !== vorher.filter) {
+ daten.filter = filter;
+ geaendert.push("Filter");
+ }
+ }
+ if (aenderung.attributZuordnung !== undefined) {
+ const zuordnung = zuordnungLesen({
+ ...zuordnungLesen(vorher.attributZuordnung),
+ ...aenderung.attributZuordnung,
+ });
+ if (JSON.stringify(zuordnung) !== JSON.stringify(zuordnungLesen(vorher.attributZuordnung))) {
+ daten.attributZuordnung = zuordnung;
+ geaendert.push("Attributzuordnung");
+ }
+ }
+ if (aenderung.caZertifikatPem !== undefined) {
+ const pem = zertifikatPruefen(aenderung.caZertifikatPem);
+ if (pem !== vorher.caZertifikatPem) {
+ daten.caZertifikatPem = pem;
+ geaendert.push("CA-Zertifikat");
+ }
+ }
+ if (aenderung.abgleichIntervallMinuten !== undefined) {
+ const minuten = intervallPruefen(aenderung.abgleichIntervallMinuten);
+ if (minuten !== undefined && minuten !== vorher.abgleichIntervallMinuten) {
+ daten.abgleichIntervallMinuten = minuten;
+ geaendert.push("Intervall");
+ }
+ }
+ if (aenderung.aktiv !== undefined && aenderung.aktiv !== vorher.aktiv) {
+ daten.aktiv = aenderung.aktiv;
+ geaendert.push(aenderung.aktiv ? "aktiviert" : "abgeschaltet");
+ }
+
+ if (geaendert.length === 0) return alsSicht(vorher);
+
+ const zeile = await prisma.$transaction(async (tx) => {
+ const nachher = await tx.verzeichnisquelle.update({ where: { id }, data: daten });
+ await tx.protokoll.create({
+ data: {
+ entitaet: "Verzeichnisquelle",
+ entitaetId: id,
+ aktion: `${geaendert.join(", ")} geändert`,
+ benutzerId: benutzer.id,
+ vorher: { name: vorher.name, url: vorher.url, basisDn: vorher.basisDn, aktiv: vorher.aktiv },
+ nachher: { name: nachher.name, url: nachher.url, basisDn: nachher.basisDn, aktiv: nachher.aktiv },
+ },
+ });
+ return nachher;
+ });
+ return alsSicht(zeile);
+}
+
+/**
+ * Loeschen, aber nur solange nichts daran haengt.
+ *
+ * Mitarbeiter werden nie geloescht, und ein archivierter Lauf muss auch in zehn
+ * Jahren noch sagen koennen, woher die Person kam. Der Fremdschluessel steht
+ * deshalb auf `Restrict`; hier steht der Satz, den ein Mensch lesen kann, samt
+ * dem Weg heraus: die Quelle abschalten statt sie zu loeschen.
+ */
+export async function quelleLoeschen(id: string, benutzer: HandelnderBenutzer): Promise {
+ await nurIntern();
+ rechtPruefen(benutzer);
+ const quelle = await quelleZeileHolen(id);
+
+ const anzahl = await prisma.mitarbeiter.count({ where: { quelleId: id } });
+ if (anzahl > 0) {
+ throw new VerzeichnisFehler(
+ `An „${quelle.name}“ hängen ${anzahl} Mitarbeiter. Die Quelle lässt sich abschalten, ` +
+ "aber nicht löschen – sonst verlieren die Personen ihre Herkunft.",
+ );
+ }
+
+ await prisma.$transaction(async (tx) => {
+ await tx.verzeichnisquelle.delete({ where: { id } });
+ await tx.protokoll.create({
+ data: {
+ entitaet: "Verzeichnisquelle",
+ entitaetId: id,
+ aktion: "Verzeichnisquelle gelöscht",
+ benutzerId: benutzer.id,
+ vorher: { name: quelle.name, url: quelle.url, basisDn: quelle.basisDn },
+ },
+ });
+ });
+}
+
+/**
+ * Liest hoechstens `grenze` Eintraege und **schreibt nichts**.
+ *
+ * Kein `Mitarbeiter`, kein `Protokoll`, kein `letzterAbgleich…` — das ist der
+ * Zweck: man soll Zugangsdaten und Filter ausprobieren koennen, ohne damit
+ * gleich Personen anzulegen. Die Rueckgabe zeigt die gemappten Felder, damit ein
+ * falsch zugeordnetes Attribut sofort auffaellt.
+ *
+ * Der Vorgesetzte wird nur gegen die gelesenen Eintraege aufgeloest. In einer
+ * Probe von zehn ist er meist nicht dabei und bleibt dann leer; das ist kein
+ * Fehler der Zuordnung.
+ */
+export async function verbindungTesten(
+ quelleId: string,
+ benutzer: HandelnderBenutzer,
+ grenze = PROBE_GRENZE,
+): Promise<{
+ readonly treffer: readonly GemappterEintrag[];
+ readonly gesamtGelesen: number;
+ /*
+ * Die uebersprungenen Eintraege gehen mit nach draussen, weil „keine Treffer“
+ * zwei ganz verschiedene Ursachen hat: der Filter passt auf niemanden, oder er
+ * passt und die Zuordnung scheitert an jedem Eintrag. Ohne diese Liste sagt der
+ * Test beide Male denselben Satz — und ein falsch gesetztes Kennungsattribut
+ * saehe aus wie ein zu enger Filter (Reviewbefund zu #016).
+ */
+ readonly uebersprungen: readonly Uebersprungen[];
+}> {
+ await nurIntern();
+ rechtPruefen(benutzer);
+ const quelle = await quelleZeileHolen(quelleId);
+ const wieViele = Math.min(Math.max(Math.trunc(grenze), 1), PROBE_GRENZE_HOECHSTENS);
+
+ const { eintraege, gesamtGelesen, uebersprungen } = await eintraegeLesen(
+ zugangAus(quelle),
+ {
+ basisDn: quelle.basisDn,
+ filter: quelle.filter,
+ zuordnung: zuordnungLesen(quelle.attributZuordnung),
+ },
+ wieViele,
+ );
+ return { treffer: vorgesetzteAufloesen(eintraege), gesamtGelesen, uebersprungen };
+}
+
+/**
+ * Die Zeile samt verschluesseltem Passwort – nur fuer diese Schicht und den
+ * Abgleich. Wer nach draussen antwortet, nimmt `alsSicht`.
+ *
+ * Geprueft wird hier auch die Zugehoerigkeit: die Quelle muss der eigenen
+ * Organisation gehoeren. `quelleAnlegen` kann keine andere anlegen — es laeuft nur
+ * im Modus INTERN —, aber Migrationen, `psql` und Skripte schreiben an dieser
+ * Schicht vorbei. „Gilt nur fuer die eigene Organisation“ ist sonst eine Zusage,
+ * die nur fuer den Weg durch das Formular gilt.
+ */
+export async function quelleZeileHolen(id: string): Promise {
+ const zeile = await prisma.verzeichnisquelle.findUnique({ where: { id } });
+ if (!zeile) throw new VerzeichnisFehler("Diese Verzeichnisquelle gibt es nicht (mehr).");
+ const eigene = await eigeneOrganisation();
+ if (zeile.kundeId !== eigene.id) {
+ throw new VerzeichnisFehler(
+ `„${zeile.name}“ gehört nicht zur eigenen Organisation. Der Verzeichnisabgleich gilt nur für sie.`,
+ );
+ }
+ return zeile;
+}
+
+/** Entschluesselt das Bind-Passwort. Das Ergebnis bleibt in der Aufrufkette. */
+export function zugangAus(quelle: Verzeichnisquelle): LdapZugang {
+ return {
+ url: quelle.url,
+ bindDn: quelle.bindDn,
+ bindPasswort: entschluesseln(quelle.bindPasswortVerschluesselt),
+ caZertifikatPem: quelle.caZertifikatPem,
+ };
+}
diff --git a/src/server/zeitplan/abgleich-plan.ts b/src/server/zeitplan/abgleich-plan.ts
new file mode 100644
index 0000000..3c417be
--- /dev/null
+++ b/src/server/zeitplan/abgleich-plan.ts
@@ -0,0 +1,109 @@
+import cron, { type ScheduledTask } from "node-cron";
+import { AbgleichLaeuftBereitsFehler, abgleichAusfuehren, faelligeQuellen } from "@/server/services/abgleich";
+
+/**
+ * Der Zeitplan des Verzeichnis-Abgleichs (#016).
+ *
+ * Er laeuft **im selben Prozess** wie die Anwendung — kein zweiter Dienst, kein
+ * Systemd-Timer, kein Cron im Container. Das ist eine Entscheidung fuer den
+ * Betrieb: beide Betriebswege dieses Projekts (Docker-Compose und die native
+ * Installation im LXC) starten genau einen Prozess, und eine zweite Einheit
+ * waere eine zweite Stelle, an der jemand `APP_SECRET` und `DATABASE_URL`
+ * hinterlegen muss.
+ *
+ * Drei Dinge halten das zusammen:
+ *
+ * 1. **Die Uhr tickt jede Minute, faellig ist trotzdem nur, was faellig ist.**
+ * Das Intervall steht je Quelle in der Datenbank (`abgleichIntervallMinuten`,
+ * Standard 60). Ein Zeitplan, der die Intervalle selbst verwaltete, muesste
+ * bei jeder Aenderung neu aufgesetzt werden — und vergaesse es irgendwann.
+ * 2. **Die Sperre setzt der Dienst, nicht der Zeitplan.** `abgleichAusfuehren`
+ * nimmt sie selbst — in der Datenbank und damit auch ueber zwei Instanzen
+ * hinter einem Lastverteiler hinweg. Hier eine **zweite** Sperre zu setzen
+ * waere schlimmer als keine: sie traege einen anderen Schluessel, liefe also
+ * an der ersten vorbei, und ein gerade von Hand angestossener Abgleich saehe
+ * fuer den Zeitplan wie ein unerwarteter Fehler aus.
+ * 3. **Ein Fehler beendet den Zeitplan nicht.** Ein Verzeichnis, das gerade
+ * nicht antwortet, ist der Normalfall im Betrieb — der naechste Durchgang
+ * versucht es wieder, und was passiert ist, steht an der Quelle.
+ */
+
+/** Jede Minute nachsehen; wer wirklich drankommt, entscheidet die Datenbank. */
+const TAKT = "* * * * *";
+
+/** Der laufende Zeitplan – `undefined`, solange keiner laeuft. */
+let plan: ScheduledTask | undefined;
+
+/**
+ * Der Abgleich laeuft **ohne Benutzer**.
+ *
+ * `HandelnderBenutzer` traegt Kennung und Rolle; der Zeitplan hat beides nicht,
+ * und ein erfundener Administrator waere eine Luege im Protokoll. `ADMIN` steht
+ * hier fuer „das System selbst“, die Kennung bleibt leer — genau dafuer duerfen
+ * `Lauf.erstelltVonId` und `Schrittergebnis.aktualisiertVonId` leer sein
+ * (CLAUDE.md, „Erweiterbarkeit“).
+ */
+const SYSTEM = { id: "", rolle: "ADMIN" } as const;
+
+/**
+ * Sieht nach, welche Quelle faellig ist, und gleicht sie ab.
+ *
+ * Exportiert, damit ein Test den Durchgang aufrufen kann, ohne eine Minute zu
+ * warten.
+ */
+export async function abgleichDurchgang(): Promise {
+ const quellen = await faelligeQuellen();
+ for (const quelle of quellen) {
+ try {
+ await abgleichAusfuehren(quelle.id, SYSTEM);
+ } catch (fehler) {
+ if (fehler instanceof AbgleichLaeuftBereitsFehler) {
+ /*
+ * Eine andere Instanz oder ein Administrator von Hand ist schon dabei.
+ * Kein Fehler und keine Zeile im Protokoll: im Normalbetrieb mit zwei
+ * Instanzen waere das jede Minute eine.
+ */
+ continue;
+ }
+ /*
+ * Bis hierher sollte nichts durchkommen — `abgleichAusfuehren` schreibt
+ * seinen Fehler an die Quelle und gibt ihn als Ergebnis zurueck. Was
+ * trotzdem hier ankommt, ist unerwartet und gehoert ins Protokoll des
+ * Prozesses; der Zeitplan laeuft weiter, sonst stuende nach einer
+ * Stoerung der Abgleich aller Quellen still.
+ */
+ console.error(
+ `Abgleich der Quelle ${quelle.name} ist unerwartet gescheitert:`,
+ fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler,
+ );
+ }
+ }
+}
+
+/**
+ * Startet den Zeitplan. Ein zweiter Aufruf tut nichts.
+ *
+ * Aufgerufen wird er aus `src/instrumentation.ts`, also einmal je
+ * Server-Prozess. Im Entwicklungsmodus laedt Next Module mehrfach — deshalb die
+ * Wache.
+ */
+export function zeitplanStarten(): void {
+ if (plan) return;
+ plan = cron.schedule(TAKT, () => void abgleichDurchgang(), {
+ // Zwei Durchgaenge duerfen sich nicht ueberholen, wenn einer laenger als
+ // eine Minute braucht. Die Sperre in der Datenbank ist das zweite Netz.
+ noOverlap: true,
+ name: "verzeichnis-abgleich",
+ });
+}
+
+/** Haelt den Zeitplan an – fuer Tests und einen geordneten Abbruch. */
+export function zeitplanAnhalten(): void {
+ void plan?.destroy();
+ plan = undefined;
+}
+
+/** Laeuft gerade ein Zeitplan? Nur fuer Tests und die Diagnose. */
+export function zeitplanLaeuft(): boolean {
+ return plan !== undefined;
+}
diff --git a/tests/abgleich.test.ts b/tests/abgleich.test.ts
new file mode 100644
index 0000000..92f0a11
--- /dev/null
+++ b/tests/abgleich.test.ts
@@ -0,0 +1,670 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
+import {
+ AbgleichLaeuftBereitsFehler,
+ abgleichAusfuehren,
+ faelligeQuellen,
+ mitAbgleichSperre,
+} from "@/server/services/abgleich";
+import type { HandelnderBenutzer } from "@/server/mandant";
+import { quelleAnlegen, quelleBearbeiten, VerzeichnisFehler } from "@/server/services/verzeichnis";
+import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
+import {
+ attributSetzen,
+ FIXTURE_GELESEN,
+ FIXTURE_PERSONEN,
+ GUID,
+ LDAP_BENUTZER_DN,
+ LDAP_BIND_DN,
+ LDAP_BIND_PASSWORT,
+ LDAP_URL,
+ ldapVerfuegbar,
+ personAnlegen,
+ personLoeschen,
+} from "./fixtures/ldap/container";
+
+/**
+ * Der Abgleich (#016).
+ *
+ * Die Akzeptanzkriterien des Auftrags stehen hier, und sie laufen gegen den
+ * echten Container: erster Lauf legt an, zweiter aktualisiert ohne Duplikate, ein
+ * entferntes Konto wird `aktiv = false` und bleibt, ein per `userAccountControl`
+ * deaktiviertes ebenso. Mit einer Attrappe waere davon nichts belegt — das
+ * Verschwinden aus dem Ergebnis ist genau das Verhalten des Servers, das der
+ * Filter ausloest.
+ *
+ * Die Sperre braucht dagegen kein Verzeichnis: sie liegt in der Datenbank, und
+ * ihre Tests kommen ohne Container aus.
+ */
+
+const ldapDa = await ldapVerfuegbar();
+
+async function grundbestand(modus: Betriebsmodus = Betriebsmodus.INTERN) {
+ const eigene = await testPrisma.kunde.create({
+ data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
+ });
+ await testPrisma.einstellung.create({ data: { id: "global", modus } });
+ const benutzer = await testPrisma.benutzer.create({
+ data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
+ });
+ const leser = await testPrisma.benutzer.create({
+ data: { email: "lisa@firma.example", name: "Lisa", rolle: "LESER" },
+ });
+ return {
+ eigene,
+ admin: { id: benutzer.id, rolle: benutzer.rolle },
+ leser: { id: leser.id, rolle: leser.rolle },
+ };
+}
+
+async function quelleFuerContainer(admin: HandelnderBenutzer, abweichend: Record = {}) {
+ return quelleAnlegen(
+ {
+ name: "Firmen-AD",
+ url: LDAP_URL,
+ bindDn: LDAP_BIND_DN,
+ bindPasswort: LDAP_BIND_PASSWORT,
+ basisDn: LDAP_BENUTZER_DN,
+ aktiv: true,
+ ...abweichend,
+ },
+ admin,
+ );
+}
+
+/** Ein Ereignis, auf das ein Test warten kann: `[ausloesen, warten]`. */
+function signal(): [() => void, Promise] {
+ let ausloesen: () => void = () => undefined;
+ const warten = new Promise((aufloesen) => {
+ ausloesen = aufloesen;
+ });
+ return [() => ausloesen(), warten];
+}
+
+// ---- Die Sperre ------------------------------------------------------------
+
+describe.runIf(datenbankVerfuegbar)("Abgleich-Sperre", () => {
+ beforeEach(async () => {
+ await datenbankLeeren();
+ });
+
+ it("laesst nur einen gleichzeitig hinein", async () => {
+ /*
+ * Zwei Laeufe, die sich wirklich ueberlappen — und zwar in einer festen
+ * Reihenfolge. Auf `Promise.all` zu setzen waere hier ein wackliger Test: wer
+ * von beiden die Zeile zuerst schreibt, entscheidet dann die Datenbank, und
+ * beide Ausgaenge waeren richtig. Deshalb sagt der erste Lauf ausdruecklich,
+ * dass er die Sperre hat, und haelt sie, bis der zweite es versucht hat.
+ */
+ const [haeltDieSperre, ersterIstDrin] = signal();
+ const [freigeben, darfWeiter] = signal();
+
+ const erster = mitAbgleichSperre("abgleich:test", 5, async () => {
+ haeltDieSperre();
+ await darfWeiter;
+ return "erster";
+ });
+ await ersterIstDrin;
+
+ const zweiter = await mitAbgleichSperre("abgleich:test", 5, async () => "zweiter");
+ freigeben();
+
+ // Abgewiesen, nicht eingereiht: `undefined` und nicht ein zweites Ergebnis.
+ expect(zweiter).toBeUndefined();
+ expect(await erster).toBe("erster");
+ // Danach ist die Sperre wieder frei.
+ expect(await testPrisma.abgleichSperre.count()).toBe(0);
+ expect(await mitAbgleichSperre("abgleich:test", 5, async () => "dritter")).toBe("dritter");
+ });
+
+ it("sperrt je Schluessel und nicht global", async () => {
+ const [haeltDieSperre, ersterIstDrin] = signal();
+ const [freigeben, darfWeiter] = signal();
+ const eine = mitAbgleichSperre("abgleich:eine", 5, async () => {
+ haeltDieSperre();
+ await darfWeiter;
+ return "eine";
+ });
+ await ersterIstDrin;
+ expect(await mitAbgleichSperre("abgleich:andere", 5, async () => "andere")).toBe("andere");
+ freigeben();
+ expect(await eine).toBe("eine");
+ });
+
+ it("gibt die Sperre auch frei, wenn der Lauf wirft", async () => {
+ await expect(
+ mitAbgleichSperre("abgleich:test", 5, async () => {
+ throw new Error("mitten im Abgleich");
+ }),
+ ).rejects.toThrow("mitten im Abgleich");
+ expect(await testPrisma.abgleichSperre.count()).toBe(0);
+ });
+
+ it("uebernimmt eine abgelaufene Sperre", async () => {
+ // Stuerzt der Prozess mitten im Abgleich ab, bleibt die Zeile stehen. Ohne
+ // Ablauf waere die Quelle fuer immer blockiert – und niemand wuesste, warum.
+ await testPrisma.abgleichSperre.create({
+ data: {
+ schluessel: "abgleich:test",
+ gehaltenVon: "abgestuerzter-rechner/1",
+ gehaltenSeit: new Date(Date.now() - 3 * 60 * 60 * 1000),
+ laeuftAbAm: new Date(Date.now() - 60 * 60 * 1000),
+ },
+ });
+ expect(await mitAbgleichSperre("abgleich:test", 5, async () => "uebernommen")).toBe("uebernommen");
+ expect(await testPrisma.abgleichSperre.count()).toBe(0);
+ });
+
+ it("laesst eine lebende Sperre eines anderen Halters in Ruhe", async () => {
+ await testPrisma.abgleichSperre.create({
+ data: {
+ schluessel: "abgleich:test",
+ gehaltenVon: "anderer-rechner/1",
+ laeuftAbAm: new Date(Date.now() + 60 * 60 * 1000),
+ },
+ });
+ expect(
+ await mitAbgleichSperre("abgleich:test", 5, async () => "haette nicht laufen duerfen"),
+ ).toBeUndefined();
+ // Und sie wird auch nicht beim Aufraeumen mitgeloescht: sie gehoert einem
+ // anderen Lauf.
+ const zeile = await testPrisma.abgleichSperre.findUniqueOrThrow({
+ where: { schluessel: "abgleich:test" },
+ });
+ expect(zeile.gehaltenVon).toBe("anderer-rechner/1");
+ });
+});
+
+// ---- Faellige Quellen ------------------------------------------------------
+
+describe.runIf(datenbankVerfuegbar)("Faellige Quellen", () => {
+ beforeEach(async () => {
+ await datenbankLeeren();
+ });
+
+ it("nennt eine Quelle, die noch nie gelaufen ist", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleFuerContainer(admin);
+ expect(await faelligeQuellen()).toEqual([{ id: quelle.id, name: "Firmen-AD" }]);
+ });
+
+ it("laesst eine abgeschaltete Quelle aus", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleFuerContainer(admin, { aktiv: false });
+ expect(await faelligeQuellen()).toEqual([]);
+ await quelleBearbeiten(quelle.id, { aktiv: true }, admin);
+ expect(await faelligeQuellen()).toHaveLength(1);
+ });
+
+ it("rechnet mit dem Intervall der einzelnen Quelle", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleFuerContainer(admin, { abgleichIntervallMinuten: 30 });
+ const vorZwanzigMinuten = new Date(Date.now() - 20 * 60 * 1000);
+ await testPrisma.verzeichnisquelle.update({
+ where: { id: quelle.id },
+ data: { letzterAbgleichAm: vorZwanzigMinuten },
+ });
+
+ // Nach zwanzig von dreissig Minuten noch nicht, nach vierzig schon.
+ expect(await faelligeQuellen()).toEqual([]);
+ expect(await faelligeQuellen(new Date(Date.now() + 20 * 60 * 1000))).toHaveLength(1);
+ });
+
+ it("ist im Modus Dienstleistung leer", async () => {
+ const { admin } = await grundbestand();
+ await quelleFuerContainer(admin);
+ await testPrisma.einstellung.update({
+ where: { id: "global" },
+ data: { modus: Betriebsmodus.DIENSTLEISTUNG },
+ });
+ expect(await faelligeQuellen()).toEqual([]);
+ });
+});
+
+// ---- Rechte und Betriebsmodus ---------------------------------------------
+
+describe.runIf(datenbankVerfuegbar)("Abgleich starten", () => {
+ beforeEach(async () => {
+ await datenbankLeeren();
+ });
+
+ it("verlangt das Recht verwaltung", async () => {
+ const { admin, leser } = await grundbestand();
+ const quelle = await quelleFuerContainer(admin);
+ await expect(abgleichAusfuehren(quelle.id, leser)).rejects.toThrow(/verwaltung/u);
+ });
+
+ it("ist im Modus Dienstleistung abgeschaltet", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleFuerContainer(admin);
+ await testPrisma.einstellung.update({
+ where: { id: "global" },
+ data: { modus: Betriebsmodus.DIENSTLEISTUNG },
+ });
+ await expect(abgleichAusfuehren(quelle.id, admin)).rejects.toThrow(/Dienstleistung/u);
+ });
+
+ it("weist den zweiten gleichzeitigen Abgleich derselben Quelle ab", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleFuerContainer(admin);
+ // Die Sperre steht schon – so, wie sie stuende, waehrend der erste Lauf
+ // laeuft. Der Weg dorthin ist oben einzeln geprueft.
+ await testPrisma.abgleichSperre.create({
+ data: {
+ schluessel: `abgleich:${quelle.id}`,
+ gehaltenVon: "erster-lauf/1",
+ laeuftAbAm: new Date(Date.now() + 60 * 60 * 1000),
+ },
+ });
+
+ await expect(abgleichAusfuehren(quelle.id, admin)).rejects.toThrow(AbgleichLaeuftBereitsFehler);
+ // Und der abgewiesene Lauf hat den Stand des laufenden nicht angefasst.
+ const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
+ expect(zeile.letzterAbgleichStatus).toBeNull();
+ expect(await testPrisma.mitarbeiter.count()).toBe(0);
+ });
+
+ it("meldet eine unbekannte Quelle", async () => {
+ const { admin } = await grundbestand();
+ await expect(abgleichAusfuehren("gibtesnicht", admin)).rejects.toThrow(VerzeichnisFehler);
+ });
+});
+
+// ---- Gegen den echten Container -------------------------------------------
+
+describe.runIf(datenbankVerfuegbar && ldapDa)("Abgleich gegen das Verzeichnis", () => {
+ const protokolliert: string[] = [];
+ /** Was der Test im Verzeichnis veraendert hat und wieder zuruecknehmen muss. */
+ let aufraeumen: (() => Promise)[] = [];
+
+ beforeEach(async () => {
+ await datenbankLeeren();
+ aufraeumen = [];
+ protokolliert.length = 0;
+ for (const kanal of ["log", "info", "warn", "error", "debug"] as const) {
+ vi.spyOn(console, kanal).mockImplementation((...teile: unknown[]) => {
+ protokolliert.push(teile.map((teil) => String(teil)).join(" "));
+ });
+ }
+ });
+
+ afterEach(async () => {
+ vi.restoreAllMocks();
+ // Der Container muss nach jedem Test wieder wie vorher sein, sonst haengt das
+ // Ergebnis des naechsten an der Reihenfolge.
+ for (const schritt of aufraeumen.reverse()) await schritt();
+ });
+
+ it("legt beim ersten Lauf an und aktualisiert beim zweiten ohne Duplikate", async () => {
+ const { admin, eigene } = await grundbestand();
+ const quelle = await quelleFuerContainer(admin);
+
+ const erster = await abgleichAusfuehren(quelle.id, admin);
+ expect(erster).toMatchObject({
+ angelegt: FIXTURE_PERSONEN,
+ aktualisiert: 0,
+ deaktiviert: 0,
+ gelesen: FIXTURE_GELESEN,
+ status: "ERFOLG",
+ fehler: null,
+ });
+
+ const zweiter = await abgleichAusfuehren(quelle.id, admin);
+ // Nichts hat sich im Verzeichnis geaendert, also ist auch nichts geaendert
+ // worden – und kein Duplikat entstanden.
+ expect(zweiter).toMatchObject({ angelegt: 0, aktualisiert: 0, deaktiviert: 0, status: "ERFOLG" });
+ expect(await testPrisma.mitarbeiter.count()).toBe(FIXTURE_PERSONEN);
+
+ const personen = await testPrisma.mitarbeiter.findMany({ orderBy: { sam: "asc" } });
+ expect(personen.map((person) => person.sam)).toEqual(["jweiss", "kberger", "mhuber"]);
+ for (const person of personen) {
+ expect(person.kundeId).toBe(eigene.id);
+ expect(person.quelleId).toBe(quelle.id);
+ expect(person.herkunft).toBe(Herkunft.ABGLEICH);
+ expect(person.aktiv).toBe(true);
+ expect(person.zuletztGesehenAm).not.toBeNull();
+ }
+ });
+
+ it("uebernimmt die gemappten Felder, den Vorgesetzten und die Umlaute", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleFuerContainer(admin);
+ await abgleichAusfuehren(quelle.id, admin);
+
+ const huber = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "mhuber" } });
+ expect(huber).toMatchObject({
+ externeId: GUID.huber,
+ vorname: "Maria",
+ nachname: "Huber",
+ anzeigename: "Huber, Maria",
+ mail: "maria.huber@firma.test",
+ upn: "maria.huber@firma.test",
+ abteilung: "Buchhaltung",
+ titel: "Leiterin Rechnungsprüfung",
+ // Der Vorgesetzte steht im Verzeichnis als DN; gespeichert wird seine
+ // Kennung, damit ein Umzug in eine andere OU ihn nicht verliert.
+ vorgesetzterExterneId: GUID.berger,
+ });
+ expect(huber.dn).toBe("cn=Maria Huber,ou=Buchhaltung,ou=Benutzer,dc=firma,dc=test");
+
+ const weiss = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "jweiss" } });
+ expect(weiss.nachname).toBe("Weiß");
+ expect(weiss.vorname).toBe("Jörg");
+
+ // Der Chef selbst hat keinen Vorgesetzten.
+ const berger = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "kberger" } });
+ expect(berger.vorgesetzterExterneId).toBeNull();
+ });
+
+ it("zaehlt eine echte Aenderung als aktualisiert", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleFuerContainer(admin);
+ await abgleichAusfuehren(quelle.id, admin);
+
+ const dn = "cn=Klaus Berger,ou=Geschaeftsfuehrung,ou=Benutzer,dc=firma,dc=test";
+ await attributSetzen(dn, "title", "Sprecher der Geschäftsführung");
+ aufraeumen.push(() => attributSetzen(dn, "title", "Geschaeftsfuehrer"));
+
+ const zweiter = await abgleichAusfuehren(quelle.id, admin);
+ expect(zweiter).toMatchObject({ angelegt: 0, aktualisiert: 1, deaktiviert: 0 });
+ const berger = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "kberger" } });
+ expect(berger.titel).toBe("Sprecher der Geschäftsführung");
+ });
+
+ it("setzt ein entferntes Konto auf aktiv = false und behaelt es", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleFuerContainer(admin);
+
+ const dn = await personAnlegen({
+ cn: "Temporaer Weg",
+ sn: "Weg",
+ givenName: "Temporaer",
+ sam: "tweg",
+ guidBase64: "ESIzRFVmd4iZqrvM3e7/AA==",
+ });
+ let nochDa = true;
+ aufraeumen.push(async () => {
+ if (nochDa) await personLoeschen(dn);
+ });
+
+ const erster = await abgleichAusfuehren(quelle.id, admin);
+ expect(erster.angelegt).toBe(FIXTURE_PERSONEN + 1);
+ const vorher = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "tweg" } });
+ expect(vorher.aktiv).toBe(true);
+
+ await personLoeschen(dn);
+ nochDa = false;
+
+ const zweiter = await abgleichAusfuehren(quelle.id, admin);
+ expect(zweiter).toMatchObject({ angelegt: 0, deaktiviert: 1, status: "ERFOLG" });
+
+ // Abgeschaltet, aber da: an einer Person haengen Laeufe, und ein
+ // archivierter Lauf muss auch in zehn Jahren noch sagen koennen, fuer wen er
+ // lief.
+ const nachher = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "tweg" } });
+ expect(nachher.aktiv).toBe(false);
+ expect(nachher.id).toBe(vorher.id);
+ expect(nachher.zuletztGesehenAm).toEqual(vorher.zuletztGesehenAm);
+ expect(await testPrisma.mitarbeiter.count()).toBe(FIXTURE_PERSONEN + 1);
+ });
+
+ it("setzt ein per userAccountControl deaktiviertes Konto auf aktiv = false", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleFuerContainer(admin);
+
+ const dn = await personAnlegen({
+ cn: "Bald Gesperrt",
+ sn: "Gesperrt",
+ givenName: "Bald",
+ sam: "bgesperrt",
+ guidBase64: "AAAAAAAAAAAAAAAAAAABAQ==",
+ });
+ aufraeumen.push(() => personLoeschen(dn));
+
+ await abgleichAusfuehren(quelle.id, admin);
+ expect((await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "bgesperrt" } })).aktiv).toBe(true);
+
+ // 514 = 512 (normales Konto) + 2 (ACCOUNTDISABLE). Der Standardfilter
+ // schliesst das Konto damit aus – es verschwindet aus dem Ergebnis, ohne
+ // dass der Abgleich etwas ueber Kontosperren wissen muss.
+ await attributSetzen(dn, "userAccountControl", "514");
+
+ const zweiter = await abgleichAusfuehren(quelle.id, admin);
+ expect(zweiter.deaktiviert).toBe(1);
+ const gesperrt = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "bgesperrt" } });
+ expect(gesperrt.aktiv).toBe(false);
+
+ // Und wird es wieder freigegeben, ist die Person wieder aktiv – mit
+ // derselben Zeile und damit denselben Laeufen.
+ await attributSetzen(dn, "userAccountControl", "512");
+ const dritter = await abgleichAusfuehren(quelle.id, admin);
+ expect(dritter).toMatchObject({ angelegt: 0, aktualisiert: 1, deaktiviert: 0 });
+ const wieder = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "bgesperrt" } });
+ expect(wieder.aktiv).toBe(true);
+ expect(wieder.id).toBe(gesperrt.id);
+ });
+
+ it("laesst von Hand angelegte Personen unberuehrt", async () => {
+ const { admin, eigene } = await grundbestand();
+ const quelle = await quelleFuerContainer(admin);
+ const vonHand = await testPrisma.mitarbeiter.create({
+ data: {
+ kundeId: eigene.id,
+ vorname: "Neue",
+ nachname: "Kollegin",
+ anzeigename: "Kollegin, Neue",
+ herkunft: Herkunft.MANUELL,
+ },
+ });
+
+ const ergebnis = await abgleichAusfuehren(quelle.id, admin);
+ // Sie steht in keinem Verzeichnis – deshalb darf der Abgleich sie auch nicht
+ // abschalten. Beim Onboarding ist sie gerade der wichtigste Datensatz.
+ expect(ergebnis.deaktiviert).toBe(0);
+ const nachher = await testPrisma.mitarbeiter.findUniqueOrThrow({ where: { id: vonHand.id } });
+ expect(nachher.aktiv).toBe(true);
+ expect(nachher.herkunft).toBe(Herkunft.MANUELL);
+ });
+
+ it("uebernimmt die von Hand angelegte Person, statt sie zu verdoppeln", async () => {
+ const { admin, eigene } = await grundbestand();
+ const quelle = await quelleFuerContainer(admin);
+ // Genau der Onboarding-Ablauf: die Person entsteht hier, das AD-Konto danach.
+ // `sam` ist derselbe – nur mit der Gross-/Kleinschreibung, die jemand tippt.
+ const vonHand = await testPrisma.mitarbeiter.create({
+ data: {
+ kundeId: eigene.id,
+ vorname: "Maria",
+ nachname: "Huber",
+ anzeigename: "Huber, Maria",
+ sam: "MHuber",
+ herkunft: Herkunft.MANUELL,
+ },
+ });
+ const lauf = await testPrisma.lauf.count();
+
+ const ergebnis = await abgleichAusfuehren(quelle.id, admin);
+
+ // Drei Personen im Verzeichnis, eine davon war schon da: zwei angelegt, eine
+ // uebernommen – und keine zweite Maria Huber.
+ expect(ergebnis).toMatchObject({ angelegt: FIXTURE_PERSONEN - 1, uebernommen: 1 });
+ expect(await testPrisma.mitarbeiter.count()).toBe(FIXTURE_PERSONEN);
+ expect(await testPrisma.mitarbeiter.count({ where: { sam: "mhuber" } })).toBe(1);
+
+ // Es ist dieselbe Zeile: ihre Laeufe und Geraete haengen daran.
+ const nachher = await testPrisma.mitarbeiter.findUniqueOrThrow({ where: { id: vonHand.id } });
+ expect(nachher.quelleId).toBe(quelle.id);
+ expect(nachher.externeId).toBe(GUID.huber);
+ expect(nachher.herkunft).toBe(Herkunft.ABGLEICH);
+ expect(nachher.sam).toBe("mhuber");
+ expect(await testPrisma.lauf.count()).toBe(lauf);
+
+ // Und der naechste Lauf findet sie ueber die Kennung, nicht mehr ueber sam.
+ const zweiter = await abgleichAusfuehren(quelle.id, admin);
+ expect(zweiter).toMatchObject({ angelegt: 0, uebernommen: 0, aktualisiert: 0 });
+ expect(await testPrisma.mitarbeiter.count()).toBe(FIXTURE_PERSONEN);
+ });
+
+ it("laesst eine von Hand angelegte Person einer anderen Quelle in Ruhe", async () => {
+ const { admin, eigene } = await grundbestand();
+ const quelle = await quelleFuerContainer(admin);
+ const fremde = await quelleFuerContainer(admin, { name: "Zweites AD", aktiv: false });
+ // Sie gehoert schon einer Quelle. Auch bei gleichem `sam` wird sie nicht
+ // uebernommen – sonst wanderte eine Person zwischen zwei Verzeichnissen hin
+ // und her, je nachdem, welcher Abgleich zuletzt lief.
+ const anderswo = await testPrisma.mitarbeiter.create({
+ data: {
+ kundeId: eigene.id,
+ quelleId: fremde.id,
+ externeId: "fremde-kennung",
+ vorname: "Maria",
+ nachname: "Huber",
+ anzeigename: "Huber, Maria",
+ sam: "mhuber",
+ herkunft: Herkunft.ABGLEICH,
+ },
+ });
+
+ const ergebnis = await abgleichAusfuehren(quelle.id, admin);
+ expect(ergebnis).toMatchObject({ angelegt: FIXTURE_PERSONEN, uebernommen: 0 });
+ const nachher = await testPrisma.mitarbeiter.findUniqueOrThrow({ where: { id: anderswo.id } });
+ expect(nachher.quelleId).toBe(fremde.id);
+ expect(nachher.externeId).toBe("fremde-kennung");
+ });
+
+ it("ueberspringt das Konto ohne objectGUID, ohne den Lauf abzubrechen", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleFuerContainer(admin);
+ const ergebnis = await abgleichAusfuehren(quelle.id, admin);
+
+ // Vier Eintraege gelesen, drei angelegt: „Ohne Kennung“ hat kein
+ // objectGUID und waere bei jedem Lauf wieder neu.
+ expect(ergebnis.gelesen).toBe(FIXTURE_GELESEN);
+ expect(ergebnis.angelegt).toBe(FIXTURE_PERSONEN);
+ expect(await testPrisma.mitarbeiter.count({ where: { sam: "okennung" } })).toBe(0);
+
+ // Der Lauf ist erfolgreich – aber nicht lautlos: was uebersprungen wurde,
+ // steht als Hinweis daneben, und `fehler` bleibt frei fuer echte Fehler.
+ expect(ergebnis.status).toBe("ERFOLG");
+ expect(ergebnis.fehler).toBeNull();
+ expect(ergebnis.hinweis).toContain("1 von 4 Einträgen übersprungen");
+ // Kein doppelter Punkt am Satzende, auch wenn der Grund schon einen tragt.
+ expect(ergebnis.hinweis).not.toContain("..");
+
+ const protokoll = await testPrisma.protokoll.findFirstOrThrow({
+ where: { entitaetId: quelle.id, aktion: "Verzeichnis abgeglichen" },
+ });
+ expect(protokoll.nachher).toMatchObject({ gelesen: FIXTURE_GELESEN, uebersprungen: 1 });
+ });
+
+ it("deaktiviert niemanden, wenn sich kein einziger Eintrag zuordnen laesst", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleFuerContainer(admin);
+ // Erst ein sauberer Lauf, damit es ueberhaupt aktive Personen gibt.
+ await abgleichAusfuehren(quelle.id, admin);
+ expect(await testPrisma.mitarbeiter.count({ where: { aktiv: true } })).toBe(FIXTURE_PERSONEN);
+
+ // Und jetzt ein Kennungsattribut, das es im Verzeichnis nicht gibt: der Server
+ // liefert weiter vier Eintraege, keiner davon hat eine Kennung. Ohne die
+ // Notbremse hiesse „in keinem Ergebnis gesehen“ hier „alle deaktivieren“ – ein
+ // Tippfehler im Mapping wuerde das halbe Haus abschalten.
+ await quelleBearbeiten(quelle.id, { attributZuordnung: { externeId: "gibtEsNichtAttr" } }, admin);
+ const ergebnis = await abgleichAusfuehren(quelle.id, admin);
+
+ expect(ergebnis).toMatchObject({
+ gelesen: FIXTURE_GELESEN,
+ angelegt: 0,
+ aktualisiert: 0,
+ deaktiviert: 0,
+ status: "FEHLER",
+ });
+ expect(ergebnis.fehler).toContain("Kein einziger");
+ expect(ergebnis.fehler).toContain("Niemand wurde deaktiviert");
+ expect(await testPrisma.mitarbeiter.count({ where: { aktiv: true } })).toBe(FIXTURE_PERSONEN);
+
+ const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
+ expect(zeile.letzterAbgleichStatus).toBe("FEHLER");
+ });
+
+ it("schreibt Ergebnis und Zeitpunkt an die Quelle und ins Protokoll", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleFuerContainer(admin);
+ await abgleichAusfuehren(quelle.id, admin);
+
+ const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
+ expect(zeile.letzterAbgleichStatus).toBe("ERFOLG");
+ expect(zeile.letzterAbgleichFehler).toBeNull();
+ expect(zeile.letzterAbgleichAm).toBeInstanceOf(Date);
+
+ const protokoll = await testPrisma.protokoll.findFirstOrThrow({
+ where: { entitaetId: quelle.id, aktion: "Verzeichnis abgeglichen" },
+ });
+ expect(protokoll.benutzerId).toBe(admin.id);
+ expect(protokoll.nachher).toMatchObject({
+ angelegt: FIXTURE_PERSONEN,
+ aktualisiert: 0,
+ deaktiviert: 0,
+ status: "ERFOLG",
+ });
+ });
+
+ it("meldet einen fehlgeschlagenen Bind, ohne das Passwort weiterzugeben", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleFuerContainer(admin);
+ await abgleichAusfuehren(quelle.id, admin);
+ await quelleBearbeiten(quelle.id, { bindPasswort: "falsches-passwort-xyz" }, admin);
+
+ const ergebnis = await abgleichAusfuehren(quelle.id, admin);
+ expect(ergebnis.status).toBe("FEHLER");
+ expect(ergebnis.fehler).toBeTruthy();
+ expect(ergebnis.fehler).not.toContain("falsches-passwort-xyz");
+
+ const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
+ expect(zeile.letzterAbgleichStatus).toBe("FEHLER");
+ expect(zeile.letzterAbgleichFehler).not.toContain("falsches-passwort-xyz");
+ // Auch nicht im Protokoll und nicht auf der Konsole.
+ const protokoll = await testPrisma.protokoll.findMany({ where: { entitaetId: quelle.id } });
+ expect(JSON.stringify(protokoll)).not.toContain("falsches-passwort-xyz");
+ expect(protokolliert.join("\n")).not.toContain("falsches-passwort-xyz");
+ });
+
+ it("schaltet bei einem Fehler niemanden ab", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleFuerContainer(admin);
+ await abgleichAusfuehren(quelle.id, admin);
+ // Eine halbe Liste saehe aus wie eine halbe Firma, die gegangen ist. Genau
+ // deshalb wird nur nach einem vollstaendigen Durchlauf deaktiviert.
+ await quelleBearbeiten(quelle.id, { url: "ldap://127.0.0.1:1" }, admin);
+
+ const ergebnis = await abgleichAusfuehren(quelle.id, admin);
+ expect(ergebnis).toMatchObject({ status: "FEHLER", deaktiviert: 0, angelegt: 0 });
+ expect(await testPrisma.mitarbeiter.count({ where: { aktiv: true } })).toBe(FIXTURE_PERSONEN);
+ });
+
+ it("laeuft auch ohne Benutzer – als Systemlauf des Zeitplans", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleFuerContainer(admin);
+ // Der Zeitplan hat keinen angemeldeten Benutzer und schickt eine leere
+ // Kennung. Eine leere Zeichenkette als `benutzerId` waere ein Verstoss gegen
+ // den Fremdschluessel – der Abgleich braeche erst beim Protokoll ab, also
+ // nachdem er die Mitarbeiter schon geaendert hat.
+ const system = { id: "", rolle: "ADMIN" } as const;
+
+ const ergebnis = await abgleichAusfuehren(quelle.id, system);
+ expect(ergebnis).toMatchObject({ angelegt: FIXTURE_PERSONEN, status: "ERFOLG" });
+ const protokoll = await testPrisma.protokoll.findFirstOrThrow({
+ where: { entitaetId: quelle.id, aktion: "Verzeichnis abgeglichen" },
+ });
+ expect(protokoll.benutzerId).toBeNull();
+ });
+
+ it("gibt die Sperre nach dem Lauf wieder frei", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleFuerContainer(admin);
+ await abgleichAusfuehren(quelle.id, admin);
+ expect(await testPrisma.abgleichSperre.count()).toBe(0);
+ // Und ein zweiter Lauf laeuft wirklich, statt an der eigenen Sperre zu
+ // scheitern.
+ await expect(abgleichAusfuehren(quelle.id, admin)).resolves.toMatchObject({ status: "ERFOLG" });
+ });
+});
diff --git a/tests/abteilungsvorschlaege.test.ts b/tests/abteilungsvorschlaege.test.ts
new file mode 100644
index 0000000..d4c4f5f
--- /dev/null
+++ b/tests/abteilungsvorschlaege.test.ts
@@ -0,0 +1,148 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
+import { abteilungAnlegen } from "@/server/services/abteilungen";
+import { ungepflegteAbteilungen } from "@/server/queries/abteilungen";
+import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
+
+/**
+ * Abteilungsvorschläge (#016).
+ *
+ * Die Liste, aus der ein Administrator mit einem Klick echte `Abteilung`-Einträge
+ * macht. Jeder Eintrag ist damit ein Versprechen: der Klick muss gelingen.
+ * Deshalb steht hier nichts, was `abteilungAnlegen` danach abweisen würde — keine
+ * bereits (auch archiviert) gepflegte Abteilung und kein archivierter Kunde.
+ */
+
+async function grundbestand(modus: Betriebsmodus = Betriebsmodus.INTERN) {
+ const eigene = await testPrisma.kunde.create({
+ data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
+ });
+ await testPrisma.einstellung.create({ data: { id: "global", modus } });
+ const benutzer = await testPrisma.benutzer.create({
+ data: { email: "admin@firma.example", name: "Administrator", rolle: "ADMIN" },
+ });
+ return { eigene, admin: { id: benutzer.id, rolle: benutzer.rolle } };
+}
+
+async function person(
+ kundeId: string,
+ abteilung: string,
+ weiteres: { aktiv?: boolean; herkunft?: Herkunft } = {},
+) {
+ const nummer = await testPrisma.mitarbeiter.count();
+ return testPrisma.mitarbeiter.create({
+ data: {
+ kundeId,
+ vorname: "Person",
+ nachname: `Nummer ${nummer}`,
+ anzeigename: `Person Nummer ${nummer}`,
+ abteilung,
+ ...weiteres,
+ },
+ });
+}
+
+describe.runIf(datenbankVerfuegbar)("Abteilungsvorschläge", () => {
+ beforeEach(async () => {
+ await datenbankLeeren();
+ delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
+ });
+
+ it("zeigt nur Namen, zu denen es noch keine Abteilung gibt", async () => {
+ const { admin, eigene } = await grundbestand();
+ await abteilungAnlegen({ name: "Buchhaltung", zielOu: "OU=Buchhaltung" }, admin);
+ await person(eigene.id, "Buchhaltung");
+ await person(eigene.id, "Vertrieb", { herkunft: Herkunft.ABGLEICH });
+ await person(eigene.id, "Vertrieb");
+
+ expect(await ungepflegteAbteilungen(admin)).toEqual([
+ { name: "Vertrieb", anzahl: 2, kundeId: eigene.id },
+ ]);
+ });
+
+ it("zählt deaktivierte Personen nicht mit", async () => {
+ // Eine Abteilung, in der nur noch Ausgeschiedene stehen, braucht keine
+ // Ziel-OU mehr — und ein Vorschlag, den niemand mehr braucht, verdeckt die
+ // übrigen.
+ const { admin, eigene } = await grundbestand();
+ await person(eigene.id, "Altlast", { aktiv: false });
+ await person(eigene.id, "Vertrieb");
+
+ expect((await ungepflegteAbteilungen(admin)).map((eintrag) => eintrag.name)).toEqual(["Vertrieb"]);
+ });
+
+ it("schlägt nicht vor, was es archiviert schon gibt", async () => {
+ // Der Name ist je Kunde eindeutig: `abteilungAnlegen` würde diesen Klick mit
+ // „gibt es schon“ abweisen. Ein Knopf, der das tut, ist keiner.
+ const { admin, eigene } = await grundbestand();
+ const abteilung = await abteilungAnlegen({ name: "Vertrieb" }, admin);
+ await testPrisma.abteilung.update({ where: { id: abteilung.id }, data: { archiviert: true } });
+ await person(eigene.id, "Vertrieb");
+
+ expect(await ungepflegteAbteilungen(admin)).toEqual([]);
+ await expect(abteilungAnlegen({ name: "Vertrieb" }, admin)).rejects.toThrow(/gibt es schon/);
+ });
+
+ it("übernimmt einen Vorschlag – und zwar nur den Namen", async () => {
+ const { admin, eigene } = await grundbestand();
+ await person(eigene.id, "Vertrieb");
+ const offen = await ungepflegteAbteilungen(admin);
+ expect(offen).toHaveLength(1);
+
+ const abteilung = await abteilungAnlegen({ name: offen[0]!.name, kundeId: offen[0]!.kundeId }, admin);
+ expect(abteilung).toMatchObject({ name: "Vertrieb", zielOu: null, standardGruppen: [] });
+ expect(await ungepflegteAbteilungen(admin)).toEqual([]);
+ });
+
+ describe("Mandantengrenze", () => {
+ async function zweiKunden() {
+ const { admin, eigene } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
+ const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
+ const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
+ const techniker = await testPrisma.benutzer.create({
+ data: { email: "t@firma.example", name: "Techniker", rolle: "TECHNIKER" },
+ });
+ await testPrisma.benutzerKunde.create({ data: { benutzerId: techniker.id, kundeId: meiner.id } });
+ process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
+ return { admin, eigene, meiner, fremder, techniker: { id: techniker.id, rolle: techniker.rolle } };
+ }
+
+ it("zeigt nur die Vorschläge der sichtbaren Kunden", async () => {
+ const { meiner, fremder, techniker } = await zweiKunden();
+ await person(meiner.id, "Vertrieb");
+ await person(fremder.id, "Vertrieb");
+ await person(fremder.id, "Technik");
+
+ expect(await ungepflegteAbteilungen(techniker)).toEqual([
+ { name: "Vertrieb", anzahl: 1, kundeId: meiner.id },
+ ]);
+ });
+
+ it("hält Kunden auseinander, die denselben Namen benutzen", async () => {
+ // „Vertrieb“ ist bei einem Kunden gepflegt und beim anderen nicht. Ohne die
+ // Gruppierung nach Kunde und Name verschwände der offene Fall — genau das
+ // stand hier, bis der Review von #011 es gefunden hat.
+ const { admin, meiner, fremder } = await zweiKunden();
+ await testPrisma.abteilung.create({ data: { kundeId: meiner.id, name: "Vertrieb" } });
+ await person(meiner.id, "Vertrieb");
+ await person(fremder.id, "Vertrieb");
+
+ expect(await ungepflegteAbteilungen(admin)).toEqual([
+ { name: "Vertrieb", anzahl: 1, kundeId: fremder.id },
+ ]);
+ });
+
+ it("schlägt nichts für einen archivierten Kunden vor", async () => {
+ // `kundeErmitteln` weist einen archivierten Kunden ab; der Klick auf den
+ // Vorschlag ergäbe nur eine Fehlermeldung.
+ const { admin, fremder } = await zweiKunden();
+ await person(fremder.id, "Vertrieb");
+ await testPrisma.kunde.update({ where: { id: fremder.id }, data: { archiviert: true } });
+
+ expect(await ungepflegteAbteilungen(admin)).toEqual([]);
+ await expect(abteilungAnlegen({ name: "Vertrieb", kundeId: fremder.id }, admin)).rejects.toThrow(
+ /archiviert/,
+ );
+ });
+ });
+});
diff --git a/tests/e2e/verzeichnis.spec.ts b/tests/e2e/verzeichnis.spec.ts
new file mode 100644
index 0000000..150cd52
--- /dev/null
+++ b/tests/e2e/verzeichnis.spec.ts
@@ -0,0 +1,87 @@
+import { expect, test, type Page } from "@playwright/test";
+import "dotenv/config";
+import { prisma } from "@/lib/db";
+
+/**
+ * Admin → Verzeichnis durch die Oberflaeche (#016).
+ *
+ * Ohne LDAP-Container: geprueft wird, was auch ohne Verzeichnis gilt — dass die
+ * Attributzuordnung bedienbar ist, dass eine krumme Eingabe abgewiesen wird und
+ * dass eine gute wirklich in der Datenbank landet. Was der Abgleich **tut**,
+ * steht in `tests/abgleich.test.ts` gegen einen echten Server.
+ *
+ * Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`).
+ * Im Entwicklungsmodus haengt React sich hier nicht an, und dann tut kein Knopf
+ * etwas (siehe CLAUDE.md, „Oberflaeche pruefen“).
+ */
+
+async function anmelden(seite: Page) {
+ const email = process.env["SEED_ADMIN_EMAIL"];
+ const passwort = process.env["SEED_ADMIN_PASSWORD"];
+ if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen.");
+ await seite.goto("/anmelden");
+ await seite.fill('input[name="email"]', email);
+ await seite.fill('input[name="passwort"]', passwort);
+ await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
+}
+
+test.describe("Verzeichnis", () => {
+ test("macht die Attributzuordnung bedienbar und prüft sie", async ({ page }) => {
+ test.slow();
+ const quelle = await prisma.verzeichnisquelle.findFirst({ orderBy: { name: "asc" } });
+ test.skip(quelle === null, "Keine Verzeichnisquelle im Bestand.");
+ if (!quelle) return;
+ const vorher = quelle.attributZuordnung;
+
+ await anmelden(page);
+ await page.goto("/admin/verzeichnis");
+ await page.locator("summary", { hasText: "Einstellungen bearbeiten" }).first().click();
+ await page.locator("summary", { hasText: "Attributzuordnung anpassen" }).first().click();
+
+ const kennung = page.locator('input[name="attr.externeId"]').first();
+ await expect(kennung).toBeVisible();
+ await expect(kennung).toHaveValue("objectGUID");
+
+ // Eine Klammer darf nicht in eine LDAP-Anfrage wandern.
+ const titel = page.locator('input[name="attr.titel"]').first();
+ await titel.fill("(objectClass=*)");
+ await page.getByRole("button", { name: "Speichern" }).first().click();
+ await expect(page.locator('[role="alert"]').first()).toContainText("kein Attributname");
+
+ // Und eine gute Eingabe steht danach wirklich in der Datenbank.
+ await titel.fill("personalTitle");
+ await page.getByRole("button", { name: "Speichern" }).first().click();
+ await expect(page.locator('[role="status"]').last()).toContainText("gespeichert");
+
+ const nachher = await prisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
+ expect((nachher.attributZuordnung as Record)["titel"]).toBe("personalTitle");
+
+ // Neu geladen steht sie auch im Formular – sonst waere sie nur gespeichert,
+ // aber nicht bearbeitbar.
+ await page.reload();
+ await page.locator("summary", { hasText: "Einstellungen bearbeiten" }).first().click();
+ await page.locator("summary", { hasText: "Attributzuordnung anpassen" }).first().click();
+ await expect(page.locator('input[name="attr.titel"]').first()).toHaveValue("personalTitle");
+
+ await prisma.verzeichnisquelle.update({
+ where: { id: quelle.id },
+ data: { attributZuordnung: vorher === null ? {} : vorher },
+ });
+ });
+
+ test("zeigt das Abgleich-Log und trägt am Handy ohne horizontales Scrollen", async ({ page }) => {
+ await anmelden(page);
+ await page.goto("/admin/verzeichnis");
+ const log = page.locator("summary", { hasText: "Abgleich-Log" }).first();
+ if ((await log.count()) > 0) {
+ await log.click();
+ await expect(page.locator("details", { has: log })).toBeVisible();
+ }
+
+ const masse = await page.evaluate(() => ({
+ scroll: document.documentElement.scrollWidth,
+ sichtbar: document.documentElement.clientWidth,
+ }));
+ expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
+ });
+});
diff --git a/tests/fixtures/ldap/container.ts b/tests/fixtures/ldap/container.ts
new file mode 100644
index 0000000..c6bc482
--- /dev/null
+++ b/tests/fixtures/ldap/container.ts
@@ -0,0 +1,161 @@
+import { Attribute, Change, Client } from "ldapts";
+
+/**
+ * Zugang zum LDAP-Testcontainer und das, was im Fixture steht (#016).
+ *
+ * Die Werte stehen hier und nicht in den Tests, weil zwei Testdateien gegen
+ * denselben Container laufen. Die Erwartungen (GUID-Zeichenketten,
+ * Kontonamen) gehoeren mit dazu: sie beschreiben das Fixture und nicht die
+ * Regeln — die stehen in den Tests.
+ *
+ * Der Container wird mit
+ * `docker compose -f docker-compose.test.yml up -d ldap` gestartet.
+ */
+
+export const LDAP_URL = "ldap://127.0.0.1:1389";
+export const LDAP_BASIS_DN = "dc=firma,dc=test";
+export const LDAP_BENUTZER_DN = "ou=Benutzer,dc=firma,dc=test";
+export const LDAP_BIND_DN = "cn=admin,dc=firma,dc=test";
+export const LDAP_BIND_PASSWORT = "ldap-test-geheim";
+
+/**
+ * Die GUID-Zeichenketten der Fixture-Personen.
+ *
+ * Ausgerechnet mit Pythons `uuid.UUID(bytes_le=…)` aus den Bytes in
+ * `daten/50-personen.ldif` — eine unabhaengige Umsetzung derselben
+ * gemischt-endianen Reihenfolge, damit hier nicht die Erwartung aus derselben
+ * Quelle stammt wie das Ergebnis.
+ */
+export const GUID = {
+ berger: "1d3e409c-b27a-4bc9-b1e6-f0a2c3d4e5f6",
+ huber: "5f074310-3c35-4a1d-836f-a50f515e7a35",
+ weiss: "04030201-0605-0807-090a-0b0c0d0e0f10",
+ /** „Alte Kraft“: im Verzeichnis vorhanden, aber deaktiviert. */
+ alteKraft: "ccddeeff-aabb-8899-7766-554433221100",
+} as const;
+
+/** So viele Konten liefert der Standardfilter: drei aktive plus das ohne objectGUID. */
+export const FIXTURE_GELESEN = 4;
+/** Davon zugeordnet, also mit stabiler Kennung. */
+export const FIXTURE_PERSONEN = 3;
+
+export type ContainerStand = { readonly da: true } | { readonly da: false; readonly grund: string };
+
+/**
+ * Laeuft der Container – und stehen die Fixture-Personen wirklich darin?
+ *
+ * Geprueft wird mit einem echten Bind und einer echten Suche, nicht mit einem
+ * offenen Port: ein gestarteter, aber noch nicht bestueckter Container waere
+ * sonst „da“, und die Tests liefen gegen ein leeres Verzeichnis. Ein stilles
+ * Ueberspringen sieht genauso gruen aus wie ein bestandener Lauf, deshalb sagt
+ * diese Funktion laut, was fehlt – und in CI bricht sie ab.
+ */
+export async function ldapContainerStand(): Promise {
+ const client = new Client({ url: LDAP_URL, connectTimeout: 2000, timeout: 5000 });
+ try {
+ await client.bind(LDAP_BIND_DN, LDAP_BIND_PASSWORT);
+ const { searchEntries } = await client.search(LDAP_BENUTZER_DN, {
+ scope: "sub",
+ filter: "(sAMAccountName=mhuber)",
+ attributes: ["dn"],
+ });
+ if (searchEntries.length === 0) {
+ return { da: false, grund: "Der Container laeuft, aber das Fixture „mhuber“ fehlt darin." };
+ }
+ return { da: true };
+ } catch (fehler) {
+ return { da: false, grund: fehler instanceof Error ? fehler.message : String(fehler) };
+ } finally {
+ await client.unbind().catch(() => undefined);
+ }
+}
+
+/**
+ * Der Stand samt Hinweis auf der Konsole. In CI ein Abbruch: `pnpm test` ist das
+ * Tor vor jedem Commit, und dort darf der Abgleich nicht ungeprueft bleiben.
+ */
+export async function ldapVerfuegbar(): Promise {
+ const stand = await ldapContainerStand();
+ if (stand.da) return true;
+ const hinweis =
+ `\n LDAP-Testcontainer nicht erreichbar (${stand.grund}).` +
+ "\n Die Verzeichnis- und Abgleichtests gegen das Verzeichnis werden uebersprungen." +
+ "\n Starten mit: docker compose -f docker-compose.test.yml up -d ldap\n";
+ if (process.env["CI"]) throw new Error(hinweis);
+ // Direkt auf stderr und nicht ueber `console`: Vitest 5 fasst die
+ // Konsolenausgabe einer Testdatei ab und zeigt sie fuer den **Modulrumpf**
+ // nicht an — nachgemessen. Ein `console.warn` hier waere also genau das stille
+ // Ueberspringen, das dieser Hinweis verhindern soll.
+ process.stderr.write(hinweis);
+ return false;
+}
+
+/** Ein Client als Verzeichnisverwalter, fuer Tests, die das Fixture veraendern. */
+export async function alsVerwalter(was: (client: Client) => Promise): Promise {
+ const client = new Client({ url: LDAP_URL, connectTimeout: 2000, timeout: 5000 });
+ try {
+ await client.bind(LDAP_BIND_DN, LDAP_BIND_PASSWORT);
+ return await was(client);
+ } finally {
+ await client.unbind().catch(() => undefined);
+ }
+}
+
+/**
+ * Legt eine zusaetzliche Person an und gibt den Weg zurueck, sie wieder zu
+ * entfernen.
+ *
+ * Das braucht der Test „ein entferntes Konto wird aktiv = false“: er muss ein
+ * Konto wirklich loeschen. Die Fixture-Dateien bleiben dabei unberuehrt, und der
+ * Container ist nach dem Test wieder wie vorher — sonst haengt das Ergebnis des
+ * naechsten Tests an der Reihenfolge.
+ */
+export async function personAnlegen(eintrag: {
+ readonly cn: string;
+ readonly sn: string;
+ readonly givenName: string;
+ readonly sam: string;
+ readonly guidBase64: string;
+ readonly userAccountControl?: number;
+}): Promise {
+ const dn = `cn=${eintrag.cn},ou=Vertrieb,${LDAP_BENUTZER_DN}`;
+ // Als `Attribute[]`, nicht als Objekt: die Objektform von `Client.add` nimmt
+ // nur Zeichenketten, und objectGUID ist ein Puffer.
+ const werte: Attribute[] = [
+ new Attribute({ type: "objectClass", values: ["inetOrgPerson", "user"] }),
+ new Attribute({ type: "cn", values: [eintrag.cn] }),
+ new Attribute({ type: "sn", values: [eintrag.sn] }),
+ new Attribute({ type: "givenName", values: [eintrag.givenName] }),
+ new Attribute({ type: "displayName", values: [`${eintrag.sn}, ${eintrag.givenName}`] }),
+ new Attribute({ type: "mail", values: [`${eintrag.sam}@firma.test`] }),
+ new Attribute({ type: "userPrincipalName", values: [`${eintrag.sam}@firma.test`] }),
+ new Attribute({ type: "sAMAccountName", values: [eintrag.sam] }),
+ new Attribute({ type: "department", values: ["Vertrieb"] }),
+ new Attribute({ type: "objectCategory", values: ["person"] }),
+ new Attribute({ type: "userAccountControl", values: [String(eintrag.userAccountControl ?? 512)] }),
+ new Attribute({ type: "objectGUID", values: [Buffer.from(eintrag.guidBase64, "base64")] }),
+ ];
+ await alsVerwalter(async (client) => {
+ await client.add(dn, werte);
+ });
+ return dn;
+}
+
+export async function personLoeschen(dn: string): Promise {
+ await alsVerwalter(async (client) => {
+ await client.del(dn);
+ });
+}
+
+/** Ein Attribut aendern, um den zweiten Abgleich etwas zu tun zu geben. */
+export async function attributSetzen(dn: string, attribut: string, wert: string): Promise {
+ await alsVerwalter(async (client) => {
+ await client.modify(
+ dn,
+ new Change({
+ operation: "replace",
+ modification: new Attribute({ type: attribut, values: [wert] }),
+ }),
+ );
+ });
+}
diff --git a/tests/fixtures/ldap/daten/50-personen.ldif b/tests/fixtures/ldap/daten/50-personen.ldif
new file mode 100644
index 0000000..69e3fa1
--- /dev/null
+++ b/tests/fixtures/ldap/daten/50-personen.ldif
@@ -0,0 +1,110 @@
+# Fixture-Personen fuer den Verzeichnis-Abgleich (#016).
+#
+# Bewusst klein, aber mit jedem Fall, den der Abgleich unterscheiden muss:
+#
+# Maria Huber vollstaendig, mit Vorgesetztem und Umlaut im Namen
+# Klaus Berger ihr Vorgesetzter, damit `manager` aufloesbar ist
+# Joerg Weiss Nachname mit Eszett, Mail-Adresse mit Umlaut-Ersatz
+# Alte Kraft userAccountControl 514 (Bit 2 = deaktiviert) -- der
+# Standardfilter muss sie ausschliessen
+# Ohne Kennung ein Konto ohne objectGUID; ohne stabile Kennung darf der
+# Abgleich es nicht anlegen, aber auch nicht abbrechen
+#
+# objectGUID steht base64-kodiert (`::`), weil es im AD ein binaeres Attribut
+# ist. Die erwarteten GUID-Zeichenketten stehen in tests/verzeichnis.test.ts.
+#
+# Achtung: osixia laeuft mit `sed -i` ueber jede Datei in diesem Verzeichnis, um
+# Platzhalter wie {{ LDAP_BASE_DN }} zu ersetzen. Hier stehen deshalb keine
+# Platzhalter -- dann schreibt sed dieselben Bytes zurueck und der Arbeitsbaum
+# bleibt sauber.
+
+dn: ou=Benutzer,dc=firma,dc=test
+objectClass: organizationalUnit
+ou: Benutzer
+
+dn: ou=Buchhaltung,ou=Benutzer,dc=firma,dc=test
+objectClass: organizationalUnit
+ou: Buchhaltung
+
+dn: ou=Vertrieb,ou=Benutzer,dc=firma,dc=test
+objectClass: organizationalUnit
+ou: Vertrieb
+
+dn: ou=Geschaeftsfuehrung,ou=Benutzer,dc=firma,dc=test
+objectClass: organizationalUnit
+ou: Geschaeftsfuehrung
+
+dn: cn=Klaus Berger,ou=Geschaeftsfuehrung,ou=Benutzer,dc=firma,dc=test
+objectClass: inetOrgPerson
+objectClass: user
+cn: Klaus Berger
+sn: Berger
+givenName: Klaus
+displayName: Berger, Klaus
+mail: klaus.berger@firma.test
+userPrincipalName: klaus.berger@firma.test
+sAMAccountName: kberger
+department: Geschaeftsfuehrung
+title: Geschaeftsfuehrer
+objectCategory: person
+userAccountControl: 512
+objectGUID:: nEA+HXqyyUux5vCiw9Tl9g==
+
+dn: cn=Maria Huber,ou=Buchhaltung,ou=Benutzer,dc=firma,dc=test
+objectClass: inetOrgPerson
+objectClass: user
+cn: Maria Huber
+sn: Huber
+givenName: Maria
+displayName:: SHViZXIsIE1hcmlh
+mail: maria.huber@firma.test
+userPrincipalName: maria.huber@firma.test
+sAMAccountName: mhuber
+department: Buchhaltung
+title:: TGVpdGVyaW4gUmVjaG51bmdzcHLDvGZ1bmc=
+objectCategory: person
+userAccountControl: 512
+manager: cn=Klaus Berger,ou=Geschaeftsfuehrung,ou=Benutzer,dc=firma,dc=test
+objectGUID:: EEMHXzU8HUqDb6UPUV56NQ==
+
+dn: cn=Joerg Weiss,ou=Vertrieb,ou=Benutzer,dc=firma,dc=test
+objectClass: inetOrgPerson
+objectClass: user
+cn: Joerg Weiss
+sn:: V2Vpw58=
+givenName:: SsO2cmc=
+displayName:: V2Vpw58sIErDtnJn
+mail: joerg.weiss@firma.test
+userPrincipalName: joerg.weiss@firma.test
+sAMAccountName: jweiss
+department: Vertrieb
+objectCategory: person
+userAccountControl: 512
+manager: cn=Klaus Berger,ou=Geschaeftsfuehrung,ou=Benutzer,dc=firma,dc=test
+objectGUID:: AQIDBAUGBwgJCgsMDQ4PEA==
+
+dn: cn=Alte Kraft,ou=Vertrieb,ou=Benutzer,dc=firma,dc=test
+objectClass: inetOrgPerson
+objectClass: user
+cn: Alte Kraft
+sn: Kraft
+givenName: Alte
+displayName: Kraft, Alte
+mail: alte.kraft@firma.test
+userPrincipalName: alte.kraft@firma.test
+sAMAccountName: akraft
+department: Vertrieb
+objectCategory: person
+userAccountControl: 514
+objectGUID:: /+7dzLuqmYh3ZlVEMyIRAA==
+
+dn: cn=Ohne Kennung,ou=Vertrieb,ou=Benutzer,dc=firma,dc=test
+objectClass: inetOrgPerson
+objectClass: user
+cn: Ohne Kennung
+sn: Kennung
+givenName: Ohne
+displayName: Kennung, Ohne
+sAMAccountName: okennung
+objectCategory: person
+userAccountControl: 512
diff --git a/tests/fixtures/ldap/schema/ad-fixture.ldif b/tests/fixtures/ldap/schema/ad-fixture.ldif
new file mode 100644
index 0000000..771ef61
--- /dev/null
+++ b/tests/fixtures/ldap/schema/ad-fixture.ldif
@@ -0,0 +1,52 @@
+# AD-aehnliches Schema fuer den Testcontainer (#016).
+#
+# OpenLDAP bringt keines der Attribute mit, an denen der Abgleich haengt:
+# sAMAccountName, userPrincipalName, objectGUID, userAccountControl,
+# objectCategory und department fehlen alle. Ohne dieses Schema liefe der
+# Integrationstest gegen ein Verzeichnis, das mit einem Active Directory nichts
+# gemeinsam hat -- und bewiese entsprechend wenig.
+#
+# `manager`, `displayName`, `givenName`, `sn`, `mail` und `title` kommen aus
+# core/cosine/inetOrgPerson und stehen deshalb nicht hier.
+#
+# Die OIDs sind die echten aus dem Microsoft-Arc 1.2.840.113556, damit das
+# Fixture nicht bloss gleich heisst, sondern auch gleich bezeichnet ist.
+#
+# Zwei bewusste Abweichungen vom echten AD, weil sie sich in OpenLDAP nicht
+# nachbauen lassen:
+#
+# 1. `objectCategory` ist hier eine Zeichenkette mit dem Wert "person". Im AD
+# ist es ein DN auf das classSchema-Objekt, und der Server nimmt die
+# Kurzform "person" als Abkuerzung dafuer an. Der Standardfilter trifft
+# damit hier und dort, aus verschiedenen Gruenden.
+# 2. `user` ist hier AUXILIARY statt STRUCTURAL. So traegt ein Eintrag
+# inetOrgPerson **und** user, statt dass die Personenklassen des Fixtures
+# gegen die des AD ausgetauscht werden muessten. Fuer `(objectClass=user)`
+# macht das keinen Unterschied.
+
+dn: cn=ad-fixture,cn=schema,cn=config
+objectClass: olcSchemaConfig
+cn: ad-fixture
+olcAttributeTypes: ( 1.2.840.113556.1.4.221 NAME 'sAMAccountName'
+ EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch
+ SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE )
+olcAttributeTypes: ( 1.2.840.113556.1.4.656 NAME 'userPrincipalName'
+ EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch
+ SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE )
+olcAttributeTypes: ( 1.2.840.113556.1.4.2 NAME 'objectGUID'
+ EQUALITY octetStringMatch
+ SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 SINGLE-VALUE )
+olcAttributeTypes: ( 1.2.840.113556.1.4.8 NAME 'userAccountControl'
+ EQUALITY integerMatch ORDERING integerOrderingMatch
+ SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE )
+olcAttributeTypes: ( 1.2.840.113556.1.4.782 NAME 'objectCategory'
+ EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch
+ SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE )
+olcAttributeTypes: ( 1.2.840.113556.1.2.141 NAME 'department'
+ EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch
+ SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE )
+olcObjectClasses: ( 1.2.840.113556.1.5.9 NAME 'user'
+ DESC 'AD-aehnliche Zusatzklasse, nur fuer Tests'
+ SUP top AUXILIARY
+ MAY ( sAMAccountName $ userPrincipalName $ objectGUID $ userAccountControl $
+ objectCategory $ department ) )
diff --git a/tests/ldap-guid.test.ts b/tests/ldap-guid.test.ts
new file mode 100644
index 0000000..8f18540
--- /dev/null
+++ b/tests/ldap-guid.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from "vitest";
+import { GuidFehler, guidAusBytes } from "@/lib/ldap-guid";
+
+/**
+ * `objectGUID` als GUID-Zeichenkette (#016).
+ *
+ * Diese Umsortierung ist der Grund, warum die Kennung ueberhaupt eine eigene
+ * Datei hat: schlaegt sie fehl, sieht das Ergebnis trotzdem wie eine GUID aus.
+ * Der Abgleich wuerde dann bei jedem Lauf dieselben Personen neu anlegen, weil
+ * die Kennung zwar stabil, aber gegen `Get-ADUser` nicht auffindbar waere — ein
+ * Fehler, der erst auffaellt, wenn jemand mit dem AD vergleicht.
+ *
+ * Die erwarteten Zeichenketten sind mit Pythons `uuid.UUID(bytes_le=…)`
+ * ausgerechnet, also mit einer unabhaengigen Umsetzung derselben Reihenfolge.
+ */
+
+describe("guidAusBytes", () => {
+ it("ordnet die ersten drei Gruppen little-endian, den Rest in Leserichtung", () => {
+ // Der aufschlussreichste Vektor: die Bytes 00…0f. An der Ausgabe ist die
+ // Umsortierung mit dem Auge nachzuvollziehen.
+ const bytes = Uint8Array.from([
+ 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
+ ]);
+ expect(guidAusBytes(bytes)).toBe("03020100-0504-0706-0809-0a0b0c0d0e0f");
+ });
+
+ it("liest den Byte-Vektor einer echten AD-Kennung", () => {
+ // Dieselben Bytes stehen base64-kodiert im Fixture des Testcontainers
+ // (tests/fixtures/ldap/daten/50-personen.ldif, „Maria Huber“).
+ const bytes = Uint8Array.from(Buffer.from("EEMHXzU8HUqDb6UPUV56NQ==", "base64"));
+ expect(guidAusBytes(bytes)).toBe("5f074310-3c35-4a1d-836f-a50f515e7a35");
+ });
+
+ it("liefert bei denselben Bytes immer dieselbe Zeichenkette", () => {
+ const bytes = Uint8Array.from(Buffer.from("nEA+HXqyyUux5vCiw9Tl9g==", "base64"));
+ expect(guidAusBytes(bytes)).toBe(guidAusBytes(bytes));
+ expect(guidAusBytes(Uint8Array.from(bytes))).toBe("1d3e409c-b27a-4bc9-b1e6-f0a2c3d4e5f6");
+ });
+
+ it("schreibt fuehrende Nullen mit", () => {
+ // Ohne padStart waere daraus "0-0-0-0-0"; die Kennung waere kuerzer als eine
+ // GUID und passte doch zu einem Konto.
+ expect(guidAusBytes(new Uint8Array(16))).toBe("00000000-0000-0000-0000-000000000000");
+ });
+
+ it("gibt Klein- und nicht Grossbuchstaben aus", () => {
+ const bytes = Uint8Array.from(Buffer.from("/+7dzLuqmYh3ZlVEMyIRAA==", "base64"));
+ const guid = guidAusBytes(bytes);
+ expect(guid).toBe("ccddeeff-aabb-8899-7766-554433221100");
+ expect(guid).toBe(guid.toLowerCase());
+ });
+
+ it("hat die Form 8-4-4-4-12", () => {
+ const guid = guidAusBytes(Uint8Array.from(Buffer.from("AQIDBAUGBwgJCgsMDQ4PEA==", "base64")));
+ expect(guid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u);
+ });
+
+ it("wirft bei falscher Laenge statt eine halbe Kennung zu liefern", () => {
+ // Genau der Fall, der ohne explicitBufferAttributes entsteht: die 16 Bytes
+ // wurden als UTF-8 gelesen und dabei veraendert.
+ expect(() => guidAusBytes(new Uint8Array(15))).toThrow(GuidFehler);
+ expect(() => guidAusBytes(new Uint8Array(0))).toThrow(GuidFehler);
+ expect(() => guidAusBytes(new Uint8Array(36))).toThrow(/16 Bytes/u);
+ });
+});
diff --git a/tests/mitarbeiter-import.test.ts b/tests/mitarbeiter-import.test.ts
new file mode 100644
index 0000000..92665e5
--- /dev/null
+++ b/tests/mitarbeiter-import.test.ts
@@ -0,0 +1,430 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
+import {
+ MITARBEITER_IMPORT_HOECHSTZEILEN,
+ mitarbeiterImportieren,
+ type MitarbeiterImportErgebnis,
+} from "@/server/services/mitarbeiter";
+import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
+
+/**
+ * CSV-Import der Personen (#016).
+ *
+ * Festgehalten wird vor allem, was in #011 am Geräteimport schiefging: der
+ * Import meldete „3 von 3 angelegt“, obwohl Zeilen verschluckt worden waren.
+ * Deshalb prüft hier fast jeder Test die **Summe** mit — je Zeile genau ein Fach,
+ * und die Datenbank danach genauso, wie die Zahlen es behaupten.
+ *
+ * Dazu die zweite Regel des Hauses: was aus dem Verzeichnis kommt, wird nicht von
+ * Hand überschrieben.
+ */
+
+const KOPF = "givenName,sn,displayName,mail,upn,sam,department,title";
+
+async function grundbestand(modus: Betriebsmodus = Betriebsmodus.INTERN) {
+ const eigene = await testPrisma.kunde.create({
+ data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
+ });
+ await testPrisma.einstellung.create({ data: { id: "global", modus } });
+ const benutzer = await testPrisma.benutzer.create({
+ data: { email: "admin@firma.example", name: "Administrator", rolle: "ADMIN" },
+ });
+ return { eigene, admin: { id: benutzer.id, rolle: benutzer.rolle } };
+}
+
+/** Jede Zeile landet in genau einem Fach – sonst lügt die Erfolgsmeldung. */
+function summePruefen(ergebnis: MitarbeiterImportErgebnis): void {
+ expect(ergebnis.angelegt + ergebnis.aktualisiert + ergebnis.unveraendert + ergebnis.abgewiesen.length).toBe(
+ ergebnis.gesamt,
+ );
+}
+
+describe.runIf(datenbankVerfuegbar)("Personen aus einer CSV-Datei", () => {
+ beforeEach(async () => {
+ await datenbankLeeren();
+ delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
+ });
+
+ describe("Zeile für Zeile", () => {
+ it("legt die guten Zeilen an und meldet die kaputte mit ihrer Zeilennummer", async () => {
+ const { admin } = await grundbestand();
+ const text = [
+ KOPF,
+ "Anna,Muster,Anna Muster,anna@firma.example,anna@firma.example,amuster,Vertrieb,Innendienst",
+ 'Bert,"Berg,Bert Berg,bert@firma.example,,bberg,Vertrieb,', // Anführungszeichen nicht geschlossen
+ "Clara,Cloud,Clara Cloud,clara@firma.example,,ccloud,Technik,",
+ ",,,dora@firma.example,,dfehlt,,", // kein Name in der Zeile
+ "Emil,Eck,Emil Eck,,,eeck,,",
+ ].join("\n");
+
+ const ergebnis = await mitarbeiterImportieren(text, admin);
+
+ expect(ergebnis.gesamt).toBe(5);
+ expect(ergebnis.angelegt).toBe(3);
+ summePruefen(ergebnis);
+ // Zeilennummern wie in der Datei: die Kopfzeile ist 1.
+ expect(ergebnis.abgewiesen.map((abweisung) => abweisung.zeile)).toEqual([3, 5]);
+ expect(ergebnis.abgewiesen[0]?.grund).toMatch(/Anführungszeichen/);
+ expect(ergebnis.abgewiesen[1]?.grund).toMatch(/fehlt ein Name/);
+
+ const namen = (await testPrisma.mitarbeiter.findMany({ select: { anzeigename: true } }))
+ .map((zeile) => zeile.anzeigename)
+ .sort();
+ expect(namen).toEqual(["Anna Muster", "Clara Cloud", "Emil Eck"]);
+ });
+
+ it("legt als MANUELL an und protokolliert die Zeilennummer", async () => {
+ // MANUELL, weil niemand aus dieser Datei im Verzeichnis steht — und weil
+ // eine so angelegte Person änderbar bleiben muss.
+ const { admin, eigene } = await grundbestand();
+ await mitarbeiterImportieren([KOPF, "Anna,Muster,,,,amuster,Vertrieb,"].join("\n"), admin);
+
+ const person = await testPrisma.mitarbeiter.findFirstOrThrow();
+ expect(person.herkunft).toBe(Herkunft.MANUELL);
+ expect(person.kundeId).toBe(eigene.id);
+ expect(person.anzeigename).toBe("Anna Muster");
+ expect(person.abteilung).toBe("Vertrieb");
+
+ const eintrag = await testPrisma.protokoll.findFirstOrThrow({
+ where: { aktion: "Mitarbeiter aus CSV angelegt" },
+ });
+ expect(eintrag.nachher).toMatchObject({ anzeigename: "Anna Muster", zeile: 2 });
+ });
+
+ it("versteht deutsches Excel: Semikolon, BOM und deutsche Spaltennamen", async () => {
+ const { admin } = await grundbestand();
+ const text =
+ "Vorname;Nachname;Anzeigename;Mail;UPN;sAMAccountName;Abteilung;Titel\r\n" +
+ 'Jörg;Öz;"Öz, Jörg";joerg@firma.example;joerg@firma.example;joez;Technik;"Leiter ""IT"""\r\n';
+
+ const ergebnis = await mitarbeiterImportieren(text, admin);
+ expect(ergebnis.angelegt).toBe(1);
+ summePruefen(ergebnis);
+ await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
+ vorname: "Jörg",
+ nachname: "Öz",
+ anzeigename: "Öz, Jörg",
+ titel: 'Leiter "IT"',
+ });
+ });
+
+ it("teilt displayName am letzten Leerzeichen, lässt den Anzeigenamen aber stehen", async () => {
+ const { admin } = await grundbestand();
+ const ergebnis = await mitarbeiterImportieren(
+ ["displayName,sam", "Anna van der Berg,aberg"].join("\n"),
+ admin,
+ );
+
+ expect(ergebnis.angelegt).toBe(1);
+ summePruefen(ergebnis);
+ // Die Teilung ist eine Annahme; der Anzeigename ist es nicht — und nur er
+ // steht später in der Oberfläche und im Laufzettel.
+ await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
+ vorname: "Anna van der",
+ nachname: "Berg",
+ anzeigename: "Anna van der Berg",
+ });
+ });
+
+ it("gibt die Regel des Dienstes auch zeilenweise weiter", async () => {
+ // Die 20-Zeichen-Grenze des sAMAccountName steht im Dienst und nirgends
+ // sonst. Der Import geht durch denselben Weg, also gilt sie hier auch.
+ const { admin } = await grundbestand();
+ const ergebnis = await mitarbeiterImportieren(
+ [KOPF, "Anna,Muster,,,,viel-zu-langer-anmeldename,,", "Bert,Berg,,,,bberg,,"].join("\n"),
+ admin,
+ );
+
+ expect(ergebnis.angelegt).toBe(1);
+ summePruefen(ergebnis);
+ expect(ergebnis.abgewiesen[0]).toMatchObject({ zeile: 2 });
+ expect(ergebnis.abgewiesen[0]?.grund).toMatch(/höchstens 20/);
+ });
+
+ it("weist eine Datei ohne Namensspalte als Ganzes ab", async () => {
+ const { admin } = await grundbestand();
+ await expect(mitarbeiterImportieren("mail,sam\nanna@firma.example,amuster\n", admin)).rejects.toThrow(
+ /displayName/,
+ );
+ expect(await testPrisma.mitarbeiter.count()).toBe(0);
+ });
+
+ it("bricht bei zu vielen Zeilen ab, bevor eine einzige geschrieben ist", async () => {
+ const { admin } = await grundbestand();
+ const zeilen = Array.from(
+ { length: MITARBEITER_IMPORT_HOECHSTZEILEN + 1 },
+ (_, index) => `Anna,Muster${index},,,,a${index},,`,
+ );
+ await expect(mitarbeiterImportieren([KOPF, ...zeilen].join("\n"), admin)).rejects.toThrow(
+ new RegExp(`mehr als ${MITARBEITER_IMPORT_HOECHSTZEILEN} Zeilen`),
+ );
+ expect(await testPrisma.mitarbeiter.count()).toBe(0);
+ });
+ });
+
+ describe("Wiedererkennen statt verdoppeln", () => {
+ it("erkennt ein Duplikat innerhalb derselben Datei und nennt die erste Zeile", async () => {
+ const { admin } = await grundbestand();
+ const ergebnis = await mitarbeiterImportieren(
+ [
+ KOPF,
+ "Anna,Muster,,anna@firma.example,,amuster,Vertrieb,",
+ "Bert,Berg,,bert@firma.example,,bberg,Vertrieb,",
+ "Anna,Muster-Neu,,anna@firma.example,,AMUSTER,Vertrieb,", // dieselbe Kennung
+ ].join("\n"),
+ admin,
+ );
+
+ expect(ergebnis.angelegt).toBe(2);
+ summePruefen(ergebnis);
+ expect(ergebnis.abgewiesen[0]?.zeile).toBe(4);
+ expect(ergebnis.abgewiesen[0]?.grund).toMatch(/schon in Zeile 2/);
+ expect(await testPrisma.mitarbeiter.count()).toBe(2);
+ // Und die erste Zeile gilt, nicht die dritte.
+ await expect(
+ testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "amuster" } }),
+ ).resolves.toMatchObject({ nachname: "Muster" });
+ });
+
+ it("legt dieselbe Datei beim zweiten Einlesen nicht erneut an", async () => {
+ // Der eigentliche Zweck der Wiedererkennung: eine Onboarding-Liste wandert
+ // mehrfach durch dieses Feld, und niemand zählt vorher nach.
+ const { admin } = await grundbestand();
+ const text = [
+ KOPF,
+ "Anna,Muster,Anna Muster,anna@firma.example,anna@firma.example,amuster,Vertrieb,Innendienst",
+ "Bert,Berg,Bert Berg,bert@firma.example,bert@firma.example,bberg,Technik,",
+ ].join("\n");
+
+ const erster = await mitarbeiterImportieren(text, admin);
+ expect(erster.angelegt).toBe(2);
+
+ const zweiter = await mitarbeiterImportieren(text, admin);
+ expect(zweiter).toMatchObject({ gesamt: 2, angelegt: 0, aktualisiert: 0, unveraendert: 2 });
+ summePruefen(zweiter);
+ expect(await testPrisma.mitarbeiter.count()).toBe(2);
+ });
+
+ it("erkennt über den UPN, wenn kein sam in der Datei steht", async () => {
+ const { admin, eigene } = await grundbestand();
+ await testPrisma.mitarbeiter.create({
+ data: {
+ kundeId: eigene.id,
+ vorname: "Anna",
+ nachname: "Muster",
+ anzeigename: "Anna Muster",
+ upn: "anna.muster@firma.example",
+ sam: "amuster",
+ },
+ });
+
+ const ergebnis = await mitarbeiterImportieren(
+ ["givenName,sn,upn,department", "Anna,Muster,ANNA.MUSTER@firma.example,Vertrieb"].join("\n"),
+ admin,
+ );
+
+ expect(ergebnis).toMatchObject({ angelegt: 0, aktualisiert: 1 });
+ summePruefen(ergebnis);
+ const person = await testPrisma.mitarbeiter.findFirstOrThrow();
+ expect(person.abteilung).toBe("Vertrieb");
+ // Gross- und Kleinschreibung unterscheidet keine Personen, deshalb bleibt
+ // der vorhandene Wert stehen, statt ihn zu überschreiben.
+ expect(person.upn).toBe("anna.muster@firma.example");
+ });
+
+ it("erkennt ohne sam und ohne UPN über den Anzeigenamen", async () => {
+ // Die Onboarding-Liste mit Name und Abteilung und sonst nichts. Ohne diesen
+ // Rückfall stünde jede Person nach dem zweiten Einlesen doppelt da.
+ const { admin } = await grundbestand();
+ const text = ["displayName,department", "Anna Muster,Vertrieb"].join("\n");
+
+ expect(await mitarbeiterImportieren(text, admin)).toMatchObject({ angelegt: 1 });
+ expect(await mitarbeiterImportieren(text, admin)).toMatchObject({ angelegt: 0, unveraendert: 1 });
+ expect(await testPrisma.mitarbeiter.count()).toBe(1);
+ });
+
+ it("weist eine Zeile ab, die auf zwei Personen passt", async () => {
+ // Hier zu raten hieße, die Stammdaten der falschen Person zu überschreiben.
+ const { admin, eigene } = await grundbestand();
+ for (const [vorname, sam, upn] of [
+ ["Anna", "amuster", "a.muster@firma.example"],
+ ["Anneliese", "amuster2", "anna@firma.example"],
+ ] as const) {
+ await testPrisma.mitarbeiter.create({
+ data: { kundeId: eigene.id, vorname, nachname: "Muster", anzeigename: vorname, sam, upn },
+ });
+ }
+
+ const ergebnis = await mitarbeiterImportieren(
+ [KOPF, "Anna,Muster,,,anna@firma.example,amuster,Vertrieb,"].join("\n"),
+ admin,
+ );
+
+ expect(ergebnis).toMatchObject({ angelegt: 0, aktualisiert: 0, unveraendert: 0 });
+ summePruefen(ergebnis);
+ expect(ergebnis.abgewiesen[0]?.grund).toMatch(/passt auf/);
+ expect(await testPrisma.mitarbeiter.count()).toBe(2);
+ });
+
+ it("aktiviert eine deaktivierte Person nicht wieder", async () => {
+ // Das Wiedereinstellen ist eine Entscheidung und keine Zeile in einer
+ // Datei. Die Oberfläche sagt es genauso — also muss es hier auch gelten.
+ const { admin, eigene } = await grundbestand();
+ await testPrisma.mitarbeiter.create({
+ data: {
+ kundeId: eigene.id,
+ vorname: "Anna",
+ nachname: "Muster",
+ anzeigename: "Anna Muster",
+ sam: "amuster",
+ aktiv: false,
+ },
+ });
+
+ const ergebnis = await mitarbeiterImportieren(
+ [KOPF, "Anna,Muster,,,,amuster,Vertrieb,"].join("\n"),
+ admin,
+ );
+
+ expect(ergebnis).toMatchObject({ angelegt: 0, aktualisiert: 1 });
+ summePruefen(ergebnis);
+ await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
+ aktiv: false,
+ abteilung: "Vertrieb",
+ });
+ });
+
+ it("lässt eine leere Zelle nichts löschen", async () => {
+ const { admin, eigene } = await grundbestand();
+ await testPrisma.mitarbeiter.create({
+ data: {
+ kundeId: eigene.id,
+ vorname: "Anna",
+ nachname: "Muster",
+ anzeigename: "Muster, Anna",
+ sam: "amuster",
+ titel: "Innendienst",
+ mail: "anna@firma.example",
+ },
+ });
+
+ const ergebnis = await mitarbeiterImportieren(
+ [KOPF, "Anna,Muster,,,,amuster,Vertrieb,"].join("\n"),
+ admin,
+ );
+
+ expect(ergebnis).toMatchObject({ aktualisiert: 1 });
+ summePruefen(ergebnis);
+ await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
+ abteilung: "Vertrieb",
+ titel: "Innendienst",
+ mail: "anna@firma.example",
+ // Kein displayName in der Zeile heißt: der vorhandene bleibt, auch wenn
+ // er anders lautet als „Vorname Nachname“.
+ anzeigename: "Muster, Anna",
+ });
+ });
+ });
+
+ describe("Herkunft ABGLEICH", () => {
+ it("überschreibt eine Person aus dem Verzeichnis nicht, sondern meldet die Zeile", async () => {
+ // Der nächste Abgleich drehte die Änderung ohnehin zurück. Stillschweigend
+ // zu überspringen wäre das Schlimmere: der Import meldete dann einen
+ // Erfolg, den es nicht gab.
+ const { admin, eigene } = await grundbestand();
+ await testPrisma.mitarbeiter.create({
+ data: {
+ kundeId: eigene.id,
+ vorname: "Maria",
+ nachname: "Huber",
+ anzeigename: "Maria Huber",
+ sam: "mhuber",
+ abteilung: "Buchhaltung",
+ titel: "Leitung",
+ herkunft: Herkunft.ABGLEICH,
+ },
+ });
+ const protokollVorher = await testPrisma.protokoll.count();
+
+ const ergebnis = await mitarbeiterImportieren(
+ [KOPF, "Maria,Huber-Neu,,neu@firma.example,,mhuber,Vertrieb,Sachbearbeitung"].join("\n"),
+ admin,
+ );
+
+ expect(ergebnis).toMatchObject({ gesamt: 1, angelegt: 0, aktualisiert: 0, unveraendert: 0 });
+ summePruefen(ergebnis);
+ expect(ergebnis.abgewiesen[0]?.zeile).toBe(2);
+ expect(ergebnis.abgewiesen[0]?.grund).toMatch(/kommt aus dem Verzeichnis/);
+
+ await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
+ nachname: "Huber",
+ abteilung: "Buchhaltung",
+ titel: "Leitung",
+ mail: null,
+ });
+ expect(await testPrisma.protokoll.count()).toBe(protokollVorher);
+ });
+ });
+
+ describe("Mandantengrenze", () => {
+ it("verwirft im Modus INTERN eine mitgeschickte Kundenkennung", async () => {
+ const { admin, eigene } = await grundbestand();
+ const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
+
+ await mitarbeiterImportieren([KOPF, "Anna,Muster,,,,amuster,,"].join("\n"), admin, fremder.id);
+
+ await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
+ kundeId: eigene.id,
+ });
+ });
+
+ it("erkennt nur innerhalb desselben Kunden – und schreibt nicht für einen fremden", async () => {
+ await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
+ const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
+ const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
+ const techniker = await testPrisma.benutzer.create({
+ data: { email: "t@firma.example", name: "Techniker", rolle: "TECHNIKER" },
+ });
+ await testPrisma.benutzerKunde.create({ data: { benutzerId: techniker.id, kundeId: meiner.id } });
+ process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
+ const handelnder = { id: techniker.id, rolle: techniker.rolle };
+
+ // Dieselbe Kennung gibt es beim fremden Kunden schon. Sie darf hier nichts
+ // treffen: sonst schriebe dieser Import in fremde Stammdaten.
+ await testPrisma.mitarbeiter.create({
+ data: {
+ kundeId: fremder.id,
+ vorname: "Anna",
+ nachname: "Fremd",
+ anzeigename: "Anna Fremd",
+ sam: "amuster",
+ },
+ });
+
+ const ergebnis = await mitarbeiterImportieren(
+ [KOPF, "Anna,Muster,,,,amuster,Vertrieb,"].join("\n"),
+ handelnder,
+ meiner.id,
+ );
+ expect(ergebnis).toMatchObject({ angelegt: 1 });
+ summePruefen(ergebnis);
+ await expect(
+ testPrisma.mitarbeiter.findFirstOrThrow({ where: { kundeId: fremder.id } }),
+ ).resolves.toMatchObject({ nachname: "Fremd", abteilung: null });
+
+ // Und für einen Kunden, der diesem Techniker nicht gehört, geht gar nichts.
+ await expect(
+ mitarbeiterImportieren([KOPF, "Bert,Berg,,,,bberg,,"].join("\n"), handelnder, fremder.id),
+ ).rejects.toThrow(/Berechtigung/);
+ expect(await testPrisma.mitarbeiter.count({ where: { kundeId: fremder.id } })).toBe(1);
+ });
+
+ it("verlangt im Modus DIENSTLEISTUNG eine Kundenwahl", async () => {
+ const { admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
+ await expect(
+ mitarbeiterImportieren([KOPF, "Anna,Muster,,,,amuster,,"].join("\n"), admin),
+ ).rejects.toThrow(/Kunde/);
+ expect(await testPrisma.mitarbeiter.count()).toBe(0);
+ });
+ });
+});
diff --git a/tests/setup-env.ts b/tests/setup-env.ts
index 5f14d5b..769bb3e 100644
--- a/tests/setup-env.ts
+++ b/tests/setup-env.ts
@@ -7,3 +7,11 @@ if (process.env["TEST_DATABASE_URL"]) {
// Fuer Tests, die ein Geheimnis brauchen, ohne eine .env zu verlangen.
process.env["APP_SECRET"] ??= "test-geheimnis-mit-mindestens-32-zeichen-laenge";
+
+/*
+ * Kein tickender Zeitplan in Tests (#016). Er wird zwar nur ueber
+ * `src/instrumentation.ts` gestartet, also gar nicht von Vitest — aber wer
+ * hier einen Dienst importiert, der ihn seinerseits startet, saehe sonst
+ * Abgleiche mitten im Testlauf.
+ */
+process.env["ZEITPLAN_AKTIV"] = "false";
diff --git a/tests/verzeichnis.test.ts b/tests/verzeichnis.test.ts
new file mode 100644
index 0000000..ee5925d
--- /dev/null
+++ b/tests/verzeichnis.test.ts
@@ -0,0 +1,824 @@
+import type { Entry } from "ldapts";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { Betriebsmodus } from "@/generated/prisma/enums";
+import { entschluesseln } from "@/lib/geheimnisse";
+import { zertifikatWahl, zuordnungAusEingabe } from "@/lib/verzeichnis";
+import { fehlerMeldung, tlsOptionenBauen, urlPruefen, VerbindungsFehler } from "@/server/ldap/verbindung";
+import {
+ AD_ZUORDNUNG,
+ angeforderteAttribute,
+ dnSchluessel,
+ eintragZuordnen,
+ STANDARD_FILTER,
+ vorgesetzteAufloesen,
+ zuordnungLesen,
+} from "@/server/ldap/zuordnung";
+import { abgleichLog, LOG_ZEILEN, quelleHolen, quellenListe } from "@/server/queries/verzeichnis";
+import {
+ quelleAnlegen,
+ quelleBearbeiten,
+ quelleLoeschen,
+ verbindungTesten,
+ VerzeichnisFehler,
+} from "@/server/services/verzeichnis";
+import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
+import {
+ FIXTURE_GELESEN,
+ FIXTURE_PERSONEN,
+ GUID,
+ LDAP_BASIS_DN,
+ LDAP_BENUTZER_DN,
+ LDAP_BIND_DN,
+ LDAP_BIND_PASSWORT,
+ LDAP_URL,
+ ldapVerfuegbar,
+} from "./fixtures/ldap/container";
+
+/**
+ * Verzeichnisquellen und das Lesen aus dem Verzeichnis (#016).
+ *
+ * Drei Arten von Pruefungen stehen hier, und die Trennung ist Absicht:
+ *
+ * 1. **Reine Funktionen** — Adressen, TLS-Optionen, Attributzuordnung,
+ * Fehlermeldungen. Ohne Netz und ohne Datenbank, deshalb vollstaendig in den
+ * Sonderfaellen.
+ * 2. **Der Service gegen die Datenbank** — Rechte, Betriebsmodus, und dass das
+ * Bind-Passwort nirgends herauskommt.
+ * 3. **Gegen den echten Container** — dass aus einem AD-aehnlichen Verzeichnis
+ * wirklich die erwarteten Felder werden. Das ist der Teil, den keine
+ * Attrappe ersetzt: `explicitBufferAttributes`, die Umsortierung der GUID und
+ * der Bit-UND-Filter entscheiden sich erst am Server.
+ */
+
+const ldapDa = await ldapVerfuegbar();
+
+async function grundbestand(modus: Betriebsmodus = Betriebsmodus.INTERN) {
+ const eigene = await testPrisma.kunde.create({
+ data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
+ });
+ await testPrisma.einstellung.create({ data: { id: "global", modus } });
+ const admin = await testPrisma.benutzer.create({
+ data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
+ });
+ const technikerin = await testPrisma.benutzer.create({
+ data: { email: "tina@firma.example", name: "Tina", rolle: "TECHNIKER" },
+ });
+ return {
+ eigene,
+ admin: { id: admin.id, rolle: admin.rolle },
+ technikerin: { id: technikerin.id, rolle: technikerin.rolle },
+ };
+}
+
+/** Eine Quelle, die auf den Testcontainer zeigt. */
+function quellenEingabe(abweichend: Record = {}) {
+ return {
+ name: "Firmen-AD",
+ url: LDAP_URL,
+ bindDn: LDAP_BIND_DN,
+ bindPasswort: LDAP_BIND_PASSWORT,
+ basisDn: LDAP_BENUTZER_DN,
+ ...abweichend,
+ };
+}
+
+// ---- 1. Reine Funktionen ---------------------------------------------------
+
+describe("Adressen", () => {
+ it("nimmt ldaps, ldap und das StartTLS-Schema", () => {
+ expect(urlPruefen("ldaps://dc1.firma.local:636")).toEqual({
+ url: "ldaps://dc1.firma.local:636",
+ startTls: false,
+ verschluesselt: true,
+ });
+ expect(urlPruefen("ldap://dc1.firma.local:389")).toEqual({
+ url: "ldap://dc1.firma.local:389",
+ startTls: false,
+ verschluesselt: false,
+ });
+ });
+
+ it("macht aus ldap+starttls eine ldap-Adresse und merkt sich die Umschaltung", () => {
+ // ldapts kennt nur ldap und ldaps; StartTLS geschieht bei uns nach dem
+ // Verbinden. Die getippte Form bleibt in der Datenbank stehen, damit noch
+ // erkennbar ist, dass StartTLS gewuenscht war.
+ expect(urlPruefen("ldap+starttls://dc1.firma.local:389")).toEqual({
+ url: "ldap://dc1.firma.local:389",
+ startTls: true,
+ verschluesselt: true,
+ });
+ });
+
+ it("weist ein fremdes Schema, einen fehlenden Rechner und einen Pfad ab", () => {
+ expect(() => urlPruefen("https://dc1.firma.local")).toThrow(VerbindungsFehler);
+ expect(() => urlPruefen("dc1.firma.local:389")).toThrow(VerbindungsFehler);
+ expect(() => urlPruefen("ldaps://")).toThrow(/Rechnername/u);
+ expect(() => urlPruefen("ldaps:///dc=firma,dc=local")).toThrow(/Rechnername/u);
+ expect(() => urlPruefen("ldaps://dc1.firma.local/dc=firma,dc=local")).toThrow(/Pfad/u);
+ });
+
+ it("weist Zugangsdaten in der Adresse ab", () => {
+ // Sonst stuende das Passwort im Klartext in der Datenbank – verschluesselt
+ // wird nur das Feld, das dafuer da ist.
+ expect(() => urlPruefen("ldaps://dienst:geheim@dc1.firma.local:636")).toThrow(/Zugangsdaten/u);
+ });
+});
+
+describe("TLS-Optionen", () => {
+ it("verlangt mindestens TLS 1.2", () => {
+ expect(tlsOptionenBauen(null).minVersion).toBe("TLSv1.2");
+ });
+
+ it("nimmt ein eigenes CA-Zertifikat auf", () => {
+ const pem = "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----";
+ expect(tlsOptionenBauen(pem).ca).toEqual([pem]);
+ });
+
+ it("laesst ca weg, wenn keines hinterlegt ist – dann gelten die Systemwurzeln", () => {
+ expect(tlsOptionenBauen(null).ca).toBeUndefined();
+ expect(tlsOptionenBauen(" ").ca).toBeUndefined();
+ });
+
+ it("hat keinen Schalter, der die Pruefung des Hostnamens abstellt", () => {
+ // Ein solcher Schalter waere genau der, der in der Eile gesetzt bleibt.
+ const optionen = tlsOptionenBauen(null) as Record;
+ expect(optionen["rejectUnauthorized"]).toBeUndefined();
+ expect(optionen["checkServerIdentity"]).toBeUndefined();
+ });
+});
+
+describe("Fehlermeldungen", () => {
+ const passwort = "S3hr-geheim!";
+
+ it("entfernt das Bind-Passwort restlos", () => {
+ const meldung = fehlerMeldung(new Error(`bind failed for cn=dienst with ${passwort}`), [passwort]);
+ expect(meldung).not.toContain(passwort);
+ expect(meldung).toContain("***");
+ });
+
+ it("entfernt es auch mehrfach und mitten im Wort", () => {
+ const meldung = fehlerMeldung(new Error(`${passwort}/${passwort}`), [passwort]);
+ expect(meldung).toBe("***/***");
+ });
+
+ it("macht eine Zeile daraus und kuerzt", () => {
+ const meldung = fehlerMeldung(new Error(`erste\n zweite\tdritte`), []);
+ expect(meldung).toBe("erste zweite dritte");
+ expect(fehlerMeldung(new Error("x".repeat(900)), []).length).toBeLessThanOrEqual(401);
+ });
+
+ it("kommt mit allem zurecht, was geworfen werden kann", () => {
+ expect(fehlerMeldung("nur Text", [])).toBe("nur Text");
+ expect(fehlerMeldung(undefined, [])).toBe("undefined");
+ expect(fehlerMeldung(new Error(" "), [])).toBe("Unbekannter Fehler beim Zugriff auf das Verzeichnis.");
+ });
+});
+
+describe("Attributzuordnung", () => {
+ it("benennt die AD-Vorgaben aus dem Auftrag", () => {
+ expect(AD_ZUORDNUNG).toEqual({
+ vorname: "givenName",
+ nachname: "sn",
+ anzeigename: "displayName",
+ mail: "mail",
+ upn: "userPrincipalName",
+ sam: "sAMAccountName",
+ abteilung: "department",
+ titel: "title",
+ vorgesetzter: "manager",
+ externeId: "objectGUID",
+ });
+ });
+
+ it("schliesst im Standardfilter die deaktivierten Konten aus", () => {
+ expect(STANDARD_FILTER).toBe(
+ "(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))",
+ );
+ });
+
+ it("fuellt fehlende und leere Felder mit der Vorgabe", () => {
+ expect(zuordnungLesen({ anzeigename: "cn", mail: " " })).toEqual({
+ ...AD_ZUORDNUNG,
+ anzeigename: "cn",
+ });
+ expect(zuordnungLesen(null)).toEqual(AD_ZUORDNUNG);
+ expect(zuordnungLesen("Unsinn")).toEqual(AD_ZUORDNUNG);
+ expect(zuordnungLesen({ vorname: 7 })).toEqual(AD_ZUORDNUNG);
+ });
+
+ it("fragt nur die zugeordneten Attribute an, und jedes nur einmal", () => {
+ const attribute = angeforderteAttribute({ ...AD_ZUORDNUNG, upn: "mail" });
+ expect(attribute).toContain("objectGUID");
+ expect(attribute).not.toContain("memberOf");
+ expect(attribute.filter((name) => name === "mail")).toHaveLength(1);
+ });
+
+ it("uebersetzt einen Eintrag und wandelt den GUID-Puffer", () => {
+ const ergebnis = eintragZuordnen(
+ {
+ dn: "CN=Maria Huber,OU=Buchhaltung,DC=firma,DC=test",
+ givenName: "Maria",
+ sn: "Huber",
+ displayName: "Huber, Maria",
+ mail: "maria.huber@firma.test",
+ userPrincipalName: "maria.huber@firma.test",
+ sAMAccountName: "mhuber",
+ department: "Buchhaltung",
+ title: "Leiterin",
+ manager: "CN=Klaus Berger,OU=GF,DC=firma,DC=test",
+ objectGUID: Buffer.from("EEMHXzU8HUqDb6UPUV56NQ==", "base64"),
+ } satisfies Entry,
+ AD_ZUORDNUNG,
+ );
+ expect(ergebnis).toEqual({
+ eintrag: {
+ externeId: GUID.huber,
+ vorname: "Maria",
+ nachname: "Huber",
+ anzeigename: "Huber, Maria",
+ mail: "maria.huber@firma.test",
+ upn: "maria.huber@firma.test",
+ sam: "mhuber",
+ dn: "CN=Maria Huber,OU=Buchhaltung,DC=firma,DC=test",
+ abteilung: "Buchhaltung",
+ titel: "Leiterin",
+ vorgesetzterDn: "CN=Klaus Berger,OU=GF,DC=firma,DC=test",
+ },
+ });
+ });
+
+ it("nimmt bei einem mehrwertigen Attribut den ersten Wert und macht aus leer null", () => {
+ const ergebnis = eintragZuordnen(
+ {
+ dn: "CN=Zwei,DC=firma,DC=test",
+ givenName: ["Anna", "Annemarie"],
+ sn: "Zwei",
+ displayName: "",
+ mail: " ",
+ objectGUID: Buffer.from("AAECAwQFBgcICQoLDA0ODw==", "base64"),
+ } satisfies Entry,
+ AD_ZUORDNUNG,
+ );
+ expect(ergebnis).toMatchObject({
+ eintrag: { vorname: "Anna", mail: null, upn: null, titel: null, vorgesetzterDn: null },
+ });
+ });
+
+ it("baut einen Anzeigenamen, wenn keiner im Verzeichnis steht", () => {
+ // `Mitarbeiter.anzeigename` ist nicht optional, und eine leere Zeile in der
+ // Auswahlliste waere fuer die Technikerin unbrauchbar.
+ const mitNamen = eintragZuordnen(
+ { dn: "CN=X,DC=f,DC=t", givenName: "Maria", sn: "Huber", objectGUID: "abc" } satisfies Entry,
+ AD_ZUORDNUNG,
+ );
+ expect(mitNamen).toMatchObject({ eintrag: { anzeigename: "Huber, Maria" } });
+
+ const ohneNamen = eintragZuordnen(
+ { dn: "CN=Nur der DN,OU=x,DC=f,DC=t", objectGUID: "abc" } satisfies Entry,
+ AD_ZUORDNUNG,
+ );
+ expect(ohneNamen).toMatchObject({ eintrag: { anzeigename: "Nur der DN", vorname: "", nachname: "" } });
+ });
+
+ it("uebersetzt eine Textkennung, ohne sie fuer eine GUID zu halten", () => {
+ // Wer `externeId` auf `entryUUID` eines OpenLDAP legt, bekommt 36 Textbytes.
+ const ergebnis = eintragZuordnen(
+ {
+ dn: "CN=Y,DC=f,DC=t",
+ objectGUID: Buffer.from("c0ffee00-1111-2222-3333-444455556666", "utf8"),
+ } satisfies Entry,
+ AD_ZUORDNUNG,
+ );
+ expect(ergebnis).toMatchObject({ eintrag: { externeId: "c0ffee00-1111-2222-3333-444455556666" } });
+ });
+
+ it("ueberspringt einen Eintrag ohne Kennung statt abzubrechen", () => {
+ // Ein einzelnes krummes Konto darf nicht den Abgleich aller anderen
+ // verhindern – ohne Kennung waere es aber bei jedem Lauf wieder neu.
+ const ohne = eintragZuordnen({ dn: "CN=Ohne,DC=f,DC=t", sn: "Ohne" } satisfies Entry, AD_ZUORDNUNG);
+ expect(ohne).toEqual({
+ uebersprungen: { dn: "CN=Ohne,DC=f,DC=t", grund: expect.stringContaining("objectGUID") },
+ });
+
+ // Sieben Bytes sind eine verstuemmelte GUID, keine Kennung. Sie als Text
+ // durchzulassen hiesse: eine Kennung aus Steuerzeichen, die zu keinem Konto
+ // im Verzeichnis passt und trotzdem kein Duplikat erzeugt – ein Fehler, der
+ // nie auffaellt.
+ const kaputt = eintragZuordnen(
+ { dn: "CN=Kaputt,DC=f,DC=t", objectGUID: Buffer.alloc(7) } satisfies Entry,
+ AD_ZUORDNUNG,
+ );
+ expect(kaputt).toMatchObject({ uebersprungen: { dn: "CN=Kaputt,DC=f,DC=t" } });
+ });
+
+ it("loest den Vorgesetzten ueber den DN auf, auch bei anderer Schreibweise", () => {
+ const chef = {
+ externeId: GUID.berger,
+ vorname: "Klaus",
+ nachname: "Berger",
+ anzeigename: "Berger, Klaus",
+ mail: null,
+ upn: null,
+ sam: "kberger",
+ dn: "CN=Klaus Berger,OU=GF,DC=firma,DC=test",
+ abteilung: null,
+ titel: null,
+ vorgesetzterDn: null,
+ };
+ const person = {
+ ...chef,
+ externeId: GUID.huber,
+ dn: "CN=Maria Huber,OU=BH,DC=firma,DC=test",
+ // Klein geschrieben und mit Leerzeichen hinter den Kommas.
+ vorgesetzterDn: "cn=klaus berger, ou=gf, dc=firma, dc=test",
+ };
+ const [aufgeloest] = vorgesetzteAufloesen([person, chef]);
+ expect(aufgeloest?.vorgesetzterExterneId).toBe(GUID.berger);
+ expect(dnSchluessel("CN=A, OU=B")).toBe("cn=a,ou=b");
+ });
+
+ it("laesst den Vorgesetzten leer, wenn er nicht mitgelesen wurde", () => {
+ // Lieber kein Vorgesetzter als ein falscher: der DN ist ausserhalb des
+ // Suchbereichs nicht aufloesbar, und geraten wird nicht.
+ const person = {
+ externeId: GUID.huber,
+ vorname: "Maria",
+ nachname: "Huber",
+ anzeigename: "Huber, Maria",
+ mail: null,
+ upn: null,
+ sam: null,
+ dn: "CN=Maria Huber,OU=BH,DC=firma,DC=test",
+ abteilung: null,
+ titel: null,
+ vorgesetzterDn: "CN=Unbekannt,OU=Extern,DC=firma,DC=test",
+ };
+ expect(vorgesetzteAufloesen([person])[0]?.vorgesetzterExterneId).toBeNull();
+ });
+});
+
+// ---- 2. Der Service gegen die Datenbank -----------------------------------
+
+describe("Zuordnung und Zertifikat aus dem Formular", () => {
+ it("nimmt gefuellte Felder und laesst leere der Vorgabe", () => {
+ const ergebnis = zuordnungAusEingabe({ externeId: " entryUUID ", sam: "", titel: "title" });
+ expect(ergebnis).toEqual({ zuordnung: { externeId: "entryUUID", titel: "title" } });
+ });
+
+ it("weist ab, was kein Attributname ist", () => {
+ for (const krumm of ["(objectClass=*)", "cn;binary", "2fach", "-sn", "sn sn"]) {
+ const ergebnis = zuordnungAusEingabe({ nachname: krumm });
+ expect(ergebnis, krumm).toHaveProperty("fehler");
+ }
+ });
+
+ it("nennt in der Meldung das Feld, damit klar ist, welche Zeile gemeint ist", () => {
+ const ergebnis = zuordnungAusEingabe({ externeId: "(guid)" });
+ expect("fehler" in ergebnis && ergebnis.fehler).toContain("Kennung");
+ });
+
+ it("unterscheidet beim Zertifikat unveraendert, neu und entfernen", () => {
+ // Leeres Feld, kein Kaestchen: das hinterlegte bleibt – deshalb `undefined`.
+ expect(zertifikatWahl("", false)).toEqual({ wert: undefined });
+ expect(zertifikatWahl(" ", false)).toEqual({ wert: undefined });
+ expect(zertifikatWahl("-----BEGIN CERTIFICATE-----", false)).toEqual({
+ wert: "-----BEGIN CERTIFICATE-----",
+ });
+ // Kaestchen: leere Zeichenkette, und der Dienst macht daraus `null`.
+ expect(zertifikatWahl("", true)).toEqual({ wert: "" });
+ expect(zertifikatWahl("-----BEGIN CERTIFICATE-----", true)).toHaveProperty("fehler");
+ });
+});
+
+describe.runIf(datenbankVerfuegbar)("Verzeichnisquellen", () => {
+ beforeEach(async () => {
+ await datenbankLeeren();
+ });
+
+ it("legt eine Quelle mit den Vorgaben an", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleAnlegen(quellenEingabe({ name: " Firmen-AD " }), admin);
+
+ expect(quelle.name).toBe("Firmen-AD");
+ expect(quelle.filter).toBe(STANDARD_FILTER);
+ expect(quelle.attributZuordnung).toEqual(AD_ZUORDNUNG);
+ expect(quelle.abgleichIntervallMinuten).toBe(60);
+ // Eine neue Quelle ist zunaechst abgeschaltet: erst Verbindung testen, dann
+ // den Zeitplan darauf loslassen.
+ expect(quelle.aktiv).toBe(false);
+ expect(quelle.caZertifikatVorhanden).toBe(false);
+ expect(quelle.letzterAbgleichAm).toBeNull();
+ });
+
+ it("uebernimmt einzelne abweichende Attributnamen", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleAnlegen(
+ quellenEingabe({ attributZuordnung: { anzeigename: "cn", externeId: "entryUUID" } }),
+ admin,
+ );
+ expect(quelle.attributZuordnung).toEqual({
+ ...AD_ZUORDNUNG,
+ anzeigename: "cn",
+ externeId: "entryUUID",
+ });
+ });
+
+ it("verlangt Name, Adresse, Bind-DN, Passwort und Basis-DN", async () => {
+ const { admin } = await grundbestand();
+ await expect(quelleAnlegen(quellenEingabe({ name: " " }), admin)).rejects.toThrow(VerzeichnisFehler);
+ await expect(quelleAnlegen(quellenEingabe({ bindDn: "" }), admin)).rejects.toThrow(/Bind-DN/u);
+ await expect(quelleAnlegen(quellenEingabe({ bindPasswort: "" }), admin)).rejects.toThrow(
+ /Bind-Passwort/u,
+ );
+ await expect(quelleAnlegen(quellenEingabe({ basisDn: "" }), admin)).rejects.toThrow(/Basis-DN/u);
+ await expect(quelleAnlegen(quellenEingabe({ url: "ftp://dc1" }), admin)).rejects.toThrow(
+ VerbindungsFehler,
+ );
+ });
+
+ it("weist ein Intervall ausserhalb der Grenzen ab", async () => {
+ const { admin } = await grundbestand();
+ await expect(quelleAnlegen(quellenEingabe({ abgleichIntervallMinuten: 1 }), admin)).rejects.toThrow(
+ /Intervall/u,
+ );
+ await expect(quelleAnlegen(quellenEingabe({ abgleichIntervallMinuten: 99999 }), admin)).rejects.toThrow(
+ /Intervall/u,
+ );
+ await expect(quelleAnlegen(quellenEingabe({ abgleichIntervallMinuten: 10.5 }), admin)).rejects.toThrow(
+ /Intervall/u,
+ );
+ const quelle = await quelleAnlegen(quellenEingabe({ abgleichIntervallMinuten: 15 }), admin);
+ expect(quelle.abgleichIntervallMinuten).toBe(15);
+ });
+
+ it("verlangt beim CA-Zertifikat die PEM-Huelle", async () => {
+ const { admin } = await grundbestand();
+ await expect(quelleAnlegen(quellenEingabe({ caZertifikatPem: "MIIB…" }), admin)).rejects.toThrow(/PEM/u);
+ const quelle = await quelleAnlegen(
+ quellenEingabe({
+ caZertifikatPem: "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----",
+ }),
+ admin,
+ );
+ // Die Oberflaeche erfaehrt nur, **dass** eines hinterlegt ist.
+ expect(quelle.caZertifikatVorhanden).toBe(true);
+ expect(JSON.stringify(quelle)).not.toContain("BEGIN CERTIFICATE");
+ });
+
+ it("nimmt ein hinterlegtes CA-Zertifikat auf Wunsch wieder weg", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleAnlegen(
+ quellenEingabe({
+ caZertifikatPem: "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----",
+ }),
+ admin,
+ );
+ expect(quelle.caZertifikatVorhanden).toBe(true);
+
+ // Eine leere Zeichenkette heisst „entfernen“; `undefined` hiesse
+ // „unveraendert“ – daran haengt, dass das Formular beides ausdruecken kann.
+ const ohne = await quelleBearbeiten(quelle.id, { caZertifikatPem: "" }, admin);
+ expect(ohne.caZertifikatVorhanden).toBe(false);
+ const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
+ expect(zeile.caZertifikatPem).toBeNull();
+
+ // Und ein Speichern ohne Angabe laesst es, wie es ist.
+ await quelleBearbeiten(
+ quelle.id,
+ { caZertifikatPem: "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----" },
+ admin,
+ );
+ const wieder = await quelleBearbeiten(quelle.id, { name: "Anderer Name" }, admin);
+ expect(wieder.caZertifikatVorhanden).toBe(true);
+ });
+
+ it("verlangt das Recht verwaltung", async () => {
+ const { technikerin } = await grundbestand();
+ // Die Aktion prueft es auch; hier steht das zweite Netz, an dem auch der
+ // Zeitplan und ein Skript nicht vorbeikommen.
+ await expect(quelleAnlegen(quellenEingabe(), technikerin)).rejects.toThrow(/verwaltung/u);
+ });
+
+ it("ist im Modus Dienstleistung abgeschaltet", async () => {
+ const { admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
+ await expect(quelleAnlegen(quellenEingabe(), admin)).rejects.toThrow(/Dienstleistung/u);
+ // Auch die Lesemodelle zeigen dort nichts – nicht einmal eine Quelle aus
+ // einer frueheren Zeit im Modus INTERN.
+ expect(await quellenListe(admin)).toEqual([]);
+ });
+
+ it("bearbeitet einzelne Felder und protokolliert die Aenderung", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleAnlegen(quellenEingabe(), admin);
+ const geaendert = await quelleBearbeiten(
+ quelle.id,
+ { name: "AD Hauptstelle", aktiv: true, abgleichIntervallMinuten: 30 },
+ admin,
+ );
+ expect(geaendert.name).toBe("AD Hauptstelle");
+ expect(geaendert.aktiv).toBe(true);
+ expect(geaendert.abgleichIntervallMinuten).toBe(30);
+
+ const protokoll = await testPrisma.protokoll.findMany({
+ where: { entitaet: "Verzeichnisquelle", entitaetId: quelle.id },
+ });
+ expect(protokoll).toHaveLength(2);
+ expect(protokoll.map((zeile) => zeile.aktion)).toContain("Verzeichnisquelle angelegt");
+ });
+
+ it("laesst das Passwort stehen, wenn das Feld leer bleibt", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleAnlegen(quellenEingabe(), admin);
+ const vorher = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
+
+ // Ein Formular kann das Passwort nicht anzeigen und schickt darum eine leere
+ // Zeichenkette. Waere das „loeschen“, waere die Quelle nach dem ersten
+ // Umbenennen kaputt.
+ await quelleBearbeiten(quelle.id, { bindPasswort: "", name: "Neu" }, admin);
+ const nachher = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
+ expect(entschluesseln(nachher.bindPasswortVerschluesselt)).toBe(LDAP_BIND_PASSWORT);
+ expect(nachher.bindPasswortVerschluesselt).toBe(vorher.bindPasswortVerschluesselt);
+
+ await quelleBearbeiten(quelle.id, { bindPasswort: "ein-neues-geheimnis" }, admin);
+ const dritte = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
+ expect(entschluesseln(dritte.bindPasswortVerschluesselt)).toBe("ein-neues-geheimnis");
+ });
+
+ it("gibt das Bind-Passwort in keiner Antwort und in keinem Protokoll heraus", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleAnlegen(quellenEingabe(), admin);
+ await quelleBearbeiten(quelle.id, { aktiv: true }, admin);
+
+ const liste = await quellenListe(admin);
+ const einzeln = await quelleHolen(quelle.id, admin);
+ const protokoll = await testPrisma.protokoll.findMany({ where: { entitaetId: quelle.id } });
+ const alles = JSON.stringify({ quelle, liste, einzeln, protokoll });
+
+ expect(alles).not.toContain(LDAP_BIND_PASSWORT);
+ expect(alles).not.toContain("bindPasswort");
+ // Auch der verschluesselte Wert hat in einer Antwort nichts zu suchen.
+ expect(alles).not.toContain("bindPasswortVerschluesselt");
+
+ const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
+ expect(zeile.bindPasswortVerschluesselt).not.toContain(LDAP_BIND_PASSWORT);
+ expect(zeile.bindPasswortVerschluesselt).toMatch(/^v1\./u);
+ expect(entschluesseln(zeile.bindPasswortVerschluesselt)).toBe(LDAP_BIND_PASSWORT);
+ });
+
+ it("loescht eine Quelle ohne Mitarbeiter und verweigert es sonst", async () => {
+ const { admin, eigene } = await grundbestand();
+ const quelle = await quelleAnlegen(quellenEingabe(), admin);
+ await testPrisma.mitarbeiter.create({
+ data: {
+ kundeId: eigene.id,
+ quelleId: quelle.id,
+ externeId: GUID.huber,
+ vorname: "Maria",
+ nachname: "Huber",
+ anzeigename: "Huber, Maria",
+ herkunft: "ABGLEICH",
+ },
+ });
+
+ await expect(quelleLoeschen(quelle.id, admin)).rejects.toThrow(/hängen 1 Mitarbeiter/u);
+
+ await testPrisma.mitarbeiter.deleteMany({ where: { quelleId: quelle.id } });
+ await quelleLoeschen(quelle.id, admin);
+ expect(await testPrisma.verzeichnisquelle.count()).toBe(0);
+ // Das Protokoll bleibt, auch ohne die Zeile, auf die es zeigt.
+ expect(await testPrisma.protokoll.count({ where: { aktion: "Verzeichnisquelle gelöscht" } })).toBe(1);
+ });
+
+ it("fasst eine Quelle eines fremden Kunden nicht an", async () => {
+ const { admin } = await grundbestand();
+ // Anlegen kann so eine Zeile niemand – `quelleAnlegen` laeuft nur im Modus
+ // INTERN und nimmt dort immer die eigene Organisation. Entstehen kann sie
+ // trotzdem: durch eine Migration, `psql` oder ein Skript. „Gilt nur fuer die
+ // eigene Organisation“ waere sonst eine Zusage, die nur fuer den Weg durch
+ // das Formular gilt.
+ const fremder = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } });
+ const fremde = await testPrisma.verzeichnisquelle.create({
+ data: {
+ kundeId: fremder.id,
+ name: "Fremdes AD",
+ url: "ldaps://dc1.fremd.local:636",
+ bindDn: "cn=dienst,dc=fremd,dc=local",
+ bindPasswortVerschluesselt: "v1.aaaa.bbbb.cccc",
+ basisDn: "dc=fremd,dc=local",
+ filter: STANDARD_FILTER,
+ attributZuordnung: { ...AD_ZUORDNUNG },
+ },
+ });
+
+ await expect(quelleBearbeiten(fremde.id, { name: "X" }, admin)).rejects.toThrow(/eigenen Organisation/u);
+ await expect(quelleLoeschen(fremde.id, admin)).rejects.toThrow(/eigenen Organisation/u);
+ await expect(verbindungTesten(fremde.id, admin)).rejects.toThrow(/eigenen Organisation/u);
+ });
+
+ it("liefert eine unbekannte Quelle als „nicht vorhanden“", async () => {
+ const { admin } = await grundbestand();
+ expect(await quelleHolen("gibtesnicht", admin)).toBeUndefined();
+ await expect(quelleBearbeiten("gibtesnicht", { name: "X" }, admin)).rejects.toThrow(VerzeichnisFehler);
+ });
+});
+
+// ---- 3. Gegen den echten Container ----------------------------------------
+
+describe.runIf(datenbankVerfuegbar && ldapDa)("Verbindung testen gegen das Verzeichnis", () => {
+ const protokolliert: string[] = [];
+
+ beforeEach(async () => {
+ await datenbankLeeren();
+ protokolliert.length = 0;
+ // Die Zusage „kein Server-Log enthaelt das Bind-Passwort“ wird hier wirklich
+ // gemessen und nicht behauptet.
+ for (const kanal of ["log", "info", "warn", "error", "debug"] as const) {
+ vi.spyOn(console, kanal).mockImplementation((...teile: unknown[]) => {
+ protokolliert.push(teile.map((teil) => String(teil)).join(" "));
+ });
+ }
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("zeigt die gemappten Felder der Fixture-Personen", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleAnlegen(quellenEingabe(), admin);
+ const { treffer, gesamtGelesen } = await verbindungTesten(quelle.id, admin);
+
+ // Vier Eintraege liefert der Filter, drei davon haben eine Kennung.
+ expect(gesamtGelesen).toBe(FIXTURE_GELESEN);
+ expect(treffer).toHaveLength(FIXTURE_PERSONEN);
+
+ const huber = treffer.find((eintrag) => eintrag.sam === "mhuber");
+ expect(huber).toMatchObject({
+ externeId: GUID.huber,
+ vorname: "Maria",
+ nachname: "Huber",
+ anzeigename: "Huber, Maria",
+ mail: "maria.huber@firma.test",
+ upn: "maria.huber@firma.test",
+ abteilung: "Buchhaltung",
+ // Umlaute kommen als UTF-8 durch, nicht als Fragezeichen.
+ titel: "Leiterin Rechnungsprüfung",
+ dn: "cn=Maria Huber,ou=Buchhaltung,ou=Benutzer,dc=firma,dc=test",
+ // Der Chef ist in derselben Probe, deshalb aufgeloest.
+ vorgesetzterExterneId: GUID.berger,
+ });
+ expect(treffer.find((eintrag) => eintrag.sam === "jweiss")).toMatchObject({
+ nachname: "Weiß",
+ vorname: "Jörg",
+ externeId: GUID.weiss,
+ });
+ });
+
+ it("laesst das per userAccountControl deaktivierte Konto aus", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleAnlegen(quellenEingabe(), admin);
+ const { treffer } = await verbindungTesten(quelle.id, admin);
+
+ // „Alte Kraft“ steht mit userAccountControl 514 im Verzeichnis. Dass der
+ // Bit-UND-Filter greift, entscheidet sich am Server – hier ist es gemessen.
+ expect(treffer.map((eintrag) => eintrag.sam)).not.toContain("akraft");
+ expect(treffer.map((eintrag) => eintrag.externeId)).not.toContain(GUID.alteKraft);
+ });
+
+ it("haelt sich an die Grenze und schreibt nichts", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleAnlegen(quellenEingabe(), admin);
+ const protokollVorher = await testPrisma.protokoll.count();
+
+ const { treffer } = await verbindungTesten(quelle.id, admin, 2);
+ expect(treffer).toHaveLength(2);
+
+ // Kein Mitarbeiter, kein Protokoll, kein Abgleichstand: der Test ist eine
+ // Probe und kein halber Abgleich.
+ expect(await testPrisma.mitarbeiter.count()).toBe(0);
+ expect(await testPrisma.protokoll.count()).toBe(protokollVorher);
+ const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
+ expect(zeile.letzterAbgleichAm).toBeNull();
+ expect(zeile.letzterAbgleichStatus).toBeNull();
+ });
+
+ it("findet mit einem eigenen Filter nur die gesuchte Abteilung", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleAnlegen(
+ quellenEingabe({ filter: "(&(objectClass=user)(department=Vertrieb)(sAMAccountName=*))" }),
+ admin,
+ );
+ const { treffer } = await verbindungTesten(quelle.id, admin);
+ expect(treffer.map((eintrag) => eintrag.sam).sort()).toEqual(["akraft", "jweiss"]);
+ });
+
+ it("liest den ganzen Baum ab dem Basis-DN", async () => {
+ const { admin } = await grundbestand();
+ // Die Personen liegen in Unter-OUs; mit scope „one“ waere die Liste leer.
+ const quelle = await quelleAnlegen(quellenEingabe({ basisDn: LDAP_BASIS_DN }), admin);
+ const { treffer } = await verbindungTesten(quelle.id, admin);
+ expect(treffer).toHaveLength(FIXTURE_PERSONEN);
+ });
+
+ it("nennt einen falschen Bind-DN, ohne das Passwort zu verraten", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleAnlegen(quellenEingabe({ bindPasswort: "falsches-passwort-xyz" }), admin);
+
+ await expect(verbindungTesten(quelle.id, admin)).rejects.toThrow(VerbindungsFehler);
+ const fehler = await verbindungTesten(quelle.id, admin).catch((f: unknown) => f);
+ expect(String(fehler)).not.toContain("falsches-passwort-xyz");
+ // Und auch nichts, was die Bibliothek unterwegs auf die Konsole geschrieben
+ // haette.
+ expect(protokolliert.join("\n")).not.toContain("falsches-passwort-xyz");
+ });
+
+ it("meldet einen unerreichbaren Server als Fehler und nicht als leeres Ergebnis", async () => {
+ const { admin } = await grundbestand();
+ // Ein leeres Ergebnis waere die gefaehrlichste Antwort: der Abgleich wuerde
+ // daraus schliessen, dass alle gegangen sind.
+ const quelle = await quelleAnlegen(quellenEingabe({ url: "ldap://127.0.0.1:1" }), admin);
+ await expect(verbindungTesten(quelle.id, admin)).rejects.toThrow(VerbindungsFehler);
+ });
+});
+
+describe.runIf(datenbankVerfuegbar)("Abgleich-Log", () => {
+ beforeEach(datenbankLeeren);
+
+ /** Ein Protokolleintrag, wie ihn ein Abgleich schreibt. */
+ async function logEintrag(
+ quelleId: string,
+ nachher: Record,
+ benutzerId: string | null,
+ gescheitert = false,
+ ) {
+ return testPrisma.protokoll.create({
+ data: {
+ entitaet: "Verzeichnisquelle",
+ entitaetId: quelleId,
+ aktion: gescheitert ? "Verzeichnisabgleich fehlgeschlagen" : "Verzeichnis abgeglichen",
+ benutzerId,
+ nachher,
+ },
+ });
+ }
+
+ it("zeigt die Laeufe mit ihren Zahlen, der neueste zuerst", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleAnlegen(quellenEingabe(), admin);
+ await logEintrag(
+ quelle.id,
+ { gelesen: 4, angelegt: 3, uebernommen: 0, aktualisiert: 0, deaktiviert: 0, uebersprungen: 1 },
+ admin.id,
+ );
+ await logEintrag(quelle.id, { gelesen: 0, fehler: "Anmeldung abgelehnt" }, null, true);
+
+ const zeilen = await abgleichLog(quelle.id, admin);
+ expect(zeilen).toHaveLength(2);
+ expect(zeilen[0]).toMatchObject({ gescheitert: true, fehler: "Anmeldung abgelehnt", durch: null });
+ expect(zeilen[1]).toMatchObject({
+ gescheitert: false,
+ gelesen: 4,
+ angelegt: 3,
+ uebersprungen: 1,
+ durch: "Admin",
+ });
+ });
+
+ it("nimmt nur die Abgleich-Eintraege dieser Quelle", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleAnlegen(quellenEingabe(), admin);
+ const andere = await quelleAnlegen(quellenEingabe({ name: "Zweites AD" }), admin);
+ await logEintrag(quelle.id, { gelesen: 1 }, admin.id);
+ await logEintrag(andere.id, { gelesen: 99 }, admin.id);
+ // Auch das Anlegen der Quelle steht im Protokoll – es ist kein Abgleich.
+ const zeilen = await abgleichLog(quelle.id, admin);
+ expect(zeilen).toHaveLength(1);
+ expect(zeilen[0]?.gelesen).toBe(1);
+ });
+
+ it("haelt sich an dieselbe Grenze wie die Quellenliste", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleAnlegen(quellenEingabe(), admin);
+ for (let lauf = 0; lauf < 12; lauf += 1) {
+ await logEintrag(quelle.id, { gelesen: lauf }, admin.id);
+ }
+ expect(await abgleichLog(quelle.id, admin)).toHaveLength(LOG_ZEILEN);
+ expect(await abgleichLog(quelle.id, admin, 3)).toHaveLength(3);
+ });
+
+ it("ist im Modus Dienstleistung leer, auch wenn Eintraege stehen", async () => {
+ const { admin } = await grundbestand();
+ const quelle = await quelleAnlegen(quellenEingabe(), admin);
+ await logEintrag(quelle.id, { gelesen: 4 }, admin.id);
+ await testPrisma.einstellung.update({
+ where: { id: "global" },
+ data: { modus: Betriebsmodus.DIENSTLEISTUNG },
+ });
+ expect(await abgleichLog(quelle.id, admin)).toEqual([]);
+ });
+
+ it("gibt zu einer unbekannten Quelle nichts heraus", async () => {
+ const { admin } = await grundbestand();
+ expect(await abgleichLog("gibtsnicht", admin)).toEqual([]);
+ });
+});
diff --git a/tests/vorlagen.test.ts b/tests/vorlagen.test.ts
index a0fb119..35daaf7 100644
--- a/tests/vorlagen.test.ts
+++ b/tests/vorlagen.test.ts
@@ -575,7 +575,7 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Versionierung", () => {
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
- await Promise.allSettled([
+ const [veroeffentlichung, speichern] = await Promise.allSettled([
versionVeroeffentlichen(entwurf.id, "Veröffentlicht", benutzer.id),
entwurfSpeichern(entwurf.id, {
variablen: [],
@@ -584,9 +584,30 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Versionierung", () => {
]);
const danach = await strukturLesen(entwurf.id);
+ /*
+ * Welcher der beiden zuerst an die Zeile kommt, entscheidet die Datenbank —
+ * **beide Ausgaenge** werden hier geprueft. Vorher stand die Erwartung in
+ * einem `if`: gewann das Speichern das Rennen, lief der Test durch, ohne
+ * etwas zu belegen, und die eigentliche Zusage waere unbemerkt gefallen
+ * (Reviewbefund zu #016).
+ */
+ expect(danach.status === "VEROEFFENTLICHT" || danach.status === "ENTWURF").toBe(true);
if (danach.status === "VEROEFFENTLICHT") {
- // Wurde veroeffentlicht, darf der nachträgliche Aufbau nicht drinstehen.
+ expect(veroeffentlichung.status).toBe("fulfilled");
+ // Veroeffentlicht heisst unveraenderlich: der nachtraegliche Aufbau darf
+ // nicht drinstehen, und das Speichern muss abgewiesen worden sein.
expect(danach.abschnitte.map((a) => a.titel)).not.toContain("NACHTRAEGLICH");
+ expect(speichern.status).toBe("rejected");
+ } else {
+ // Das Speichern war zuerst da. Dann darf nicht veroeffentlicht worden sein
+ // – sonst trueege die veroeffentlichte Version genau diesen Abschnitt.
+ expect(speichern.status).toBe("fulfilled");
+ expect(veroeffentlichung.status).toBe("rejected");
+ expect(
+ await testPrisma.protokoll.count({
+ where: { entitaetId: entwurf.id, aktion: "Version veröffentlicht" },
+ }),
+ ).toBe(0);
}
});
diff --git a/tests/zeitplan.test.ts b/tests/zeitplan.test.ts
new file mode 100644
index 0000000..52a304e
--- /dev/null
+++ b/tests/zeitplan.test.ts
@@ -0,0 +1,138 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * Der Zeitplan des Verzeichnis-Abgleichs (#016).
+ *
+ * Geprueft wird der **Durchgang**, nicht die Uhr: dass `node-cron` jede Minute
+ * ausloest, ist Sache der Bibliothek; dass ein Durchgang die faelligen Quellen
+ * holt, jede unter der Sperre abgleicht und an einem Fehler nicht abbricht, ist
+ * unsere.
+ *
+ * Die Dienste darunter werden ersetzt. Das ist hier richtig und nicht faul: was
+ * der Abgleich **tut**, prueft `tests/abgleich.test.ts` gegen einen echten
+ * LDAP-Container. Hier geht es allein um die Ablaufsteuerung — und die laesst
+ * sich mit echten Diensten nur pruefen, indem man eine Minute wartet und ein
+ * Verzeichnis danebenstellt.
+ */
+
+const abgleichAusfuehren = vi.fn();
+const faelligeQuellen = vi.fn();
+/** Nur da, um zu belegen, dass der Zeitplan sie **nicht** nimmt. */
+const mitAbgleichSperre = vi.fn();
+
+/** Dieselbe Klasse wie im Dienst – `instanceof` entscheidet ueber das Verhalten. */
+class AbgleichLaeuftBereitsFehler extends Error {}
+
+vi.mock("@/server/services/abgleich", () => ({
+ AbgleichLaeuftBereitsFehler,
+ abgleichAusfuehren: (...args: unknown[]) => abgleichAusfuehren(...args),
+ faelligeQuellen: (...args: unknown[]) => faelligeQuellen(...args),
+ mitAbgleichSperre: (...args: unknown[]) => mitAbgleichSperre(...args),
+}));
+
+const { abgleichDurchgang, zeitplanAnhalten, zeitplanLaeuft, zeitplanStarten } =
+ await import("@/server/zeitplan/abgleich-plan");
+
+afterEach(() => {
+ zeitplanAnhalten();
+ vi.clearAllMocks();
+ vi.restoreAllMocks();
+});
+
+describe("Ein Durchgang des Zeitplans", () => {
+ it("gleicht jede fällige Quelle ab", async () => {
+ faelligeQuellen.mockResolvedValue([
+ { id: "q1", name: "Firma AD" },
+ { id: "q2", name: "Zweigstelle" },
+ ]);
+ abgleichAusfuehren.mockResolvedValue({ status: "ERFOLG" });
+
+ await abgleichDurchgang();
+
+ expect(abgleichAusfuehren).toHaveBeenCalledTimes(2);
+ expect(abgleichAusfuehren.mock.calls.map((aufruf) => aufruf[0])).toEqual(["q1", "q2"]);
+ /*
+ * **Ohne eigene Sperre.** Der Dienst nimmt sie selbst, in der Datenbank und
+ * damit ueber Instanzen hinweg. Eine zweite hier traege einen anderen
+ * Schluessel, liefe an der ersten vorbei — und ein von Hand angestossener
+ * Abgleich saehe fuer den Zeitplan wie ein unerwarteter Fehler aus.
+ */
+ expect(mitAbgleichSperre).not.toHaveBeenCalled();
+ /*
+ * Der handelnde „Benutzer“ ist der Systemlauf: ADMIN, damit die Rechtepruefung
+ * des Dienstes greift, und ohne Id, weil kein Mensch es war. Der Dienst macht
+ * daraus im Protokoll `benutzerId: null`.
+ */
+ const zweitesArgument = abgleichAusfuehren.mock.calls[0]?.[1];
+ expect(zweitesArgument).toEqual({ id: "", rolle: "ADMIN" });
+ });
+
+ it("überspringt still, was gerade schon abgeglichen wird", async () => {
+ /*
+ * Die belegte Sperre meldet der Dienst als eigene Ausnahme. Im Normalbetrieb
+ * mit zwei Instanzen — oder waehrend jemand „Jetzt abgleichen“ drueckt —
+ * waere das jede Minute eine Zeile im Protokoll, deshalb weder Fehler noch
+ * Ausgabe.
+ */
+ faelligeQuellen.mockResolvedValue([
+ { id: "q1", name: "Firma AD" },
+ { id: "q2", name: "Zweigstelle" },
+ ]);
+ abgleichAusfuehren.mockImplementation(async (id: string) => {
+ if (id === "q1") throw new AbgleichLaeuftBereitsFehler("läuft schon");
+ return { status: "ERFOLG" };
+ });
+ const fehlerAusgabe = vi.spyOn(console, "error").mockImplementation(() => {});
+
+ await abgleichDurchgang();
+
+ // Und die naechste Quelle kommt trotzdem dran.
+ expect(abgleichAusfuehren).toHaveBeenCalledTimes(2);
+ expect(fehlerAusgabe).not.toHaveBeenCalled();
+ });
+
+ it("macht nach einem unerwarteten Fehler mit der nächsten Quelle weiter", async () => {
+ /*
+ * Der Fall, der den Zeitplan sonst still stillegt: eine Quelle wirft etwas,
+ * das der Dienst nicht abgefangen hat. Ohne diese Schleife stuende danach
+ * der Abgleich **aller** Quellen — bis jemand den Prozess neu startet.
+ */
+ faelligeQuellen.mockResolvedValue([
+ { id: "kaputt", name: "Antwortet nicht" },
+ { id: "q2", name: "Zweigstelle" },
+ ]);
+ abgleichAusfuehren.mockImplementation(async (id: string) => {
+ if (id === "kaputt") throw new Error("Netz weg");
+ return { status: "ERFOLG" };
+ });
+ const fehlerAusgabe = vi.spyOn(console, "error").mockImplementation(() => {});
+
+ await abgleichDurchgang();
+
+ expect(abgleichAusfuehren).toHaveBeenCalledTimes(2);
+ expect(abgleichAusfuehren.mock.calls[1]?.[0]).toBe("q2");
+ expect(fehlerAusgabe).toHaveBeenCalledOnce();
+ // Der Name der Quelle gehört in die Meldung, sonst sucht jemand im Dunkeln.
+ expect(String(fehlerAusgabe.mock.calls[0]?.[0])).toContain("Antwortet nicht");
+ });
+
+ it("tut nichts, wenn keine Quelle fällig ist", async () => {
+ faelligeQuellen.mockResolvedValue([]);
+ await abgleichDurchgang();
+ expect(abgleichAusfuehren).not.toHaveBeenCalled();
+ });
+});
+
+describe("Der Zeitplan selbst", () => {
+ it("startet genau einmal", () => {
+ // Next laedt Module im Entwicklungsmodus mehrfach; ein zweiter Zeitplan
+ // liefe unbemerkt mit und verdoppelte jeden Durchgang.
+ expect(zeitplanLaeuft()).toBe(false);
+ zeitplanStarten();
+ zeitplanStarten();
+ expect(zeitplanLaeuft()).toBe(true);
+
+ zeitplanAnhalten();
+ expect(zeitplanLaeuft()).toBe(false);
+ });
+});