import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums"; import { AbgleichLaeuftBereitsFehler, abgleichAusfuehren, faelligeQuellen, mitAbgleichSperre, } from "@/server/services/abgleich"; import type { HandelnderBenutzer } from "@/server/mandant"; import { quelleAnlegen, quelleBearbeiten, VerzeichnisFehler } from "@/server/services/verzeichnis"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; import { attributSetzen, FIXTURE_GELESEN, FIXTURE_PERSONEN, GUID, LDAP_BENUTZER_DN, LDAP_BIND_DN, LDAP_BIND_PASSWORT, LDAP_URL, ldapVerfuegbar, personAnlegen, personLoeschen, } from "./fixtures/ldap/container"; /** * Der Abgleich (#016). * * Die Akzeptanzkriterien des Auftrags stehen hier, und sie laufen gegen den * echten Container: erster Lauf legt an, zweiter aktualisiert ohne Duplikate, ein * entferntes Konto wird `aktiv = false` und bleibt, ein per `userAccountControl` * deaktiviertes ebenso. Mit einer Attrappe waere davon nichts belegt — das * Verschwinden aus dem Ergebnis ist genau das Verhalten des Servers, das der * Filter ausloest. * * Die Sperre braucht dagegen kein Verzeichnis: sie liegt in der Datenbank, und * ihre Tests kommen ohne Container aus. */ const ldapDa = await ldapVerfuegbar(); async function grundbestand(modus: Betriebsmodus = Betriebsmodus.INTERN) { const eigene = await testPrisma.kunde.create({ data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true }, }); await testPrisma.einstellung.create({ data: { id: "global", modus } }); const benutzer = await testPrisma.benutzer.create({ data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" }, }); const leser = await testPrisma.benutzer.create({ data: { email: "lisa@firma.example", name: "Lisa", rolle: "LESER" }, }); return { eigene, admin: { id: benutzer.id, rolle: benutzer.rolle }, leser: { id: leser.id, rolle: leser.rolle }, }; } async function quelleFuerContainer(admin: HandelnderBenutzer, abweichend: Record = {}) { return quelleAnlegen( { name: "Firmen-AD", url: LDAP_URL, bindDn: LDAP_BIND_DN, bindPasswort: LDAP_BIND_PASSWORT, basisDn: LDAP_BENUTZER_DN, aktiv: true, ...abweichend, }, admin, ); } /** Ein Ereignis, auf das ein Test warten kann: `[ausloesen, warten]`. */ function signal(): [() => void, Promise] { let ausloesen: () => void = () => undefined; const warten = new Promise((aufloesen) => { ausloesen = aufloesen; }); return [() => ausloesen(), warten]; } // ---- Die Sperre ------------------------------------------------------------ describe.runIf(datenbankVerfuegbar)("Abgleich-Sperre", () => { beforeEach(async () => { await datenbankLeeren(); }); it("laesst nur einen gleichzeitig hinein", async () => { /* * Zwei Laeufe, die sich wirklich ueberlappen — und zwar in einer festen * Reihenfolge. Auf `Promise.all` zu setzen waere hier ein wackliger Test: wer * von beiden die Zeile zuerst schreibt, entscheidet dann die Datenbank, und * beide Ausgaenge waeren richtig. Deshalb sagt der erste Lauf ausdruecklich, * dass er die Sperre hat, und haelt sie, bis der zweite es versucht hat. */ const [haeltDieSperre, ersterIstDrin] = signal(); const [freigeben, darfWeiter] = signal(); const erster = mitAbgleichSperre("abgleich:test", 5, async () => { haeltDieSperre(); await darfWeiter; return "erster"; }); await ersterIstDrin; const zweiter = await mitAbgleichSperre("abgleich:test", 5, async () => "zweiter"); freigeben(); // Abgewiesen, nicht eingereiht: `undefined` und nicht ein zweites Ergebnis. expect(zweiter).toBeUndefined(); expect(await erster).toBe("erster"); // Danach ist die Sperre wieder frei. expect(await testPrisma.abgleichSperre.count()).toBe(0); expect(await mitAbgleichSperre("abgleich:test", 5, async () => "dritter")).toBe("dritter"); }); it("sperrt je Schluessel und nicht global", async () => { const [haeltDieSperre, ersterIstDrin] = signal(); const [freigeben, darfWeiter] = signal(); const eine = mitAbgleichSperre("abgleich:eine", 5, async () => { haeltDieSperre(); await darfWeiter; return "eine"; }); await ersterIstDrin; expect(await mitAbgleichSperre("abgleich:andere", 5, async () => "andere")).toBe("andere"); freigeben(); expect(await eine).toBe("eine"); }); it("gibt die Sperre auch frei, wenn der Lauf wirft", async () => { await expect( mitAbgleichSperre("abgleich:test", 5, async () => { throw new Error("mitten im Abgleich"); }), ).rejects.toThrow("mitten im Abgleich"); expect(await testPrisma.abgleichSperre.count()).toBe(0); }); it("uebernimmt eine abgelaufene Sperre", async () => { // Stuerzt der Prozess mitten im Abgleich ab, bleibt die Zeile stehen. Ohne // Ablauf waere die Quelle fuer immer blockiert – und niemand wuesste, warum. await testPrisma.abgleichSperre.create({ data: { schluessel: "abgleich:test", gehaltenVon: "abgestuerzter-rechner/1", gehaltenSeit: new Date(Date.now() - 3 * 60 * 60 * 1000), laeuftAbAm: new Date(Date.now() - 60 * 60 * 1000), }, }); expect(await mitAbgleichSperre("abgleich:test", 5, async () => "uebernommen")).toBe("uebernommen"); expect(await testPrisma.abgleichSperre.count()).toBe(0); }); it("laesst eine lebende Sperre eines anderen Halters in Ruhe", async () => { await testPrisma.abgleichSperre.create({ data: { schluessel: "abgleich:test", gehaltenVon: "anderer-rechner/1", laeuftAbAm: new Date(Date.now() + 60 * 60 * 1000), }, }); expect( await mitAbgleichSperre("abgleich:test", 5, async () => "haette nicht laufen duerfen"), ).toBeUndefined(); // Und sie wird auch nicht beim Aufraeumen mitgeloescht: sie gehoert einem // anderen Lauf. const zeile = await testPrisma.abgleichSperre.findUniqueOrThrow({ where: { schluessel: "abgleich:test" }, }); expect(zeile.gehaltenVon).toBe("anderer-rechner/1"); }); }); // ---- Faellige Quellen ------------------------------------------------------ describe.runIf(datenbankVerfuegbar)("Faellige Quellen", () => { beforeEach(async () => { await datenbankLeeren(); }); it("nennt eine Quelle, die noch nie gelaufen ist", async () => { const { admin } = await grundbestand(); const quelle = await quelleFuerContainer(admin); expect(await faelligeQuellen()).toEqual([{ id: quelle.id, name: "Firmen-AD" }]); }); it("laesst eine abgeschaltete Quelle aus", async () => { const { admin } = await grundbestand(); const quelle = await quelleFuerContainer(admin, { aktiv: false }); expect(await faelligeQuellen()).toEqual([]); await quelleBearbeiten(quelle.id, { aktiv: true }, admin); expect(await faelligeQuellen()).toHaveLength(1); }); it("rechnet mit dem Intervall der einzelnen Quelle", async () => { const { admin } = await grundbestand(); const quelle = await quelleFuerContainer(admin, { abgleichIntervallMinuten: 30 }); const vorZwanzigMinuten = new Date(Date.now() - 20 * 60 * 1000); await testPrisma.verzeichnisquelle.update({ where: { id: quelle.id }, data: { letzterAbgleichAm: vorZwanzigMinuten }, }); // Nach zwanzig von dreissig Minuten noch nicht, nach vierzig schon. expect(await faelligeQuellen()).toEqual([]); expect(await faelligeQuellen(new Date(Date.now() + 20 * 60 * 1000))).toHaveLength(1); }); it("ist im Modus Dienstleistung leer", async () => { const { admin } = await grundbestand(); await quelleFuerContainer(admin); await testPrisma.einstellung.update({ where: { id: "global" }, data: { modus: Betriebsmodus.DIENSTLEISTUNG }, }); expect(await faelligeQuellen()).toEqual([]); }); }); // ---- Rechte und Betriebsmodus --------------------------------------------- describe.runIf(datenbankVerfuegbar)("Abgleich starten", () => { beforeEach(async () => { await datenbankLeeren(); }); it("verlangt das Recht verwaltung", async () => { const { admin, leser } = await grundbestand(); const quelle = await quelleFuerContainer(admin); await expect(abgleichAusfuehren(quelle.id, leser)).rejects.toThrow(/verwaltung/u); }); it("ist im Modus Dienstleistung abgeschaltet", async () => { const { admin } = await grundbestand(); const quelle = await quelleFuerContainer(admin); await testPrisma.einstellung.update({ where: { id: "global" }, data: { modus: Betriebsmodus.DIENSTLEISTUNG }, }); await expect(abgleichAusfuehren(quelle.id, admin)).rejects.toThrow(/Dienstleistung/u); }); it("weist den zweiten gleichzeitigen Abgleich derselben Quelle ab", async () => { const { admin } = await grundbestand(); const quelle = await quelleFuerContainer(admin); // Die Sperre steht schon – so, wie sie stuende, waehrend der erste Lauf // laeuft. Der Weg dorthin ist oben einzeln geprueft. await testPrisma.abgleichSperre.create({ data: { schluessel: `abgleich:${quelle.id}`, gehaltenVon: "erster-lauf/1", laeuftAbAm: new Date(Date.now() + 60 * 60 * 1000), }, }); await expect(abgleichAusfuehren(quelle.id, admin)).rejects.toThrow(AbgleichLaeuftBereitsFehler); // Und der abgewiesene Lauf hat den Stand des laufenden nicht angefasst. const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } }); expect(zeile.letzterAbgleichStatus).toBeNull(); expect(await testPrisma.mitarbeiter.count()).toBe(0); }); it("meldet eine unbekannte Quelle", async () => { const { admin } = await grundbestand(); await expect(abgleichAusfuehren("gibtesnicht", admin)).rejects.toThrow(VerzeichnisFehler); }); }); // ---- Gegen den echten Container ------------------------------------------- describe.runIf(datenbankVerfuegbar && ldapDa)("Abgleich gegen das Verzeichnis", () => { const protokolliert: string[] = []; /** Was der Test im Verzeichnis veraendert hat und wieder zuruecknehmen muss. */ let aufraeumen: (() => Promise)[] = []; beforeEach(async () => { await datenbankLeeren(); aufraeumen = []; protokolliert.length = 0; for (const kanal of ["log", "info", "warn", "error", "debug"] as const) { vi.spyOn(console, kanal).mockImplementation((...teile: unknown[]) => { protokolliert.push(teile.map((teil) => String(teil)).join(" ")); }); } }); afterEach(async () => { vi.restoreAllMocks(); // Der Container muss nach jedem Test wieder wie vorher sein, sonst haengt das // Ergebnis des naechsten an der Reihenfolge. for (const schritt of aufraeumen.reverse()) await schritt(); }); it("legt beim ersten Lauf an und aktualisiert beim zweiten ohne Duplikate", async () => { const { admin, eigene } = await grundbestand(); const quelle = await quelleFuerContainer(admin); const erster = await abgleichAusfuehren(quelle.id, admin); expect(erster).toMatchObject({ angelegt: FIXTURE_PERSONEN, aktualisiert: 0, deaktiviert: 0, gelesen: FIXTURE_GELESEN, status: "ERFOLG", fehler: null, }); const zweiter = await abgleichAusfuehren(quelle.id, admin); // Nichts hat sich im Verzeichnis geaendert, also ist auch nichts geaendert // worden – und kein Duplikat entstanden. expect(zweiter).toMatchObject({ angelegt: 0, aktualisiert: 0, deaktiviert: 0, status: "ERFOLG" }); expect(await testPrisma.mitarbeiter.count()).toBe(FIXTURE_PERSONEN); const personen = await testPrisma.mitarbeiter.findMany({ orderBy: { sam: "asc" } }); expect(personen.map((person) => person.sam)).toEqual(["jweiss", "kberger", "mhuber"]); for (const person of personen) { expect(person.kundeId).toBe(eigene.id); expect(person.quelleId).toBe(quelle.id); expect(person.herkunft).toBe(Herkunft.ABGLEICH); expect(person.aktiv).toBe(true); expect(person.zuletztGesehenAm).not.toBeNull(); } }); it("uebernimmt die gemappten Felder, den Vorgesetzten und die Umlaute", async () => { const { admin } = await grundbestand(); const quelle = await quelleFuerContainer(admin); await abgleichAusfuehren(quelle.id, admin); const huber = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "mhuber" } }); expect(huber).toMatchObject({ externeId: GUID.huber, vorname: "Maria", nachname: "Huber", anzeigename: "Huber, Maria", mail: "maria.huber@firma.test", upn: "maria.huber@firma.test", abteilung: "Buchhaltung", titel: "Leiterin Rechnungsprüfung", // Der Vorgesetzte steht im Verzeichnis als DN; gespeichert wird seine // Kennung, damit ein Umzug in eine andere OU ihn nicht verliert. vorgesetzterExterneId: GUID.berger, }); expect(huber.dn).toBe("cn=Maria Huber,ou=Buchhaltung,ou=Benutzer,dc=firma,dc=test"); const weiss = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "jweiss" } }); expect(weiss.nachname).toBe("Weiß"); expect(weiss.vorname).toBe("Jörg"); // Der Chef selbst hat keinen Vorgesetzten. const berger = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "kberger" } }); expect(berger.vorgesetzterExterneId).toBeNull(); }); it("zaehlt eine echte Aenderung als aktualisiert", async () => { const { admin } = await grundbestand(); const quelle = await quelleFuerContainer(admin); await abgleichAusfuehren(quelle.id, admin); const dn = "cn=Klaus Berger,ou=Geschaeftsfuehrung,ou=Benutzer,dc=firma,dc=test"; await attributSetzen(dn, "title", "Sprecher der Geschäftsführung"); aufraeumen.push(() => attributSetzen(dn, "title", "Geschaeftsfuehrer")); const zweiter = await abgleichAusfuehren(quelle.id, admin); expect(zweiter).toMatchObject({ angelegt: 0, aktualisiert: 1, deaktiviert: 0 }); const berger = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "kberger" } }); expect(berger.titel).toBe("Sprecher der Geschäftsführung"); }); it("setzt ein entferntes Konto auf aktiv = false und behaelt es", async () => { const { admin } = await grundbestand(); const quelle = await quelleFuerContainer(admin); const dn = await personAnlegen({ cn: "Temporaer Weg", sn: "Weg", givenName: "Temporaer", sam: "tweg", guidBase64: "ESIzRFVmd4iZqrvM3e7/AA==", }); let nochDa = true; aufraeumen.push(async () => { if (nochDa) await personLoeschen(dn); }); const erster = await abgleichAusfuehren(quelle.id, admin); expect(erster.angelegt).toBe(FIXTURE_PERSONEN + 1); const vorher = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "tweg" } }); expect(vorher.aktiv).toBe(true); await personLoeschen(dn); nochDa = false; const zweiter = await abgleichAusfuehren(quelle.id, admin); expect(zweiter).toMatchObject({ angelegt: 0, deaktiviert: 1, status: "ERFOLG" }); // Abgeschaltet, aber da: an einer Person haengen Laeufe, und ein // archivierter Lauf muss auch in zehn Jahren noch sagen koennen, fuer wen er // lief. const nachher = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "tweg" } }); expect(nachher.aktiv).toBe(false); expect(nachher.id).toBe(vorher.id); expect(nachher.zuletztGesehenAm).toEqual(vorher.zuletztGesehenAm); expect(await testPrisma.mitarbeiter.count()).toBe(FIXTURE_PERSONEN + 1); }); it("loescht eine von Hand gesetzte Vorgesetzten-Zuordnung nicht", async () => { // `kberger` traegt im Verzeichnis keinen `manager` — mhuber und jweiss tun // es. Wer die Zuordnung fuer kberger von Hand setzt, pflegt etwas, das das // Verzeichnis nicht fuehrt; also hat es sie auch nicht zu loeschen (#020). const { admin } = await grundbestand(); const quelle = await quelleFuerContainer(admin); await abgleichAusfuehren(quelle.id, admin); const ohneChef = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "kberger" } }); const vonHand = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "jweiss" } }); expect(ohneChef.vorgesetzterExterneId).toBeNull(); expect(ohneChef.vorgesetzterId).toBeNull(); await testPrisma.mitarbeiter.update({ where: { id: ohneChef.id }, data: { vorgesetzterId: vonHand.id }, }); const zweiter = await abgleichAusfuehren(quelle.id, admin); expect(zweiter.status).toBe("ERFOLG"); const nachher = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { id: ohneChef.id } }); expect(nachher.vorgesetzterId).toBe(vonHand.id); // Gegenstueck: wo das Verzeichnis einen `manager` fuehrt, bestimmt es — und // zwar in **beiden** Feldern, dem rohen Wert und der Zuordnung. const mitChef = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "mhuber" } }); expect(mitChef.vorgesetzterId).toBe(ohneChef.id); expect(mitChef.vorgesetzterExterneId).toBe(ohneChef.externeId); }); 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" }); }); });