Files
Laufzettel/tests/graph.test.ts
T
Florian WildeandClaude Opus 5 76a61ec771
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
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>
2026-10-06 23:36:36 +02:00

619 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});