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

This commit was merged in pull request #30.
This commit is contained in:
2026-09-26 00:21:56 +00:00
parent ab5777a57e
commit ae290345b7
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] }),
}),
);
});
}