feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log (#30)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #30.
This commit is contained in:
2026-09-26 00:21:56 +00:00
parent ab5777a57e
commit ae290345b7
57 changed files with 6834 additions and 61 deletions
+670
View File
@@ -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" });
});
});
+148
View File
@@ -0,0 +1,148 @@
import { beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
import { abteilungAnlegen } from "@/server/services/abteilungen";
import { ungepflegteAbteilungen } from "@/server/queries/abteilungen";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Abteilungsvorschläge (#016).
*
* Die Liste, aus der ein Administrator mit einem Klick echte `Abteilung`-Einträge
* macht. Jeder Eintrag ist damit ein Versprechen: der Klick muss gelingen.
* Deshalb steht hier nichts, was `abteilungAnlegen` danach abweisen würde — keine
* bereits (auch archiviert) gepflegte Abteilung und kein archivierter Kunde.
*/
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: "Administrator", rolle: "ADMIN" },
});
return { eigene, admin: { id: benutzer.id, rolle: benutzer.rolle } };
}
async function person(
kundeId: string,
abteilung: string,
weiteres: { aktiv?: boolean; herkunft?: Herkunft } = {},
) {
const nummer = await testPrisma.mitarbeiter.count();
return testPrisma.mitarbeiter.create({
data: {
kundeId,
vorname: "Person",
nachname: `Nummer ${nummer}`,
anzeigename: `Person Nummer ${nummer}`,
abteilung,
...weiteres,
},
});
}
describe.runIf(datenbankVerfuegbar)("Abteilungsvorschläge", () => {
beforeEach(async () => {
await datenbankLeeren();
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
});
it("zeigt nur Namen, zu denen es noch keine Abteilung gibt", async () => {
const { admin, eigene } = await grundbestand();
await abteilungAnlegen({ name: "Buchhaltung", zielOu: "OU=Buchhaltung" }, admin);
await person(eigene.id, "Buchhaltung");
await person(eigene.id, "Vertrieb", { herkunft: Herkunft.ABGLEICH });
await person(eigene.id, "Vertrieb");
expect(await ungepflegteAbteilungen(admin)).toEqual([
{ name: "Vertrieb", anzahl: 2, kundeId: eigene.id },
]);
});
it("zählt deaktivierte Personen nicht mit", async () => {
// Eine Abteilung, in der nur noch Ausgeschiedene stehen, braucht keine
// Ziel-OU mehr — und ein Vorschlag, den niemand mehr braucht, verdeckt die
// übrigen.
const { admin, eigene } = await grundbestand();
await person(eigene.id, "Altlast", { aktiv: false });
await person(eigene.id, "Vertrieb");
expect((await ungepflegteAbteilungen(admin)).map((eintrag) => eintrag.name)).toEqual(["Vertrieb"]);
});
it("schlägt nicht vor, was es archiviert schon gibt", async () => {
// Der Name ist je Kunde eindeutig: `abteilungAnlegen` würde diesen Klick mit
// „gibt es schon“ abweisen. Ein Knopf, der das tut, ist keiner.
const { admin, eigene } = await grundbestand();
const abteilung = await abteilungAnlegen({ name: "Vertrieb" }, admin);
await testPrisma.abteilung.update({ where: { id: abteilung.id }, data: { archiviert: true } });
await person(eigene.id, "Vertrieb");
expect(await ungepflegteAbteilungen(admin)).toEqual([]);
await expect(abteilungAnlegen({ name: "Vertrieb" }, admin)).rejects.toThrow(/gibt es schon/);
});
it("übernimmt einen Vorschlag – und zwar nur den Namen", async () => {
const { admin, eigene } = await grundbestand();
await person(eigene.id, "Vertrieb");
const offen = await ungepflegteAbteilungen(admin);
expect(offen).toHaveLength(1);
const abteilung = await abteilungAnlegen({ name: offen[0]!.name, kundeId: offen[0]!.kundeId }, admin);
expect(abteilung).toMatchObject({ name: "Vertrieb", zielOu: null, standardGruppen: [] });
expect(await ungepflegteAbteilungen(admin)).toEqual([]);
});
describe("Mandantengrenze", () => {
async function zweiKunden() {
const { admin, eigene } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
const techniker = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Techniker", rolle: "TECHNIKER" },
});
await testPrisma.benutzerKunde.create({ data: { benutzerId: techniker.id, kundeId: meiner.id } });
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
return { admin, eigene, meiner, fremder, techniker: { id: techniker.id, rolle: techniker.rolle } };
}
it("zeigt nur die Vorschläge der sichtbaren Kunden", async () => {
const { meiner, fremder, techniker } = await zweiKunden();
await person(meiner.id, "Vertrieb");
await person(fremder.id, "Vertrieb");
await person(fremder.id, "Technik");
expect(await ungepflegteAbteilungen(techniker)).toEqual([
{ name: "Vertrieb", anzahl: 1, kundeId: meiner.id },
]);
});
it("hält Kunden auseinander, die denselben Namen benutzen", async () => {
// „Vertrieb“ ist bei einem Kunden gepflegt und beim anderen nicht. Ohne die
// Gruppierung nach Kunde und Name verschwände der offene Fall — genau das
// stand hier, bis der Review von #011 es gefunden hat.
const { admin, meiner, fremder } = await zweiKunden();
await testPrisma.abteilung.create({ data: { kundeId: meiner.id, name: "Vertrieb" } });
await person(meiner.id, "Vertrieb");
await person(fremder.id, "Vertrieb");
expect(await ungepflegteAbteilungen(admin)).toEqual([
{ name: "Vertrieb", anzahl: 1, kundeId: fremder.id },
]);
});
it("schlägt nichts für einen archivierten Kunden vor", async () => {
// `kundeErmitteln` weist einen archivierten Kunden ab; der Klick auf den
// Vorschlag ergäbe nur eine Fehlermeldung.
const { admin, fremder } = await zweiKunden();
await person(fremder.id, "Vertrieb");
await testPrisma.kunde.update({ where: { id: fremder.id }, data: { archiviert: true } });
expect(await ungepflegteAbteilungen(admin)).toEqual([]);
await expect(abteilungAnlegen({ name: "Vertrieb", kundeId: fremder.id }, admin)).rejects.toThrow(
/archiviert/,
);
});
});
});
+87
View File
@@ -0,0 +1,87 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
/**
* Admin → Verzeichnis durch die Oberflaeche (#016).
*
* Ohne LDAP-Container: geprueft wird, was auch ohne Verzeichnis gilt — dass die
* Attributzuordnung bedienbar ist, dass eine krumme Eingabe abgewiesen wird und
* dass eine gute wirklich in der Datenbank landet. Was der Abgleich **tut**,
* steht in `tests/abgleich.test.ts` gegen einen echten Server.
*
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`).
* Im Entwicklungsmodus haengt React sich hier nicht an, und dann tut kein Knopf
* etwas (siehe CLAUDE.md, „Oberflaeche pruefen“).
*/
async function anmelden(seite: Page) {
const email = process.env["SEED_ADMIN_EMAIL"];
const passwort = process.env["SEED_ADMIN_PASSWORD"];
if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen.");
await seite.goto("/anmelden");
await seite.fill('input[name="email"]', email);
await seite.fill('input[name="passwort"]', passwort);
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
}
test.describe("Verzeichnis", () => {
test("macht die Attributzuordnung bedienbar und prüft sie", async ({ page }) => {
test.slow();
const quelle = await prisma.verzeichnisquelle.findFirst({ orderBy: { name: "asc" } });
test.skip(quelle === null, "Keine Verzeichnisquelle im Bestand.");
if (!quelle) return;
const vorher = quelle.attributZuordnung;
await anmelden(page);
await page.goto("/admin/verzeichnis");
await page.locator("summary", { hasText: "Einstellungen bearbeiten" }).first().click();
await page.locator("summary", { hasText: "Attributzuordnung anpassen" }).first().click();
const kennung = page.locator('input[name="attr.externeId"]').first();
await expect(kennung).toBeVisible();
await expect(kennung).toHaveValue("objectGUID");
// Eine Klammer darf nicht in eine LDAP-Anfrage wandern.
const titel = page.locator('input[name="attr.titel"]').first();
await titel.fill("(objectClass=*)");
await page.getByRole("button", { name: "Speichern" }).first().click();
await expect(page.locator('[role="alert"]').first()).toContainText("kein Attributname");
// Und eine gute Eingabe steht danach wirklich in der Datenbank.
await titel.fill("personalTitle");
await page.getByRole("button", { name: "Speichern" }).first().click();
await expect(page.locator('[role="status"]').last()).toContainText("gespeichert");
const nachher = await prisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
expect((nachher.attributZuordnung as Record<string, string>)["titel"]).toBe("personalTitle");
// Neu geladen steht sie auch im Formular – sonst waere sie nur gespeichert,
// aber nicht bearbeitbar.
await page.reload();
await page.locator("summary", { hasText: "Einstellungen bearbeiten" }).first().click();
await page.locator("summary", { hasText: "Attributzuordnung anpassen" }).first().click();
await expect(page.locator('input[name="attr.titel"]').first()).toHaveValue("personalTitle");
await prisma.verzeichnisquelle.update({
where: { id: quelle.id },
data: { attributZuordnung: vorher === null ? {} : vorher },
});
});
test("zeigt das Abgleich-Log und trägt am Handy ohne horizontales Scrollen", async ({ page }) => {
await anmelden(page);
await page.goto("/admin/verzeichnis");
const log = page.locator("summary", { hasText: "Abgleich-Log" }).first();
if ((await log.count()) > 0) {
await log.click();
await expect(page.locator("details", { has: log })).toBeVisible();
}
const masse = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
sichtbar: document.documentElement.clientWidth,
}));
expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
});
});
+161
View File
@@ -0,0 +1,161 @@
import { Attribute, Change, Client } from "ldapts";
/**
* Zugang zum LDAP-Testcontainer und das, was im Fixture steht (#016).
*
* Die Werte stehen hier und nicht in den Tests, weil zwei Testdateien gegen
* denselben Container laufen. Die Erwartungen (GUID-Zeichenketten,
* Kontonamen) gehoeren mit dazu: sie beschreiben das Fixture und nicht die
* Regeln — die stehen in den Tests.
*
* Der Container wird mit
* `docker compose -f docker-compose.test.yml up -d ldap` gestartet.
*/
export const LDAP_URL = "ldap://127.0.0.1:1389";
export const LDAP_BASIS_DN = "dc=firma,dc=test";
export const LDAP_BENUTZER_DN = "ou=Benutzer,dc=firma,dc=test";
export const LDAP_BIND_DN = "cn=admin,dc=firma,dc=test";
export const LDAP_BIND_PASSWORT = "ldap-test-geheim";
/**
* Die GUID-Zeichenketten der Fixture-Personen.
*
* Ausgerechnet mit Pythons `uuid.UUID(bytes_le=…)` aus den Bytes in
* `daten/50-personen.ldif` — eine unabhaengige Umsetzung derselben
* gemischt-endianen Reihenfolge, damit hier nicht die Erwartung aus derselben
* Quelle stammt wie das Ergebnis.
*/
export const GUID = {
berger: "1d3e409c-b27a-4bc9-b1e6-f0a2c3d4e5f6",
huber: "5f074310-3c35-4a1d-836f-a50f515e7a35",
weiss: "04030201-0605-0807-090a-0b0c0d0e0f10",
/** „Alte Kraft“: im Verzeichnis vorhanden, aber deaktiviert. */
alteKraft: "ccddeeff-aabb-8899-7766-554433221100",
} as const;
/** So viele Konten liefert der Standardfilter: drei aktive plus das ohne objectGUID. */
export const FIXTURE_GELESEN = 4;
/** Davon zugeordnet, also mit stabiler Kennung. */
export const FIXTURE_PERSONEN = 3;
export type ContainerStand = { readonly da: true } | { readonly da: false; readonly grund: string };
/**
* Laeuft der Container – und stehen die Fixture-Personen wirklich darin?
*
* Geprueft wird mit einem echten Bind und einer echten Suche, nicht mit einem
* offenen Port: ein gestarteter, aber noch nicht bestueckter Container waere
* sonst „da“, und die Tests liefen gegen ein leeres Verzeichnis. Ein stilles
* Ueberspringen sieht genauso gruen aus wie ein bestandener Lauf, deshalb sagt
* diese Funktion laut, was fehlt – und in CI bricht sie ab.
*/
export async function ldapContainerStand(): Promise<ContainerStand> {
const client = new Client({ url: LDAP_URL, connectTimeout: 2000, timeout: 5000 });
try {
await client.bind(LDAP_BIND_DN, LDAP_BIND_PASSWORT);
const { searchEntries } = await client.search(LDAP_BENUTZER_DN, {
scope: "sub",
filter: "(sAMAccountName=mhuber)",
attributes: ["dn"],
});
if (searchEntries.length === 0) {
return { da: false, grund: "Der Container laeuft, aber das Fixture „mhuber“ fehlt darin." };
}
return { da: true };
} catch (fehler) {
return { da: false, grund: fehler instanceof Error ? fehler.message : String(fehler) };
} finally {
await client.unbind().catch(() => undefined);
}
}
/**
* Der Stand samt Hinweis auf der Konsole. In CI ein Abbruch: `pnpm test` ist das
* Tor vor jedem Commit, und dort darf der Abgleich nicht ungeprueft bleiben.
*/
export async function ldapVerfuegbar(): Promise<boolean> {
const stand = await ldapContainerStand();
if (stand.da) return true;
const hinweis =
`\n LDAP-Testcontainer nicht erreichbar (${stand.grund}).` +
"\n Die Verzeichnis- und Abgleichtests gegen das Verzeichnis werden uebersprungen." +
"\n Starten mit: docker compose -f docker-compose.test.yml up -d ldap\n";
if (process.env["CI"]) throw new Error(hinweis);
// Direkt auf stderr und nicht ueber `console`: Vitest 5 fasst die
// Konsolenausgabe einer Testdatei ab und zeigt sie fuer den **Modulrumpf**
// nicht an — nachgemessen. Ein `console.warn` hier waere also genau das stille
// Ueberspringen, das dieser Hinweis verhindern soll.
process.stderr.write(hinweis);
return false;
}
/** Ein Client als Verzeichnisverwalter, fuer Tests, die das Fixture veraendern. */
export async function alsVerwalter<T>(was: (client: Client) => Promise<T>): Promise<T> {
const client = new Client({ url: LDAP_URL, connectTimeout: 2000, timeout: 5000 });
try {
await client.bind(LDAP_BIND_DN, LDAP_BIND_PASSWORT);
return await was(client);
} finally {
await client.unbind().catch(() => undefined);
}
}
/**
* Legt eine zusaetzliche Person an und gibt den Weg zurueck, sie wieder zu
* entfernen.
*
* Das braucht der Test „ein entferntes Konto wird aktiv = false“: er muss ein
* Konto wirklich loeschen. Die Fixture-Dateien bleiben dabei unberuehrt, und der
* Container ist nach dem Test wieder wie vorher — sonst haengt das Ergebnis des
* naechsten Tests an der Reihenfolge.
*/
export async function personAnlegen(eintrag: {
readonly cn: string;
readonly sn: string;
readonly givenName: string;
readonly sam: string;
readonly guidBase64: string;
readonly userAccountControl?: number;
}): Promise<string> {
const dn = `cn=${eintrag.cn},ou=Vertrieb,${LDAP_BENUTZER_DN}`;
// Als `Attribute[]`, nicht als Objekt: die Objektform von `Client.add` nimmt
// nur Zeichenketten, und objectGUID ist ein Puffer.
const werte: Attribute[] = [
new Attribute({ type: "objectClass", values: ["inetOrgPerson", "user"] }),
new Attribute({ type: "cn", values: [eintrag.cn] }),
new Attribute({ type: "sn", values: [eintrag.sn] }),
new Attribute({ type: "givenName", values: [eintrag.givenName] }),
new Attribute({ type: "displayName", values: [`${eintrag.sn}, ${eintrag.givenName}`] }),
new Attribute({ type: "mail", values: [`${eintrag.sam}@firma.test`] }),
new Attribute({ type: "userPrincipalName", values: [`${eintrag.sam}@firma.test`] }),
new Attribute({ type: "sAMAccountName", values: [eintrag.sam] }),
new Attribute({ type: "department", values: ["Vertrieb"] }),
new Attribute({ type: "objectCategory", values: ["person"] }),
new Attribute({ type: "userAccountControl", values: [String(eintrag.userAccountControl ?? 512)] }),
new Attribute({ type: "objectGUID", values: [Buffer.from(eintrag.guidBase64, "base64")] }),
];
await alsVerwalter(async (client) => {
await client.add(dn, werte);
});
return dn;
}
export async function personLoeschen(dn: string): Promise<void> {
await alsVerwalter(async (client) => {
await client.del(dn);
});
}
/** Ein Attribut aendern, um den zweiten Abgleich etwas zu tun zu geben. */
export async function attributSetzen(dn: string, attribut: string, wert: string): Promise<void> {
await alsVerwalter(async (client) => {
await client.modify(
dn,
new Change({
operation: "replace",
modification: new Attribute({ type: attribut, values: [wert] }),
}),
);
});
}
+110
View File
@@ -0,0 +1,110 @@
# Fixture-Personen fuer den Verzeichnis-Abgleich (#016).
#
# Bewusst klein, aber mit jedem Fall, den der Abgleich unterscheiden muss:
#
# Maria Huber vollstaendig, mit Vorgesetztem und Umlaut im Namen
# Klaus Berger ihr Vorgesetzter, damit `manager` aufloesbar ist
# Joerg Weiss Nachname mit Eszett, Mail-Adresse mit Umlaut-Ersatz
# Alte Kraft userAccountControl 514 (Bit 2 = deaktiviert) -- der
# Standardfilter muss sie ausschliessen
# Ohne Kennung ein Konto ohne objectGUID; ohne stabile Kennung darf der
# Abgleich es nicht anlegen, aber auch nicht abbrechen
#
# objectGUID steht base64-kodiert (`::`), weil es im AD ein binaeres Attribut
# ist. Die erwarteten GUID-Zeichenketten stehen in tests/verzeichnis.test.ts.
#
# Achtung: osixia laeuft mit `sed -i` ueber jede Datei in diesem Verzeichnis, um
# Platzhalter wie {{ LDAP_BASE_DN }} zu ersetzen. Hier stehen deshalb keine
# Platzhalter -- dann schreibt sed dieselben Bytes zurueck und der Arbeitsbaum
# bleibt sauber.
dn: ou=Benutzer,dc=firma,dc=test
objectClass: organizationalUnit
ou: Benutzer
dn: ou=Buchhaltung,ou=Benutzer,dc=firma,dc=test
objectClass: organizationalUnit
ou: Buchhaltung
dn: ou=Vertrieb,ou=Benutzer,dc=firma,dc=test
objectClass: organizationalUnit
ou: Vertrieb
dn: ou=Geschaeftsfuehrung,ou=Benutzer,dc=firma,dc=test
objectClass: organizationalUnit
ou: Geschaeftsfuehrung
dn: cn=Klaus Berger,ou=Geschaeftsfuehrung,ou=Benutzer,dc=firma,dc=test
objectClass: inetOrgPerson
objectClass: user
cn: Klaus Berger
sn: Berger
givenName: Klaus
displayName: Berger, Klaus
mail: klaus.berger@firma.test
userPrincipalName: klaus.berger@firma.test
sAMAccountName: kberger
department: Geschaeftsfuehrung
title: Geschaeftsfuehrer
objectCategory: person
userAccountControl: 512
objectGUID:: nEA+HXqyyUux5vCiw9Tl9g==
dn: cn=Maria Huber,ou=Buchhaltung,ou=Benutzer,dc=firma,dc=test
objectClass: inetOrgPerson
objectClass: user
cn: Maria Huber
sn: Huber
givenName: Maria
displayName:: SHViZXIsIE1hcmlh
mail: maria.huber@firma.test
userPrincipalName: maria.huber@firma.test
sAMAccountName: mhuber
department: Buchhaltung
title:: TGVpdGVyaW4gUmVjaG51bmdzcHLDvGZ1bmc=
objectCategory: person
userAccountControl: 512
manager: cn=Klaus Berger,ou=Geschaeftsfuehrung,ou=Benutzer,dc=firma,dc=test
objectGUID:: EEMHXzU8HUqDb6UPUV56NQ==
dn: cn=Joerg Weiss,ou=Vertrieb,ou=Benutzer,dc=firma,dc=test
objectClass: inetOrgPerson
objectClass: user
cn: Joerg Weiss
sn:: V2Vpw58=
givenName:: SsO2cmc=
displayName:: V2Vpw58sIErDtnJn
mail: joerg.weiss@firma.test
userPrincipalName: joerg.weiss@firma.test
sAMAccountName: jweiss
department: Vertrieb
objectCategory: person
userAccountControl: 512
manager: cn=Klaus Berger,ou=Geschaeftsfuehrung,ou=Benutzer,dc=firma,dc=test
objectGUID:: AQIDBAUGBwgJCgsMDQ4PEA==
dn: cn=Alte Kraft,ou=Vertrieb,ou=Benutzer,dc=firma,dc=test
objectClass: inetOrgPerson
objectClass: user
cn: Alte Kraft
sn: Kraft
givenName: Alte
displayName: Kraft, Alte
mail: alte.kraft@firma.test
userPrincipalName: alte.kraft@firma.test
sAMAccountName: akraft
department: Vertrieb
objectCategory: person
userAccountControl: 514
objectGUID:: /+7dzLuqmYh3ZlVEMyIRAA==
dn: cn=Ohne Kennung,ou=Vertrieb,ou=Benutzer,dc=firma,dc=test
objectClass: inetOrgPerson
objectClass: user
cn: Ohne Kennung
sn: Kennung
givenName: Ohne
displayName: Kennung, Ohne
sAMAccountName: okennung
objectCategory: person
userAccountControl: 512
+52
View File
@@ -0,0 +1,52 @@
# AD-aehnliches Schema fuer den Testcontainer (#016).
#
# OpenLDAP bringt keines der Attribute mit, an denen der Abgleich haengt:
# sAMAccountName, userPrincipalName, objectGUID, userAccountControl,
# objectCategory und department fehlen alle. Ohne dieses Schema liefe der
# Integrationstest gegen ein Verzeichnis, das mit einem Active Directory nichts
# gemeinsam hat -- und bewiese entsprechend wenig.
#
# `manager`, `displayName`, `givenName`, `sn`, `mail` und `title` kommen aus
# core/cosine/inetOrgPerson und stehen deshalb nicht hier.
#
# Die OIDs sind die echten aus dem Microsoft-Arc 1.2.840.113556, damit das
# Fixture nicht bloss gleich heisst, sondern auch gleich bezeichnet ist.
#
# Zwei bewusste Abweichungen vom echten AD, weil sie sich in OpenLDAP nicht
# nachbauen lassen:
#
# 1. `objectCategory` ist hier eine Zeichenkette mit dem Wert "person". Im AD
# ist es ein DN auf das classSchema-Objekt, und der Server nimmt die
# Kurzform "person" als Abkuerzung dafuer an. Der Standardfilter trifft
# damit hier und dort, aus verschiedenen Gruenden.
# 2. `user` ist hier AUXILIARY statt STRUCTURAL. So traegt ein Eintrag
# inetOrgPerson **und** user, statt dass die Personenklassen des Fixtures
# gegen die des AD ausgetauscht werden muessten. Fuer `(objectClass=user)`
# macht das keinen Unterschied.
dn: cn=ad-fixture,cn=schema,cn=config
objectClass: olcSchemaConfig
cn: ad-fixture
olcAttributeTypes: ( 1.2.840.113556.1.4.221 NAME 'sAMAccountName'
EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE )
olcAttributeTypes: ( 1.2.840.113556.1.4.656 NAME 'userPrincipalName'
EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE )
olcAttributeTypes: ( 1.2.840.113556.1.4.2 NAME 'objectGUID'
EQUALITY octetStringMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 SINGLE-VALUE )
olcAttributeTypes: ( 1.2.840.113556.1.4.8 NAME 'userAccountControl'
EQUALITY integerMatch ORDERING integerOrderingMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE )
olcAttributeTypes: ( 1.2.840.113556.1.4.782 NAME 'objectCategory'
EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE )
olcAttributeTypes: ( 1.2.840.113556.1.2.141 NAME 'department'
EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE )
olcObjectClasses: ( 1.2.840.113556.1.5.9 NAME 'user'
DESC 'AD-aehnliche Zusatzklasse, nur fuer Tests'
SUP top AUXILIARY
MAY ( sAMAccountName $ userPrincipalName $ objectGUID $ userAccountControl $
objectCategory $ department ) )
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { GuidFehler, guidAusBytes } from "@/lib/ldap-guid";
/**
* `objectGUID` als GUID-Zeichenkette (#016).
*
* Diese Umsortierung ist der Grund, warum die Kennung ueberhaupt eine eigene
* Datei hat: schlaegt sie fehl, sieht das Ergebnis trotzdem wie eine GUID aus.
* Der Abgleich wuerde dann bei jedem Lauf dieselben Personen neu anlegen, weil
* die Kennung zwar stabil, aber gegen `Get-ADUser` nicht auffindbar waere — ein
* Fehler, der erst auffaellt, wenn jemand mit dem AD vergleicht.
*
* Die erwarteten Zeichenketten sind mit Pythons `uuid.UUID(bytes_le=…)`
* ausgerechnet, also mit einer unabhaengigen Umsetzung derselben Reihenfolge.
*/
describe("guidAusBytes", () => {
it("ordnet die ersten drei Gruppen little-endian, den Rest in Leserichtung", () => {
// Der aufschlussreichste Vektor: die Bytes 00…0f. An der Ausgabe ist die
// Umsortierung mit dem Auge nachzuvollziehen.
const bytes = Uint8Array.from([
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
]);
expect(guidAusBytes(bytes)).toBe("03020100-0504-0706-0809-0a0b0c0d0e0f");
});
it("liest den Byte-Vektor einer echten AD-Kennung", () => {
// Dieselben Bytes stehen base64-kodiert im Fixture des Testcontainers
// (tests/fixtures/ldap/daten/50-personen.ldif, „Maria Huber“).
const bytes = Uint8Array.from(Buffer.from("EEMHXzU8HUqDb6UPUV56NQ==", "base64"));
expect(guidAusBytes(bytes)).toBe("5f074310-3c35-4a1d-836f-a50f515e7a35");
});
it("liefert bei denselben Bytes immer dieselbe Zeichenkette", () => {
const bytes = Uint8Array.from(Buffer.from("nEA+HXqyyUux5vCiw9Tl9g==", "base64"));
expect(guidAusBytes(bytes)).toBe(guidAusBytes(bytes));
expect(guidAusBytes(Uint8Array.from(bytes))).toBe("1d3e409c-b27a-4bc9-b1e6-f0a2c3d4e5f6");
});
it("schreibt fuehrende Nullen mit", () => {
// Ohne padStart waere daraus "0-0-0-0-0"; die Kennung waere kuerzer als eine
// GUID und passte doch zu einem Konto.
expect(guidAusBytes(new Uint8Array(16))).toBe("00000000-0000-0000-0000-000000000000");
});
it("gibt Klein- und nicht Grossbuchstaben aus", () => {
const bytes = Uint8Array.from(Buffer.from("/+7dzLuqmYh3ZlVEMyIRAA==", "base64"));
const guid = guidAusBytes(bytes);
expect(guid).toBe("ccddeeff-aabb-8899-7766-554433221100");
expect(guid).toBe(guid.toLowerCase());
});
it("hat die Form 8-4-4-4-12", () => {
const guid = guidAusBytes(Uint8Array.from(Buffer.from("AQIDBAUGBwgJCgsMDQ4PEA==", "base64")));
expect(guid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u);
});
it("wirft bei falscher Laenge statt eine halbe Kennung zu liefern", () => {
// Genau der Fall, der ohne explicitBufferAttributes entsteht: die 16 Bytes
// wurden als UTF-8 gelesen und dabei veraendert.
expect(() => guidAusBytes(new Uint8Array(15))).toThrow(GuidFehler);
expect(() => guidAusBytes(new Uint8Array(0))).toThrow(GuidFehler);
expect(() => guidAusBytes(new Uint8Array(36))).toThrow(/16 Bytes/u);
});
});
+430
View File
@@ -0,0 +1,430 @@
import { beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
import {
MITARBEITER_IMPORT_HOECHSTZEILEN,
mitarbeiterImportieren,
type MitarbeiterImportErgebnis,
} from "@/server/services/mitarbeiter";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* CSV-Import der Personen (#016).
*
* Festgehalten wird vor allem, was in #011 am Geräteimport schiefging: der
* Import meldete „3 von 3 angelegt“, obwohl Zeilen verschluckt worden waren.
* Deshalb prüft hier fast jeder Test die **Summe** mit — je Zeile genau ein Fach,
* und die Datenbank danach genauso, wie die Zahlen es behaupten.
*
* Dazu die zweite Regel des Hauses: was aus dem Verzeichnis kommt, wird nicht von
* Hand überschrieben.
*/
const KOPF = "givenName,sn,displayName,mail,upn,sam,department,title";
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: "Administrator", rolle: "ADMIN" },
});
return { eigene, admin: { id: benutzer.id, rolle: benutzer.rolle } };
}
/** Jede Zeile landet in genau einem Fach – sonst lügt die Erfolgsmeldung. */
function summePruefen(ergebnis: MitarbeiterImportErgebnis): void {
expect(ergebnis.angelegt + ergebnis.aktualisiert + ergebnis.unveraendert + ergebnis.abgewiesen.length).toBe(
ergebnis.gesamt,
);
}
describe.runIf(datenbankVerfuegbar)("Personen aus einer CSV-Datei", () => {
beforeEach(async () => {
await datenbankLeeren();
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
});
describe("Zeile für Zeile", () => {
it("legt die guten Zeilen an und meldet die kaputte mit ihrer Zeilennummer", async () => {
const { admin } = await grundbestand();
const text = [
KOPF,
"Anna,Muster,Anna Muster,anna@firma.example,anna@firma.example,amuster,Vertrieb,Innendienst",
'Bert,"Berg,Bert Berg,bert@firma.example,,bberg,Vertrieb,', // Anführungszeichen nicht geschlossen
"Clara,Cloud,Clara Cloud,clara@firma.example,,ccloud,Technik,",
",,,dora@firma.example,,dfehlt,,", // kein Name in der Zeile
"Emil,Eck,Emil Eck,,,eeck,,",
].join("\n");
const ergebnis = await mitarbeiterImportieren(text, admin);
expect(ergebnis.gesamt).toBe(5);
expect(ergebnis.angelegt).toBe(3);
summePruefen(ergebnis);
// Zeilennummern wie in der Datei: die Kopfzeile ist 1.
expect(ergebnis.abgewiesen.map((abweisung) => abweisung.zeile)).toEqual([3, 5]);
expect(ergebnis.abgewiesen[0]?.grund).toMatch(/Anführungszeichen/);
expect(ergebnis.abgewiesen[1]?.grund).toMatch(/fehlt ein Name/);
const namen = (await testPrisma.mitarbeiter.findMany({ select: { anzeigename: true } }))
.map((zeile) => zeile.anzeigename)
.sort();
expect(namen).toEqual(["Anna Muster", "Clara Cloud", "Emil Eck"]);
});
it("legt als MANUELL an und protokolliert die Zeilennummer", async () => {
// MANUELL, weil niemand aus dieser Datei im Verzeichnis steht — und weil
// eine so angelegte Person änderbar bleiben muss.
const { admin, eigene } = await grundbestand();
await mitarbeiterImportieren([KOPF, "Anna,Muster,,,,amuster,Vertrieb,"].join("\n"), admin);
const person = await testPrisma.mitarbeiter.findFirstOrThrow();
expect(person.herkunft).toBe(Herkunft.MANUELL);
expect(person.kundeId).toBe(eigene.id);
expect(person.anzeigename).toBe("Anna Muster");
expect(person.abteilung).toBe("Vertrieb");
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { aktion: "Mitarbeiter aus CSV angelegt" },
});
expect(eintrag.nachher).toMatchObject({ anzeigename: "Anna Muster", zeile: 2 });
});
it("versteht deutsches Excel: Semikolon, BOM und deutsche Spaltennamen", async () => {
const { admin } = await grundbestand();
const text =
"Vorname;Nachname;Anzeigename;Mail;UPN;sAMAccountName;Abteilung;Titel\r\n" +
'Jörg;Öz;"Öz, Jörg";joerg@firma.example;joerg@firma.example;joez;Technik;"Leiter ""IT"""\r\n';
const ergebnis = await mitarbeiterImportieren(text, admin);
expect(ergebnis.angelegt).toBe(1);
summePruefen(ergebnis);
await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
vorname: "Jörg",
nachname: "Öz",
anzeigename: "Öz, Jörg",
titel: 'Leiter "IT"',
});
});
it("teilt displayName am letzten Leerzeichen, lässt den Anzeigenamen aber stehen", async () => {
const { admin } = await grundbestand();
const ergebnis = await mitarbeiterImportieren(
["displayName,sam", "Anna van der Berg,aberg"].join("\n"),
admin,
);
expect(ergebnis.angelegt).toBe(1);
summePruefen(ergebnis);
// Die Teilung ist eine Annahme; der Anzeigename ist es nicht — und nur er
// steht später in der Oberfläche und im Laufzettel.
await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
vorname: "Anna van der",
nachname: "Berg",
anzeigename: "Anna van der Berg",
});
});
it("gibt die Regel des Dienstes auch zeilenweise weiter", async () => {
// Die 20-Zeichen-Grenze des sAMAccountName steht im Dienst und nirgends
// sonst. Der Import geht durch denselben Weg, also gilt sie hier auch.
const { admin } = await grundbestand();
const ergebnis = await mitarbeiterImportieren(
[KOPF, "Anna,Muster,,,,viel-zu-langer-anmeldename,,", "Bert,Berg,,,,bberg,,"].join("\n"),
admin,
);
expect(ergebnis.angelegt).toBe(1);
summePruefen(ergebnis);
expect(ergebnis.abgewiesen[0]).toMatchObject({ zeile: 2 });
expect(ergebnis.abgewiesen[0]?.grund).toMatch(/höchstens 20/);
});
it("weist eine Datei ohne Namensspalte als Ganzes ab", async () => {
const { admin } = await grundbestand();
await expect(mitarbeiterImportieren("mail,sam\nanna@firma.example,amuster\n", admin)).rejects.toThrow(
/displayName/,
);
expect(await testPrisma.mitarbeiter.count()).toBe(0);
});
it("bricht bei zu vielen Zeilen ab, bevor eine einzige geschrieben ist", async () => {
const { admin } = await grundbestand();
const zeilen = Array.from(
{ length: MITARBEITER_IMPORT_HOECHSTZEILEN + 1 },
(_, index) => `Anna,Muster${index},,,,a${index},,`,
);
await expect(mitarbeiterImportieren([KOPF, ...zeilen].join("\n"), admin)).rejects.toThrow(
new RegExp(`mehr als ${MITARBEITER_IMPORT_HOECHSTZEILEN} Zeilen`),
);
expect(await testPrisma.mitarbeiter.count()).toBe(0);
});
});
describe("Wiedererkennen statt verdoppeln", () => {
it("erkennt ein Duplikat innerhalb derselben Datei und nennt die erste Zeile", async () => {
const { admin } = await grundbestand();
const ergebnis = await mitarbeiterImportieren(
[
KOPF,
"Anna,Muster,,anna@firma.example,,amuster,Vertrieb,",
"Bert,Berg,,bert@firma.example,,bberg,Vertrieb,",
"Anna,Muster-Neu,,anna@firma.example,,AMUSTER,Vertrieb,", // dieselbe Kennung
].join("\n"),
admin,
);
expect(ergebnis.angelegt).toBe(2);
summePruefen(ergebnis);
expect(ergebnis.abgewiesen[0]?.zeile).toBe(4);
expect(ergebnis.abgewiesen[0]?.grund).toMatch(/schon in Zeile 2/);
expect(await testPrisma.mitarbeiter.count()).toBe(2);
// Und die erste Zeile gilt, nicht die dritte.
await expect(
testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "amuster" } }),
).resolves.toMatchObject({ nachname: "Muster" });
});
it("legt dieselbe Datei beim zweiten Einlesen nicht erneut an", async () => {
// Der eigentliche Zweck der Wiedererkennung: eine Onboarding-Liste wandert
// mehrfach durch dieses Feld, und niemand zählt vorher nach.
const { admin } = await grundbestand();
const text = [
KOPF,
"Anna,Muster,Anna Muster,anna@firma.example,anna@firma.example,amuster,Vertrieb,Innendienst",
"Bert,Berg,Bert Berg,bert@firma.example,bert@firma.example,bberg,Technik,",
].join("\n");
const erster = await mitarbeiterImportieren(text, admin);
expect(erster.angelegt).toBe(2);
const zweiter = await mitarbeiterImportieren(text, admin);
expect(zweiter).toMatchObject({ gesamt: 2, angelegt: 0, aktualisiert: 0, unveraendert: 2 });
summePruefen(zweiter);
expect(await testPrisma.mitarbeiter.count()).toBe(2);
});
it("erkennt über den UPN, wenn kein sam in der Datei steht", async () => {
const { admin, eigene } = await grundbestand();
await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: "Anna",
nachname: "Muster",
anzeigename: "Anna Muster",
upn: "anna.muster@firma.example",
sam: "amuster",
},
});
const ergebnis = await mitarbeiterImportieren(
["givenName,sn,upn,department", "Anna,Muster,ANNA.MUSTER@firma.example,Vertrieb"].join("\n"),
admin,
);
expect(ergebnis).toMatchObject({ angelegt: 0, aktualisiert: 1 });
summePruefen(ergebnis);
const person = await testPrisma.mitarbeiter.findFirstOrThrow();
expect(person.abteilung).toBe("Vertrieb");
// Gross- und Kleinschreibung unterscheidet keine Personen, deshalb bleibt
// der vorhandene Wert stehen, statt ihn zu überschreiben.
expect(person.upn).toBe("anna.muster@firma.example");
});
it("erkennt ohne sam und ohne UPN über den Anzeigenamen", async () => {
// Die Onboarding-Liste mit Name und Abteilung und sonst nichts. Ohne diesen
// Rückfall stünde jede Person nach dem zweiten Einlesen doppelt da.
const { admin } = await grundbestand();
const text = ["displayName,department", "Anna Muster,Vertrieb"].join("\n");
expect(await mitarbeiterImportieren(text, admin)).toMatchObject({ angelegt: 1 });
expect(await mitarbeiterImportieren(text, admin)).toMatchObject({ angelegt: 0, unveraendert: 1 });
expect(await testPrisma.mitarbeiter.count()).toBe(1);
});
it("weist eine Zeile ab, die auf zwei Personen passt", async () => {
// Hier zu raten hieße, die Stammdaten der falschen Person zu überschreiben.
const { admin, eigene } = await grundbestand();
for (const [vorname, sam, upn] of [
["Anna", "amuster", "a.muster@firma.example"],
["Anneliese", "amuster2", "anna@firma.example"],
] as const) {
await testPrisma.mitarbeiter.create({
data: { kundeId: eigene.id, vorname, nachname: "Muster", anzeigename: vorname, sam, upn },
});
}
const ergebnis = await mitarbeiterImportieren(
[KOPF, "Anna,Muster,,,anna@firma.example,amuster,Vertrieb,"].join("\n"),
admin,
);
expect(ergebnis).toMatchObject({ angelegt: 0, aktualisiert: 0, unveraendert: 0 });
summePruefen(ergebnis);
expect(ergebnis.abgewiesen[0]?.grund).toMatch(/passt auf/);
expect(await testPrisma.mitarbeiter.count()).toBe(2);
});
it("aktiviert eine deaktivierte Person nicht wieder", async () => {
// Das Wiedereinstellen ist eine Entscheidung und keine Zeile in einer
// Datei. Die Oberfläche sagt es genauso — also muss es hier auch gelten.
const { admin, eigene } = await grundbestand();
await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: "Anna",
nachname: "Muster",
anzeigename: "Anna Muster",
sam: "amuster",
aktiv: false,
},
});
const ergebnis = await mitarbeiterImportieren(
[KOPF, "Anna,Muster,,,,amuster,Vertrieb,"].join("\n"),
admin,
);
expect(ergebnis).toMatchObject({ angelegt: 0, aktualisiert: 1 });
summePruefen(ergebnis);
await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
aktiv: false,
abteilung: "Vertrieb",
});
});
it("lässt eine leere Zelle nichts löschen", async () => {
const { admin, eigene } = await grundbestand();
await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: "Anna",
nachname: "Muster",
anzeigename: "Muster, Anna",
sam: "amuster",
titel: "Innendienst",
mail: "anna@firma.example",
},
});
const ergebnis = await mitarbeiterImportieren(
[KOPF, "Anna,Muster,,,,amuster,Vertrieb,"].join("\n"),
admin,
);
expect(ergebnis).toMatchObject({ aktualisiert: 1 });
summePruefen(ergebnis);
await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
abteilung: "Vertrieb",
titel: "Innendienst",
mail: "anna@firma.example",
// Kein displayName in der Zeile heißt: der vorhandene bleibt, auch wenn
// er anders lautet als „Vorname Nachname“.
anzeigename: "Muster, Anna",
});
});
});
describe("Herkunft ABGLEICH", () => {
it("überschreibt eine Person aus dem Verzeichnis nicht, sondern meldet die Zeile", async () => {
// Der nächste Abgleich drehte die Änderung ohnehin zurück. Stillschweigend
// zu überspringen wäre das Schlimmere: der Import meldete dann einen
// Erfolg, den es nicht gab.
const { admin, eigene } = await grundbestand();
await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: "Maria",
nachname: "Huber",
anzeigename: "Maria Huber",
sam: "mhuber",
abteilung: "Buchhaltung",
titel: "Leitung",
herkunft: Herkunft.ABGLEICH,
},
});
const protokollVorher = await testPrisma.protokoll.count();
const ergebnis = await mitarbeiterImportieren(
[KOPF, "Maria,Huber-Neu,,neu@firma.example,,mhuber,Vertrieb,Sachbearbeitung"].join("\n"),
admin,
);
expect(ergebnis).toMatchObject({ gesamt: 1, angelegt: 0, aktualisiert: 0, unveraendert: 0 });
summePruefen(ergebnis);
expect(ergebnis.abgewiesen[0]?.zeile).toBe(2);
expect(ergebnis.abgewiesen[0]?.grund).toMatch(/kommt aus dem Verzeichnis/);
await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
nachname: "Huber",
abteilung: "Buchhaltung",
titel: "Leitung",
mail: null,
});
expect(await testPrisma.protokoll.count()).toBe(protokollVorher);
});
});
describe("Mandantengrenze", () => {
it("verwirft im Modus INTERN eine mitgeschickte Kundenkennung", async () => {
const { admin, eigene } = await grundbestand();
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
await mitarbeiterImportieren([KOPF, "Anna,Muster,,,,amuster,,"].join("\n"), admin, fremder.id);
await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
kundeId: eigene.id,
});
});
it("erkennt nur innerhalb desselben Kunden – und schreibt nicht für einen fremden", async () => {
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
const techniker = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Techniker", rolle: "TECHNIKER" },
});
await testPrisma.benutzerKunde.create({ data: { benutzerId: techniker.id, kundeId: meiner.id } });
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
const handelnder = { id: techniker.id, rolle: techniker.rolle };
// Dieselbe Kennung gibt es beim fremden Kunden schon. Sie darf hier nichts
// treffen: sonst schriebe dieser Import in fremde Stammdaten.
await testPrisma.mitarbeiter.create({
data: {
kundeId: fremder.id,
vorname: "Anna",
nachname: "Fremd",
anzeigename: "Anna Fremd",
sam: "amuster",
},
});
const ergebnis = await mitarbeiterImportieren(
[KOPF, "Anna,Muster,,,,amuster,Vertrieb,"].join("\n"),
handelnder,
meiner.id,
);
expect(ergebnis).toMatchObject({ angelegt: 1 });
summePruefen(ergebnis);
await expect(
testPrisma.mitarbeiter.findFirstOrThrow({ where: { kundeId: fremder.id } }),
).resolves.toMatchObject({ nachname: "Fremd", abteilung: null });
// Und für einen Kunden, der diesem Techniker nicht gehört, geht gar nichts.
await expect(
mitarbeiterImportieren([KOPF, "Bert,Berg,,,,bberg,,"].join("\n"), handelnder, fremder.id),
).rejects.toThrow(/Berechtigung/);
expect(await testPrisma.mitarbeiter.count({ where: { kundeId: fremder.id } })).toBe(1);
});
it("verlangt im Modus DIENSTLEISTUNG eine Kundenwahl", async () => {
const { admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
await expect(
mitarbeiterImportieren([KOPF, "Anna,Muster,,,,amuster,,"].join("\n"), admin),
).rejects.toThrow(/Kunde/);
expect(await testPrisma.mitarbeiter.count()).toBe(0);
});
});
});
+8
View File
@@ -7,3 +7,11 @@ if (process.env["TEST_DATABASE_URL"]) {
// Fuer Tests, die ein Geheimnis brauchen, ohne eine .env zu verlangen.
process.env["APP_SECRET"] ??= "test-geheimnis-mit-mindestens-32-zeichen-laenge";
/*
* Kein tickender Zeitplan in Tests (#016). Er wird zwar nur ueber
* `src/instrumentation.ts` gestartet, also gar nicht von Vitest — aber wer
* hier einen Dienst importiert, der ihn seinerseits startet, saehe sonst
* Abgleiche mitten im Testlauf.
*/
process.env["ZEITPLAN_AKTIV"] = "false";
+824
View File
@@ -0,0 +1,824 @@
import type { Entry } from "ldapts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Betriebsmodus } from "@/generated/prisma/enums";
import { entschluesseln } from "@/lib/geheimnisse";
import { zertifikatWahl, zuordnungAusEingabe } from "@/lib/verzeichnis";
import { fehlerMeldung, tlsOptionenBauen, urlPruefen, VerbindungsFehler } from "@/server/ldap/verbindung";
import {
AD_ZUORDNUNG,
angeforderteAttribute,
dnSchluessel,
eintragZuordnen,
STANDARD_FILTER,
vorgesetzteAufloesen,
zuordnungLesen,
} from "@/server/ldap/zuordnung";
import { abgleichLog, LOG_ZEILEN, quelleHolen, quellenListe } from "@/server/queries/verzeichnis";
import {
quelleAnlegen,
quelleBearbeiten,
quelleLoeschen,
verbindungTesten,
VerzeichnisFehler,
} from "@/server/services/verzeichnis";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
import {
FIXTURE_GELESEN,
FIXTURE_PERSONEN,
GUID,
LDAP_BASIS_DN,
LDAP_BENUTZER_DN,
LDAP_BIND_DN,
LDAP_BIND_PASSWORT,
LDAP_URL,
ldapVerfuegbar,
} from "./fixtures/ldap/container";
/**
* Verzeichnisquellen und das Lesen aus dem Verzeichnis (#016).
*
* Drei Arten von Pruefungen stehen hier, und die Trennung ist Absicht:
*
* 1. **Reine Funktionen** — Adressen, TLS-Optionen, Attributzuordnung,
* Fehlermeldungen. Ohne Netz und ohne Datenbank, deshalb vollstaendig in den
* Sonderfaellen.
* 2. **Der Service gegen die Datenbank** — Rechte, Betriebsmodus, und dass das
* Bind-Passwort nirgends herauskommt.
* 3. **Gegen den echten Container** — dass aus einem AD-aehnlichen Verzeichnis
* wirklich die erwarteten Felder werden. Das ist der Teil, den keine
* Attrappe ersetzt: `explicitBufferAttributes`, die Umsortierung der GUID und
* der Bit-UND-Filter entscheiden sich erst am Server.
*/
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 admin = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
});
const technikerin = await testPrisma.benutzer.create({
data: { email: "tina@firma.example", name: "Tina", rolle: "TECHNIKER" },
});
return {
eigene,
admin: { id: admin.id, rolle: admin.rolle },
technikerin: { id: technikerin.id, rolle: technikerin.rolle },
};
}
/** Eine Quelle, die auf den Testcontainer zeigt. */
function quellenEingabe(abweichend: Record<string, unknown> = {}) {
return {
name: "Firmen-AD",
url: LDAP_URL,
bindDn: LDAP_BIND_DN,
bindPasswort: LDAP_BIND_PASSWORT,
basisDn: LDAP_BENUTZER_DN,
...abweichend,
};
}
// ---- 1. Reine Funktionen ---------------------------------------------------
describe("Adressen", () => {
it("nimmt ldaps, ldap und das StartTLS-Schema", () => {
expect(urlPruefen("ldaps://dc1.firma.local:636")).toEqual({
url: "ldaps://dc1.firma.local:636",
startTls: false,
verschluesselt: true,
});
expect(urlPruefen("ldap://dc1.firma.local:389")).toEqual({
url: "ldap://dc1.firma.local:389",
startTls: false,
verschluesselt: false,
});
});
it("macht aus ldap+starttls eine ldap-Adresse und merkt sich die Umschaltung", () => {
// ldapts kennt nur ldap und ldaps; StartTLS geschieht bei uns nach dem
// Verbinden. Die getippte Form bleibt in der Datenbank stehen, damit noch
// erkennbar ist, dass StartTLS gewuenscht war.
expect(urlPruefen("ldap+starttls://dc1.firma.local:389")).toEqual({
url: "ldap://dc1.firma.local:389",
startTls: true,
verschluesselt: true,
});
});
it("weist ein fremdes Schema, einen fehlenden Rechner und einen Pfad ab", () => {
expect(() => urlPruefen("https://dc1.firma.local")).toThrow(VerbindungsFehler);
expect(() => urlPruefen("dc1.firma.local:389")).toThrow(VerbindungsFehler);
expect(() => urlPruefen("ldaps://")).toThrow(/Rechnername/u);
expect(() => urlPruefen("ldaps:///dc=firma,dc=local")).toThrow(/Rechnername/u);
expect(() => urlPruefen("ldaps://dc1.firma.local/dc=firma,dc=local")).toThrow(/Pfad/u);
});
it("weist Zugangsdaten in der Adresse ab", () => {
// Sonst stuende das Passwort im Klartext in der Datenbank – verschluesselt
// wird nur das Feld, das dafuer da ist.
expect(() => urlPruefen("ldaps://dienst:geheim@dc1.firma.local:636")).toThrow(/Zugangsdaten/u);
});
});
describe("TLS-Optionen", () => {
it("verlangt mindestens TLS 1.2", () => {
expect(tlsOptionenBauen(null).minVersion).toBe("TLSv1.2");
});
it("nimmt ein eigenes CA-Zertifikat auf", () => {
const pem = "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----";
expect(tlsOptionenBauen(pem).ca).toEqual([pem]);
});
it("laesst ca weg, wenn keines hinterlegt ist – dann gelten die Systemwurzeln", () => {
expect(tlsOptionenBauen(null).ca).toBeUndefined();
expect(tlsOptionenBauen(" ").ca).toBeUndefined();
});
it("hat keinen Schalter, der die Pruefung des Hostnamens abstellt", () => {
// Ein solcher Schalter waere genau der, der in der Eile gesetzt bleibt.
const optionen = tlsOptionenBauen(null) as Record<string, unknown>;
expect(optionen["rejectUnauthorized"]).toBeUndefined();
expect(optionen["checkServerIdentity"]).toBeUndefined();
});
});
describe("Fehlermeldungen", () => {
const passwort = "S3hr-geheim!";
it("entfernt das Bind-Passwort restlos", () => {
const meldung = fehlerMeldung(new Error(`bind failed for cn=dienst with ${passwort}`), [passwort]);
expect(meldung).not.toContain(passwort);
expect(meldung).toContain("***");
});
it("entfernt es auch mehrfach und mitten im Wort", () => {
const meldung = fehlerMeldung(new Error(`${passwort}/${passwort}`), [passwort]);
expect(meldung).toBe("***/***");
});
it("macht eine Zeile daraus und kuerzt", () => {
const meldung = fehlerMeldung(new Error(`erste\n zweite\tdritte`), []);
expect(meldung).toBe("erste zweite dritte");
expect(fehlerMeldung(new Error("x".repeat(900)), []).length).toBeLessThanOrEqual(401);
});
it("kommt mit allem zurecht, was geworfen werden kann", () => {
expect(fehlerMeldung("nur Text", [])).toBe("nur Text");
expect(fehlerMeldung(undefined, [])).toBe("undefined");
expect(fehlerMeldung(new Error(" "), [])).toBe("Unbekannter Fehler beim Zugriff auf das Verzeichnis.");
});
});
describe("Attributzuordnung", () => {
it("benennt die AD-Vorgaben aus dem Auftrag", () => {
expect(AD_ZUORDNUNG).toEqual({
vorname: "givenName",
nachname: "sn",
anzeigename: "displayName",
mail: "mail",
upn: "userPrincipalName",
sam: "sAMAccountName",
abteilung: "department",
titel: "title",
vorgesetzter: "manager",
externeId: "objectGUID",
});
});
it("schliesst im Standardfilter die deaktivierten Konten aus", () => {
expect(STANDARD_FILTER).toBe(
"(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))",
);
});
it("fuellt fehlende und leere Felder mit der Vorgabe", () => {
expect(zuordnungLesen({ anzeigename: "cn", mail: " " })).toEqual({
...AD_ZUORDNUNG,
anzeigename: "cn",
});
expect(zuordnungLesen(null)).toEqual(AD_ZUORDNUNG);
expect(zuordnungLesen("Unsinn")).toEqual(AD_ZUORDNUNG);
expect(zuordnungLesen({ vorname: 7 })).toEqual(AD_ZUORDNUNG);
});
it("fragt nur die zugeordneten Attribute an, und jedes nur einmal", () => {
const attribute = angeforderteAttribute({ ...AD_ZUORDNUNG, upn: "mail" });
expect(attribute).toContain("objectGUID");
expect(attribute).not.toContain("memberOf");
expect(attribute.filter((name) => name === "mail")).toHaveLength(1);
});
it("uebersetzt einen Eintrag und wandelt den GUID-Puffer", () => {
const ergebnis = eintragZuordnen(
{
dn: "CN=Maria Huber,OU=Buchhaltung,DC=firma,DC=test",
givenName: "Maria",
sn: "Huber",
displayName: "Huber, Maria",
mail: "maria.huber@firma.test",
userPrincipalName: "maria.huber@firma.test",
sAMAccountName: "mhuber",
department: "Buchhaltung",
title: "Leiterin",
manager: "CN=Klaus Berger,OU=GF,DC=firma,DC=test",
objectGUID: Buffer.from("EEMHXzU8HUqDb6UPUV56NQ==", "base64"),
} satisfies Entry,
AD_ZUORDNUNG,
);
expect(ergebnis).toEqual({
eintrag: {
externeId: GUID.huber,
vorname: "Maria",
nachname: "Huber",
anzeigename: "Huber, Maria",
mail: "maria.huber@firma.test",
upn: "maria.huber@firma.test",
sam: "mhuber",
dn: "CN=Maria Huber,OU=Buchhaltung,DC=firma,DC=test",
abteilung: "Buchhaltung",
titel: "Leiterin",
vorgesetzterDn: "CN=Klaus Berger,OU=GF,DC=firma,DC=test",
},
});
});
it("nimmt bei einem mehrwertigen Attribut den ersten Wert und macht aus leer null", () => {
const ergebnis = eintragZuordnen(
{
dn: "CN=Zwei,DC=firma,DC=test",
givenName: ["Anna", "Annemarie"],
sn: "Zwei",
displayName: "",
mail: " ",
objectGUID: Buffer.from("AAECAwQFBgcICQoLDA0ODw==", "base64"),
} satisfies Entry,
AD_ZUORDNUNG,
);
expect(ergebnis).toMatchObject({
eintrag: { vorname: "Anna", mail: null, upn: null, titel: null, vorgesetzterDn: null },
});
});
it("baut einen Anzeigenamen, wenn keiner im Verzeichnis steht", () => {
// `Mitarbeiter.anzeigename` ist nicht optional, und eine leere Zeile in der
// Auswahlliste waere fuer die Technikerin unbrauchbar.
const mitNamen = eintragZuordnen(
{ dn: "CN=X,DC=f,DC=t", givenName: "Maria", sn: "Huber", objectGUID: "abc" } satisfies Entry,
AD_ZUORDNUNG,
);
expect(mitNamen).toMatchObject({ eintrag: { anzeigename: "Huber, Maria" } });
const ohneNamen = eintragZuordnen(
{ dn: "CN=Nur der DN,OU=x,DC=f,DC=t", objectGUID: "abc" } satisfies Entry,
AD_ZUORDNUNG,
);
expect(ohneNamen).toMatchObject({ eintrag: { anzeigename: "Nur der DN", vorname: "", nachname: "" } });
});
it("uebersetzt eine Textkennung, ohne sie fuer eine GUID zu halten", () => {
// Wer `externeId` auf `entryUUID` eines OpenLDAP legt, bekommt 36 Textbytes.
const ergebnis = eintragZuordnen(
{
dn: "CN=Y,DC=f,DC=t",
objectGUID: Buffer.from("c0ffee00-1111-2222-3333-444455556666", "utf8"),
} satisfies Entry,
AD_ZUORDNUNG,
);
expect(ergebnis).toMatchObject({ eintrag: { externeId: "c0ffee00-1111-2222-3333-444455556666" } });
});
it("ueberspringt einen Eintrag ohne Kennung statt abzubrechen", () => {
// Ein einzelnes krummes Konto darf nicht den Abgleich aller anderen
// verhindern – ohne Kennung waere es aber bei jedem Lauf wieder neu.
const ohne = eintragZuordnen({ dn: "CN=Ohne,DC=f,DC=t", sn: "Ohne" } satisfies Entry, AD_ZUORDNUNG);
expect(ohne).toEqual({
uebersprungen: { dn: "CN=Ohne,DC=f,DC=t", grund: expect.stringContaining("objectGUID") },
});
// Sieben Bytes sind eine verstuemmelte GUID, keine Kennung. Sie als Text
// durchzulassen hiesse: eine Kennung aus Steuerzeichen, die zu keinem Konto
// im Verzeichnis passt und trotzdem kein Duplikat erzeugt – ein Fehler, der
// nie auffaellt.
const kaputt = eintragZuordnen(
{ dn: "CN=Kaputt,DC=f,DC=t", objectGUID: Buffer.alloc(7) } satisfies Entry,
AD_ZUORDNUNG,
);
expect(kaputt).toMatchObject({ uebersprungen: { dn: "CN=Kaputt,DC=f,DC=t" } });
});
it("loest den Vorgesetzten ueber den DN auf, auch bei anderer Schreibweise", () => {
const chef = {
externeId: GUID.berger,
vorname: "Klaus",
nachname: "Berger",
anzeigename: "Berger, Klaus",
mail: null,
upn: null,
sam: "kberger",
dn: "CN=Klaus Berger,OU=GF,DC=firma,DC=test",
abteilung: null,
titel: null,
vorgesetzterDn: null,
};
const person = {
...chef,
externeId: GUID.huber,
dn: "CN=Maria Huber,OU=BH,DC=firma,DC=test",
// Klein geschrieben und mit Leerzeichen hinter den Kommas.
vorgesetzterDn: "cn=klaus berger, ou=gf, dc=firma, dc=test",
};
const [aufgeloest] = vorgesetzteAufloesen([person, chef]);
expect(aufgeloest?.vorgesetzterExterneId).toBe(GUID.berger);
expect(dnSchluessel("CN=A, OU=B")).toBe("cn=a,ou=b");
});
it("laesst den Vorgesetzten leer, wenn er nicht mitgelesen wurde", () => {
// Lieber kein Vorgesetzter als ein falscher: der DN ist ausserhalb des
// Suchbereichs nicht aufloesbar, und geraten wird nicht.
const person = {
externeId: GUID.huber,
vorname: "Maria",
nachname: "Huber",
anzeigename: "Huber, Maria",
mail: null,
upn: null,
sam: null,
dn: "CN=Maria Huber,OU=BH,DC=firma,DC=test",
abteilung: null,
titel: null,
vorgesetzterDn: "CN=Unbekannt,OU=Extern,DC=firma,DC=test",
};
expect(vorgesetzteAufloesen([person])[0]?.vorgesetzterExterneId).toBeNull();
});
});
// ---- 2. Der Service gegen die Datenbank -----------------------------------
describe("Zuordnung und Zertifikat aus dem Formular", () => {
it("nimmt gefuellte Felder und laesst leere der Vorgabe", () => {
const ergebnis = zuordnungAusEingabe({ externeId: " entryUUID ", sam: "", titel: "title" });
expect(ergebnis).toEqual({ zuordnung: { externeId: "entryUUID", titel: "title" } });
});
it("weist ab, was kein Attributname ist", () => {
for (const krumm of ["(objectClass=*)", "cn;binary", "2fach", "-sn", "sn sn"]) {
const ergebnis = zuordnungAusEingabe({ nachname: krumm });
expect(ergebnis, krumm).toHaveProperty("fehler");
}
});
it("nennt in der Meldung das Feld, damit klar ist, welche Zeile gemeint ist", () => {
const ergebnis = zuordnungAusEingabe({ externeId: "(guid)" });
expect("fehler" in ergebnis && ergebnis.fehler).toContain("Kennung");
});
it("unterscheidet beim Zertifikat unveraendert, neu und entfernen", () => {
// Leeres Feld, kein Kaestchen: das hinterlegte bleibt – deshalb `undefined`.
expect(zertifikatWahl("", false)).toEqual({ wert: undefined });
expect(zertifikatWahl(" ", false)).toEqual({ wert: undefined });
expect(zertifikatWahl("-----BEGIN CERTIFICATE-----", false)).toEqual({
wert: "-----BEGIN CERTIFICATE-----",
});
// Kaestchen: leere Zeichenkette, und der Dienst macht daraus `null`.
expect(zertifikatWahl("", true)).toEqual({ wert: "" });
expect(zertifikatWahl("-----BEGIN CERTIFICATE-----", true)).toHaveProperty("fehler");
});
});
describe.runIf(datenbankVerfuegbar)("Verzeichnisquellen", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("legt eine Quelle mit den Vorgaben an", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe({ name: " Firmen-AD " }), admin);
expect(quelle.name).toBe("Firmen-AD");
expect(quelle.filter).toBe(STANDARD_FILTER);
expect(quelle.attributZuordnung).toEqual(AD_ZUORDNUNG);
expect(quelle.abgleichIntervallMinuten).toBe(60);
// Eine neue Quelle ist zunaechst abgeschaltet: erst Verbindung testen, dann
// den Zeitplan darauf loslassen.
expect(quelle.aktiv).toBe(false);
expect(quelle.caZertifikatVorhanden).toBe(false);
expect(quelle.letzterAbgleichAm).toBeNull();
});
it("uebernimmt einzelne abweichende Attributnamen", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(
quellenEingabe({ attributZuordnung: { anzeigename: "cn", externeId: "entryUUID" } }),
admin,
);
expect(quelle.attributZuordnung).toEqual({
...AD_ZUORDNUNG,
anzeigename: "cn",
externeId: "entryUUID",
});
});
it("verlangt Name, Adresse, Bind-DN, Passwort und Basis-DN", async () => {
const { admin } = await grundbestand();
await expect(quelleAnlegen(quellenEingabe({ name: " " }), admin)).rejects.toThrow(VerzeichnisFehler);
await expect(quelleAnlegen(quellenEingabe({ bindDn: "" }), admin)).rejects.toThrow(/Bind-DN/u);
await expect(quelleAnlegen(quellenEingabe({ bindPasswort: "" }), admin)).rejects.toThrow(
/Bind-Passwort/u,
);
await expect(quelleAnlegen(quellenEingabe({ basisDn: "" }), admin)).rejects.toThrow(/Basis-DN/u);
await expect(quelleAnlegen(quellenEingabe({ url: "ftp://dc1" }), admin)).rejects.toThrow(
VerbindungsFehler,
);
});
it("weist ein Intervall ausserhalb der Grenzen ab", async () => {
const { admin } = await grundbestand();
await expect(quelleAnlegen(quellenEingabe({ abgleichIntervallMinuten: 1 }), admin)).rejects.toThrow(
/Intervall/u,
);
await expect(quelleAnlegen(quellenEingabe({ abgleichIntervallMinuten: 99999 }), admin)).rejects.toThrow(
/Intervall/u,
);
await expect(quelleAnlegen(quellenEingabe({ abgleichIntervallMinuten: 10.5 }), admin)).rejects.toThrow(
/Intervall/u,
);
const quelle = await quelleAnlegen(quellenEingabe({ abgleichIntervallMinuten: 15 }), admin);
expect(quelle.abgleichIntervallMinuten).toBe(15);
});
it("verlangt beim CA-Zertifikat die PEM-Huelle", async () => {
const { admin } = await grundbestand();
await expect(quelleAnlegen(quellenEingabe({ caZertifikatPem: "MIIB…" }), admin)).rejects.toThrow(/PEM/u);
const quelle = await quelleAnlegen(
quellenEingabe({
caZertifikatPem: "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----",
}),
admin,
);
// Die Oberflaeche erfaehrt nur, **dass** eines hinterlegt ist.
expect(quelle.caZertifikatVorhanden).toBe(true);
expect(JSON.stringify(quelle)).not.toContain("BEGIN CERTIFICATE");
});
it("nimmt ein hinterlegtes CA-Zertifikat auf Wunsch wieder weg", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(
quellenEingabe({
caZertifikatPem: "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----",
}),
admin,
);
expect(quelle.caZertifikatVorhanden).toBe(true);
// Eine leere Zeichenkette heisst „entfernen“; `undefined` hiesse
// „unveraendert“ – daran haengt, dass das Formular beides ausdruecken kann.
const ohne = await quelleBearbeiten(quelle.id, { caZertifikatPem: "" }, admin);
expect(ohne.caZertifikatVorhanden).toBe(false);
const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
expect(zeile.caZertifikatPem).toBeNull();
// Und ein Speichern ohne Angabe laesst es, wie es ist.
await quelleBearbeiten(
quelle.id,
{ caZertifikatPem: "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----" },
admin,
);
const wieder = await quelleBearbeiten(quelle.id, { name: "Anderer Name" }, admin);
expect(wieder.caZertifikatVorhanden).toBe(true);
});
it("verlangt das Recht verwaltung", async () => {
const { technikerin } = await grundbestand();
// Die Aktion prueft es auch; hier steht das zweite Netz, an dem auch der
// Zeitplan und ein Skript nicht vorbeikommen.
await expect(quelleAnlegen(quellenEingabe(), technikerin)).rejects.toThrow(/verwaltung/u);
});
it("ist im Modus Dienstleistung abgeschaltet", async () => {
const { admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
await expect(quelleAnlegen(quellenEingabe(), admin)).rejects.toThrow(/Dienstleistung/u);
// Auch die Lesemodelle zeigen dort nichts – nicht einmal eine Quelle aus
// einer frueheren Zeit im Modus INTERN.
expect(await quellenListe(admin)).toEqual([]);
});
it("bearbeitet einzelne Felder und protokolliert die Aenderung", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
const geaendert = await quelleBearbeiten(
quelle.id,
{ name: "AD Hauptstelle", aktiv: true, abgleichIntervallMinuten: 30 },
admin,
);
expect(geaendert.name).toBe("AD Hauptstelle");
expect(geaendert.aktiv).toBe(true);
expect(geaendert.abgleichIntervallMinuten).toBe(30);
const protokoll = await testPrisma.protokoll.findMany({
where: { entitaet: "Verzeichnisquelle", entitaetId: quelle.id },
});
expect(protokoll).toHaveLength(2);
expect(protokoll.map((zeile) => zeile.aktion)).toContain("Verzeichnisquelle angelegt");
});
it("laesst das Passwort stehen, wenn das Feld leer bleibt", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
const vorher = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
// Ein Formular kann das Passwort nicht anzeigen und schickt darum eine leere
// Zeichenkette. Waere das „loeschen“, waere die Quelle nach dem ersten
// Umbenennen kaputt.
await quelleBearbeiten(quelle.id, { bindPasswort: "", name: "Neu" }, admin);
const nachher = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
expect(entschluesseln(nachher.bindPasswortVerschluesselt)).toBe(LDAP_BIND_PASSWORT);
expect(nachher.bindPasswortVerschluesselt).toBe(vorher.bindPasswortVerschluesselt);
await quelleBearbeiten(quelle.id, { bindPasswort: "ein-neues-geheimnis" }, admin);
const dritte = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
expect(entschluesseln(dritte.bindPasswortVerschluesselt)).toBe("ein-neues-geheimnis");
});
it("gibt das Bind-Passwort in keiner Antwort und in keinem Protokoll heraus", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
await quelleBearbeiten(quelle.id, { aktiv: true }, admin);
const liste = await quellenListe(admin);
const einzeln = await quelleHolen(quelle.id, admin);
const protokoll = await testPrisma.protokoll.findMany({ where: { entitaetId: quelle.id } });
const alles = JSON.stringify({ quelle, liste, einzeln, protokoll });
expect(alles).not.toContain(LDAP_BIND_PASSWORT);
expect(alles).not.toContain("bindPasswort");
// Auch der verschluesselte Wert hat in einer Antwort nichts zu suchen.
expect(alles).not.toContain("bindPasswortVerschluesselt");
const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
expect(zeile.bindPasswortVerschluesselt).not.toContain(LDAP_BIND_PASSWORT);
expect(zeile.bindPasswortVerschluesselt).toMatch(/^v1\./u);
expect(entschluesseln(zeile.bindPasswortVerschluesselt)).toBe(LDAP_BIND_PASSWORT);
});
it("loescht eine Quelle ohne Mitarbeiter und verweigert es sonst", async () => {
const { admin, eigene } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
quelleId: quelle.id,
externeId: GUID.huber,
vorname: "Maria",
nachname: "Huber",
anzeigename: "Huber, Maria",
herkunft: "ABGLEICH",
},
});
await expect(quelleLoeschen(quelle.id, admin)).rejects.toThrow(/hängen 1 Mitarbeiter/u);
await testPrisma.mitarbeiter.deleteMany({ where: { quelleId: quelle.id } });
await quelleLoeschen(quelle.id, admin);
expect(await testPrisma.verzeichnisquelle.count()).toBe(0);
// Das Protokoll bleibt, auch ohne die Zeile, auf die es zeigt.
expect(await testPrisma.protokoll.count({ where: { aktion: "Verzeichnisquelle gelöscht" } })).toBe(1);
});
it("fasst eine Quelle eines fremden Kunden nicht an", async () => {
const { admin } = await grundbestand();
// Anlegen kann so eine Zeile niemand – `quelleAnlegen` laeuft nur im Modus
// INTERN und nimmt dort immer die eigene Organisation. Entstehen kann sie
// trotzdem: durch eine Migration, `psql` oder ein Skript. „Gilt nur fuer die
// eigene Organisation“ waere sonst eine Zusage, die nur fuer den Weg durch
// das Formular gilt.
const fremder = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } });
const fremde = await testPrisma.verzeichnisquelle.create({
data: {
kundeId: fremder.id,
name: "Fremdes AD",
url: "ldaps://dc1.fremd.local:636",
bindDn: "cn=dienst,dc=fremd,dc=local",
bindPasswortVerschluesselt: "v1.aaaa.bbbb.cccc",
basisDn: "dc=fremd,dc=local",
filter: STANDARD_FILTER,
attributZuordnung: { ...AD_ZUORDNUNG },
},
});
await expect(quelleBearbeiten(fremde.id, { name: "X" }, admin)).rejects.toThrow(/eigenen Organisation/u);
await expect(quelleLoeschen(fremde.id, admin)).rejects.toThrow(/eigenen Organisation/u);
await expect(verbindungTesten(fremde.id, admin)).rejects.toThrow(/eigenen Organisation/u);
});
it("liefert eine unbekannte Quelle als „nicht vorhanden“", async () => {
const { admin } = await grundbestand();
expect(await quelleHolen("gibtesnicht", admin)).toBeUndefined();
await expect(quelleBearbeiten("gibtesnicht", { name: "X" }, admin)).rejects.toThrow(VerzeichnisFehler);
});
});
// ---- 3. Gegen den echten Container ----------------------------------------
describe.runIf(datenbankVerfuegbar && ldapDa)("Verbindung testen gegen das Verzeichnis", () => {
const protokolliert: string[] = [];
beforeEach(async () => {
await datenbankLeeren();
protokolliert.length = 0;
// Die Zusage „kein Server-Log enthaelt das Bind-Passwort“ wird hier wirklich
// gemessen und nicht behauptet.
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(() => {
vi.restoreAllMocks();
});
it("zeigt die gemappten Felder der Fixture-Personen", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
const { treffer, gesamtGelesen } = await verbindungTesten(quelle.id, admin);
// Vier Eintraege liefert der Filter, drei davon haben eine Kennung.
expect(gesamtGelesen).toBe(FIXTURE_GELESEN);
expect(treffer).toHaveLength(FIXTURE_PERSONEN);
const huber = treffer.find((eintrag) => eintrag.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",
// Umlaute kommen als UTF-8 durch, nicht als Fragezeichen.
titel: "Leiterin Rechnungsprüfung",
dn: "cn=Maria Huber,ou=Buchhaltung,ou=Benutzer,dc=firma,dc=test",
// Der Chef ist in derselben Probe, deshalb aufgeloest.
vorgesetzterExterneId: GUID.berger,
});
expect(treffer.find((eintrag) => eintrag.sam === "jweiss")).toMatchObject({
nachname: "Weiß",
vorname: "Jörg",
externeId: GUID.weiss,
});
});
it("laesst das per userAccountControl deaktivierte Konto aus", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
const { treffer } = await verbindungTesten(quelle.id, admin);
// „Alte Kraft“ steht mit userAccountControl 514 im Verzeichnis. Dass der
// Bit-UND-Filter greift, entscheidet sich am Server – hier ist es gemessen.
expect(treffer.map((eintrag) => eintrag.sam)).not.toContain("akraft");
expect(treffer.map((eintrag) => eintrag.externeId)).not.toContain(GUID.alteKraft);
});
it("haelt sich an die Grenze und schreibt nichts", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
const protokollVorher = await testPrisma.protokoll.count();
const { treffer } = await verbindungTesten(quelle.id, admin, 2);
expect(treffer).toHaveLength(2);
// Kein Mitarbeiter, kein Protokoll, kein Abgleichstand: der Test ist eine
// Probe und kein halber Abgleich.
expect(await testPrisma.mitarbeiter.count()).toBe(0);
expect(await testPrisma.protokoll.count()).toBe(protokollVorher);
const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
expect(zeile.letzterAbgleichAm).toBeNull();
expect(zeile.letzterAbgleichStatus).toBeNull();
});
it("findet mit einem eigenen Filter nur die gesuchte Abteilung", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(
quellenEingabe({ filter: "(&(objectClass=user)(department=Vertrieb)(sAMAccountName=*))" }),
admin,
);
const { treffer } = await verbindungTesten(quelle.id, admin);
expect(treffer.map((eintrag) => eintrag.sam).sort()).toEqual(["akraft", "jweiss"]);
});
it("liest den ganzen Baum ab dem Basis-DN", async () => {
const { admin } = await grundbestand();
// Die Personen liegen in Unter-OUs; mit scope „one“ waere die Liste leer.
const quelle = await quelleAnlegen(quellenEingabe({ basisDn: LDAP_BASIS_DN }), admin);
const { treffer } = await verbindungTesten(quelle.id, admin);
expect(treffer).toHaveLength(FIXTURE_PERSONEN);
});
it("nennt einen falschen Bind-DN, ohne das Passwort zu verraten", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe({ bindPasswort: "falsches-passwort-xyz" }), admin);
await expect(verbindungTesten(quelle.id, admin)).rejects.toThrow(VerbindungsFehler);
const fehler = await verbindungTesten(quelle.id, admin).catch((f: unknown) => f);
expect(String(fehler)).not.toContain("falsches-passwort-xyz");
// Und auch nichts, was die Bibliothek unterwegs auf die Konsole geschrieben
// haette.
expect(protokolliert.join("\n")).not.toContain("falsches-passwort-xyz");
});
it("meldet einen unerreichbaren Server als Fehler und nicht als leeres Ergebnis", async () => {
const { admin } = await grundbestand();
// Ein leeres Ergebnis waere die gefaehrlichste Antwort: der Abgleich wuerde
// daraus schliessen, dass alle gegangen sind.
const quelle = await quelleAnlegen(quellenEingabe({ url: "ldap://127.0.0.1:1" }), admin);
await expect(verbindungTesten(quelle.id, admin)).rejects.toThrow(VerbindungsFehler);
});
});
describe.runIf(datenbankVerfuegbar)("Abgleich-Log", () => {
beforeEach(datenbankLeeren);
/** Ein Protokolleintrag, wie ihn ein Abgleich schreibt. */
async function logEintrag(
quelleId: string,
nachher: Record<string, number | string>,
benutzerId: string | null,
gescheitert = false,
) {
return testPrisma.protokoll.create({
data: {
entitaet: "Verzeichnisquelle",
entitaetId: quelleId,
aktion: gescheitert ? "Verzeichnisabgleich fehlgeschlagen" : "Verzeichnis abgeglichen",
benutzerId,
nachher,
},
});
}
it("zeigt die Laeufe mit ihren Zahlen, der neueste zuerst", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
await logEintrag(
quelle.id,
{ gelesen: 4, angelegt: 3, uebernommen: 0, aktualisiert: 0, deaktiviert: 0, uebersprungen: 1 },
admin.id,
);
await logEintrag(quelle.id, { gelesen: 0, fehler: "Anmeldung abgelehnt" }, null, true);
const zeilen = await abgleichLog(quelle.id, admin);
expect(zeilen).toHaveLength(2);
expect(zeilen[0]).toMatchObject({ gescheitert: true, fehler: "Anmeldung abgelehnt", durch: null });
expect(zeilen[1]).toMatchObject({
gescheitert: false,
gelesen: 4,
angelegt: 3,
uebersprungen: 1,
durch: "Admin",
});
});
it("nimmt nur die Abgleich-Eintraege dieser Quelle", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
const andere = await quelleAnlegen(quellenEingabe({ name: "Zweites AD" }), admin);
await logEintrag(quelle.id, { gelesen: 1 }, admin.id);
await logEintrag(andere.id, { gelesen: 99 }, admin.id);
// Auch das Anlegen der Quelle steht im Protokoll – es ist kein Abgleich.
const zeilen = await abgleichLog(quelle.id, admin);
expect(zeilen).toHaveLength(1);
expect(zeilen[0]?.gelesen).toBe(1);
});
it("haelt sich an dieselbe Grenze wie die Quellenliste", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
for (let lauf = 0; lauf < 12; lauf += 1) {
await logEintrag(quelle.id, { gelesen: lauf }, admin.id);
}
expect(await abgleichLog(quelle.id, admin)).toHaveLength(LOG_ZEILEN);
expect(await abgleichLog(quelle.id, admin, 3)).toHaveLength(3);
});
it("ist im Modus Dienstleistung leer, auch wenn Eintraege stehen", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
await logEintrag(quelle.id, { gelesen: 4 }, admin.id);
await testPrisma.einstellung.update({
where: { id: "global" },
data: { modus: Betriebsmodus.DIENSTLEISTUNG },
});
expect(await abgleichLog(quelle.id, admin)).toEqual([]);
});
it("gibt zu einer unbekannten Quelle nichts heraus", async () => {
const { admin } = await grundbestand();
expect(await abgleichLog("gibtsnicht", admin)).toEqual([]);
});
});
+23 -2
View File
@@ -575,7 +575,7 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Versionierung", () => {
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, benutzer.id);
await Promise.allSettled([
const [veroeffentlichung, speichern] = await Promise.allSettled([
versionVeroeffentlichen(entwurf.id, "Veröffentlicht", benutzer.id),
entwurfSpeichern(entwurf.id, {
variablen: [],
@@ -584,9 +584,30 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Versionierung", () => {
]);
const danach = await strukturLesen(entwurf.id);
/*
* Welcher der beiden zuerst an die Zeile kommt, entscheidet die Datenbank —
* **beide Ausgaenge** werden hier geprueft. Vorher stand die Erwartung in
* einem `if`: gewann das Speichern das Rennen, lief der Test durch, ohne
* etwas zu belegen, und die eigentliche Zusage waere unbemerkt gefallen
* (Reviewbefund zu #016).
*/
expect(danach.status === "VEROEFFENTLICHT" || danach.status === "ENTWURF").toBe(true);
if (danach.status === "VEROEFFENTLICHT") {
// Wurde veroeffentlicht, darf der nachträgliche Aufbau nicht drinstehen.
expect(veroeffentlichung.status).toBe("fulfilled");
// Veroeffentlicht heisst unveraenderlich: der nachtraegliche Aufbau darf
// nicht drinstehen, und das Speichern muss abgewiesen worden sein.
expect(danach.abschnitte.map((a) => a.titel)).not.toContain("NACHTRAEGLICH");
expect(speichern.status).toBe("rejected");
} else {
// Das Speichern war zuerst da. Dann darf nicht veroeffentlicht worden sein
// – sonst trueege die veroeffentlichte Version genau diesen Abschnitt.
expect(speichern.status).toBe("fulfilled");
expect(veroeffentlichung.status).toBe("rejected");
expect(
await testPrisma.protokoll.count({
where: { entitaetId: entwurf.id, aktion: "Version veröffentlicht" },
}),
).toBe(0);
}
});
+138
View File
@@ -0,0 +1,138 @@
import { afterEach, describe, expect, it, vi } from "vitest";
/**
* Der Zeitplan des Verzeichnis-Abgleichs (#016).
*
* Geprueft wird der **Durchgang**, nicht die Uhr: dass `node-cron` jede Minute
* ausloest, ist Sache der Bibliothek; dass ein Durchgang die faelligen Quellen
* holt, jede unter der Sperre abgleicht und an einem Fehler nicht abbricht, ist
* unsere.
*
* Die Dienste darunter werden ersetzt. Das ist hier richtig und nicht faul: was
* der Abgleich **tut**, prueft `tests/abgleich.test.ts` gegen einen echten
* LDAP-Container. Hier geht es allein um die Ablaufsteuerung — und die laesst
* sich mit echten Diensten nur pruefen, indem man eine Minute wartet und ein
* Verzeichnis danebenstellt.
*/
const abgleichAusfuehren = vi.fn();
const faelligeQuellen = vi.fn();
/** Nur da, um zu belegen, dass der Zeitplan sie **nicht** nimmt. */
const mitAbgleichSperre = vi.fn();
/** Dieselbe Klasse wie im Dienst – `instanceof` entscheidet ueber das Verhalten. */
class AbgleichLaeuftBereitsFehler extends Error {}
vi.mock("@/server/services/abgleich", () => ({
AbgleichLaeuftBereitsFehler,
abgleichAusfuehren: (...args: unknown[]) => abgleichAusfuehren(...args),
faelligeQuellen: (...args: unknown[]) => faelligeQuellen(...args),
mitAbgleichSperre: (...args: unknown[]) => mitAbgleichSperre(...args),
}));
const { abgleichDurchgang, zeitplanAnhalten, zeitplanLaeuft, zeitplanStarten } =
await import("@/server/zeitplan/abgleich-plan");
afterEach(() => {
zeitplanAnhalten();
vi.clearAllMocks();
vi.restoreAllMocks();
});
describe("Ein Durchgang des Zeitplans", () => {
it("gleicht jede fällige Quelle ab", async () => {
faelligeQuellen.mockResolvedValue([
{ id: "q1", name: "Firma AD" },
{ id: "q2", name: "Zweigstelle" },
]);
abgleichAusfuehren.mockResolvedValue({ status: "ERFOLG" });
await abgleichDurchgang();
expect(abgleichAusfuehren).toHaveBeenCalledTimes(2);
expect(abgleichAusfuehren.mock.calls.map((aufruf) => aufruf[0])).toEqual(["q1", "q2"]);
/*
* **Ohne eigene Sperre.** Der Dienst nimmt sie selbst, in der Datenbank und
* damit ueber Instanzen hinweg. Eine zweite hier traege einen anderen
* Schluessel, liefe an der ersten vorbei — und ein von Hand angestossener
* Abgleich saehe fuer den Zeitplan wie ein unerwarteter Fehler aus.
*/
expect(mitAbgleichSperre).not.toHaveBeenCalled();
/*
* Der handelnde „Benutzer“ ist der Systemlauf: ADMIN, damit die Rechtepruefung
* des Dienstes greift, und ohne Id, weil kein Mensch es war. Der Dienst macht
* daraus im Protokoll `benutzerId: null`.
*/
const zweitesArgument = abgleichAusfuehren.mock.calls[0]?.[1];
expect(zweitesArgument).toEqual({ id: "", rolle: "ADMIN" });
});
it("überspringt still, was gerade schon abgeglichen wird", async () => {
/*
* Die belegte Sperre meldet der Dienst als eigene Ausnahme. Im Normalbetrieb
* mit zwei Instanzen — oder waehrend jemand „Jetzt abgleichen“ drueckt —
* waere das jede Minute eine Zeile im Protokoll, deshalb weder Fehler noch
* Ausgabe.
*/
faelligeQuellen.mockResolvedValue([
{ id: "q1", name: "Firma AD" },
{ id: "q2", name: "Zweigstelle" },
]);
abgleichAusfuehren.mockImplementation(async (id: string) => {
if (id === "q1") throw new AbgleichLaeuftBereitsFehler("läuft schon");
return { status: "ERFOLG" };
});
const fehlerAusgabe = vi.spyOn(console, "error").mockImplementation(() => {});
await abgleichDurchgang();
// Und die naechste Quelle kommt trotzdem dran.
expect(abgleichAusfuehren).toHaveBeenCalledTimes(2);
expect(fehlerAusgabe).not.toHaveBeenCalled();
});
it("macht nach einem unerwarteten Fehler mit der nächsten Quelle weiter", async () => {
/*
* Der Fall, der den Zeitplan sonst still stillegt: eine Quelle wirft etwas,
* das der Dienst nicht abgefangen hat. Ohne diese Schleife stuende danach
* der Abgleich **aller** Quellen — bis jemand den Prozess neu startet.
*/
faelligeQuellen.mockResolvedValue([
{ id: "kaputt", name: "Antwortet nicht" },
{ id: "q2", name: "Zweigstelle" },
]);
abgleichAusfuehren.mockImplementation(async (id: string) => {
if (id === "kaputt") throw new Error("Netz weg");
return { status: "ERFOLG" };
});
const fehlerAusgabe = vi.spyOn(console, "error").mockImplementation(() => {});
await abgleichDurchgang();
expect(abgleichAusfuehren).toHaveBeenCalledTimes(2);
expect(abgleichAusfuehren.mock.calls[1]?.[0]).toBe("q2");
expect(fehlerAusgabe).toHaveBeenCalledOnce();
// Der Name der Quelle gehört in die Meldung, sonst sucht jemand im Dunkeln.
expect(String(fehlerAusgabe.mock.calls[0]?.[0])).toContain("Antwortet nicht");
});
it("tut nichts, wenn keine Quelle fällig ist", async () => {
faelligeQuellen.mockResolvedValue([]);
await abgleichDurchgang();
expect(abgleichAusfuehren).not.toHaveBeenCalled();
});
});
describe("Der Zeitplan selbst", () => {
it("startet genau einmal", () => {
// Next laedt Module im Entwicklungsmodus mehrfach; ein zweiter Zeitplan
// liefe unbemerkt mit und verdoppelte jeden Durchgang.
expect(zeitplanLaeuft()).toBe(false);
zeitplanStarten();
zeitplanStarten();
expect(zeitplanLaeuft()).toBe(true);
zeitplanAnhalten();
expect(zeitplanLaeuft()).toBe(false);
});
});