feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log #30
@@ -70,6 +70,14 @@ SEED_ORG_NAME=Eigene Organisation
|
|||||||
# DIENSTLEISTUNG steht das Kuerzel auf Etiketten und in der Hostname-Regel.
|
# DIENSTLEISTUNG steht das Kuerzel auf Etiketten und in der Hostname-Regel.
|
||||||
SEED_ORG_KUERZEL=EIGEN
|
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 ----------
|
# ---------- Tests ----------
|
||||||
|
|
||||||
# Datenbank fuer Vitest. Ohne diese Variable ueberspringen sich die Datenbanktests.
|
# Datenbank fuer Vitest. Ohne diese Variable ueberspringen sich die Datenbanktests.
|
||||||
|
|||||||
@@ -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,
|
- Druckansicht eines Laufs: hell, ohne Navigation, auf A4, mit allen Schritten, Werten, Notizen,
|
||||||
Namen und Zeitstempeln. Sie ersetzt im MVP den PDF-Export (#015)
|
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
|
### Sicherheit
|
||||||
|
|
||||||
- Der Compose-Stand bricht ab, wenn `APP_SECRET`, `POSTGRES_PASSWORD` oder `APP_URL` fehlen,
|
- 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)
|
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
|
- Die Archivsuche setzt jeden Wert als Parameter und maskiert `%`, `_` und `\` für `ILIKE`: die
|
||||||
Suche nach „50 %“ trifft nicht „5099“ (#015)
|
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)
|
||||||
|
|||||||
@@ -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)
|
Die Anbindung an authentik ist in [`docs/betrieb/authentik.md`](docs/betrieb/authentik.md)
|
||||||
Schritt für Schritt beschrieben.
|
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
|
## Umgebungsvariablen
|
||||||
|
|
||||||
Alle Variablen mit Erklärung stehen in [`.env.example`](.env.example). Pflicht sind `APP_URL`,
|
Alle Variablen mit Erklärung stehen in [`.env.example`](.env.example). Pflicht sind `APP_URL`,
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
# Dienste fuer die Testsuite. Nicht fuer den Betrieb.
|
# 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
|
name: laufzettel-test
|
||||||
|
|
||||||
@@ -18,3 +16,48 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
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
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ services:
|
|||||||
OIDC_GROUP_TECHNICIAN: ${OIDC_GROUP_TECHNICIAN:-}
|
OIDC_GROUP_TECHNICIAN: ${OIDC_GROUP_TECHNICIAN:-}
|
||||||
OIDC_GROUP_VIEWER: ${OIDC_GROUP_VIEWER:-}
|
OIDC_GROUP_VIEWER: ${OIDC_GROUP_VIEWER:-}
|
||||||
OIDC_CUSTOMER_GROUP_PREFIX: ${OIDC_CUSTOMER_GROUP_PREFIX:-}
|
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.
|
# Standardmaessig nur auf localhost; davor gehoert ein Reverse Proxy.
|
||||||
ports:
|
ports:
|
||||||
- "${BIND_ADDR:-127.0.0.1}:${APP_PORT:-3000}:3000"
|
- "${BIND_ADDR:-127.0.0.1}:${APP_PORT:-3000}:3000"
|
||||||
|
|||||||
@@ -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;
|
alles und dann gefiltert. Ein Ausdruck liegt herum, wird kopiert und weitergegeben;
|
||||||
was darauf steht, ist aus der Hand gegeben.
|
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
|
## 6. Betrieb: zwei Wege, eine Regel
|
||||||
|
|
||||||
Die Datenbank ist auf beiden Wegen von außen nicht erreichbar:
|
Die Datenbank ist auf beiden Wegen von außen nicht erreichbar:
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ als Unterpunkte mit Aufgabe und Dauer.
|
|||||||
| #013 | M8 – Läufe: Service und Abarbeitung | 3 h 05 min |
|
| #013 | M8 – Läufe: Service und Abarbeitung | 3 h 05 min |
|
||||||
| #014 | M9 – QR-Tags, Geräteseite, Etiketten, Scanner | 3 h 35 min |
|
| #014 | M9 – QR-Tags, Geräteseite, Etiketten, Scanner | 3 h 35 min |
|
||||||
| #015 | M10 – Dashboard und Archiv | 2 h 20 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 |
|
| 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,
|
- #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.
|
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“.
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# #016 M11 – Verzeichnis und LDAP-Abgleich
|
# #016 M11 – Verzeichnis und LDAP-Abgleich
|
||||||
|
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.1.0
|
**Meilenstein:** v0.1.0
|
||||||
**Labels:** phase-1, backend, frontend, security
|
**Labels:** phase-1, backend, frontend, security
|
||||||
**Geschätzt:** 12 h **Tatsächlich:** –
|
**Geschätzt:** 12 h **Tatsächlich:** 3 h 25 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
@@ -11,29 +11,61 @@ Die Mitarbeiter der eigenen Firma kommen aus dem Active Directory, damit beim St
|
|||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
|
|
||||||
- [ ] `ldapts` 9.x: `ldaps` bevorzugt, StartTLS optional, eigenes CA-Zertifikat hinterlegbar (`caCertPem`), `minVersion: TLSv1.2`
|
- [x] `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
|
- [x] `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`
|
- [x] `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)))`
|
- [x] 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`
|
- [x] 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
|
- [x] „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`
|
- [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`
|
||||||
- [ ] 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] 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
|
- [x] 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“
|
- [x] 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`)
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [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
|
||||||
- [ ] Screenshots Verzeichnis, Verbindungstest, Abgleich-Log, CSV-Import in `docs/screenshots/m11/`
|
- [x] Screenshots Verzeichnis, Verbindungstest, Abgleich-Log, CSV-Import in `docs/screenshots/m11/`
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
- [ ] Integrationstest gegen den Container: erster Lauf legt Mitarbeiter an, zweiter Lauf aktualisiert sie ohne Duplikate
|
- [x] 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
|
- [x] Integrationstest: ein aus dem Verzeichnis entferntes Konto wird `aktiv = false` und bleibt in der Datenbank
|
||||||
- [ ] Integrationstest: ein per `userAccountControl` deaktiviertes Konto wird `aktiv = false`
|
- [x] Integrationstest: ein per `userAccountControl` deaktiviertes Konto wird `aktiv = false`
|
||||||
- [ ] Test: `{{ou}}` wird korrekt aus dem `dn` abgeleitet
|
- [x] Test: `{{ou}}` wird korrekt aus dem `dn` abgeleitet
|
||||||
- [ ] Test: `objectGUID` als Buffer ergibt reproduzierbar dieselbe GUID-Zeichenkette (bekannter Byte-Vektor als Fixture)
|
- [x] 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
|
- [x] 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
|
- [x] Test: zwei gleichzeitig gestartete Abgleiche derselben Quelle — der zweite wird von der Sperre abgewiesen
|
||||||
- [ ] Im Modus `DIENSTLEISTUNG` ist Admin → Verzeichnis nicht erreichbar
|
- [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.
|
||||||
|
|||||||
@@ -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 |
|
| #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 |
|
| #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 |
|
| #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 | – |
|
| #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 | – |
|
| #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 | – |
|
| #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** |
|
||||||
|
|||||||
|
After Width: | Height: | Size: 427 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 375 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 508 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 437 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 123 KiB |
@@ -18,7 +18,7 @@
|
|||||||
* Bild sieht dann richtig aus, zeigt aber eine Seite, auf der kein Knopf etwas
|
* Bild sieht dann richtig aus, zeigt aber eine Seite, auf der kein Knopf etwas
|
||||||
* tut. Siehe CLAUDE.md, Abschnitt „Oberflaeche pruefen“.
|
* 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 { join } from "node:path";
|
||||||
import { chromium, type Browser, type Cookie, type Locator, type Page } from "@playwright/test";
|
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: "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
|
* Die Druckansicht braucht einen Lauf, und welcher das ist, weiss nur die
|
||||||
@@ -263,15 +313,30 @@ async function anmelden(browser: Browser): Promise<Cookie[]> {
|
|||||||
return kekse;
|
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<void> {
|
async function kachelnErzeugen(browser: Browser): Promise<void> {
|
||||||
const svg = new URL(`${APP_URL}/icon-kachel.svg`).toString();
|
const svg = new URL(`${APP_URL}/icon-kachel.svg`).toString();
|
||||||
for (const kante of [192, 512]) {
|
for (const kante of [192, 512]) {
|
||||||
const seite = await browser.newPage({ viewport: { width: kante, height: kante } });
|
const seite = await browser.newPage({ viewport: { width: kante, height: kante } });
|
||||||
await seite.goto(svg, { waitUntil: "load" });
|
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();
|
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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Building, Plus } from "lucide-react";
|
import { Building, Plus } from "lucide-react";
|
||||||
import { AbteilungArchivieren } from "@/components/abteilung-archivieren";
|
import { AbteilungArchivieren } from "@/components/abteilung-archivieren";
|
||||||
import { AbteilungFormular } from "@/components/abteilung-formular";
|
import { AbteilungFormular } from "@/components/abteilung-formular";
|
||||||
|
import { AbteilungVorschlaege } from "@/components/abteilung-vorschlaege";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
import { kundenEbeneSichtbar } from "@/server/mandant";
|
import { kundenEbeneSichtbar } from "@/server/mandant";
|
||||||
import { kundenListe } from "@/server/queries/kunden";
|
import { kundenListe } from "@/server/queries/kunden";
|
||||||
@@ -44,19 +45,16 @@ export default async function AbteilungenSeite() {
|
|||||||
Standardgruppen bleiben die Platzhalter <code className="mono">{"{{ou}}"}</code> und{" "}
|
Standardgruppen bleiben die Platzhalter <code className="mono">{"{{ou}}"}</code> und{" "}
|
||||||
<code className="mono">{"{{gruppen}}"}</code> im Lauf leer.
|
<code className="mono">{"{{gruppen}}"}</code> im Lauf leer.
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-3 flex flex-wrap gap-2">
|
{/* Der Knopf je Vorschlag ruft dieselbe Server Action wie das Formular
|
||||||
{offene.map((eintrag) => (
|
unten (#016) — eine zweite Regel zum Anlegen gibt es nicht. */}
|
||||||
<li
|
<AbteilungVorschlaege
|
||||||
key={eintrag.name}
|
vorschlaege={offene.map((eintrag) => ({
|
||||||
className="rounded-pille bg-flaeche-hoch px-3 py-1.5 text-sm text-nebentext"
|
...eintrag,
|
||||||
>
|
kundeName: kundenEbene
|
||||||
{eintrag.name}{" "}
|
? (kunden.find((kunde) => kunde.id === eintrag.kundeId)?.name ?? null)
|
||||||
<span className="text-xs">
|
: null,
|
||||||
({eintrag.anzahl} {eintrag.anzahl === 1 ? "Person" : "Personen"})
|
}))}
|
||||||
</span>
|
/>
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import { Betriebsmodus } from "@/generated/prisma/enums";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { betriebsmodus } from "@/server/mandant";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Der Verwaltungsbereich. Die Rechtepruefung steht hier **und** in jeder Seite
|
* Der Verwaltungsbereich. Die Rechtepruefung steht hier **und** in jeder Seite
|
||||||
@@ -11,21 +13,31 @@ const REITER = [
|
|||||||
{ pfad: "/admin", beschriftung: "Einstellungen" },
|
{ pfad: "/admin", beschriftung: "Einstellungen" },
|
||||||
{ pfad: "/admin/benutzer", beschriftung: "Benutzer" },
|
{ pfad: "/admin/benutzer", beschriftung: "Benutzer" },
|
||||||
{ pfad: "/admin/abteilungen", beschriftung: "Abteilungen" },
|
{ 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;
|
] as const;
|
||||||
|
|
||||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
await sitzungErforderlich("verwaltung", "/admin");
|
await sitzungErforderlich("verwaltung", "/admin");
|
||||||
const pfad = (await headers()).get("x-pfad") ?? "/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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav aria-label="Verwaltung" className="mb-8 flex flex-wrap gap-2">
|
<nav aria-label="Verwaltung" className="mb-8 flex flex-wrap gap-2">
|
||||||
{REITER.map((reiter) => {
|
{reiter.map((eintrag) => {
|
||||||
const aktiv = pfad === reiter.pfad;
|
const aktiv = pfad === eintrag.pfad;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={reiter.pfad}
|
key={eintrag.pfad}
|
||||||
href={reiter.pfad}
|
href={eintrag.pfad}
|
||||||
aria-current={aktiv ? "page" : undefined}
|
aria-current={aktiv ? "page" : undefined}
|
||||||
className={`flex min-h-11 items-center rounded-pille px-4 text-sm transition-colors md:min-h-9 ${
|
className={`flex min-h-11 items-center rounded-pille px-4 text-sm transition-colors md:min-h-9 ${
|
||||||
aktiv
|
aktiv
|
||||||
@@ -33,7 +45,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||||||
: "border border-linie text-nebentext hover:bg-flaeche-hoch hover:text-text"
|
: "border border-linie text-nebentext hover:bg-flaeche-hoch hover:text-text"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{reiter.beschriftung}
|
{eintrag.beschriftung}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<p className="mt-2 text-sm text-nebentext">
|
||||||
|
Noch nie abgeglichen. „Verbindung testen“ zeigt, was ankäme, ohne etwas zu schreiben.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gescheitert = quelle.letzterAbgleichStatus === "FEHLER";
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex flex-col gap-1">
|
||||||
|
<p className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
{gescheitert ? (
|
||||||
|
<AlertTriangle className="size-4 shrink-0 text-fehler" strokeWidth={2} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="size-4 shrink-0 text-erledigt" strokeWidth={2} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<span className={gescheitert ? "text-fehler" : "text-nebentext"}>
|
||||||
|
{gescheitert ? "Letzter Abgleich gescheitert" : "Zuletzt abgeglichen"} am{" "}
|
||||||
|
{ZEITPUNKT.format(quelle.letzterAbgleichAm)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{quelle.letzterAbgleichFehler ? (
|
||||||
|
<p className="max-w-prose text-sm break-words text-fehler">{quelle.letzterAbgleichFehler}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<p className="mt-3 text-sm text-nebentext">
|
||||||
|
Noch kein Lauf. Hier steht danach, was jeder Abgleich getan hat.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="mt-3 flex flex-col gap-3">
|
||||||
|
{zeilen.map((zeile) => (
|
||||||
|
<li key={zeile.id} className="border-t border-linie pt-3 first:border-t-0 first:pt-0">
|
||||||
|
<p className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
{zeile.gescheitert ? (
|
||||||
|
<AlertTriangle className="size-4 shrink-0 text-fehler" strokeWidth={2} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="size-4 shrink-0 text-erledigt" strokeWidth={2} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<span>{ZEITPUNKT.format(zeile.zeitpunkt)}</span>
|
||||||
|
<span className="text-nebentext">
|
||||||
|
{/* Ohne Menschen war es der Zeitplan – das ist der Regelfall und keine Lücke. */}
|
||||||
|
{zeile.durch === null ? "Zeitplan" : zeile.durch}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-nebentext">
|
||||||
|
<span>{zeile.gelesen} gelesen</span>
|
||||||
|
<span>{zeile.angelegt} angelegt</span>
|
||||||
|
{zeile.uebernommen > 0 ? <span>{zeile.uebernommen} übernommen</span> : null}
|
||||||
|
<span>{zeile.aktualisiert} aktualisiert</span>
|
||||||
|
<span>{zeile.deaktiviert} deaktiviert</span>
|
||||||
|
{zeile.uebersprungen > 0 ? (
|
||||||
|
<span className="text-faellig">{zeile.uebersprungen} übersprungen</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
{zeile.fehler ? (
|
||||||
|
<p className="mt-1 max-w-prose text-xs break-words text-fehler">{zeile.fehler}</p>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<h1 className="text-2xl font-bold">Verzeichnis</h1>
|
||||||
|
<p className="mt-2 max-w-prose text-nebentext">
|
||||||
|
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 <strong>inaktiv</strong>{" "}
|
||||||
|
— gelöscht wird nie, sonst verlöre die Historie ihre Namen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{quellen.length === 0 ? (
|
||||||
|
<p className="mt-6 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-6 flex flex-col gap-4">
|
||||||
|
{quellen.map((quelle) => (
|
||||||
|
<li key={quelle.id} className="rounded-karte border border-linie bg-flaeche p-5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Server className="size-5 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" />
|
||||||
|
<h2 className="text-lg font-semibold break-words">{quelle.name}</h2>
|
||||||
|
{quelle.aktiv ? (
|
||||||
|
<span className="flex items-center gap-1 rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent">
|
||||||
|
<Clock className="size-3" strokeWidth={2} aria-hidden="true" />
|
||||||
|
alle {quelle.abgleichIntervallMinuten} min
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
|
||||||
|
kein Zeitplan
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mono mt-2 text-xs break-all text-nebentext">
|
||||||
|
{quelle.url} · {quelle.basisDn}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Status quelle={quelle} />
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
|
<VerbindungTesten quelleId={quelle.id} />
|
||||||
|
<JetztAbgleichen quelleId={quelle.id} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="mt-4 border-t border-linie pt-4">
|
||||||
|
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
|
||||||
|
Abgleich-Log
|
||||||
|
</summary>
|
||||||
|
<Log zeilen={logs.get(quelle.id) ?? []} />
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details className="mt-4 border-t border-linie pt-4">
|
||||||
|
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
|
||||||
|
Einstellungen bearbeiten
|
||||||
|
</summary>
|
||||||
|
<div className="mt-3">
|
||||||
|
<QuelleFormular quelle={quelle} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
|
||||||
|
<h2 className="text-lg font-semibold">Quelle anlegen</h2>
|
||||||
|
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
|
||||||
|
Ein Dienstkonto mit Leserechten genügt. Laufzettel schreibt nie ins Verzeichnis und speichert nur
|
||||||
|
die zugeordneten Felder — keine Passwörter, keine Gruppenmitgliedschaften.
|
||||||
|
</p>
|
||||||
|
<QuelleFormular />
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
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 { MitarbeiterAnlegen } from "@/components/mitarbeiter-anlegen";
|
||||||
|
import { MitarbeiterImportieren } from "@/components/mitarbeiter-importieren";
|
||||||
import { MitarbeiterSuche } from "@/components/mitarbeiter-suche";
|
import { MitarbeiterSuche } from "@/components/mitarbeiter-suche";
|
||||||
import { StammdatenReiter } from "@/components/stammdaten-reiter";
|
import { StammdatenReiter } from "@/components/stammdaten-reiter";
|
||||||
import { Herkunft } from "@/generated/prisma/enums";
|
import { Herkunft } from "@/generated/prisma/enums";
|
||||||
@@ -9,6 +10,7 @@ import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
|||||||
import { kundenEbeneSichtbar } from "@/server/mandant";
|
import { kundenEbeneSichtbar } from "@/server/mandant";
|
||||||
import { kundenListe } from "@/server/queries/kunden";
|
import { kundenListe } from "@/server/queries/kunden";
|
||||||
import { abteilungsNamen, mitarbeiterListe, type MitarbeiterZeile } from "@/server/queries/mitarbeiter";
|
import { abteilungsNamen, mitarbeiterListe, type MitarbeiterZeile } from "@/server/queries/mitarbeiter";
|
||||||
|
import { MITARBEITER_IMPORT_HOECHSTZEILEN, MITARBEITER_IMPORT_SPALTEN } from "@/server/services/mitarbeiter";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mitarbeiterliste (#011).
|
* Mitarbeiterliste (#011).
|
||||||
@@ -138,6 +140,27 @@ export default async function MitarbeiterSeite({
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
) : null}
|
) : 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.
|
||||||
|
*/
|
||||||
|
<details className="mt-4 rounded-karte border border-linie bg-flaeche">
|
||||||
|
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 px-5 text-sm text-nebentext hover:text-text">
|
||||||
|
<Upload className="size-4 text-akzent" strokeWidth={2} aria-hidden="true" />
|
||||||
|
Personen aus einer CSV-Datei einlesen
|
||||||
|
</summary>
|
||||||
|
<div className="px-5 pb-5">
|
||||||
|
<MitarbeiterImportieren
|
||||||
|
hoechstZeilen={MITARBEITER_IMPORT_HOECHSTZEILEN}
|
||||||
|
spalten={Object.values(MITARBEITER_IMPORT_SPALTEN).map((namen) => namen[0])}
|
||||||
|
kunden={kunden.map(({ id, name }) => ({ id, name }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<p className="mt-3 max-w-prose text-sm text-nebentext">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<ul className="mt-3 flex flex-col gap-2">
|
||||||
|
{vorschlaege.map((vorschlag) => (
|
||||||
|
// Der Name allein taugt nicht als Schluessel: bei zwei Kunden kann
|
||||||
|
// „Vertrieb“ zweimal offen sein, und das sind zwei Vorgaenge.
|
||||||
|
<Vorschlag key={`${vorschlag.kundeId}\u0000${vorschlag.name}`} vorschlag={vorschlag} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Vorschlag({ vorschlag }: { vorschlag: AbteilungsVorschlag }) {
|
||||||
|
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(abteilungAnlegenAktion, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="rounded-karte border border-linie bg-grund p-3">
|
||||||
|
<form action={aktion} className="flex flex-wrap items-center gap-3">
|
||||||
|
<input type="hidden" name="name" value={vorschlag.name} />
|
||||||
|
{/* Im Modus INTERN verwirft `kundeErmitteln` die Kennung ohnehin; im Modus
|
||||||
|
Dienstleistung ist sie Pflicht, und der Vorschlag weiss, zu welchem
|
||||||
|
Kunden er gehoert. */}
|
||||||
|
<input type="hidden" name="kundeId" value={vorschlag.kundeId} />
|
||||||
|
|
||||||
|
<span className="min-w-0 grow">
|
||||||
|
<span className="font-medium break-words">{vorschlag.name}</span>
|
||||||
|
<span className="ml-2 text-xs text-nebentext">
|
||||||
|
{vorschlag.anzahl} {vorschlag.anzahl === 1 ? "Person" : "Personen"}
|
||||||
|
{vorschlag.kundeName === null ? "" : ` · ${vorschlag.kundeName}`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={laeuft}
|
||||||
|
className="h-11 shrink-0 rounded-pille px-4 text-sm font-semibold md:h-10"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" strokeWidth={2} aria-hidden="true" />
|
||||||
|
{laeuft ? "Wird angelegt …" : "Anlegen"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{ergebnis.fehler ? (
|
||||||
|
<p role="alert" className="mt-2 text-sm text-fehler">
|
||||||
|
{ergebnis.fehler}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{ergebnis.erfolg ? (
|
||||||
|
<p role="status" className="mt-2 text-sm text-erledigt">
|
||||||
|
{ergebnis.erfolg}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<AktionsErgebnis, FormData>(
|
||||||
|
mitarbeiterImportierenAktion,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const [inhalt, setInhalt] = useState("");
|
||||||
|
const [leseFehler, setLeseFehler] = useState<string | null>(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 (
|
||||||
|
<form action={aktion} className="flex flex-col gap-4">
|
||||||
|
<p className="max-w-prose text-sm text-nebentext">
|
||||||
|
Spalten: <span className="mono text-text">{spalten.join(", ")}</span>. Die deutschen Namen (Vorname,
|
||||||
|
Nachname, Anzeigename, Abteilung, Titel) gehen genauso, Groß- und Kleinschreibung ist unerheblich.
|
||||||
|
Trennzeichen darf Komma oder Semikolon sein. Höchstens {hoechstZeilen} Zeilen je Datei. Eine
|
||||||
|
fehlerhafte Zeile bricht den Import nicht ab — sie wird unten mit ihrer Zeilennummer gemeldet.
|
||||||
|
</p>
|
||||||
|
<ul className="max-w-prose list-disc pl-5 text-sm text-nebentext">
|
||||||
|
<li>
|
||||||
|
Wiedererkannt wird eine Person über <span className="mono text-text">sam</span> oder{" "}
|
||||||
|
<span className="mono text-text">upn</span>, sonst über den Anzeigenamen. Wer schon da ist, wird
|
||||||
|
aktualisiert statt ein zweites Mal angelegt.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Eine leere Zelle löscht nichts: was in der Datei nicht steht, bleibt so, wie es ist. Fehlen{" "}
|
||||||
|
<span className="mono text-text">givenName</span> und <span className="mono text-text">sn</span>,
|
||||||
|
wird <span className="mono text-text">displayName</span> am letzten Leerzeichen geteilt.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Personen aus dem Verzeichnis werden nicht überschrieben; solche Zeilen werden gemeldet. Was hier
|
||||||
|
entsteht, gilt als von Hand angelegt und bleibt änderbar.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Eine deaktivierte Person wird erkannt und aktualisiert, aber <strong>nicht</strong> wieder aktiviert
|
||||||
|
— dafür gibt es den Schalter auf ihrer Seite.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Abteilungen legt der Import nicht an — der Name wird nur bei der Person eingetragen. Als Abteilung
|
||||||
|
mit Ziel-OU und Standardgruppen übernimmt sie ein Administrator.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">CSV-Datei</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="text/csv,.csv,text/plain,.txt"
|
||||||
|
onChange={(ereignis) => void dateiLesen(ereignis.target.files?.[0])}
|
||||||
|
className="min-h-11 rounded-karte border border-linie bg-grund px-3 py-2 text-sm text-nebentext file:mr-3 file:rounded-pille file:border-0 file:bg-flaeche-hoch file:px-3 file:py-1.5 file:text-sm file:text-text"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">… oder Inhalt einfügen</span>
|
||||||
|
<textarea
|
||||||
|
name="inhalt"
|
||||||
|
rows={6}
|
||||||
|
required
|
||||||
|
value={inhalt}
|
||||||
|
onChange={(ereignis) => setInhalt(ereignis.target.value)}
|
||||||
|
className="mono rounded-karte border border-linie bg-grund px-3 py-2 text-xs text-text outline-none focus-visible:border-akzent"
|
||||||
|
placeholder={
|
||||||
|
"givenName;sn;displayName;mail;upn;sam;department;title\n" +
|
||||||
|
"Anna;Muster;Anna Muster;anna.muster@firma.de;anna.muster@firma.de;amuster;Vertrieb;Innendienst"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{kunden.length > 0 ? (
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Kunde</span>
|
||||||
|
<select name="kundeId" defaultValue="" className={FELD} required>
|
||||||
|
<option value="">bitte wählen</option>
|
||||||
|
{kunden.map((kunde) => (
|
||||||
|
<option key={kunde.id} value={kunde.id}>
|
||||||
|
{kunde.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{leseFehler ? (
|
||||||
|
<p role="alert" className="text-sm text-fehler">
|
||||||
|
{leseFehler}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{ergebnis.fehler ? (
|
||||||
|
<p role="alert" className="text-sm text-fehler">
|
||||||
|
{ergebnis.fehler}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{ergebnis.erfolg ? (
|
||||||
|
<p role="status" className="text-sm text-erledigt">
|
||||||
|
{ergebnis.erfolg}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Je abgewiesene Zeile die Nummer, die der Editor des Benutzers anzeigt,
|
||||||
|
und der Grund. Eine Sammelmeldung waere hier nutzlos: die Datei muss an
|
||||||
|
genau diesen Zeilen geaendert werden. */}
|
||||||
|
{ergebnis.abgewiesen && ergebnis.abgewiesen.length > 0 ? (
|
||||||
|
<ul className="flex flex-col gap-1.5 rounded-karte border border-linie bg-grund p-3 text-xs">
|
||||||
|
{ergebnis.abgewiesen.map((abweisung) => (
|
||||||
|
<li key={abweisung.zeile} className="flex flex-wrap gap-2">
|
||||||
|
<span className="mono shrink-0 text-nebentext">Zeile {abweisung.zeile}</span>
|
||||||
|
<span className="min-w-0 text-fehler">{abweisung.grund}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={laeuft}
|
||||||
|
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
|
||||||
|
>
|
||||||
|
{laeuft ? "Wird eingelesen …" : "Einlesen"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<AbgleichAktionsErgebnis, FormData>(
|
||||||
|
abgleichJetztAktion,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<form action={aktion}>
|
||||||
|
<input type="hidden" name="id" value={quelleId} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={laeuft}
|
||||||
|
className="flex min-h-11 w-fit items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-[18px]" strokeWidth={2} aria-hidden="true" />
|
||||||
|
{laeuft ? "Wird abgeglichen …" : "Jetzt abgleichen"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{ergebnis.fehler ? (
|
||||||
|
<p role="alert" className="max-w-prose text-sm text-fehler">
|
||||||
|
{ergebnis.fehler}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{ergebnis.erfolg ? (
|
||||||
|
<p role="status" className="max-w-prose text-sm text-erledigt">
|
||||||
|
{ergebnis.erfolg}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<AktionsErgebnis, FormData>(
|
||||||
|
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 (
|
||||||
|
<form action={aktion} className="flex flex-col gap-4">
|
||||||
|
{bearbeiten ? <input type="hidden" name="id" value={quelle.id} /> : null}
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Name</span>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
defaultValue={quelle?.name ?? ""}
|
||||||
|
required
|
||||||
|
placeholder="Firmen-AD"
|
||||||
|
className={FELD}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* 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.
|
||||||
|
*/}
|
||||||
|
<div className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<label htmlFor="quelle-url" className="text-nebentext">
|
||||||
|
Adresse
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="quelle-url"
|
||||||
|
name="url"
|
||||||
|
value={adresse}
|
||||||
|
onChange={(ereignis) => setzeAdresse(ereignis.target.value)}
|
||||||
|
required
|
||||||
|
aria-describedby="quelle-url-hinweis"
|
||||||
|
placeholder="ldaps://dc01.firma.local:636"
|
||||||
|
className={`${FELD} mono`}
|
||||||
|
/>
|
||||||
|
<span id="quelle-url-hinweis" className="text-xs text-nebentext">
|
||||||
|
Drei Formen: <span className="mono">ldaps://</span> (verschlüsselt ab dem ersten Byte, die richtige
|
||||||
|
Wahl), <span className="mono">ldap+starttls://</span> (unverschlüsselt verbunden und dann
|
||||||
|
hochgestuft) und <span className="mono">ldap://</span>.
|
||||||
|
</span>
|
||||||
|
{klartext ? (
|
||||||
|
/*
|
||||||
|
* Die Warnung erscheint erst, wenn sie zutrifft — eine, die immer
|
||||||
|
* dasteht, liest nach dem dritten Mal niemand mehr.
|
||||||
|
*/
|
||||||
|
<span role="status" className="flex items-start gap-2 text-xs text-faellig">
|
||||||
|
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
Mit <span className="mono">ldap://</span> geht auch das Bind-Passwort im Klartext über das Netz.
|
||||||
|
Für ein Dienstkonto im Active Directory ist das keine gute Idee.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Anmelde-DN</span>
|
||||||
|
<input
|
||||||
|
name="bindDn"
|
||||||
|
defaultValue={quelle?.bindDn ?? ""}
|
||||||
|
required
|
||||||
|
placeholder="CN=laufzettel,OU=Dienstkonten,DC=firma,DC=local"
|
||||||
|
className={`${FELD} mono`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<label htmlFor="quelle-passwort" className="flex items-center gap-2 text-nebentext">
|
||||||
|
<KeyRound className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="quelle-passwort"
|
||||||
|
name="bindPasswort"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
aria-describedby="quelle-passwort-hinweis"
|
||||||
|
placeholder={bearbeiten ? "unverändert lassen" : ""}
|
||||||
|
required={!bearbeiten}
|
||||||
|
className={FELD}
|
||||||
|
/>
|
||||||
|
<span id="quelle-passwort-hinweis" className="text-xs text-nebentext">
|
||||||
|
{bearbeiten
|
||||||
|
? "Leer lassen behält das gespeicherte Passwort."
|
||||||
|
: "Wird verschlüsselt gespeichert und nie wieder angezeigt."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Basis-DN</span>
|
||||||
|
<input
|
||||||
|
name="basisDn"
|
||||||
|
defaultValue={quelle?.basisDn ?? ""}
|
||||||
|
required
|
||||||
|
placeholder="OU=Benutzer,DC=firma,DC=local"
|
||||||
|
className={`${FELD} mono`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<label htmlFor="quelle-filter" className="text-nebentext">
|
||||||
|
Filter (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="quelle-filter"
|
||||||
|
name="filter"
|
||||||
|
defaultValue={quelle?.filter ?? ""}
|
||||||
|
aria-describedby="quelle-filter-hinweis"
|
||||||
|
className={`${FELD} mono`}
|
||||||
|
/>
|
||||||
|
<span id="quelle-filter-hinweis" className="text-xs text-nebentext">
|
||||||
|
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.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Abgleich alle … Minuten</span>
|
||||||
|
<input
|
||||||
|
name="abgleichIntervallMinuten"
|
||||||
|
type="number"
|
||||||
|
min={INTERVALL_MINDESTENS}
|
||||||
|
max={INTERVALL_HOECHSTENS}
|
||||||
|
defaultValue={quelle?.abgleichIntervallMinuten ?? 60}
|
||||||
|
className={FELD}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex min-h-11 items-center gap-2 self-end text-sm text-nebentext md:min-h-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="aktiv"
|
||||||
|
value="1"
|
||||||
|
defaultChecked={quelle?.aktiv ?? false}
|
||||||
|
className={KAESTCHEN}
|
||||||
|
/>
|
||||||
|
Nach Zeitplan abgleichen
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* 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).
|
||||||
|
*/}
|
||||||
|
<details open={zuordnungOffen} onToggle={(e) => setzeZuordnungOffen(e.currentTarget.open)}>
|
||||||
|
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
|
||||||
|
Attributzuordnung anpassen
|
||||||
|
</summary>
|
||||||
|
<p className="mt-2 max-w-prose text-xs text-nebentext">
|
||||||
|
Die Vorgaben sind die Attributnamen eines Active Directory. Ein OpenLDAP heißt an einigen Stellen
|
||||||
|
anders — dort ist die dauerhafte Kennung etwa <span className="mono">entryUUID</span>.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||||
|
{ZUORDNUNGS_FELDER.map((feld) => (
|
||||||
|
<label key={feld} className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">{ZUORDNUNGS_BESCHRIFTUNG[feld]}</span>
|
||||||
|
<input
|
||||||
|
name={`attr.${feld}`}
|
||||||
|
defaultValue={quelle?.attributZuordnung[feld] ?? AD_ZUORDNUNG[feld]}
|
||||||
|
placeholder={AD_ZUORDNUNG[feld]}
|
||||||
|
className={`${FELD} mono`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 max-w-prose text-xs text-nebentext">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details open={zertifikatOffen} onToggle={(e) => setzeZertifikatOffen(e.currentTarget.open)}>
|
||||||
|
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
|
||||||
|
Eigenes CA-Zertifikat hinterlegen
|
||||||
|
</summary>
|
||||||
|
{/*
|
||||||
|
* 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 ? (
|
||||||
|
<p className="mt-2 flex items-start gap-2 text-xs text-erledigt">
|
||||||
|
<ShieldCheck className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
Ein CA-Zertifikat ist hinterlegt. Das Feld unten bleibt leer — Text eintragen ersetzt es, leer
|
||||||
|
lassen behält es.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<label className="mt-2 flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">PEM</span>
|
||||||
|
<textarea
|
||||||
|
name="caZertifikatPem"
|
||||||
|
rows={4}
|
||||||
|
defaultValue=""
|
||||||
|
placeholder="-----BEGIN CERTIFICATE-----"
|
||||||
|
className={`${FELD} mono py-2`}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-nebentext">
|
||||||
|
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."}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{quelle?.caZertifikatVorhanden ? (
|
||||||
|
<label className="mt-2 flex min-h-11 items-center gap-2 text-sm text-nebentext md:min-h-10">
|
||||||
|
<input type="checkbox" name="caZertifikatEntfernen" value="1" className={KAESTCHEN} />
|
||||||
|
Hinterlegtes Zertifikat entfernen
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{ergebnis.fehler ? (
|
||||||
|
<p role="alert" className="max-w-prose text-sm text-fehler">
|
||||||
|
{ergebnis.fehler}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{ergebnis.erfolg ? (
|
||||||
|
<p role="status" className="max-w-prose text-sm text-erledigt">
|
||||||
|
{ergebnis.erfolg}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={laeuft}
|
||||||
|
className="flex min-h-11 w-fit items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
|
||||||
|
>
|
||||||
|
{bearbeiten ? (
|
||||||
|
<Save className="size-[18px]" strokeWidth={2} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Server className="size-[18px]" strokeWidth={2} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{laeuft ? "Wird gespeichert …" : bearbeiten ? "Speichern" : "Quelle anlegen"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<TestErgebnis, FormData>(verbindungTestenAktion, {});
|
||||||
|
const treffer = ergebnis.treffer ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<form action={aktion}>
|
||||||
|
<input type="hidden" name="id" value={quelleId} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={laeuft}
|
||||||
|
className="flex min-h-11 w-fit items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
|
||||||
|
>
|
||||||
|
<PlugZap className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
{laeuft ? "Wird geprüft …" : "Verbindung testen"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{ergebnis.fehler ? (
|
||||||
|
<p role="alert" className="max-w-prose text-sm text-fehler">
|
||||||
|
{ergebnis.fehler}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{ergebnis.erfolg ? (
|
||||||
|
<p role="status" className="max-w-prose text-sm text-erledigt">
|
||||||
|
{ergebnis.erfolg}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{treffer.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p className="max-w-prose text-xs text-nebentext">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
{/*
|
||||||
|
* 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.
|
||||||
|
*/}
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{treffer.map((eintrag) => (
|
||||||
|
<li key={eintrag.externeId} className="rounded-karte border border-linie bg-grund p-3 text-sm">
|
||||||
|
<p className="font-medium break-words">{eintrag.anzeigename}</p>
|
||||||
|
<dl className="mt-1 grid gap-x-4 gap-y-1 sm:grid-cols-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["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]) => (
|
||||||
|
<div key={name} className="flex min-w-0 gap-2">
|
||||||
|
<dt className="shrink-0 text-xs text-nebentext">{name}</dt>
|
||||||
|
<dd className="mono min-w-0 text-xs break-all">{wert}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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("-");
|
||||||
|
}
|
||||||
@@ -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}.`;
|
||||||
|
}
|
||||||
@@ -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<keyof AttributZuordnung, string> = {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<Record<keyof AttributZuordnung, string>>,
|
||||||
|
): { readonly zuordnung: Partial<AttributZuordnung> } | { readonly fehler: string } {
|
||||||
|
const zuordnung: Partial<AttributZuordnung> = {};
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "@/lib/validierung";
|
import { z } from "@/lib/validierung";
|
||||||
import { MandantFehler } from "@/server/mandant";
|
import { MandantFehler, kundenEbeneSichtbar } from "@/server/mandant";
|
||||||
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
import {
|
import {
|
||||||
MitarbeiterFehler,
|
MitarbeiterFehler,
|
||||||
mitarbeiterAktivSetzen,
|
mitarbeiterAktivSetzen,
|
||||||
mitarbeiterAnlegen,
|
mitarbeiterAnlegen,
|
||||||
mitarbeiterBearbeiten,
|
mitarbeiterBearbeiten,
|
||||||
|
mitarbeiterImportieren,
|
||||||
|
type MitarbeiterImportAbweisung,
|
||||||
} from "@/server/services/mitarbeiter";
|
} from "@/server/services/mitarbeiter";
|
||||||
import type { HandelnderBenutzer } from "@/server/mandant";
|
import type { HandelnderBenutzer } from "@/server/mandant";
|
||||||
|
|
||||||
@@ -21,7 +23,12 @@ import type { HandelnderBenutzer } from "@/server/mandant";
|
|||||||
* Verzeichnisabgleich gelaufen ist.
|
* 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 =
|
const RECHTE_MELDUNG =
|
||||||
"Deine Anmeldung gilt nicht mehr. Melde dich in einem zweiten Tab neu an und klicke dann hier " +
|
"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.");
|
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<AktionsErgebnis> {
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.<feld>`, 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<AktionsErgebnis> {
|
||||||
|
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<AktionsErgebnis> {
|
||||||
|
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<AktionsErgebnis> {
|
||||||
|
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<TestErgebnis> {
|
||||||
|
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<AbgleichAktionsErgebnis> {
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> | void,
|
||||||
|
grenze?: number,
|
||||||
|
): Promise<LeseBilanz> {
|
||||||
|
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<LeseBilanz & { readonly eintraege: readonly RoherEintrag[] }> {
|
||||||
|
const eintraege: RoherEintrag[] = [];
|
||||||
|
const bilanz = await eintraegeStroemen(zugang, vorgabe, (eintrag) => void eintraege.push(eintrag), grenze);
|
||||||
|
return { ...bilanz, eintraege };
|
||||||
|
}
|
||||||
@@ -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<readonly [RegExp, string]> = [
|
||||||
|
[/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<T>(zugang: LdapZugang, was: (client: Client) => Promise<T>): Promise<T> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GemappterEintrag, "vorgesetzterExterneId"> & {
|
||||||
|
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<string, string>,
|
||||||
|
): 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -80,6 +80,15 @@ export async function abteilungsListe(
|
|||||||
* Lauf leer, ohne dass diese Seite es je sagte. Genau so stand es hier, bis der
|
* 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
|
* Review es gefunden hat; `abteilungsListe` darueber macht es seit jeher
|
||||||
* richtig.
|
* 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(
|
export async function ungepflegteAbteilungen(
|
||||||
benutzer: HandelnderBenutzer,
|
benutzer: HandelnderBenutzer,
|
||||||
@@ -89,7 +98,9 @@ export async function ungepflegteAbteilungen(
|
|||||||
prisma.abteilung.findMany({ where: { AND: mandant }, select: { name: true, kundeId: true } }),
|
prisma.abteilung.findMany({ where: { AND: mandant }, select: { name: true, kundeId: true } }),
|
||||||
prisma.mitarbeiter.groupBy({
|
prisma.mitarbeiter.groupBy({
|
||||||
by: ["abteilung", "kundeId"],
|
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 },
|
_count: { _all: true },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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<readonly QuellenSicht[]> {
|
||||||
|
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<QuellenSicht | undefined> {
|
||||||
|
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<string, unknown>)[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<string, unknown>)[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<readonly AbgleichLogZeile[]> {
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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<T>(
|
||||||
|
schluessel: string,
|
||||||
|
dauerMinuten: number,
|
||||||
|
was: () => Promise<T>,
|
||||||
|
): Promise<T | undefined> {
|
||||||
|
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<readonly { id: string; name: string }[]> {
|
||||||
|
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<Mitarbeiter | null> {
|
||||||
|
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<AbgleichErgebnis> {
|
||||||
|
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<AbgleichErgebnis> {
|
||||||
|
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<string>();
|
||||||
|
let bindPasswort: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const zugang = zugangAus(quelle);
|
||||||
|
bindPasswort = zugang.bindPasswort;
|
||||||
|
const kennungNachDn = new Map<string, string>();
|
||||||
|
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<void> {
|
||||||
|
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 }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Mitarbeiter, Prisma } from "@/generated/prisma/client";
|
import type { Mitarbeiter, Prisma } from "@/generated/prisma/client";
|
||||||
import { Herkunft } from "@/generated/prisma/enums";
|
import { Herkunft } from "@/generated/prisma/enums";
|
||||||
|
import { CsvFehler, csvZerlegen, hatSpalte, wertAus, type CsvZeile } from "@/lib/csv";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { kundeErmitteln, kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant";
|
import { kundeErmitteln, kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant";
|
||||||
|
|
||||||
@@ -91,10 +92,23 @@ function anzeigenameBilden(eingabe: MitarbeiterEingabe, vorname: string, nachnam
|
|||||||
return gewuenscht ?? `${vorname} ${nachname}`.trim();
|
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`. */
|
/** Legt einen Mitarbeiter von Hand an — `herkunft = MANUELL`. */
|
||||||
export async function mitarbeiterAnlegen(
|
export async function mitarbeiterAnlegen(
|
||||||
eingabe: MitarbeiterEingabe,
|
eingabe: MitarbeiterEingabe,
|
||||||
benutzer: HandelnderBenutzer,
|
benutzer: HandelnderBenutzer,
|
||||||
|
vermerk?: ProtokollVermerk,
|
||||||
): Promise<Mitarbeiter> {
|
): Promise<Mitarbeiter> {
|
||||||
const vorname = pflicht(eingabe.vorname, "Der Vorname");
|
const vorname = pflicht(eingabe.vorname, "Der Vorname");
|
||||||
const nachname = pflicht(eingabe.nachname, "Der Nachname");
|
const nachname = pflicht(eingabe.nachname, "Der Nachname");
|
||||||
@@ -119,9 +133,13 @@ export async function mitarbeiterAnlegen(
|
|||||||
data: {
|
data: {
|
||||||
entitaet: "Mitarbeiter",
|
entitaet: "Mitarbeiter",
|
||||||
entitaetId: mitarbeiter.id,
|
entitaetId: mitarbeiter.id,
|
||||||
aktion: "Mitarbeiter angelegt",
|
aktion: vermerk?.aktion ?? "Mitarbeiter angelegt",
|
||||||
benutzerId: benutzer.id,
|
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;
|
return mitarbeiter;
|
||||||
@@ -250,3 +268,336 @@ function auszug(zeile: Mitarbeiter, felder: readonly string[]): Prisma.InputJson
|
|||||||
}
|
}
|
||||||
return ausschnitt;
|
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<VorhandenePerson | null> {
|
||||||
|
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<MitarbeiterImportErgebnis> {
|
||||||
|
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<string, number>();
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<AttributZuordnung>;
|
||||||
|
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<void> {
|
||||||
|
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<QuellenSicht> {
|
||||||
|
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<NeueQuelle>,
|
||||||
|
benutzer: HandelnderBenutzer,
|
||||||
|
): Promise<QuellenSicht> {
|
||||||
|
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<void> {
|
||||||
|
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<Verzeichnisquelle> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<string, unknown> = {}) {
|
||||||
|
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<void>] {
|
||||||
|
let ausloesen: () => void = () => undefined;
|
||||||
|
const warten = new Promise<void>((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<void>)[] = [];
|
||||||
|
|
||||||
|
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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string>)["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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<ContainerStand> {
|
||||||
|
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<boolean> {
|
||||||
|
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<T>(was: (client: Client) => Promise<T>): Promise<T> {
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await alsVerwalter(async (client) => {
|
||||||
|
await client.modify(
|
||||||
|
dn,
|
||||||
|
new Change({
|
||||||
|
operation: "replace",
|
||||||
|
modification: new Attribute({ type: attribut, values: [wert] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 ) )
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,3 +7,11 @@ if (process.env["TEST_DATABASE_URL"]) {
|
|||||||
|
|
||||||
// Fuer Tests, die ein Geheimnis brauchen, ohne eine .env zu verlangen.
|
// Fuer Tests, die ein Geheimnis brauchen, ohne eine .env zu verlangen.
|
||||||
process.env["APP_SECRET"] ??= "test-geheimnis-mit-mindestens-32-zeichen-laenge";
|
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";
|
||||||
|
|||||||
@@ -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<string, unknown> = {}) {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, number | string>,
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -575,7 +575,7 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Versionierung", () => {
|
|||||||
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
|
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
|
||||||
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, 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),
|
versionVeroeffentlichen(entwurf.id, "Veröffentlicht", benutzer.id),
|
||||||
entwurfSpeichern(entwurf.id, {
|
entwurfSpeichern(entwurf.id, {
|
||||||
variablen: [],
|
variablen: [],
|
||||||
@@ -584,9 +584,30 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Versionierung", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const danach = await strukturLesen(entwurf.id);
|
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") {
|
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(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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||