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>
162 lines
6.5 KiB
TypeScript
162 lines
6.5 KiB
TypeScript
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] }),
|
||
}),
|
||
);
|
||
});
|
||
}
|