feat(verzeichnis): Entra ID als Quelle ueber Microsoft Graph (#48)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Ein Haus ohne lokales Active Directory hat kein LDAP, auf das sich
zeigen liesse. Eine Verzeichnisquelle hat jetzt eine **Art** — LDAP oder
GRAPH —, und beide laufen durch **denselben** Abgleich: anlegen,
aktualisieren, deaktivieren, nie loeschen. Nur das Holen der Eintraege
unterscheidet sich; zwei Abgleiche nebeneinander wuerden
auseinanderdriften, und die Zusage „es wird nichts geloescht“ gaelte
dann fuer eine Art und fuer die andere nicht mehr ganz.

Gelesen wird mit dem Client-Credentials-Flow, seitenweise ueber
@odata.nextLink, der Vorgesetzte ueber $expand=manager($select=id). Das
Client-Secret wird verschluesselt gespeichert wie das Bind-Passwort und
erreicht keine Antwort und keinen Protokolleintrag.

Die Art ist **unveraenderlich**, und das steht nicht im Issue: an ihr
haengt, was `externeId` bedeutet. Im AD eine objectGUID, in Entra ID
eine andere GUID — nach einem Wechsel wuerde kein einziger Mitarbeiter
wiedererkannt, der naechste Abgleich legte die ganze Belegschaft neu an
und setzte die alten Zeilen auf aktiv = false, mit allen Laeufen und
Geraeten daran.

Der teuerste Fund beim Bauen: „in Entra ID deaktiviert“ als
*uebersprungen* zu melden haette die Leute **aktiv** gelassen.
Uebersprungen heisst im Abgleich „nicht zuordenbar“, und solche
Eintraege werden seit #016 absichtlich nicht abgeschaltet. Gefiltert
wird deshalb serverseitig mit `$filter=accountEnabled eq true`, als
Gegenstueck zum LDAP-Standardfilter. Gefunden hat es der Test — aber nur,
weil der Doppelgaenger den Filter wirklich auswertet statt ihn bloss zu
empfangen.

{{ou}} bleibt bei einer Graph-Quelle leer: Entra ID hat keine OU, und
etwas hineinzudeuten waere ein Wert, den kein PowerShell-Befehl brauchen
kann — er saehe nur richtig aus. Steht an der Abteilung eine Ziel-OU
gepflegt, greift die.

Gegen einen echten Entra-Mandanten ist das **nicht** gelaufen. Geprueft
ist es gegen einen HTTP-Doppelgaenger mit aufgezeichneten Antworten; die
Pruefliste fuer den ersten echten Lauf steht am Ende von
docs/betrieb/entra-id.md.

Closes #48

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-06 23:36:36 +02:00
co-authored by Claude Opus 5
parent 0158d0292f
commit 76a61ec771
25 changed files with 2446 additions and 382 deletions
+618
View File
@@ -0,0 +1,618 @@
import { createServer, type Server } from "node:http";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus } from "@/generated/prisma/enums";
import { GRAPH_ZUORDNUNG, zuordnungLesen } from "@/lib/verzeichnis";
import { verschluesseln } from "@/lib/geheimnisse";
import {
angefragteFelder,
benutzerZuordnen,
ersteAdresse,
graphEintraegeStroemen,
} from "@/server/graph/leser";
import { GraphFehler, graphZugangAus, tokenHolen, tokenSpeicherLeeren } from "@/server/graph/zugang";
import { vorgesetzteAufloesen } from "@/server/ldap/zuordnung";
import { abgleichAusfuehren } from "@/server/services/abgleich";
import { eintraegeDerQuelleStroemen } from "@/server/verzeichnis/quellenleser";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Microsoft-Graph-Quelle (#028).
*
* **Geprueft wird gegen einen HTTP-Doppelgaenger mit aufgezeichneten Antworten,
* nicht gegen eine abgefangene Funktion.** Dass der Seitenabruf, der
* Token-Austausch und die Zuordnung zusammenpassen, zeigt erst eine Antwort, die
* durch `fetch`, JSON und `@odata.nextLink` gegangen ist.
*
* **Was dieser Test nicht leisten kann:** gegen einen echten Entra-Mandanten ist
* das nicht gelaufen. Die Gegenprobe steht als Pruefliste im Issue, wie bei #024
* fuer Proxmox. Was der Doppelgaenger liefert, ist der dokumentierten Form von
* Graph nachgebaut — stimmt die Form nicht, stimmt dieser Test und die
* Wirklichkeit nicht.
*/
const MANDANT = "11111111-2222-3333-4444-555555555555";
const ANWENDUNG = "66666666-7777-8888-9999-000000000000";
const GEHEIMNIS = "streng-geheimes-client-secret";
const TOKEN = "ein-zugriffstoken";
/** Ein Benutzer in der Form, in der Graph ihn liefert. */
function nutzer(nummer: number, zusatz: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: `guid-${nummer}`,
givenName: `Vorname${nummer}`,
surname: `Nachname${nummer}`,
displayName: `Vorname${nummer} Nachname${nummer}`,
mail: `person${nummer}@firma.example`,
userPrincipalName: `person${nummer}@firma.example`,
onPremisesSamAccountName: `person${nummer}`,
department: "Buchhaltung",
jobTitle: "Sachbearbeiter",
accountEnabled: true,
manager: null,
...zusatz,
};
}
/* ------------------------------------------------------------------ */
/* Der Doppelgaenger */
/* ------------------------------------------------------------------ */
type Aufzeichnung = {
/** Seiten in der Reihenfolge, in der sie geliefert werden. */
seiten: Record<string, unknown>[][];
/** Antwortcode fuer `/users`; 200, wenn nicht gesetzt. */
usersStatus?: number;
usersFehler?: { code: string; message: string };
tokenStatus?: number;
};
let server: Server | undefined;
let basis = "";
let aufzeichnung: Aufzeichnung = { seiten: [] };
let tokenAnfragen = 0;
let tokenRuempfe: string[] = [];
let usersAnfragen: string[] = [];
let usersKopfzeilen: (string | undefined)[] = [];
beforeAll(async () => {
server = createServer((anfrage, antwort) => {
const adresse = new URL(anfrage.url ?? "/", basis);
if (adresse.pathname.endsWith("/oauth2/v2.0/token")) {
tokenAnfragen += 1;
let rumpf = "";
anfrage.on("data", (stueck) => {
rumpf += stueck;
});
anfrage.on("end", () => {
tokenRuempfe.push(rumpf);
const status = aufzeichnung.tokenStatus ?? 200;
antwort.writeHead(status, { "Content-Type": "application/json" });
if (status !== 200) {
/*
* Entra ID antwortet sehr ausfuehrlich — mit Mandant, Anwendung und
* einer Ablaufverfolgungskennung. Genau das soll nicht nach draussen
* gelangen, und deshalb steht es hier im Doppelgaenger.
*/
antwort.end(
JSON.stringify({
error: "invalid_client",
error_description: `AADSTS7000215: Invalid client secret provided. Trace ID: abc Tenant: ${MANDANT} App: ${ANWENDUNG}`,
}),
);
return;
}
antwort.end(JSON.stringify({ access_token: TOKEN, expires_in: 3600 }));
});
return;
}
if (adresse.pathname === "/v1.0/users") {
usersAnfragen.push(anfrage.url ?? "");
usersKopfzeilen.push(anfrage.headers.authorization);
const status = aufzeichnung.usersStatus ?? 200;
if (status !== 200) {
antwort.writeHead(status, { "Content-Type": "application/json" });
antwort.end(JSON.stringify({ error: aufzeichnung.usersFehler ?? { code: "x", message: "y" } }));
return;
}
// Die Seitennummer steckt in `$skiptoken`; die erste Anfrage hat keinen.
const marke = Number.parseInt(adresse.searchParams.get("$skiptoken") ?? "0", 10);
/*
* **Der Doppelgaenger filtert, wie Graph es tut.** Sonst prueft der Test
* den Filter nicht, sondern nur, dass er in der Adresse steht — und der
* Unterschied ist genau der Fehler, den der Test „setzt aktiv = false“
* gefunden hat.
*/
const filter = adresse.searchParams.get("$filter") ?? "";
const nurAktive = filter.includes("accountEnabled eq true");
const seite = (aufzeichnung.seiten[marke] ?? []).filter(
(einer) => !nurAktive || einer["accountEnabled"] !== false,
);
const weiter =
marke + 1 < aufzeichnung.seiten.length ? `${basis}/v1.0/users?$skiptoken=${marke + 1}` : undefined;
antwort.writeHead(200, { "Content-Type": "application/json" });
antwort.end(JSON.stringify({ value: seite, ...(weiter ? { "@odata.nextLink": weiter } : {}) }));
return;
}
antwort.writeHead(404).end("unbekannt");
});
await new Promise<void>((fertig) => {
server?.listen(0, "127.0.0.1", () => fertig());
});
const adresse = server?.address();
const port = typeof adresse === "object" && adresse ? adresse.port : 0;
basis = `http://127.0.0.1:${port}`;
process.env["GRAPH_LOGIN_URL"] = basis;
process.env["GRAPH_API_URL"] = basis;
});
afterAll(async () => {
delete process.env["GRAPH_LOGIN_URL"];
delete process.env["GRAPH_API_URL"];
await new Promise<void>((fertig) => {
server?.close(() => fertig());
});
});
beforeEach(() => {
aufzeichnung = { seiten: [] };
tokenAnfragen = 0;
tokenRuempfe = [];
usersAnfragen = [];
usersKopfzeilen = [];
tokenSpeicherLeeren();
});
const ZUGANG = { tenantId: MANDANT, clientId: ANWENDUNG, clientSecret: GEHEIMNIS };
/* ------------------------------------------------------------------ */
/* Die reinen Teile */
/* ------------------------------------------------------------------ */
describe("Die Abfrage, die gestellt wird", () => {
it("fragt genau die zugeordneten Felder an, plus id und accountEnabled", () => {
const felder = angefragteFelder(GRAPH_ZUORDNUNG);
for (const erwartet of [
"givenName",
"surname",
"displayName",
"mail",
"userPrincipalName",
"onPremisesSamAccountName",
"department",
"jobTitle",
"id",
"accountEnabled",
]) {
expect(felder, `${erwartet} fehlt`).toContain(erwartet);
}
});
it("schreibt manager nicht in $select", () => {
/*
* `manager` ist eine **Beziehung**, keine Eigenschaft. In `$select` antwortet
* Graph mit einem Fehler, der nach einem Tippfehler aussieht und keiner ist.
*/
expect(angefragteFelder(GRAPH_ZUORDNUNG)).not.toContain("manager");
});
it("baut $expand mit unkodierten Klammern", () => {
const adresse = ersteAdresse(GRAPH_ZUORDNUNG);
// Prozentkodierte Klammern beantwortet Graph mit „Invalid expand clause“.
expect(adresse).toContain("$expand=manager($select=id)");
expect(adresse).not.toContain("%28");
expect(adresse).toContain("$top=999");
expect(adresse).toContain("$select=");
// Kein %24: die Adresse soll so aussehen wie in der Dokumentation.
expect(adresse).not.toContain("%24");
});
it("nimmt eine abweichende Zuordnung mit", () => {
const eigen = { ...GRAPH_ZUORDNUNG, abteilung: "companyName" };
expect(angefragteFelder(eigen)).toContain("companyName");
expect(angefragteFelder(eigen)).not.toContain("department");
});
});
describe("Die Zuordnung eines Graph-Benutzers", () => {
it("füllt die Felder und lässt den DN leer", () => {
const ergebnis = benutzerZuordnen(nutzer(1), GRAPH_ZUORDNUNG);
expect("eintrag" in ergebnis).toBe(true);
if (!("eintrag" in ergebnis)) return;
expect(ergebnis.eintrag).toMatchObject({
externeId: "guid-1",
vorname: "Vorname1",
nachname: "Nachname1",
upn: "person1@firma.example",
sam: "person1",
abteilung: "Buchhaltung",
titel: "Sachbearbeiter",
// Kein DN in Entra ID – und deshalb auch kein {{ou}}.
dn: null,
vorgesetzterDn: null,
});
});
it("nimmt den Vorgesetzten direkt aus dem expand", () => {
const ergebnis = benutzerZuordnen(nutzer(2, { manager: { id: "guid-1" } }), GRAPH_ZUORDNUNG);
if (!("eintrag" in ergebnis)) throw new Error("übersprungen");
expect(ergebnis.eintrag.vorgesetzterDirekt).toBe("guid-1");
expect(ergebnis.eintrag.vorgesetzterDn).toBeNull();
});
it("überspringt einen Benutzer ohne id", () => {
const ergebnis = benutzerZuordnen(nutzer(3, { id: null }), GRAPH_ZUORDNUNG);
expect("uebersprungen" in ergebnis).toBe(true);
if (!("uebersprungen" in ergebnis)) return;
// Als Kennzeichen der UPN: „wer“ wurde übersprungen, nicht „welcher DN“.
expect(ergebnis.uebersprungen.dn).toBe("person3@firma.example");
expect(ergebnis.uebersprungen.grund).toContain("id");
});
it("wirft bei einem deaktivierten Konto, statt es zu überspringen", () => {
/*
* **Der Unterschied kostet eine Person ihren Zugang.** „Übersprungen“ heißt
* im Abgleich „nicht zuordenbar“, und solche Einträge werden absichtlich
* nicht abgeschaltet (#016). Ein deaktiviertes Konto als übersprungen zu
* melden hätte es also **aktiv** gelassen — genau umgekehrt.
*
* Gefiltert wird deshalb serverseitig. Kommt hier doch eines an, ist der
* Filter nicht angekommen, und das ist ein Fehler in der Abfrage.
*/
expect(() => benutzerZuordnen(nutzer(4, { accountEnabled: false }), GRAPH_ZUORDNUNG)).toThrow(/Filter/);
});
it("nennt den Filter in der Abfrage", () => {
expect(ersteAdresse(GRAPH_ZUORDNUNG)).toContain("accountEnabled%20eq%20true");
});
it("baut einen Anzeigenamen, wenn keiner da ist", () => {
const ergebnis = benutzerZuordnen(
nutzer(5, { displayName: null, givenName: "Ada", surname: "Lovelace" }),
GRAPH_ZUORDNUNG,
);
if (!("eintrag" in ergebnis)) throw new Error("übersprungen");
expect(ergebnis.eintrag.anzeigename).toBe("Lovelace, Ada");
});
it("lässt den Anmeldenamen leer, wenn der Mandant kein AD synchronisiert", () => {
// Eine reine Cloud-Umgebung hat kein `onPremisesSamAccountName`. `{{sam}}`
// bleibt dann leer, statt dass etwas hineingedeutet wird.
const ergebnis = benutzerZuordnen(nutzer(6, { onPremisesSamAccountName: null }), GRAPH_ZUORDNUNG);
if (!("eintrag" in ergebnis)) throw new Error("übersprungen");
expect(ergebnis.eintrag.sam).toBeNull();
});
it("gibt dem Vorgesetzten den Vorrang vor der DN-Auflösung", () => {
const eintraege = [{ ...nutzer(1) }, { ...nutzer(2, { manager: { id: "guid-1" } }) }].map((n) => {
const e = benutzerZuordnen(n, GRAPH_ZUORDNUNG);
if (!("eintrag" in e)) throw new Error("übersprungen");
return e.eintrag;
});
const aufgeloest = vorgesetzteAufloesen(eintraege);
expect(aufgeloest[1]?.vorgesetzterExterneId).toBe("guid-1");
expect(aufgeloest[0]?.vorgesetzterExterneId).toBeNull();
});
});
describe("Die Zuordnungs-Vorgaben", () => {
it("nimmt für eine Graph-Quelle die Graph-Namen", () => {
expect(zuordnungLesen({}, "GRAPH")).toEqual(GRAPH_ZUORDNUNG);
// Und für LDAP weiter die AD-Namen – der Standardwert hält jeden Aufrufer
// von vor #028 bei seinem Verhalten.
expect(zuordnungLesen({}).nachname).toBe("sn");
expect(zuordnungLesen({}, "GRAPH").nachname).toBe("surname");
});
it("lässt eine gespeicherte Abweichung gewinnen", () => {
expect(zuordnungLesen({ titel: "eigenesFeld" }, "GRAPH").titel).toBe("eigenesFeld");
});
});
/* ------------------------------------------------------------------ */
/* Gegen den Doppelgaenger */
/* ------------------------------------------------------------------ */
describe("Der Token-Austausch", () => {
it("holt ein Token und benutzt es wieder", async () => {
const erst = await tokenHolen(ZUGANG);
expect(erst).toBe(TOKEN);
expect(tokenAnfragen).toBe(1);
// Zweiter Aufruf aus dem Zwischenspeicher: Microsoft begrenzt die Zahl der
// Token-Anfragen, und ein Abgleich mit fünf Seiten wäre sonst fünf Anmeldungen.
await tokenHolen(ZUGANG);
expect(tokenAnfragen).toBe(1);
});
it("holt kurz vor Ablauf ein neues", async () => {
const jetzt = Date.now();
await tokenHolen(ZUGANG, jetzt);
expect(tokenAnfragen).toBe(1);
/*
* 3600 s Gültigkeit, 60 s Sicherheitsabstand — die Grenze liegt also bei
* +3540 s. Die Zahlen hier standen beim ersten Lauf daneben, und der Test
* war rot: ein Beispiel dafür, warum die Grenze eine eigene Prüfung braucht
* und nicht „so ungefähr eine Stunde“ heißt.
*/
await tokenHolen(ZUGANG, jetzt + 3_530_000);
expect(tokenAnfragen).toBe(1);
await tokenHolen(ZUGANG, jetzt + 3_545_000);
expect(tokenAnfragen).toBe(2);
});
it("schickt den Client-Credentials-Flow mit dem Scope .default", async () => {
await tokenHolen(ZUGANG);
const rumpf = new URLSearchParams(tokenRuempfe[0] ?? "");
expect(rumpf.get("grant_type")).toBe("client_credentials");
expect(rumpf.get("client_id")).toBe(ANWENDUNG);
expect(rumpf.get("client_secret")).toBe(GEHEIMNIS);
/*
* `.default` und nicht einzelne Berechtigungen: beim
* Client-Credentials-Flow gelten die, die in der App-Registrierung
* eingewilligt wurden. Eine Liste hier waere eine zweite Wahrheit.
*/
expect(rumpf.get("scope")).toMatch(/\/\.default$/u);
});
it("schickt das Token als Bearer an die Abfrage", async () => {
aufzeichnung.seiten = [[nutzer(1)]];
await graphEintraegeStroemen(ZUGANG, GRAPH_ZUORDNUNG, () => undefined);
expect(usersKopfzeilen[0]).toBe(`Bearer ${TOKEN}`);
});
it("nennt das Geheimnis nicht in der Fehlermeldung", async () => {
aufzeichnung.tokenStatus = 401;
try {
await tokenHolen(ZUGANG);
throw new Error("hätte werfen müssen");
} catch (fehler) {
expect(fehler).toBeInstanceOf(GraphFehler);
const meldung = (fehler as Error).message;
expect(meldung).not.toContain(GEHEIMNIS);
// Und auch nicht die ausführliche Antwort von Entra ID – die geht ins
// Protokoll des Prozesses, nicht in eine Oberfläche.
expect(meldung).not.toContain("AADSTS7000215");
expect(meldung).not.toContain(MANDANT);
expect(meldung).toContain("401");
}
});
});
describe("Der Seitenabruf", () => {
it("folgt @odata.nextLink über drei Seiten", async () => {
aufzeichnung.seiten = [[nutzer(1), nutzer(2)], [nutzer(3), nutzer(4)], [nutzer(5)]];
const gesehen: string[] = [];
const bilanz = await graphEintraegeStroemen(
ZUGANG,
GRAPH_ZUORDNUNG,
(eintrag) => void gesehen.push(eintrag.externeId),
);
expect(bilanz.seiten).toBe(3);
expect(bilanz.gesamtGelesen).toBe(5);
expect(gesehen).toEqual(["guid-1", "guid-2", "guid-3", "guid-4", "guid-5"]);
// Drei Abrufe, und nur der erste trägt die Abfrageparameter.
expect(usersAnfragen).toHaveLength(3);
expect(usersAnfragen[0]).toContain("$select=");
expect(usersAnfragen[1]).toContain("$skiptoken=1");
});
it("hört bei der Grenze auf und holt keine weitere Seite", async () => {
aufzeichnung.seiten = [[nutzer(1), nutzer(2)], [nutzer(3)], [nutzer(4)]];
const bilanz = await graphEintraegeStroemen(ZUGANG, GRAPH_ZUORDNUNG, () => undefined, 2);
expect(bilanz.seiten).toBe(1);
expect(usersAnfragen).toHaveLength(1);
});
it("zählt übersprungene mit und liefert sie als Liste", async () => {
// Das deaktivierte Konto liefert der Server gar nicht; übersprungen wird
// nur das ohne Kennung.
aufzeichnung.seiten = [[nutzer(1), nutzer(2, { accountEnabled: false }), nutzer(3, { id: null })]];
const gesehen: string[] = [];
const bilanz = await graphEintraegeStroemen(
ZUGANG,
GRAPH_ZUORDNUNG,
(eintrag) => void gesehen.push(eintrag.externeId),
);
expect(bilanz.gesamtGelesen).toBe(2);
expect(gesehen).toEqual(["guid-1"]);
expect(bilanz.uebersprungen).toHaveLength(1);
});
it("nennt bei 403 die fehlende Einwilligung", async () => {
aufzeichnung.usersStatus = 403;
aufzeichnung.usersFehler = { code: "Authorization_RequestDenied", message: "Insufficient privileges" };
await expect(graphEintraegeStroemen(ZUGANG, GRAPH_ZUORDNUNG, () => undefined)).rejects.toThrow(
/User\.Read\.All/,
);
});
});
/* ------------------------------------------------------------------ */
/* Der Abgleich – derselbe Dienst wie bei LDAP */
/* ------------------------------------------------------------------ */
const ADMIN = { id: "", rolle: "ADMIN" } as const;
async function graphQuelleAnlegen() {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
const quelle = await testPrisma.verzeichnisquelle.create({
data: {
kundeId: eigene.id,
name: "Entra ID",
art: "GRAPH",
aktiv: true,
tenantId: MANDANT,
clientId: ANWENDUNG,
clientSecretVerschluesselt: verschluesseln(GEHEIMNIS),
attributZuordnung: {},
},
});
return { eigene, quelle };
}
describe.runIf(datenbankVerfuegbar)("Der Abgleich einer Graph-Quelle", () => {
beforeEach(async () => {
await datenbankLeeren();
});
afterEach(() => {
tokenSpeicherLeeren();
});
it("legt beim ersten Lauf an und aktualisiert beim zweiten ohne Dubletten", async () => {
const { quelle } = await graphQuelleAnlegen();
aufzeichnung.seiten = [[nutzer(1), nutzer(2, { manager: { id: "guid-1" } })]];
const erst = await abgleichAusfuehren(quelle.id, ADMIN);
expect(erst.status).toBe("ERFOLG");
expect(erst.angelegt).toBe(2);
expect(await testPrisma.mitarbeiter.count()).toBe(2);
// Der Vorgesetzte kommt über `$expand` und nicht über eine DN-Auflösung.
const zwei = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { externeId: "guid-2" } });
const eins = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { externeId: "guid-1" } });
expect(zwei.vorgesetzterExterneId).toBe("guid-1");
expect(zwei.vorgesetzterId).toBe(eins.id);
// Und kein DN, also auch kein erfundenes {{ou}}.
expect(eins.dn).toBeNull();
// Zweiter Lauf: eine Änderung, keine Dublette.
aufzeichnung.seiten = [[nutzer(1, { department: "Vertrieb" }), nutzer(2, { manager: { id: "guid-1" } })]];
const zweit = await abgleichAusfuehren(quelle.id, ADMIN);
expect(zweit.angelegt).toBe(0);
expect(zweit.aktualisiert).toBe(1);
expect(await testPrisma.mitarbeiter.count()).toBe(2);
expect(
(await testPrisma.mitarbeiter.findFirstOrThrow({ where: { externeId: "guid-1" } })).abteilung,
).toBe("Vertrieb");
});
it("setzt aktiv = false, wenn accountEnabled auf false steht", async () => {
const { quelle } = await graphQuelleAnlegen();
aufzeichnung.seiten = [[nutzer(1), nutzer(2)]];
await abgleichAusfuehren(quelle.id, ADMIN);
// Konto in Entra ID gesperrt: es wird übersprungen, also nicht mehr
// wiedergesehen — und was nicht wiedergesehen wird, ist inaktiv.
aufzeichnung.seiten = [[nutzer(1), nutzer(2, { accountEnabled: false })]];
const ergebnis = await abgleichAusfuehren(quelle.id, ADMIN);
expect(ergebnis.deaktiviert).toBe(1);
const zwei = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { externeId: "guid-2" } });
expect(zwei.aktiv).toBe(false);
// Gelöscht wird nichts – die Zeile bleibt mit allem daran.
expect(await testPrisma.mitarbeiter.count()).toBe(2);
});
it("gibt das Client-Secret in keiner Antwort und in keinem Protokoll heraus", async () => {
const { quelle } = await graphQuelleAnlegen();
aufzeichnung.seiten = [[nutzer(1)]];
await abgleichAusfuehren(quelle.id, ADMIN);
const protokoll = await testPrisma.protokoll.findMany({ where: { entitaetId: quelle.id } });
const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
const alles = JSON.stringify({ protokoll, letzterFehler: zeile.letzterAbgleichFehler });
expect(alles).not.toContain(GEHEIMNIS);
expect(alles).not.toContain("clientSecret");
// In der Datenbank steht es verschlüsselt.
expect(zeile.clientSecretVerschluesselt).toMatch(/^v[0-9]+\./u);
expect(zeile.clientSecretVerschluesselt).not.toContain(GEHEIMNIS);
});
it("schreibt den Fehler an die Quelle, ohne das Geheimnis zu nennen", async () => {
const { quelle } = await graphQuelleAnlegen();
aufzeichnung.tokenStatus = 401;
const ergebnis = await abgleichAusfuehren(quelle.id, ADMIN);
expect(ergebnis.status).not.toBe("ERFOLG");
const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
expect(zeile.letzterAbgleichFehler).toBeTruthy();
expect(zeile.letzterAbgleichFehler).not.toContain(GEHEIMNIS);
expect(zeile.letzterAbgleichFehler).not.toContain("AADSTS7000215");
});
it("weist eine SCIM-Quelle ab, statt stillschweigend nichts zu tun", async () => {
/*
* `SCIM` steht im Enum, weil #029 dieselbe Weiche braucht. Ein Abgleich, der
* null Einträge liest und ERFOLG meldet, hätte über die Abschaltregel die
* ganze Belegschaft deaktiviert — genau der Fall, der in #016 einen
* Reviewbefund gekostet hat.
*/
const { eigene } = await graphQuelleAnlegen();
const scim = await testPrisma.verzeichnisquelle.create({
data: { kundeId: eigene.id, name: "SCIM später", art: "SCIM", aktiv: true, attributZuordnung: {} },
});
const ergebnis = await abgleichAusfuehren(scim.id, ADMIN);
expect(ergebnis.status).not.toBe("ERFOLG");
expect(ergebnis.fehler).toContain("#029");
});
it("liest eine LDAP-Quelle weiter über den LDAP-Weg", async () => {
// Die Weiche darf den bestehenden Weg nicht umlenken: eine LDAP-Quelle ohne
// erreichbaren Server scheitert an der **Verbindung** und nicht daran, dass
// Graph gefragt würde.
const { eigene } = await graphQuelleAnlegen();
const ldap = await testPrisma.verzeichnisquelle.create({
data: {
kundeId: eigene.id,
name: "AD",
art: "LDAP",
aktiv: true,
url: "ldap://127.0.0.1:1",
bindDn: "cn=admin,dc=firma,dc=test",
bindPasswortVerschluesselt: verschluesseln("egal"),
basisDn: "dc=firma,dc=test",
filter: "(objectClass=user)",
attributZuordnung: {},
},
});
await expect(
eintraegeDerQuelleStroemen(
await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: ldap.id } }),
() => undefined,
),
).rejects.toThrow();
// Keine Graph-Anfrage dabei.
expect(usersAnfragen).toHaveLength(0);
expect(tokenAnfragen).toBe(0);
});
});
describe("Der Zugang aus einer Quellenzeile", () => {
it("entschlüsselt das Geheimnis", () => {
const zugang = graphZugangAus({
art: "GRAPH",
tenantId: MANDANT,
clientId: ANWENDUNG,
clientSecretVerschluesselt: verschluesseln(GEHEIMNIS),
});
expect(zugang.clientSecret).toBe(GEHEIMNIS);
});
it("wirft bei einer Zeile ohne Graph-Felder", () => {
expect(() =>
graphZugangAus({ art: "LDAP", tenantId: null, clientId: null, clientSecretVerschluesselt: null }),
).toThrow(GraphFehler);
});
});