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

This commit was merged in pull request #30.
This commit is contained in:
2026-09-26 00:21:56 +00:00
parent ab5777a57e
commit ae290345b7
57 changed files with 6834 additions and 61 deletions
+824
View File
@@ -0,0 +1,824 @@
import type { Entry } from "ldapts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Betriebsmodus } from "@/generated/prisma/enums";
import { entschluesseln } from "@/lib/geheimnisse";
import { zertifikatWahl, zuordnungAusEingabe } from "@/lib/verzeichnis";
import { fehlerMeldung, tlsOptionenBauen, urlPruefen, VerbindungsFehler } from "@/server/ldap/verbindung";
import {
AD_ZUORDNUNG,
angeforderteAttribute,
dnSchluessel,
eintragZuordnen,
STANDARD_FILTER,
vorgesetzteAufloesen,
zuordnungLesen,
} from "@/server/ldap/zuordnung";
import { abgleichLog, LOG_ZEILEN, quelleHolen, quellenListe } from "@/server/queries/verzeichnis";
import {
quelleAnlegen,
quelleBearbeiten,
quelleLoeschen,
verbindungTesten,
VerzeichnisFehler,
} from "@/server/services/verzeichnis";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
import {
FIXTURE_GELESEN,
FIXTURE_PERSONEN,
GUID,
LDAP_BASIS_DN,
LDAP_BENUTZER_DN,
LDAP_BIND_DN,
LDAP_BIND_PASSWORT,
LDAP_URL,
ldapVerfuegbar,
} from "./fixtures/ldap/container";
/**
* Verzeichnisquellen und das Lesen aus dem Verzeichnis (#016).
*
* Drei Arten von Pruefungen stehen hier, und die Trennung ist Absicht:
*
* 1. **Reine Funktionen** — Adressen, TLS-Optionen, Attributzuordnung,
* Fehlermeldungen. Ohne Netz und ohne Datenbank, deshalb vollstaendig in den
* Sonderfaellen.
* 2. **Der Service gegen die Datenbank** — Rechte, Betriebsmodus, und dass das
* Bind-Passwort nirgends herauskommt.
* 3. **Gegen den echten Container** — dass aus einem AD-aehnlichen Verzeichnis
* wirklich die erwarteten Felder werden. Das ist der Teil, den keine
* Attrappe ersetzt: `explicitBufferAttributes`, die Umsortierung der GUID und
* der Bit-UND-Filter entscheiden sich erst am Server.
*/
const ldapDa = await ldapVerfuegbar();
async function grundbestand(modus: Betriebsmodus = Betriebsmodus.INTERN) {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus } });
const admin = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
});
const technikerin = await testPrisma.benutzer.create({
data: { email: "tina@firma.example", name: "Tina", rolle: "TECHNIKER" },
});
return {
eigene,
admin: { id: admin.id, rolle: admin.rolle },
technikerin: { id: technikerin.id, rolle: technikerin.rolle },
};
}
/** Eine Quelle, die auf den Testcontainer zeigt. */
function quellenEingabe(abweichend: Record<string, unknown> = {}) {
return {
name: "Firmen-AD",
url: LDAP_URL,
bindDn: LDAP_BIND_DN,
bindPasswort: LDAP_BIND_PASSWORT,
basisDn: LDAP_BENUTZER_DN,
...abweichend,
};
}
// ---- 1. Reine Funktionen ---------------------------------------------------
describe("Adressen", () => {
it("nimmt ldaps, ldap und das StartTLS-Schema", () => {
expect(urlPruefen("ldaps://dc1.firma.local:636")).toEqual({
url: "ldaps://dc1.firma.local:636",
startTls: false,
verschluesselt: true,
});
expect(urlPruefen("ldap://dc1.firma.local:389")).toEqual({
url: "ldap://dc1.firma.local:389",
startTls: false,
verschluesselt: false,
});
});
it("macht aus ldap+starttls eine ldap-Adresse und merkt sich die Umschaltung", () => {
// ldapts kennt nur ldap und ldaps; StartTLS geschieht bei uns nach dem
// Verbinden. Die getippte Form bleibt in der Datenbank stehen, damit noch
// erkennbar ist, dass StartTLS gewuenscht war.
expect(urlPruefen("ldap+starttls://dc1.firma.local:389")).toEqual({
url: "ldap://dc1.firma.local:389",
startTls: true,
verschluesselt: true,
});
});
it("weist ein fremdes Schema, einen fehlenden Rechner und einen Pfad ab", () => {
expect(() => urlPruefen("https://dc1.firma.local")).toThrow(VerbindungsFehler);
expect(() => urlPruefen("dc1.firma.local:389")).toThrow(VerbindungsFehler);
expect(() => urlPruefen("ldaps://")).toThrow(/Rechnername/u);
expect(() => urlPruefen("ldaps:///dc=firma,dc=local")).toThrow(/Rechnername/u);
expect(() => urlPruefen("ldaps://dc1.firma.local/dc=firma,dc=local")).toThrow(/Pfad/u);
});
it("weist Zugangsdaten in der Adresse ab", () => {
// Sonst stuende das Passwort im Klartext in der Datenbank – verschluesselt
// wird nur das Feld, das dafuer da ist.
expect(() => urlPruefen("ldaps://dienst:geheim@dc1.firma.local:636")).toThrow(/Zugangsdaten/u);
});
});
describe("TLS-Optionen", () => {
it("verlangt mindestens TLS 1.2", () => {
expect(tlsOptionenBauen(null).minVersion).toBe("TLSv1.2");
});
it("nimmt ein eigenes CA-Zertifikat auf", () => {
const pem = "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----";
expect(tlsOptionenBauen(pem).ca).toEqual([pem]);
});
it("laesst ca weg, wenn keines hinterlegt ist – dann gelten die Systemwurzeln", () => {
expect(tlsOptionenBauen(null).ca).toBeUndefined();
expect(tlsOptionenBauen(" ").ca).toBeUndefined();
});
it("hat keinen Schalter, der die Pruefung des Hostnamens abstellt", () => {
// Ein solcher Schalter waere genau der, der in der Eile gesetzt bleibt.
const optionen = tlsOptionenBauen(null) as Record<string, unknown>;
expect(optionen["rejectUnauthorized"]).toBeUndefined();
expect(optionen["checkServerIdentity"]).toBeUndefined();
});
});
describe("Fehlermeldungen", () => {
const passwort = "S3hr-geheim!";
it("entfernt das Bind-Passwort restlos", () => {
const meldung = fehlerMeldung(new Error(`bind failed for cn=dienst with ${passwort}`), [passwort]);
expect(meldung).not.toContain(passwort);
expect(meldung).toContain("***");
});
it("entfernt es auch mehrfach und mitten im Wort", () => {
const meldung = fehlerMeldung(new Error(`${passwort}/${passwort}`), [passwort]);
expect(meldung).toBe("***/***");
});
it("macht eine Zeile daraus und kuerzt", () => {
const meldung = fehlerMeldung(new Error(`erste\n zweite\tdritte`), []);
expect(meldung).toBe("erste zweite dritte");
expect(fehlerMeldung(new Error("x".repeat(900)), []).length).toBeLessThanOrEqual(401);
});
it("kommt mit allem zurecht, was geworfen werden kann", () => {
expect(fehlerMeldung("nur Text", [])).toBe("nur Text");
expect(fehlerMeldung(undefined, [])).toBe("undefined");
expect(fehlerMeldung(new Error(" "), [])).toBe("Unbekannter Fehler beim Zugriff auf das Verzeichnis.");
});
});
describe("Attributzuordnung", () => {
it("benennt die AD-Vorgaben aus dem Auftrag", () => {
expect(AD_ZUORDNUNG).toEqual({
vorname: "givenName",
nachname: "sn",
anzeigename: "displayName",
mail: "mail",
upn: "userPrincipalName",
sam: "sAMAccountName",
abteilung: "department",
titel: "title",
vorgesetzter: "manager",
externeId: "objectGUID",
});
});
it("schliesst im Standardfilter die deaktivierten Konten aus", () => {
expect(STANDARD_FILTER).toBe(
"(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))",
);
});
it("fuellt fehlende und leere Felder mit der Vorgabe", () => {
expect(zuordnungLesen({ anzeigename: "cn", mail: " " })).toEqual({
...AD_ZUORDNUNG,
anzeigename: "cn",
});
expect(zuordnungLesen(null)).toEqual(AD_ZUORDNUNG);
expect(zuordnungLesen("Unsinn")).toEqual(AD_ZUORDNUNG);
expect(zuordnungLesen({ vorname: 7 })).toEqual(AD_ZUORDNUNG);
});
it("fragt nur die zugeordneten Attribute an, und jedes nur einmal", () => {
const attribute = angeforderteAttribute({ ...AD_ZUORDNUNG, upn: "mail" });
expect(attribute).toContain("objectGUID");
expect(attribute).not.toContain("memberOf");
expect(attribute.filter((name) => name === "mail")).toHaveLength(1);
});
it("uebersetzt einen Eintrag und wandelt den GUID-Puffer", () => {
const ergebnis = eintragZuordnen(
{
dn: "CN=Maria Huber,OU=Buchhaltung,DC=firma,DC=test",
givenName: "Maria",
sn: "Huber",
displayName: "Huber, Maria",
mail: "maria.huber@firma.test",
userPrincipalName: "maria.huber@firma.test",
sAMAccountName: "mhuber",
department: "Buchhaltung",
title: "Leiterin",
manager: "CN=Klaus Berger,OU=GF,DC=firma,DC=test",
objectGUID: Buffer.from("EEMHXzU8HUqDb6UPUV56NQ==", "base64"),
} satisfies Entry,
AD_ZUORDNUNG,
);
expect(ergebnis).toEqual({
eintrag: {
externeId: GUID.huber,
vorname: "Maria",
nachname: "Huber",
anzeigename: "Huber, Maria",
mail: "maria.huber@firma.test",
upn: "maria.huber@firma.test",
sam: "mhuber",
dn: "CN=Maria Huber,OU=Buchhaltung,DC=firma,DC=test",
abteilung: "Buchhaltung",
titel: "Leiterin",
vorgesetzterDn: "CN=Klaus Berger,OU=GF,DC=firma,DC=test",
},
});
});
it("nimmt bei einem mehrwertigen Attribut den ersten Wert und macht aus leer null", () => {
const ergebnis = eintragZuordnen(
{
dn: "CN=Zwei,DC=firma,DC=test",
givenName: ["Anna", "Annemarie"],
sn: "Zwei",
displayName: "",
mail: " ",
objectGUID: Buffer.from("AAECAwQFBgcICQoLDA0ODw==", "base64"),
} satisfies Entry,
AD_ZUORDNUNG,
);
expect(ergebnis).toMatchObject({
eintrag: { vorname: "Anna", mail: null, upn: null, titel: null, vorgesetzterDn: null },
});
});
it("baut einen Anzeigenamen, wenn keiner im Verzeichnis steht", () => {
// `Mitarbeiter.anzeigename` ist nicht optional, und eine leere Zeile in der
// Auswahlliste waere fuer die Technikerin unbrauchbar.
const mitNamen = eintragZuordnen(
{ dn: "CN=X,DC=f,DC=t", givenName: "Maria", sn: "Huber", objectGUID: "abc" } satisfies Entry,
AD_ZUORDNUNG,
);
expect(mitNamen).toMatchObject({ eintrag: { anzeigename: "Huber, Maria" } });
const ohneNamen = eintragZuordnen(
{ dn: "CN=Nur der DN,OU=x,DC=f,DC=t", objectGUID: "abc" } satisfies Entry,
AD_ZUORDNUNG,
);
expect(ohneNamen).toMatchObject({ eintrag: { anzeigename: "Nur der DN", vorname: "", nachname: "" } });
});
it("uebersetzt eine Textkennung, ohne sie fuer eine GUID zu halten", () => {
// Wer `externeId` auf `entryUUID` eines OpenLDAP legt, bekommt 36 Textbytes.
const ergebnis = eintragZuordnen(
{
dn: "CN=Y,DC=f,DC=t",
objectGUID: Buffer.from("c0ffee00-1111-2222-3333-444455556666", "utf8"),
} satisfies Entry,
AD_ZUORDNUNG,
);
expect(ergebnis).toMatchObject({ eintrag: { externeId: "c0ffee00-1111-2222-3333-444455556666" } });
});
it("ueberspringt einen Eintrag ohne Kennung statt abzubrechen", () => {
// Ein einzelnes krummes Konto darf nicht den Abgleich aller anderen
// verhindern – ohne Kennung waere es aber bei jedem Lauf wieder neu.
const ohne = eintragZuordnen({ dn: "CN=Ohne,DC=f,DC=t", sn: "Ohne" } satisfies Entry, AD_ZUORDNUNG);
expect(ohne).toEqual({
uebersprungen: { dn: "CN=Ohne,DC=f,DC=t", grund: expect.stringContaining("objectGUID") },
});
// Sieben Bytes sind eine verstuemmelte GUID, keine Kennung. Sie als Text
// durchzulassen hiesse: eine Kennung aus Steuerzeichen, die zu keinem Konto
// im Verzeichnis passt und trotzdem kein Duplikat erzeugt – ein Fehler, der
// nie auffaellt.
const kaputt = eintragZuordnen(
{ dn: "CN=Kaputt,DC=f,DC=t", objectGUID: Buffer.alloc(7) } satisfies Entry,
AD_ZUORDNUNG,
);
expect(kaputt).toMatchObject({ uebersprungen: { dn: "CN=Kaputt,DC=f,DC=t" } });
});
it("loest den Vorgesetzten ueber den DN auf, auch bei anderer Schreibweise", () => {
const chef = {
externeId: GUID.berger,
vorname: "Klaus",
nachname: "Berger",
anzeigename: "Berger, Klaus",
mail: null,
upn: null,
sam: "kberger",
dn: "CN=Klaus Berger,OU=GF,DC=firma,DC=test",
abteilung: null,
titel: null,
vorgesetzterDn: null,
};
const person = {
...chef,
externeId: GUID.huber,
dn: "CN=Maria Huber,OU=BH,DC=firma,DC=test",
// Klein geschrieben und mit Leerzeichen hinter den Kommas.
vorgesetzterDn: "cn=klaus berger, ou=gf, dc=firma, dc=test",
};
const [aufgeloest] = vorgesetzteAufloesen([person, chef]);
expect(aufgeloest?.vorgesetzterExterneId).toBe(GUID.berger);
expect(dnSchluessel("CN=A, OU=B")).toBe("cn=a,ou=b");
});
it("laesst den Vorgesetzten leer, wenn er nicht mitgelesen wurde", () => {
// Lieber kein Vorgesetzter als ein falscher: der DN ist ausserhalb des
// Suchbereichs nicht aufloesbar, und geraten wird nicht.
const person = {
externeId: GUID.huber,
vorname: "Maria",
nachname: "Huber",
anzeigename: "Huber, Maria",
mail: null,
upn: null,
sam: null,
dn: "CN=Maria Huber,OU=BH,DC=firma,DC=test",
abteilung: null,
titel: null,
vorgesetzterDn: "CN=Unbekannt,OU=Extern,DC=firma,DC=test",
};
expect(vorgesetzteAufloesen([person])[0]?.vorgesetzterExterneId).toBeNull();
});
});
// ---- 2. Der Service gegen die Datenbank -----------------------------------
describe("Zuordnung und Zertifikat aus dem Formular", () => {
it("nimmt gefuellte Felder und laesst leere der Vorgabe", () => {
const ergebnis = zuordnungAusEingabe({ externeId: " entryUUID ", sam: "", titel: "title" });
expect(ergebnis).toEqual({ zuordnung: { externeId: "entryUUID", titel: "title" } });
});
it("weist ab, was kein Attributname ist", () => {
for (const krumm of ["(objectClass=*)", "cn;binary", "2fach", "-sn", "sn sn"]) {
const ergebnis = zuordnungAusEingabe({ nachname: krumm });
expect(ergebnis, krumm).toHaveProperty("fehler");
}
});
it("nennt in der Meldung das Feld, damit klar ist, welche Zeile gemeint ist", () => {
const ergebnis = zuordnungAusEingabe({ externeId: "(guid)" });
expect("fehler" in ergebnis && ergebnis.fehler).toContain("Kennung");
});
it("unterscheidet beim Zertifikat unveraendert, neu und entfernen", () => {
// Leeres Feld, kein Kaestchen: das hinterlegte bleibt – deshalb `undefined`.
expect(zertifikatWahl("", false)).toEqual({ wert: undefined });
expect(zertifikatWahl(" ", false)).toEqual({ wert: undefined });
expect(zertifikatWahl("-----BEGIN CERTIFICATE-----", false)).toEqual({
wert: "-----BEGIN CERTIFICATE-----",
});
// Kaestchen: leere Zeichenkette, und der Dienst macht daraus `null`.
expect(zertifikatWahl("", true)).toEqual({ wert: "" });
expect(zertifikatWahl("-----BEGIN CERTIFICATE-----", true)).toHaveProperty("fehler");
});
});
describe.runIf(datenbankVerfuegbar)("Verzeichnisquellen", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("legt eine Quelle mit den Vorgaben an", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe({ name: " Firmen-AD " }), admin);
expect(quelle.name).toBe("Firmen-AD");
expect(quelle.filter).toBe(STANDARD_FILTER);
expect(quelle.attributZuordnung).toEqual(AD_ZUORDNUNG);
expect(quelle.abgleichIntervallMinuten).toBe(60);
// Eine neue Quelle ist zunaechst abgeschaltet: erst Verbindung testen, dann
// den Zeitplan darauf loslassen.
expect(quelle.aktiv).toBe(false);
expect(quelle.caZertifikatVorhanden).toBe(false);
expect(quelle.letzterAbgleichAm).toBeNull();
});
it("uebernimmt einzelne abweichende Attributnamen", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(
quellenEingabe({ attributZuordnung: { anzeigename: "cn", externeId: "entryUUID" } }),
admin,
);
expect(quelle.attributZuordnung).toEqual({
...AD_ZUORDNUNG,
anzeigename: "cn",
externeId: "entryUUID",
});
});
it("verlangt Name, Adresse, Bind-DN, Passwort und Basis-DN", async () => {
const { admin } = await grundbestand();
await expect(quelleAnlegen(quellenEingabe({ name: " " }), admin)).rejects.toThrow(VerzeichnisFehler);
await expect(quelleAnlegen(quellenEingabe({ bindDn: "" }), admin)).rejects.toThrow(/Bind-DN/u);
await expect(quelleAnlegen(quellenEingabe({ bindPasswort: "" }), admin)).rejects.toThrow(
/Bind-Passwort/u,
);
await expect(quelleAnlegen(quellenEingabe({ basisDn: "" }), admin)).rejects.toThrow(/Basis-DN/u);
await expect(quelleAnlegen(quellenEingabe({ url: "ftp://dc1" }), admin)).rejects.toThrow(
VerbindungsFehler,
);
});
it("weist ein Intervall ausserhalb der Grenzen ab", async () => {
const { admin } = await grundbestand();
await expect(quelleAnlegen(quellenEingabe({ abgleichIntervallMinuten: 1 }), admin)).rejects.toThrow(
/Intervall/u,
);
await expect(quelleAnlegen(quellenEingabe({ abgleichIntervallMinuten: 99999 }), admin)).rejects.toThrow(
/Intervall/u,
);
await expect(quelleAnlegen(quellenEingabe({ abgleichIntervallMinuten: 10.5 }), admin)).rejects.toThrow(
/Intervall/u,
);
const quelle = await quelleAnlegen(quellenEingabe({ abgleichIntervallMinuten: 15 }), admin);
expect(quelle.abgleichIntervallMinuten).toBe(15);
});
it("verlangt beim CA-Zertifikat die PEM-Huelle", async () => {
const { admin } = await grundbestand();
await expect(quelleAnlegen(quellenEingabe({ caZertifikatPem: "MIIB…" }), admin)).rejects.toThrow(/PEM/u);
const quelle = await quelleAnlegen(
quellenEingabe({
caZertifikatPem: "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----",
}),
admin,
);
// Die Oberflaeche erfaehrt nur, **dass** eines hinterlegt ist.
expect(quelle.caZertifikatVorhanden).toBe(true);
expect(JSON.stringify(quelle)).not.toContain("BEGIN CERTIFICATE");
});
it("nimmt ein hinterlegtes CA-Zertifikat auf Wunsch wieder weg", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(
quellenEingabe({
caZertifikatPem: "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----",
}),
admin,
);
expect(quelle.caZertifikatVorhanden).toBe(true);
// Eine leere Zeichenkette heisst „entfernen“; `undefined` hiesse
// „unveraendert“ – daran haengt, dass das Formular beides ausdruecken kann.
const ohne = await quelleBearbeiten(quelle.id, { caZertifikatPem: "" }, admin);
expect(ohne.caZertifikatVorhanden).toBe(false);
const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
expect(zeile.caZertifikatPem).toBeNull();
// Und ein Speichern ohne Angabe laesst es, wie es ist.
await quelleBearbeiten(
quelle.id,
{ caZertifikatPem: "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----" },
admin,
);
const wieder = await quelleBearbeiten(quelle.id, { name: "Anderer Name" }, admin);
expect(wieder.caZertifikatVorhanden).toBe(true);
});
it("verlangt das Recht verwaltung", async () => {
const { technikerin } = await grundbestand();
// Die Aktion prueft es auch; hier steht das zweite Netz, an dem auch der
// Zeitplan und ein Skript nicht vorbeikommen.
await expect(quelleAnlegen(quellenEingabe(), technikerin)).rejects.toThrow(/verwaltung/u);
});
it("ist im Modus Dienstleistung abgeschaltet", async () => {
const { admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
await expect(quelleAnlegen(quellenEingabe(), admin)).rejects.toThrow(/Dienstleistung/u);
// Auch die Lesemodelle zeigen dort nichts – nicht einmal eine Quelle aus
// einer frueheren Zeit im Modus INTERN.
expect(await quellenListe(admin)).toEqual([]);
});
it("bearbeitet einzelne Felder und protokolliert die Aenderung", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
const geaendert = await quelleBearbeiten(
quelle.id,
{ name: "AD Hauptstelle", aktiv: true, abgleichIntervallMinuten: 30 },
admin,
);
expect(geaendert.name).toBe("AD Hauptstelle");
expect(geaendert.aktiv).toBe(true);
expect(geaendert.abgleichIntervallMinuten).toBe(30);
const protokoll = await testPrisma.protokoll.findMany({
where: { entitaet: "Verzeichnisquelle", entitaetId: quelle.id },
});
expect(protokoll).toHaveLength(2);
expect(protokoll.map((zeile) => zeile.aktion)).toContain("Verzeichnisquelle angelegt");
});
it("laesst das Passwort stehen, wenn das Feld leer bleibt", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
const vorher = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
// Ein Formular kann das Passwort nicht anzeigen und schickt darum eine leere
// Zeichenkette. Waere das „loeschen“, waere die Quelle nach dem ersten
// Umbenennen kaputt.
await quelleBearbeiten(quelle.id, { bindPasswort: "", name: "Neu" }, admin);
const nachher = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
expect(entschluesseln(nachher.bindPasswortVerschluesselt)).toBe(LDAP_BIND_PASSWORT);
expect(nachher.bindPasswortVerschluesselt).toBe(vorher.bindPasswortVerschluesselt);
await quelleBearbeiten(quelle.id, { bindPasswort: "ein-neues-geheimnis" }, admin);
const dritte = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
expect(entschluesseln(dritte.bindPasswortVerschluesselt)).toBe("ein-neues-geheimnis");
});
it("gibt das Bind-Passwort in keiner Antwort und in keinem Protokoll heraus", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
await quelleBearbeiten(quelle.id, { aktiv: true }, admin);
const liste = await quellenListe(admin);
const einzeln = await quelleHolen(quelle.id, admin);
const protokoll = await testPrisma.protokoll.findMany({ where: { entitaetId: quelle.id } });
const alles = JSON.stringify({ quelle, liste, einzeln, protokoll });
expect(alles).not.toContain(LDAP_BIND_PASSWORT);
expect(alles).not.toContain("bindPasswort");
// Auch der verschluesselte Wert hat in einer Antwort nichts zu suchen.
expect(alles).not.toContain("bindPasswortVerschluesselt");
const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
expect(zeile.bindPasswortVerschluesselt).not.toContain(LDAP_BIND_PASSWORT);
expect(zeile.bindPasswortVerschluesselt).toMatch(/^v1\./u);
expect(entschluesseln(zeile.bindPasswortVerschluesselt)).toBe(LDAP_BIND_PASSWORT);
});
it("loescht eine Quelle ohne Mitarbeiter und verweigert es sonst", async () => {
const { admin, eigene } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
quelleId: quelle.id,
externeId: GUID.huber,
vorname: "Maria",
nachname: "Huber",
anzeigename: "Huber, Maria",
herkunft: "ABGLEICH",
},
});
await expect(quelleLoeschen(quelle.id, admin)).rejects.toThrow(/hängen 1 Mitarbeiter/u);
await testPrisma.mitarbeiter.deleteMany({ where: { quelleId: quelle.id } });
await quelleLoeschen(quelle.id, admin);
expect(await testPrisma.verzeichnisquelle.count()).toBe(0);
// Das Protokoll bleibt, auch ohne die Zeile, auf die es zeigt.
expect(await testPrisma.protokoll.count({ where: { aktion: "Verzeichnisquelle gelöscht" } })).toBe(1);
});
it("fasst eine Quelle eines fremden Kunden nicht an", async () => {
const { admin } = await grundbestand();
// Anlegen kann so eine Zeile niemand – `quelleAnlegen` laeuft nur im Modus
// INTERN und nimmt dort immer die eigene Organisation. Entstehen kann sie
// trotzdem: durch eine Migration, `psql` oder ein Skript. „Gilt nur fuer die
// eigene Organisation“ waere sonst eine Zusage, die nur fuer den Weg durch
// das Formular gilt.
const fremder = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } });
const fremde = await testPrisma.verzeichnisquelle.create({
data: {
kundeId: fremder.id,
name: "Fremdes AD",
url: "ldaps://dc1.fremd.local:636",
bindDn: "cn=dienst,dc=fremd,dc=local",
bindPasswortVerschluesselt: "v1.aaaa.bbbb.cccc",
basisDn: "dc=fremd,dc=local",
filter: STANDARD_FILTER,
attributZuordnung: { ...AD_ZUORDNUNG },
},
});
await expect(quelleBearbeiten(fremde.id, { name: "X" }, admin)).rejects.toThrow(/eigenen Organisation/u);
await expect(quelleLoeschen(fremde.id, admin)).rejects.toThrow(/eigenen Organisation/u);
await expect(verbindungTesten(fremde.id, admin)).rejects.toThrow(/eigenen Organisation/u);
});
it("liefert eine unbekannte Quelle als „nicht vorhanden“", async () => {
const { admin } = await grundbestand();
expect(await quelleHolen("gibtesnicht", admin)).toBeUndefined();
await expect(quelleBearbeiten("gibtesnicht", { name: "X" }, admin)).rejects.toThrow(VerzeichnisFehler);
});
});
// ---- 3. Gegen den echten Container ----------------------------------------
describe.runIf(datenbankVerfuegbar && ldapDa)("Verbindung testen gegen das Verzeichnis", () => {
const protokolliert: string[] = [];
beforeEach(async () => {
await datenbankLeeren();
protokolliert.length = 0;
// Die Zusage „kein Server-Log enthaelt das Bind-Passwort“ wird hier wirklich
// gemessen und nicht behauptet.
for (const kanal of ["log", "info", "warn", "error", "debug"] as const) {
vi.spyOn(console, kanal).mockImplementation((...teile: unknown[]) => {
protokolliert.push(teile.map((teil) => String(teil)).join(" "));
});
}
});
afterEach(() => {
vi.restoreAllMocks();
});
it("zeigt die gemappten Felder der Fixture-Personen", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
const { treffer, gesamtGelesen } = await verbindungTesten(quelle.id, admin);
// Vier Eintraege liefert der Filter, drei davon haben eine Kennung.
expect(gesamtGelesen).toBe(FIXTURE_GELESEN);
expect(treffer).toHaveLength(FIXTURE_PERSONEN);
const huber = treffer.find((eintrag) => eintrag.sam === "mhuber");
expect(huber).toMatchObject({
externeId: GUID.huber,
vorname: "Maria",
nachname: "Huber",
anzeigename: "Huber, Maria",
mail: "maria.huber@firma.test",
upn: "maria.huber@firma.test",
abteilung: "Buchhaltung",
// Umlaute kommen als UTF-8 durch, nicht als Fragezeichen.
titel: "Leiterin Rechnungsprüfung",
dn: "cn=Maria Huber,ou=Buchhaltung,ou=Benutzer,dc=firma,dc=test",
// Der Chef ist in derselben Probe, deshalb aufgeloest.
vorgesetzterExterneId: GUID.berger,
});
expect(treffer.find((eintrag) => eintrag.sam === "jweiss")).toMatchObject({
nachname: "Weiß",
vorname: "Jörg",
externeId: GUID.weiss,
});
});
it("laesst das per userAccountControl deaktivierte Konto aus", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
const { treffer } = await verbindungTesten(quelle.id, admin);
// „Alte Kraft“ steht mit userAccountControl 514 im Verzeichnis. Dass der
// Bit-UND-Filter greift, entscheidet sich am Server – hier ist es gemessen.
expect(treffer.map((eintrag) => eintrag.sam)).not.toContain("akraft");
expect(treffer.map((eintrag) => eintrag.externeId)).not.toContain(GUID.alteKraft);
});
it("haelt sich an die Grenze und schreibt nichts", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
const protokollVorher = await testPrisma.protokoll.count();
const { treffer } = await verbindungTesten(quelle.id, admin, 2);
expect(treffer).toHaveLength(2);
// Kein Mitarbeiter, kein Protokoll, kein Abgleichstand: der Test ist eine
// Probe und kein halber Abgleich.
expect(await testPrisma.mitarbeiter.count()).toBe(0);
expect(await testPrisma.protokoll.count()).toBe(protokollVorher);
const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
expect(zeile.letzterAbgleichAm).toBeNull();
expect(zeile.letzterAbgleichStatus).toBeNull();
});
it("findet mit einem eigenen Filter nur die gesuchte Abteilung", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(
quellenEingabe({ filter: "(&(objectClass=user)(department=Vertrieb)(sAMAccountName=*))" }),
admin,
);
const { treffer } = await verbindungTesten(quelle.id, admin);
expect(treffer.map((eintrag) => eintrag.sam).sort()).toEqual(["akraft", "jweiss"]);
});
it("liest den ganzen Baum ab dem Basis-DN", async () => {
const { admin } = await grundbestand();
// Die Personen liegen in Unter-OUs; mit scope „one“ waere die Liste leer.
const quelle = await quelleAnlegen(quellenEingabe({ basisDn: LDAP_BASIS_DN }), admin);
const { treffer } = await verbindungTesten(quelle.id, admin);
expect(treffer).toHaveLength(FIXTURE_PERSONEN);
});
it("nennt einen falschen Bind-DN, ohne das Passwort zu verraten", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe({ bindPasswort: "falsches-passwort-xyz" }), admin);
await expect(verbindungTesten(quelle.id, admin)).rejects.toThrow(VerbindungsFehler);
const fehler = await verbindungTesten(quelle.id, admin).catch((f: unknown) => f);
expect(String(fehler)).not.toContain("falsches-passwort-xyz");
// Und auch nichts, was die Bibliothek unterwegs auf die Konsole geschrieben
// haette.
expect(protokolliert.join("\n")).not.toContain("falsches-passwort-xyz");
});
it("meldet einen unerreichbaren Server als Fehler und nicht als leeres Ergebnis", async () => {
const { admin } = await grundbestand();
// Ein leeres Ergebnis waere die gefaehrlichste Antwort: der Abgleich wuerde
// daraus schliessen, dass alle gegangen sind.
const quelle = await quelleAnlegen(quellenEingabe({ url: "ldap://127.0.0.1:1" }), admin);
await expect(verbindungTesten(quelle.id, admin)).rejects.toThrow(VerbindungsFehler);
});
});
describe.runIf(datenbankVerfuegbar)("Abgleich-Log", () => {
beforeEach(datenbankLeeren);
/** Ein Protokolleintrag, wie ihn ein Abgleich schreibt. */
async function logEintrag(
quelleId: string,
nachher: Record<string, number | string>,
benutzerId: string | null,
gescheitert = false,
) {
return testPrisma.protokoll.create({
data: {
entitaet: "Verzeichnisquelle",
entitaetId: quelleId,
aktion: gescheitert ? "Verzeichnisabgleich fehlgeschlagen" : "Verzeichnis abgeglichen",
benutzerId,
nachher,
},
});
}
it("zeigt die Laeufe mit ihren Zahlen, der neueste zuerst", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
await logEintrag(
quelle.id,
{ gelesen: 4, angelegt: 3, uebernommen: 0, aktualisiert: 0, deaktiviert: 0, uebersprungen: 1 },
admin.id,
);
await logEintrag(quelle.id, { gelesen: 0, fehler: "Anmeldung abgelehnt" }, null, true);
const zeilen = await abgleichLog(quelle.id, admin);
expect(zeilen).toHaveLength(2);
expect(zeilen[0]).toMatchObject({ gescheitert: true, fehler: "Anmeldung abgelehnt", durch: null });
expect(zeilen[1]).toMatchObject({
gescheitert: false,
gelesen: 4,
angelegt: 3,
uebersprungen: 1,
durch: "Admin",
});
});
it("nimmt nur die Abgleich-Eintraege dieser Quelle", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
const andere = await quelleAnlegen(quellenEingabe({ name: "Zweites AD" }), admin);
await logEintrag(quelle.id, { gelesen: 1 }, admin.id);
await logEintrag(andere.id, { gelesen: 99 }, admin.id);
// Auch das Anlegen der Quelle steht im Protokoll – es ist kein Abgleich.
const zeilen = await abgleichLog(quelle.id, admin);
expect(zeilen).toHaveLength(1);
expect(zeilen[0]?.gelesen).toBe(1);
});
it("haelt sich an dieselbe Grenze wie die Quellenliste", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
for (let lauf = 0; lauf < 12; lauf += 1) {
await logEintrag(quelle.id, { gelesen: lauf }, admin.id);
}
expect(await abgleichLog(quelle.id, admin)).toHaveLength(LOG_ZEILEN);
expect(await abgleichLog(quelle.id, admin, 3)).toHaveLength(3);
});
it("ist im Modus Dienstleistung leer, auch wenn Eintraege stehen", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
await logEintrag(quelle.id, { gelesen: 4 }, admin.id);
await testPrisma.einstellung.update({
where: { id: "global" },
data: { modus: Betriebsmodus.DIENSTLEISTUNG },
});
expect(await abgleichLog(quelle.id, admin)).toEqual([]);
});
it("gibt zu einer unbekannten Quelle nichts heraus", async () => {
const { admin } = await grundbestand();
expect(await abgleichLog("gibtsnicht", admin)).toEqual([]);
});
});