Ein Haus ohne lokales Active Directory hat kein LDAP, auf das sich zeigen liesse. Eine Verzeichnisquelle hat jetzt eine **Art** — LDAP oder GRAPH —, und beide laufen durch **denselben** Abgleich: anlegen, aktualisieren, deaktivieren, nie loeschen. Nur das Holen der Eintraege unterscheidet sich; zwei Abgleiche nebeneinander wuerden auseinanderdriften, und die Zusage „es wird nichts geloescht“ gaelte dann fuer eine Art und fuer die andere nicht mehr ganz. Gelesen wird mit dem Client-Credentials-Flow, seitenweise ueber @odata.nextLink, der Vorgesetzte ueber $expand=manager($select=id). Das Client-Secret wird verschluesselt gespeichert wie das Bind-Passwort und erreicht keine Antwort und keinen Protokolleintrag. Die Art ist **unveraenderlich**, und das steht nicht im Issue: an ihr haengt, was `externeId` bedeutet. Im AD eine objectGUID, in Entra ID eine andere GUID — nach einem Wechsel wuerde kein einziger Mitarbeiter wiedererkannt, der naechste Abgleich legte die ganze Belegschaft neu an und setzte die alten Zeilen auf aktiv = false, mit allen Laeufen und Geraeten daran. Der teuerste Fund beim Bauen: „in Entra ID deaktiviert“ als *uebersprungen* zu melden haette die Leute **aktiv** gelassen. Uebersprungen heisst im Abgleich „nicht zuordenbar“, und solche Eintraege werden seit #016 absichtlich nicht abgeschaltet. Gefiltert wird deshalb serverseitig mit `$filter=accountEnabled eq true`, als Gegenstueck zum LDAP-Standardfilter. Gefunden hat es der Test — aber nur, weil der Doppelgaenger den Filter wirklich auswertet statt ihn bloss zu empfangen. {{ou}} bleibt bei einer Graph-Quelle leer: Entra ID hat keine OU, und etwas hineinzudeuten waere ein Wert, den kein PowerShell-Befehl brauchen kann — er saehe nur richtig aus. Steht an der Abteilung eine Ziel-OU gepflegt, greift die. Gegen einen echten Entra-Mandanten ist das **nicht** gelaufen. Geprueft ist es gegen einen HTTP-Doppelgaenger mit aufgezeichneten Antworten; die Pruefliste fuer den ersten echten Lauf steht am Ende von docs/betrieb/entra-id.md. Closes #48 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
825 lines
33 KiB
TypeScript
825 lines
33 KiB
TypeScript
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([]);
|
||
});
|
||
});
|