feat(verzeichnis): Entra ID als Quelle ueber Microsoft Graph (#48)
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:
co-authored by
Claude Opus 5
parent
0158d0292f
commit
76a61ec771
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user