Die Zuordnung lief allein ueber vorgesetzterExterneId, und die ist nur
innerhalb einer Verzeichnisquelle eindeutig: eine von Hand angelegte Person
konnte keinen Vorgesetzten haben, und {{vorgesetzter_sam}} blieb im
Onboarding-Befehl leer -- genau dort, wo er gebraucht wird.
Entschieden fuer eine eigene Selbstbeziehung vorgesetzterId. Die befuerchteten
"zwei Wege zur selben Auskunft" entstehen nicht, weil der Abgleich sie
mitschreibt: vorgesetzterExterneId ist jetzt wie abteilung nur der rohe Wert
des Verzeichnisses, gelesen wird allein vorgesetzterId. Die Alternative haette
manuellen Personen eine erfundene externeId geben muessen, und externeId heisst
"objectGUID aus dem Verzeichnis".
Liefert das Verzeichnis keinen manager, bleibt eine von Hand gesetzte Zuordnung
stehen -- geleert wird nur, was das Verzeichnis selbst gesetzt hatte.
Die Migration fuehrt bestehende Daten nach. Ohne das waere die Auskunft nach
dem Einspielen bei jeder Verzeichnis-Person still leer gewesen, bis der
naechste Abgleich laeuft. An einem nachgebauten Vor-Zustand gefahren.
Drei Grenzen, drei Stellen: der zusammengesetzte Fremdschluessel haelt die
Mandantengrenze, ein CHECK die kuerzeste Schleife, und der Dienst laeuft die
Kette hinauf fuer laengere.
Im gebauten Stand durchgeklickt: speichern, neu laden, Wert steht, entfernen,
390 px ohne horizontales Scrollen. Gegenprobe am Abgleich gefahren.
Closes #31
701 lines
29 KiB
TypeScript
701 lines
29 KiB
TypeScript
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("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" });
|
||
});
|
||
});
|