feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s

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:
Florian Wilde
2026-09-26 02:21:08 +02:00
co-authored by Claude Opus 5
parent ab5777a57e
commit 16059e7846
57 changed files with 6834 additions and 61 deletions
+161
View File
@@ -0,0 +1,161 @@
import { Attribute, Change, Client } from "ldapts";
/**
* Zugang zum LDAP-Testcontainer und das, was im Fixture steht (#016).
*
* Die Werte stehen hier und nicht in den Tests, weil zwei Testdateien gegen
* denselben Container laufen. Die Erwartungen (GUID-Zeichenketten,
* Kontonamen) gehoeren mit dazu: sie beschreiben das Fixture und nicht die
* Regeln — die stehen in den Tests.
*
* Der Container wird mit
* `docker compose -f docker-compose.test.yml up -d ldap` gestartet.
*/
export const LDAP_URL = "ldap://127.0.0.1:1389";
export const LDAP_BASIS_DN = "dc=firma,dc=test";
export const LDAP_BENUTZER_DN = "ou=Benutzer,dc=firma,dc=test";
export const LDAP_BIND_DN = "cn=admin,dc=firma,dc=test";
export const LDAP_BIND_PASSWORT = "ldap-test-geheim";
/**
* Die GUID-Zeichenketten der Fixture-Personen.
*
* Ausgerechnet mit Pythons `uuid.UUID(bytes_le=…)` aus den Bytes in
* `daten/50-personen.ldif` — eine unabhaengige Umsetzung derselben
* gemischt-endianen Reihenfolge, damit hier nicht die Erwartung aus derselben
* Quelle stammt wie das Ergebnis.
*/
export const GUID = {
berger: "1d3e409c-b27a-4bc9-b1e6-f0a2c3d4e5f6",
huber: "5f074310-3c35-4a1d-836f-a50f515e7a35",
weiss: "04030201-0605-0807-090a-0b0c0d0e0f10",
/** „Alte Kraft“: im Verzeichnis vorhanden, aber deaktiviert. */
alteKraft: "ccddeeff-aabb-8899-7766-554433221100",
} as const;
/** So viele Konten liefert der Standardfilter: drei aktive plus das ohne objectGUID. */
export const FIXTURE_GELESEN = 4;
/** Davon zugeordnet, also mit stabiler Kennung. */
export const FIXTURE_PERSONEN = 3;
export type ContainerStand = { readonly da: true } | { readonly da: false; readonly grund: string };
/**
* Laeuft der Container – und stehen die Fixture-Personen wirklich darin?
*
* Geprueft wird mit einem echten Bind und einer echten Suche, nicht mit einem
* offenen Port: ein gestarteter, aber noch nicht bestueckter Container waere
* sonst „da“, und die Tests liefen gegen ein leeres Verzeichnis. Ein stilles
* Ueberspringen sieht genauso gruen aus wie ein bestandener Lauf, deshalb sagt
* diese Funktion laut, was fehlt – und in CI bricht sie ab.
*/
export async function ldapContainerStand(): Promise<ContainerStand> {
const client = new Client({ url: LDAP_URL, connectTimeout: 2000, timeout: 5000 });
try {
await client.bind(LDAP_BIND_DN, LDAP_BIND_PASSWORT);
const { searchEntries } = await client.search(LDAP_BENUTZER_DN, {
scope: "sub",
filter: "(sAMAccountName=mhuber)",
attributes: ["dn"],
});
if (searchEntries.length === 0) {
return { da: false, grund: "Der Container laeuft, aber das Fixture „mhuber“ fehlt darin." };
}
return { da: true };
} catch (fehler) {
return { da: false, grund: fehler instanceof Error ? fehler.message : String(fehler) };
} finally {
await client.unbind().catch(() => undefined);
}
}
/**
* Der Stand samt Hinweis auf der Konsole. In CI ein Abbruch: `pnpm test` ist das
* Tor vor jedem Commit, und dort darf der Abgleich nicht ungeprueft bleiben.
*/
export async function ldapVerfuegbar(): Promise<boolean> {
const stand = await ldapContainerStand();
if (stand.da) return true;
const hinweis =
`\n LDAP-Testcontainer nicht erreichbar (${stand.grund}).` +
"\n Die Verzeichnis- und Abgleichtests gegen das Verzeichnis werden uebersprungen." +
"\n Starten mit: docker compose -f docker-compose.test.yml up -d ldap\n";
if (process.env["CI"]) throw new Error(hinweis);
// Direkt auf stderr und nicht ueber `console`: Vitest 5 fasst die
// Konsolenausgabe einer Testdatei ab und zeigt sie fuer den **Modulrumpf**
// nicht an — nachgemessen. Ein `console.warn` hier waere also genau das stille
// Ueberspringen, das dieser Hinweis verhindern soll.
process.stderr.write(hinweis);
return false;
}
/** Ein Client als Verzeichnisverwalter, fuer Tests, die das Fixture veraendern. */
export async function alsVerwalter<T>(was: (client: Client) => Promise<T>): Promise<T> {
const client = new Client({ url: LDAP_URL, connectTimeout: 2000, timeout: 5000 });
try {
await client.bind(LDAP_BIND_DN, LDAP_BIND_PASSWORT);
return await was(client);
} finally {
await client.unbind().catch(() => undefined);
}
}
/**
* Legt eine zusaetzliche Person an und gibt den Weg zurueck, sie wieder zu
* entfernen.
*
* Das braucht der Test „ein entferntes Konto wird aktiv = false“: er muss ein
* Konto wirklich loeschen. Die Fixture-Dateien bleiben dabei unberuehrt, und der
* Container ist nach dem Test wieder wie vorher — sonst haengt das Ergebnis des
* naechsten Tests an der Reihenfolge.
*/
export async function personAnlegen(eintrag: {
readonly cn: string;
readonly sn: string;
readonly givenName: string;
readonly sam: string;
readonly guidBase64: string;
readonly userAccountControl?: number;
}): Promise<string> {
const dn = `cn=${eintrag.cn},ou=Vertrieb,${LDAP_BENUTZER_DN}`;
// Als `Attribute[]`, nicht als Objekt: die Objektform von `Client.add` nimmt
// nur Zeichenketten, und objectGUID ist ein Puffer.
const werte: Attribute[] = [
new Attribute({ type: "objectClass", values: ["inetOrgPerson", "user"] }),
new Attribute({ type: "cn", values: [eintrag.cn] }),
new Attribute({ type: "sn", values: [eintrag.sn] }),
new Attribute({ type: "givenName", values: [eintrag.givenName] }),
new Attribute({ type: "displayName", values: [`${eintrag.sn}, ${eintrag.givenName}`] }),
new Attribute({ type: "mail", values: [`${eintrag.sam}@firma.test`] }),
new Attribute({ type: "userPrincipalName", values: [`${eintrag.sam}@firma.test`] }),
new Attribute({ type: "sAMAccountName", values: [eintrag.sam] }),
new Attribute({ type: "department", values: ["Vertrieb"] }),
new Attribute({ type: "objectCategory", values: ["person"] }),
new Attribute({ type: "userAccountControl", values: [String(eintrag.userAccountControl ?? 512)] }),
new Attribute({ type: "objectGUID", values: [Buffer.from(eintrag.guidBase64, "base64")] }),
];
await alsVerwalter(async (client) => {
await client.add(dn, werte);
});
return dn;
}
export async function personLoeschen(dn: string): Promise<void> {
await alsVerwalter(async (client) => {
await client.del(dn);
});
}
/** Ein Attribut aendern, um den zweiten Abgleich etwas zu tun zu geben. */
export async function attributSetzen(dn: string, attribut: string, wert: string): Promise<void> {
await alsVerwalter(async (client) => {
await client.modify(
dn,
new Change({
operation: "replace",
modification: new Attribute({ type: attribut, values: [wert] }),
}),
);
});
}
+110
View File
@@ -0,0 +1,110 @@
# Fixture-Personen fuer den Verzeichnis-Abgleich (#016).
#
# Bewusst klein, aber mit jedem Fall, den der Abgleich unterscheiden muss:
#
# Maria Huber vollstaendig, mit Vorgesetztem und Umlaut im Namen
# Klaus Berger ihr Vorgesetzter, damit `manager` aufloesbar ist
# Joerg Weiss Nachname mit Eszett, Mail-Adresse mit Umlaut-Ersatz
# Alte Kraft userAccountControl 514 (Bit 2 = deaktiviert) -- der
# Standardfilter muss sie ausschliessen
# Ohne Kennung ein Konto ohne objectGUID; ohne stabile Kennung darf der
# Abgleich es nicht anlegen, aber auch nicht abbrechen
#
# objectGUID steht base64-kodiert (`::`), weil es im AD ein binaeres Attribut
# ist. Die erwarteten GUID-Zeichenketten stehen in tests/verzeichnis.test.ts.
#
# Achtung: osixia laeuft mit `sed -i` ueber jede Datei in diesem Verzeichnis, um
# Platzhalter wie {{ LDAP_BASE_DN }} zu ersetzen. Hier stehen deshalb keine
# Platzhalter -- dann schreibt sed dieselben Bytes zurueck und der Arbeitsbaum
# bleibt sauber.
dn: ou=Benutzer,dc=firma,dc=test
objectClass: organizationalUnit
ou: Benutzer
dn: ou=Buchhaltung,ou=Benutzer,dc=firma,dc=test
objectClass: organizationalUnit
ou: Buchhaltung
dn: ou=Vertrieb,ou=Benutzer,dc=firma,dc=test
objectClass: organizationalUnit
ou: Vertrieb
dn: ou=Geschaeftsfuehrung,ou=Benutzer,dc=firma,dc=test
objectClass: organizationalUnit
ou: Geschaeftsfuehrung
dn: cn=Klaus Berger,ou=Geschaeftsfuehrung,ou=Benutzer,dc=firma,dc=test
objectClass: inetOrgPerson
objectClass: user
cn: Klaus Berger
sn: Berger
givenName: Klaus
displayName: Berger, Klaus
mail: klaus.berger@firma.test
userPrincipalName: klaus.berger@firma.test
sAMAccountName: kberger
department: Geschaeftsfuehrung
title: Geschaeftsfuehrer
objectCategory: person
userAccountControl: 512
objectGUID:: nEA+HXqyyUux5vCiw9Tl9g==
dn: cn=Maria Huber,ou=Buchhaltung,ou=Benutzer,dc=firma,dc=test
objectClass: inetOrgPerson
objectClass: user
cn: Maria Huber
sn: Huber
givenName: Maria
displayName:: SHViZXIsIE1hcmlh
mail: maria.huber@firma.test
userPrincipalName: maria.huber@firma.test
sAMAccountName: mhuber
department: Buchhaltung
title:: TGVpdGVyaW4gUmVjaG51bmdzcHLDvGZ1bmc=
objectCategory: person
userAccountControl: 512
manager: cn=Klaus Berger,ou=Geschaeftsfuehrung,ou=Benutzer,dc=firma,dc=test
objectGUID:: EEMHXzU8HUqDb6UPUV56NQ==
dn: cn=Joerg Weiss,ou=Vertrieb,ou=Benutzer,dc=firma,dc=test
objectClass: inetOrgPerson
objectClass: user
cn: Joerg Weiss
sn:: V2Vpw58=
givenName:: SsO2cmc=
displayName:: V2Vpw58sIErDtnJn
mail: joerg.weiss@firma.test
userPrincipalName: joerg.weiss@firma.test
sAMAccountName: jweiss
department: Vertrieb
objectCategory: person
userAccountControl: 512
manager: cn=Klaus Berger,ou=Geschaeftsfuehrung,ou=Benutzer,dc=firma,dc=test
objectGUID:: AQIDBAUGBwgJCgsMDQ4PEA==
dn: cn=Alte Kraft,ou=Vertrieb,ou=Benutzer,dc=firma,dc=test
objectClass: inetOrgPerson
objectClass: user
cn: Alte Kraft
sn: Kraft
givenName: Alte
displayName: Kraft, Alte
mail: alte.kraft@firma.test
userPrincipalName: alte.kraft@firma.test
sAMAccountName: akraft
department: Vertrieb
objectCategory: person
userAccountControl: 514
objectGUID:: /+7dzLuqmYh3ZlVEMyIRAA==
dn: cn=Ohne Kennung,ou=Vertrieb,ou=Benutzer,dc=firma,dc=test
objectClass: inetOrgPerson
objectClass: user
cn: Ohne Kennung
sn: Kennung
givenName: Ohne
displayName: Kennung, Ohne
sAMAccountName: okennung
objectCategory: person
userAccountControl: 512
+52
View File
@@ -0,0 +1,52 @@
# AD-aehnliches Schema fuer den Testcontainer (#016).
#
# OpenLDAP bringt keines der Attribute mit, an denen der Abgleich haengt:
# sAMAccountName, userPrincipalName, objectGUID, userAccountControl,
# objectCategory und department fehlen alle. Ohne dieses Schema liefe der
# Integrationstest gegen ein Verzeichnis, das mit einem Active Directory nichts
# gemeinsam hat -- und bewiese entsprechend wenig.
#
# `manager`, `displayName`, `givenName`, `sn`, `mail` und `title` kommen aus
# core/cosine/inetOrgPerson und stehen deshalb nicht hier.
#
# Die OIDs sind die echten aus dem Microsoft-Arc 1.2.840.113556, damit das
# Fixture nicht bloss gleich heisst, sondern auch gleich bezeichnet ist.
#
# Zwei bewusste Abweichungen vom echten AD, weil sie sich in OpenLDAP nicht
# nachbauen lassen:
#
# 1. `objectCategory` ist hier eine Zeichenkette mit dem Wert "person". Im AD
# ist es ein DN auf das classSchema-Objekt, und der Server nimmt die
# Kurzform "person" als Abkuerzung dafuer an. Der Standardfilter trifft
# damit hier und dort, aus verschiedenen Gruenden.
# 2. `user` ist hier AUXILIARY statt STRUCTURAL. So traegt ein Eintrag
# inetOrgPerson **und** user, statt dass die Personenklassen des Fixtures
# gegen die des AD ausgetauscht werden muessten. Fuer `(objectClass=user)`
# macht das keinen Unterschied.
dn: cn=ad-fixture,cn=schema,cn=config
objectClass: olcSchemaConfig
cn: ad-fixture
olcAttributeTypes: ( 1.2.840.113556.1.4.221 NAME 'sAMAccountName'
EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE )
olcAttributeTypes: ( 1.2.840.113556.1.4.656 NAME 'userPrincipalName'
EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE )
olcAttributeTypes: ( 1.2.840.113556.1.4.2 NAME 'objectGUID'
EQUALITY octetStringMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 SINGLE-VALUE )
olcAttributeTypes: ( 1.2.840.113556.1.4.8 NAME 'userAccountControl'
EQUALITY integerMatch ORDERING integerOrderingMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE )
olcAttributeTypes: ( 1.2.840.113556.1.4.782 NAME 'objectCategory'
EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE )
olcAttributeTypes: ( 1.2.840.113556.1.2.141 NAME 'department'
EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE )
olcObjectClasses: ( 1.2.840.113556.1.5.9 NAME 'user'
DESC 'AD-aehnliche Zusatzklasse, nur fuer Tests'
SUP top AUXILIARY
MAY ( sAMAccountName $ userPrincipalName $ objectGUID $ userAccountControl $
objectCategory $ department ) )