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" });
|
||
});
|
||
});
|