Files
Laufzettel/tests/verzeichnis.test.ts
Florian WildeandClaude Opus 5 80d97467da
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
fix: Reviewbefunde aus der Durchsicht von Phase 2 (#036, #027)
Acht Meilensteine lagen ohne fremden Blick auf main. Ein Review mit eigener
Datenbank, Gegenproben und vollem Suitenlauf hat sie gegen die
Akzeptanzkriterien und gegen CLAUDE.md geprueft.

Die Kernregeln halten: fuenf von fuenf Gegenproben gegen Versionierung,
Mandantengrenze, Abschlussregel und Selbstauskunft wurden rot. Alle Befunde
lagen am Rand — und die drei wertvollsten waren Gegenproben, die gruen blieben.

Schwer:

- Der Link in der Mail "Verzeichnis-Abgleich fehlgeschlagen" war ein 404
  (/verwaltung/verzeichnis statt /admin/verzeichnis). Die eine Meldung, die an
  alle Administratoren geht, im Moment eines Ausfalls. Ein Test prueft jetzt
  jeden Meldungspfad gegen die vorhandenen Routen.
- Der Export eines Entwurfs mit Baustein lieferte einen leeren Abschnitt unter
  dem Platzhalter "wird ersetzt" — der Zweck des Formats ist, eine Vorlage in
  eine zweite Installation zu bringen. Bausteine werden jetzt aufgeloest.

Mittel:

- Der Anhang-Handler hatte keinen Test; die ganze Rechtepruefung liess sich
  durch einen hartcodierten ADMIN ersetzen, Suite gruen. Es ist die einzige
  Stelle, die rohe Dateibytes ausliefert. tests/anhang-route.test.ts faehrt ihn
  jetzt echt, acht Pruefungen.
- /api/scim in der Proxy-Allowlist stand in keinem Test. OEFFENTLICH ist
  exportiert, der Test vergleicht beide Richtungen: eine Liste, die nur in einer
  Richtung geprueft wird, laeuft dem Code hinterher.
- Ein offener Ereignisstrom ueberlebte Abmelden, Deaktivieren und
  Sitzungswiderruf. Der Herzschlag prueft die Sitzung jetzt nach. Dass es kein
  Test zeigte, lag an einer Attrappe `sitzung: {}` — eine Attrappe ohne die Form
  der Wirklichkeit prueft etwas anderes als die Wirklichkeit.
- README kannte MITARBEITER nicht und behauptete, unbekannte Benutzer wuerden
  angelegt. Wer auf v0.2.0 hebt und nur das README liest, haette genau diese
  Leute ausgesperrt.
- sicherung.md nannte einen Anhaenge-Pfad, den es nirgends gibt — als
  Rueckfallwert in einem Skript mit rm -rf. Jetzt bricht es ab statt zu raten.
- Attributnamen wurden nur in der Server Action geprueft, waehrend der
  Graph-Leser sich im Kommentar darauf berief.

Dazu #027: die Ende-zu-Ende-Ablaeufe raeumen jetzt zentral auf, vor und nach
dem Lauf, und ein struktureller Test bewacht es. Beim ersten echten Lauf seit
dem 26. September waren sieben Ablaeufe rot, vier Ursachen, keine ein
Produktfehler — unbemerkt, weil pnpm test:e2e in keinem Tor steht. Das ist jetzt
#038 und keine Fussnote.

Ein Befund des Reviews war falsch (der Zustands-Handler habe keinen Test; er hat
acht direkte Aufrufe). Nachgeprueft, bevor etwas geaendert wurde.

1454 Vitest-Tests (vorher 1432), 113 E2E-Ablaeufe gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-11 01:32:09 +02:00

876 lines
36 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Entry } from "ldapts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Betriebsmodus } from "@/generated/prisma/enums";
import { entschluesseln } from "@/lib/geheimnisse";
import { zertifikatWahl, zuordnungAusEingabe } from "@/lib/verzeichnis";
import { fehlerMeldung, tlsOptionenBauen, urlPruefen, VerbindungsFehler } from "@/server/ldap/verbindung";
import {
AD_ZUORDNUNG,
angeforderteAttribute,
dnSchluessel,
eintragZuordnen,
STANDARD_FILTER,
vorgesetzteAufloesen,
zuordnungLesen,
} from "@/server/ldap/zuordnung";
import { abgleichLog, LOG_ZEILEN, quelleHolen, quellenListe } from "@/server/queries/verzeichnis";
import {
quelleAnlegen,
quelleBearbeiten,
quelleLoeschen,
verbindungTesten,
VerzeichnisFehler,
} from "@/server/services/verzeichnis";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
import {
FIXTURE_GELESEN,
FIXTURE_PERSONEN,
GUID,
LDAP_BASIS_DN,
LDAP_BENUTZER_DN,
LDAP_BIND_DN,
LDAP_BIND_PASSWORT,
LDAP_URL,
ldapVerfuegbar,
} from "./fixtures/ldap/container";
/**
* Verzeichnisquellen und das Lesen aus dem Verzeichnis (#016).
*
* Drei Arten von Pruefungen stehen hier, und die Trennung ist Absicht:
*
* 1. **Reine Funktionen** — Adressen, TLS-Optionen, Attributzuordnung,
* Fehlermeldungen. Ohne Netz und ohne Datenbank, deshalb vollstaendig in den
* Sonderfaellen.
* 2. **Der Service gegen die Datenbank** — Rechte, Betriebsmodus, und dass das
* Bind-Passwort nirgends herauskommt.
* 3. **Gegen den echten Container** — dass aus einem AD-aehnlichen Verzeichnis
* wirklich die erwarteten Felder werden. Das ist der Teil, den keine
* Attrappe ersetzt: `explicitBufferAttributes`, die Umsortierung der GUID und
* der Bit-UND-Filter entscheiden sich erst am Server.
*/
const ldapDa = await ldapVerfuegbar();
async function grundbestand(modus: Betriebsmodus = Betriebsmodus.INTERN) {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus } });
const admin = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
});
const technikerin = await testPrisma.benutzer.create({
data: { email: "tina@firma.example", name: "Tina", rolle: "TECHNIKER" },
});
return {
eigene,
admin: { id: admin.id, rolle: admin.rolle },
technikerin: { id: technikerin.id, rolle: technikerin.rolle },
};
}
/** Eine Quelle, die auf den Testcontainer zeigt. */
function quellenEingabe(abweichend: Record<string, unknown> = {}) {
return {
name: "Firmen-AD",
url: LDAP_URL,
bindDn: LDAP_BIND_DN,
bindPasswort: LDAP_BIND_PASSWORT,
basisDn: LDAP_BENUTZER_DN,
...abweichend,
};
}
// ---- 1. Reine Funktionen ---------------------------------------------------
describe("Adressen", () => {
it("nimmt ldaps, ldap und das StartTLS-Schema", () => {
expect(urlPruefen("ldaps://dc1.firma.local:636")).toEqual({
url: "ldaps://dc1.firma.local:636",
startTls: false,
verschluesselt: true,
});
expect(urlPruefen("ldap://dc1.firma.local:389")).toEqual({
url: "ldap://dc1.firma.local:389",
startTls: false,
verschluesselt: false,
});
});
it("macht aus ldap+starttls eine ldap-Adresse und merkt sich die Umschaltung", () => {
// ldapts kennt nur ldap und ldaps; StartTLS geschieht bei uns nach dem
// Verbinden. Die getippte Form bleibt in der Datenbank stehen, damit noch
// erkennbar ist, dass StartTLS gewuenscht war.
expect(urlPruefen("ldap+starttls://dc1.firma.local:389")).toEqual({
url: "ldap://dc1.firma.local:389",
startTls: true,
verschluesselt: true,
});
});
it("weist ein fremdes Schema, einen fehlenden Rechner und einen Pfad ab", () => {
expect(() => urlPruefen("https://dc1.firma.local")).toThrow(VerbindungsFehler);
expect(() => urlPruefen("dc1.firma.local:389")).toThrow(VerbindungsFehler);
expect(() => urlPruefen("ldaps://")).toThrow(/Rechnername/u);
expect(() => urlPruefen("ldaps:///dc=firma,dc=local")).toThrow(/Rechnername/u);
expect(() => urlPruefen("ldaps://dc1.firma.local/dc=firma,dc=local")).toThrow(/Pfad/u);
});
it("weist Zugangsdaten in der Adresse ab", () => {
// Sonst stuende das Passwort im Klartext in der Datenbank – verschluesselt
// wird nur das Feld, das dafuer da ist.
expect(() => urlPruefen("ldaps://dienst:geheim@dc1.firma.local:636")).toThrow(/Zugangsdaten/u);
});
});
describe("TLS-Optionen", () => {
it("verlangt mindestens TLS 1.2", () => {
expect(tlsOptionenBauen(null).minVersion).toBe("TLSv1.2");
});
it("nimmt ein eigenes CA-Zertifikat auf", () => {
const pem = "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----";
expect(tlsOptionenBauen(pem).ca).toEqual([pem]);
});
it("laesst ca weg, wenn keines hinterlegt ist – dann gelten die Systemwurzeln", () => {
expect(tlsOptionenBauen(null).ca).toBeUndefined();
expect(tlsOptionenBauen(" ").ca).toBeUndefined();
});
it("hat keinen Schalter, der die Pruefung des Hostnamens abstellt", () => {
// Ein solcher Schalter waere genau der, der in der Eile gesetzt bleibt.
const optionen = tlsOptionenBauen(null) as Record<string, unknown>;
expect(optionen["rejectUnauthorized"]).toBeUndefined();
expect(optionen["checkServerIdentity"]).toBeUndefined();
});
});
describe("Fehlermeldungen", () => {
const passwort = "S3hr-geheim!";
it("entfernt das Bind-Passwort restlos", () => {
const meldung = fehlerMeldung(new Error(`bind failed for cn=dienst with ${passwort}`), [passwort]);
expect(meldung).not.toContain(passwort);
expect(meldung).toContain("***");
});
it("entfernt es auch mehrfach und mitten im Wort", () => {
const meldung = fehlerMeldung(new Error(`${passwort}/${passwort}`), [passwort]);
expect(meldung).toBe("***/***");
});
it("macht eine Zeile daraus und kuerzt", () => {
const meldung = fehlerMeldung(new Error(`erste\n zweite\tdritte`), []);
expect(meldung).toBe("erste zweite dritte");
expect(fehlerMeldung(new Error("x".repeat(900)), []).length).toBeLessThanOrEqual(401);
});
it("kommt mit allem zurecht, was geworfen werden kann", () => {
expect(fehlerMeldung("nur Text", [])).toBe("nur Text");
expect(fehlerMeldung(undefined, [])).toBe("undefined");
expect(fehlerMeldung(new Error(" "), [])).toBe("Unbekannter Fehler beim Zugriff auf das Verzeichnis.");
});
});
describe("Attributzuordnung", () => {
it("benennt die AD-Vorgaben aus dem Auftrag", () => {
expect(AD_ZUORDNUNG).toEqual({
vorname: "givenName",
nachname: "sn",
anzeigename: "displayName",
mail: "mail",
upn: "userPrincipalName",
sam: "sAMAccountName",
abteilung: "department",
titel: "title",
vorgesetzter: "manager",
externeId: "objectGUID",
});
});
it("schliesst im Standardfilter die deaktivierten Konten aus", () => {
expect(STANDARD_FILTER).toBe(
"(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))",
);
});
it("fuellt fehlende und leere Felder mit der Vorgabe", () => {
expect(zuordnungLesen({ anzeigename: "cn", mail: " " })).toEqual({
...AD_ZUORDNUNG,
anzeigename: "cn",
});
expect(zuordnungLesen(null)).toEqual(AD_ZUORDNUNG);
expect(zuordnungLesen("Unsinn")).toEqual(AD_ZUORDNUNG);
expect(zuordnungLesen({ vorname: 7 })).toEqual(AD_ZUORDNUNG);
});
it("fragt nur die zugeordneten Attribute an, und jedes nur einmal", () => {
const attribute = angeforderteAttribute({ ...AD_ZUORDNUNG, upn: "mail" });
expect(attribute).toContain("objectGUID");
expect(attribute).not.toContain("memberOf");
expect(attribute.filter((name) => name === "mail")).toHaveLength(1);
});
it("uebersetzt einen Eintrag und wandelt den GUID-Puffer", () => {
const ergebnis = eintragZuordnen(
{
dn: "CN=Maria Huber,OU=Buchhaltung,DC=firma,DC=test",
givenName: "Maria",
sn: "Huber",
displayName: "Huber, Maria",
mail: "maria.huber@firma.test",
userPrincipalName: "maria.huber@firma.test",
sAMAccountName: "mhuber",
department: "Buchhaltung",
title: "Leiterin",
manager: "CN=Klaus Berger,OU=GF,DC=firma,DC=test",
objectGUID: Buffer.from("EEMHXzU8HUqDb6UPUV56NQ==", "base64"),
} satisfies Entry,
AD_ZUORDNUNG,
);
expect(ergebnis).toEqual({
eintrag: {
externeId: GUID.huber,
vorname: "Maria",
nachname: "Huber",
anzeigename: "Huber, Maria",
mail: "maria.huber@firma.test",
upn: "maria.huber@firma.test",
sam: "mhuber",
dn: "CN=Maria Huber,OU=Buchhaltung,DC=firma,DC=test",
abteilung: "Buchhaltung",
titel: "Leiterin",
vorgesetzterDn: "CN=Klaus Berger,OU=GF,DC=firma,DC=test",
},
});
});
it("nimmt bei einem mehrwertigen Attribut den ersten Wert und macht aus leer null", () => {
const ergebnis = eintragZuordnen(
{
dn: "CN=Zwei,DC=firma,DC=test",
givenName: ["Anna", "Annemarie"],
sn: "Zwei",
displayName: "",
mail: " ",
objectGUID: Buffer.from("AAECAwQFBgcICQoLDA0ODw==", "base64"),
} satisfies Entry,
AD_ZUORDNUNG,
);
expect(ergebnis).toMatchObject({
eintrag: { vorname: "Anna", mail: null, upn: null, titel: null, vorgesetzterDn: null },
});
});
it("baut einen Anzeigenamen, wenn keiner im Verzeichnis steht", () => {
// `Mitarbeiter.anzeigename` ist nicht optional, und eine leere Zeile in der
// Auswahlliste waere fuer die Technikerin unbrauchbar.
const mitNamen = eintragZuordnen(
{ dn: "CN=X,DC=f,DC=t", givenName: "Maria", sn: "Huber", objectGUID: "abc" } satisfies Entry,
AD_ZUORDNUNG,
);
expect(mitNamen).toMatchObject({ eintrag: { anzeigename: "Huber, Maria" } });
const ohneNamen = eintragZuordnen(
{ dn: "CN=Nur der DN,OU=x,DC=f,DC=t", objectGUID: "abc" } satisfies Entry,
AD_ZUORDNUNG,
);
expect(ohneNamen).toMatchObject({ eintrag: { anzeigename: "Nur der DN", vorname: "", nachname: "" } });
});
it("uebersetzt eine Textkennung, ohne sie fuer eine GUID zu halten", () => {
// Wer `externeId` auf `entryUUID` eines OpenLDAP legt, bekommt 36 Textbytes.
const ergebnis = eintragZuordnen(
{
dn: "CN=Y,DC=f,DC=t",
objectGUID: Buffer.from("c0ffee00-1111-2222-3333-444455556666", "utf8"),
} satisfies Entry,
AD_ZUORDNUNG,
);
expect(ergebnis).toMatchObject({ eintrag: { externeId: "c0ffee00-1111-2222-3333-444455556666" } });
});
it("ueberspringt einen Eintrag ohne Kennung statt abzubrechen", () => {
// Ein einzelnes krummes Konto darf nicht den Abgleich aller anderen
// verhindern – ohne Kennung waere es aber bei jedem Lauf wieder neu.
const ohne = eintragZuordnen({ dn: "CN=Ohne,DC=f,DC=t", sn: "Ohne" } satisfies Entry, AD_ZUORDNUNG);
expect(ohne).toEqual({
uebersprungen: { dn: "CN=Ohne,DC=f,DC=t", grund: expect.stringContaining("objectGUID") },
});
// Sieben Bytes sind eine verstuemmelte GUID, keine Kennung. Sie als Text
// durchzulassen hiesse: eine Kennung aus Steuerzeichen, die zu keinem Konto
// im Verzeichnis passt und trotzdem kein Duplikat erzeugt – ein Fehler, der
// nie auffaellt.
const kaputt = eintragZuordnen(
{ dn: "CN=Kaputt,DC=f,DC=t", objectGUID: Buffer.alloc(7) } satisfies Entry,
AD_ZUORDNUNG,
);
expect(kaputt).toMatchObject({ uebersprungen: { dn: "CN=Kaputt,DC=f,DC=t" } });
});
it("loest den Vorgesetzten ueber den DN auf, auch bei anderer Schreibweise", () => {
const chef = {
externeId: GUID.berger,
vorname: "Klaus",
nachname: "Berger",
anzeigename: "Berger, Klaus",
mail: null,
upn: null,
sam: "kberger",
dn: "CN=Klaus Berger,OU=GF,DC=firma,DC=test",
abteilung: null,
titel: null,
vorgesetzterDn: null,
};
const person = {
...chef,
externeId: GUID.huber,
dn: "CN=Maria Huber,OU=BH,DC=firma,DC=test",
// Klein geschrieben und mit Leerzeichen hinter den Kommas.
vorgesetzterDn: "cn=klaus berger, ou=gf, dc=firma, dc=test",
};
const [aufgeloest] = vorgesetzteAufloesen([person, chef]);
expect(aufgeloest?.vorgesetzterExterneId).toBe(GUID.berger);
expect(dnSchluessel("CN=A, OU=B")).toBe("cn=a,ou=b");
});
it("laesst den Vorgesetzten leer, wenn er nicht mitgelesen wurde", () => {
// Lieber kein Vorgesetzter als ein falscher: der DN ist ausserhalb des
// Suchbereichs nicht aufloesbar, und geraten wird nicht.
const person = {
externeId: GUID.huber,
vorname: "Maria",
nachname: "Huber",
anzeigename: "Huber, Maria",
mail: null,
upn: null,
sam: null,
dn: "CN=Maria Huber,OU=BH,DC=firma,DC=test",
abteilung: null,
titel: null,
vorgesetzterDn: "CN=Unbekannt,OU=Extern,DC=firma,DC=test",
};
expect(vorgesetzteAufloesen([person])[0]?.vorgesetzterExterneId).toBeNull();
});
});
// ---- 2. Der Service gegen die Datenbank -----------------------------------
describe("Zuordnung und Zertifikat aus dem Formular", () => {
it("nimmt gefuellte Felder und laesst leere der Vorgabe", () => {
const ergebnis = zuordnungAusEingabe({ externeId: " entryUUID ", sam: "", titel: "title" });
expect(ergebnis).toEqual({ zuordnung: { externeId: "entryUUID", titel: "title" } });
});
it("weist ab, was kein Attributname ist", () => {
for (const krumm of ["(objectClass=*)", "cn;binary", "2fach", "-sn", "sn sn"]) {
const ergebnis = zuordnungAusEingabe({ nachname: krumm });
expect(ergebnis, krumm).toHaveProperty("fehler");
}
});
it("nennt in der Meldung das Feld, damit klar ist, welche Zeile gemeint ist", () => {
const ergebnis = zuordnungAusEingabe({ externeId: "(guid)" });
expect("fehler" in ergebnis && ergebnis.fehler).toContain("Kennung");
});
it("unterscheidet beim Zertifikat unveraendert, neu und entfernen", () => {
// Leeres Feld, kein Kaestchen: das hinterlegte bleibt – deshalb `undefined`.
expect(zertifikatWahl("", false)).toEqual({ wert: undefined });
expect(zertifikatWahl(" ", false)).toEqual({ wert: undefined });
expect(zertifikatWahl("-----BEGIN CERTIFICATE-----", false)).toEqual({
wert: "-----BEGIN CERTIFICATE-----",
});
// Kaestchen: leere Zeichenkette, und der Dienst macht daraus `null`.
expect(zertifikatWahl("", true)).toEqual({ wert: "" });
expect(zertifikatWahl("-----BEGIN CERTIFICATE-----", true)).toHaveProperty("fehler");
});
});
describe.runIf(datenbankVerfuegbar)("Verzeichnisquellen", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("legt eine Quelle mit den Vorgaben an", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe({ name: " Firmen-AD " }), admin);
expect(quelle.name).toBe("Firmen-AD");
expect(quelle.filter).toBe(STANDARD_FILTER);
expect(quelle.attributZuordnung).toEqual(AD_ZUORDNUNG);
expect(quelle.abgleichIntervallMinuten).toBe(60);
// Eine neue Quelle ist zunaechst abgeschaltet: erst Verbindung testen, dann
// den Zeitplan darauf loslassen.
expect(quelle.aktiv).toBe(false);
expect(quelle.caZertifikatVorhanden).toBe(false);
expect(quelle.letzterAbgleichAm).toBeNull();
});
it("uebernimmt einzelne abweichende Attributnamen", async () => {
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(
quellenEingabe({ attributZuordnung: { anzeigename: "cn", externeId: "entryUUID" } }),
admin,
);
expect(quelle.attributZuordnung).toEqual({
...AD_ZUORDNUNG,
anzeigename: "cn",
externeId: "entryUUID",
});
});
it("verlangt Name, Adresse, Bind-DN, Passwort und Basis-DN", async () => {
const { admin } = await grundbestand();
await expect(quelleAnlegen(quellenEingabe({ name: " " }), admin)).rejects.toThrow(VerzeichnisFehler);
await expect(quelleAnlegen(quellenEingabe({ bindDn: "" }), admin)).rejects.toThrow(/Bind-DN/u);
await expect(quelleAnlegen(quellenEingabe({ bindPasswort: "" }), admin)).rejects.toThrow(
/Bind-Passwort/u,
);
await expect(quelleAnlegen(quellenEingabe({ basisDn: "" }), admin)).rejects.toThrow(/Basis-DN/u);
await expect(quelleAnlegen(quellenEingabe({ url: "ftp://dc1" }), admin)).rejects.toThrow(
VerbindungsFehler,
);
});
it("weist einen Attributnamen ab, der keiner ist – auch am Formular vorbei", async () => {
/*
* **Die Regel sass eine Schicht ueber dem Code, der sich auf sie berief**
* (Reviewbefund zu #029). `src/server/graph/leser.ts` schrieb im Kommentar
* „die Werte selbst sind feste Feldnamen aus ATTRIBUT_MUSTER, also ist hier
* nichts zu maskieren“ — gepruefet wurde das aber nur in
* `zuordnungAusFormular`, also in der Server Action.
*
* Nachgewiesen wurde, dass genau dieser Name durch den Dienst kam und einen
* **zweiten `$filter`** in die Graph-Adresse schrieb. Hereinkommen konnte er
* nur mit `verwaltung` oder per Skript, Seed und `psql` — die
* Sicherheitswirkung ist also klein. Der Befund ist der Ort der Regel:
* CLAUDE.md verlangt sie in der Datenbank, und sie stand nicht einmal im
* Dienst.
*/
const { admin } = await grundbestand();
const boese = { abteilung: "department,id&$filter=boese eq 1" };
await expect(quelleAnlegen(quellenEingabe({ attributZuordnung: boese }), admin)).rejects.toThrow(
/kein Attributname/u,
);
// Und beim Bearbeiten genauso.
const quelle = await quelleAnlegen(quellenEingabe(), admin);
await expect(quelleBearbeiten(quelle.id, { attributZuordnung: boese }, admin)).rejects.toThrow(
/kein Attributname/u,
);
// Die gespeicherte Zeile ist unberührt.
expect((await quelleHolen(quelle.id, admin))?.attributZuordnung.abteilung).toBe(AD_ZUORDNUNG.abteilung);
});
it("verwirft beim Lesen einen unbrauchbaren Namen, statt die Liste zu sprengen", async () => {
/*
* Die Gegenrichtung: eine Zeile, die vor dieser Prüfung angelegt wurde oder
* von Hand in die Datenbank kam. Hier zu werfen wäre falsch — eine einzige
* kaputte Zeile machte die ganze Quellenliste unbenutzbar. Sie bekommt
* deshalb die Vorgabe.
*/
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
await testPrisma.verzeichnisquelle.update({
where: { id: quelle.id },
data: { attributZuordnung: { abteilung: "department,id&$filter=boese eq 1" } },
});
const gelesen = await quelleHolen(quelle.id, admin);
expect(gelesen?.attributZuordnung.abteilung).toBe(AD_ZUORDNUNG.abteilung);
expect(JSON.stringify(gelesen)).not.toContain("$filter");
});
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([]);
});
});