Files
Laufzettel/tests/abgleich.test.ts
T
Florian WildeandClaude Opus 5 2d3325ef20
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
feat(meldungen): Benachrichtigungen per Mail und Webhook (#54)
Ein zugewiesener Lauf fiel bisher nur auf, wenn jemand die Uebersicht
oeffnete; ein ueberfaelliger ueberhaupt nicht. Vier Anlaesse gehen jetzt
hinaus — zugewiesen, ueberfaellig, abgeschlossen und Verzeichnisabgleich
fehlgeschlagen —, je Person abschaltbar. Niemand bekommt Post ueber die
eigene Tat: das ist der haeufigste Grund, Benachrichtigungen ganz
abzuschalten, und deshalb faellt der Handelnde immer aus der
Empfaengerliste.

Eingereiht wird im Anfrage-Pfad, gesendet wird es nicht. Ein haengender
Mailserver darf kein Abhaken blockieren, also schreibt der Dienst eine
Zeile in `Zustellung` und ein eigener Zeitplan schickt sie. Betreff und
Rumpf stehen fertig darin: eine Meldung sagt, was *damals* war.

In keiner Nachricht steht ein Schrittinhalt, und damit auch kein
erzeugtes Passwort. Darueber wacht src/lib/meldungen.ts — die Funktionen
dort nehmen nur Kopfdaten, der Typ laesst nichts anderes zu. Geprueft
wird es zusaetzlich ueber alle Rumpftexte, die wirklich verschickt
wurden, so wie #012 es fuer die Druckansicht tat.

Zwei Dinge haetten ohne Datenbank nicht gehalten. Die Einmaligkeit der
Ueberfaelligkeitsmeldung liegt in einem partiellen eindeutigen Index:
der Zeitplan sieht jede Minute nach, und ohne ihn bekaeme der
Zustaendige fuer einen Lauf, der ueber das Wochenende steht, 2880
Mails — jede fuer sich korrekt, kein Test rot. Und die Sperre der
Warteschlange liegt in derselben Tabelle wie die des Abgleichs, denn
hinter einem Lastverteiler laufen zwei Instanzen.

Eine stille Warteschlange ist schlimmer als keine: Verwaltung →
Zustellungen zeigt mit Grund, was hinausging und was nicht.

Geprueft gegen einen echten SMTP-Faenger (mailpit) statt gegen eine
abgefangene Funktion — und `mailpit` gehoert damit ins Tor vor dem
Commit, sonst ueberspringt die Suite 3 Tests und sieht gruen aus.

Closes #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 17:22:20 +02:00

698 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
import { AbgleichLaeuftBereitsFehler, abgleichAusfuehren, faelligeQuellen } from "@/server/services/abgleich";
// Seit #034 liegt die Sperre in ihrem eigenen Modul: die Zustell-Warteschlange
// braucht dieselbe Mechanik, und zwei Fassungen davon waeren die schlechteste
// aller Loesungen.
import { mitSperre } from "@/server/services/sperre";
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 = mitSperre("abgleich:test", 5, async () => {
haeltDieSperre();
await darfWeiter;
return "erster";
});
await ersterIstDrin;
const zweiter = await mitSperre("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 mitSperre("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 = mitSperre("abgleich:eine", 5, async () => {
haeltDieSperre();
await darfWeiter;
return "eine";
});
await ersterIstDrin;
expect(await mitSperre("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(
mitSperre("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 mitSperre("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 mitSperre("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("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" });
});
});