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