Files
Laufzettel/tests/verzeichnis.test.ts
T
florian.wilde ae290345b7
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log (#30)
2026-09-26 00:21:56 +00:00

825 lines
33 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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([]);
});
});