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 = {}): Record { 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[][]; /** 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((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((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); }); });