feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s

Quelle anlegen, Verbindung testen, nach Zeitplan oder auf Knopfdruck
abgleichen. Gelesen wird seitenweise mit searchPaginated, objectGUID
kommt als Puffer und wird gemischt-endian zur GUID umsortiert. Wer im
Verzeichnis verschwindet, wird hier inaktiv und nie geloescht.

Drei Regeln verhindern, dass eine kaputte Eingabe wie "alle sind
gegangen" aussieht: laesst sich kein einziger Eintrag zuordnen, wird
niemand deaktiviert; uebersprungene Eintraege werden am dn erkannt und
ausgenommen; eine von Hand angelegte Person wird uebernommen statt
verdoppelt.

Der Zeitplan laeuft ueber node-cron im selben Prozess, gestartet aus
src/instrumentation.ts, und nimmt keine eigene Sperre - die liegt in der
Datenbank, damit zwei Instanzen sich einig sind.

Closes #16

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-26 02:21:08 +02:00
co-authored by Claude Opus 5
parent ab5777a57e
commit 16059e7846
57 changed files with 6834 additions and 61 deletions
+670
View File
@@ -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" });
});
});