Files
Florian WildeandClaude Opus 5 80d97467da
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
fix: Reviewbefunde aus der Durchsicht von Phase 2 (#036, #027)
Acht Meilensteine lagen ohne fremden Blick auf main. Ein Review mit eigener
Datenbank, Gegenproben und vollem Suitenlauf hat sie gegen die
Akzeptanzkriterien und gegen CLAUDE.md geprueft.

Die Kernregeln halten: fuenf von fuenf Gegenproben gegen Versionierung,
Mandantengrenze, Abschlussregel und Selbstauskunft wurden rot. Alle Befunde
lagen am Rand — und die drei wertvollsten waren Gegenproben, die gruen blieben.

Schwer:

- Der Link in der Mail "Verzeichnis-Abgleich fehlgeschlagen" war ein 404
  (/verwaltung/verzeichnis statt /admin/verzeichnis). Die eine Meldung, die an
  alle Administratoren geht, im Moment eines Ausfalls. Ein Test prueft jetzt
  jeden Meldungspfad gegen die vorhandenen Routen.
- Der Export eines Entwurfs mit Baustein lieferte einen leeren Abschnitt unter
  dem Platzhalter "wird ersetzt" — der Zweck des Formats ist, eine Vorlage in
  eine zweite Installation zu bringen. Bausteine werden jetzt aufgeloest.

Mittel:

- Der Anhang-Handler hatte keinen Test; die ganze Rechtepruefung liess sich
  durch einen hartcodierten ADMIN ersetzen, Suite gruen. Es ist die einzige
  Stelle, die rohe Dateibytes ausliefert. tests/anhang-route.test.ts faehrt ihn
  jetzt echt, acht Pruefungen.
- /api/scim in der Proxy-Allowlist stand in keinem Test. OEFFENTLICH ist
  exportiert, der Test vergleicht beide Richtungen: eine Liste, die nur in einer
  Richtung geprueft wird, laeuft dem Code hinterher.
- Ein offener Ereignisstrom ueberlebte Abmelden, Deaktivieren und
  Sitzungswiderruf. Der Herzschlag prueft die Sitzung jetzt nach. Dass es kein
  Test zeigte, lag an einer Attrappe `sitzung: {}` — eine Attrappe ohne die Form
  der Wirklichkeit prueft etwas anderes als die Wirklichkeit.
- README kannte MITARBEITER nicht und behauptete, unbekannte Benutzer wuerden
  angelegt. Wer auf v0.2.0 hebt und nur das README liest, haette genau diese
  Leute ausgesperrt.
- sicherung.md nannte einen Anhaenge-Pfad, den es nirgends gibt — als
  Rueckfallwert in einem Skript mit rm -rf. Jetzt bricht es ab statt zu raten.
- Attributnamen wurden nur in der Server Action geprueft, waehrend der
  Graph-Leser sich im Kommentar darauf berief.

Dazu #027: die Ende-zu-Ende-Ablaeufe raeumen jetzt zentral auf, vor und nach
dem Lauf, und ein struktureller Test bewacht es. Beim ersten echten Lauf seit
dem 26. September waren sieben Ablaeufe rot, vier Ursachen, keine ein
Produktfehler — unbemerkt, weil pnpm test:e2e in keinem Tor steht. Das ist jetzt
#038 und keine Fussnote.

Ein Befund des Reviews war falsch (der Zustands-Handler habe keinen Test; er hat
acht direkte Aufrufe). Nachgeprueft, bevor etwas geaendert wurde.

1454 Vitest-Tests (vorher 1432), 113 E2E-Ablaeufe gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-11 01:32:09 +02:00

651 lines
26 KiB
TypeScript
Raw Permalink 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,
naechsteSeite,
} 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 einem nextLink nur auf denselben Host", () => {
/*
* **Jede Seite wird mit dem Zugangstoken geholt**, und das gilt mandantenweit
* (`User.Read.All`). Der Antwort von Graph über TLS zu trauen ist
* vertretbar — aber `GRAPH_API_URL` ist absichtlich einstellbar, für
* nationale Clouds und für den Doppelgänger in diesem Test. Ein Tippfehler
* darin oder ein aufbrechender Firmenproxy dazwischen schickte das Token
* sonst an eine fremde Adresse (Reviewbefund zu #029).
*/
const basis = "https://graph.microsoft.com";
expect(naechsteSeite(`${basis}/v1.0/users?$skiptoken=abc`, basis)).toBe(
`${basis}/v1.0/users?$skiptoken=abc`,
);
// Anderer Host: Abruf endet, statt das Token mitzuschicken.
expect(naechsteSeite("https://boese.example/v1.0/users", basis)).toBeUndefined();
// Auch ein anderes Schema oder ein anderer Port ist ein anderer Ursprung.
expect(naechsteSeite("http://graph.microsoft.com/v1.0/users", basis)).toBeUndefined();
expect(naechsteSeite("https://graph.microsoft.com:8443/v1.0/users", basis)).toBeUndefined();
// Und das Übliche: nichts, leer, kein gültiges URL.
expect(naechsteSeite(undefined, basis)).toBeUndefined();
expect(naechsteSeite("", basis)).toBeUndefined();
expect(naechsteSeite("kein-url", basis)).toBeUndefined();
expect(naechsteSeite(42, basis)).toBeUndefined();
});
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);
});
});