Files
Laufzettel/tests/graph.test.ts
T
Florian WildeandClaude Opus 5 fcd67018b8
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(verzeichnis): SCIM-Endpunkt als dritte Quellenart (#029)
Bisher holt Laufzettel Personen aus dem Verzeichnis. Das setzt Lesezugriff und
einen Netzweg dorthin voraus, und beides ist nicht ueberall gegeben. Mit der Art
SCIM ruft stattdessen der Anbieter an — sofort, nicht beim naechsten Intervall.

Umgesetzt ist, was authentik, Entra ID und Okta wirklich schicken: Users mit dem
Filter userName eq, POST/GET/PUT/PATCH/DELETE, dazu ServiceProviderConfig,
Schemas und ResourceTypes. Jeder Handler prueft sein Token selbst.

Ein Token je Quelle, argon2-gehasht, einmal angezeigt — es ist der einzige
Mandantenbeweis, den eine Zustellung mitbringt. DELETE deaktiviert und loescht
nichts. Eine SCIM-Quelle wird nicht abgeglichen, an zwei Stellen gehalten: ein
Abgleich, der dort nichts liest und ERFOLG meldet, haette ueber die
Abschaltregel die ganze Belegschaft deaktiviert.

Zwei Befunde kamen aus Gegenproben, nicht aus Tests:

- PATCH remove wirkte gar nicht. Der Schalter nurGesetzte las bei PATCH jedes
  null als "nicht mitgeschickt"; eine beim Anbieter entfernte Abteilung stand
  hier weiter. Noetig war er nie — patchAnwenden liefert immer den
  vollstaendigen Zielzustand.
- Entra ID schickt active als "True"/"False". Beim Deaktivieren kommt mit einem
  reinen value === true zufaellig das Richtige heraus, beim Aktivieren nicht:
  ein wieder freigeschaltetes Konto waere fuer immer inaktiv geblieben.

Dazu ein Fehler aus #028: in verbindungTestenAktion stand zweimal dieselbe
Bedingung, der zweite Zweig war seit dem ersten Tag unerreichbar.

1432 Tests (vorher 1373). Klickprobe am gebauten Stand mit einer echten
Zustellung. Gegen echte Mandanten ist nichts gelaufen — die Anfrageformen
stammen aus der Dokumentation, und das steht so in der Testdatei.

Closes #49

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-10 19:50:54 +02:00

623 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 () => {
/*
* Dieselbe Weiche, dritter Fall. 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.
*
* Seit #029 ist SCIM umgesetzt, und die Antwort bleibt dieselbe: dort wird
* nicht geholt, sondern geliefert. Die Meldung sagt das und nennt keine
* Issue-Nummer mehr — sie steht auch in der Oberfläche.
*/
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).toMatch(/beliefert/);
expect(ergebnis.fehler).toContain("SCIM später");
});
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);
});
});