feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log
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:
co-authored by
Claude Opus 5
parent
ab5777a57e
commit
16059e7846
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user