feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log (#30)
This commit was merged in pull request #30.
This commit is contained in:
Vendored
+161
@@ -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] }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user