feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log (#30)
This commit was merged in pull request #30.
This commit is contained in:
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Vendored
+161
@@ -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
@@ -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
@@ -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 ) )
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user