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 = {}) { 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; 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, 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([]); }); });