import { beforeEach, describe, expect, it } from "vitest"; import { Betriebsmodus } from "@/generated/prisma/enums"; import { SCHEMA_ENTERPRISE, SCIM_TYP, benutzerLesen, benutzernameAusFilter, fehlerDarstellen, listeDarstellen, mailAus, patchAnwenden, patchLesen, personDarstellen, vorgesetzterAus, } from "@/lib/scim"; import { abgleichAusfuehren, faelligeQuellen } from "@/server/services/abgleich"; import { quelleHolen, quellenListe } from "@/server/queries/verzeichnis"; import { quelleAnlegen, quelleBearbeiten, verbindungTesten } from "@/server/services/verzeichnis"; import { TOKEN_PRAEFIX, quelleZuToken, scimTokenErzeugen, scimTokenWiderrufen, tokenErzeugen, } from "@/server/services/scim"; import { GET as usersGet, POST as usersPost } from "@/app/api/scim/v2/Users/route"; import { DELETE as userDelete, GET as userGet, PATCH as userPatch, PUT as userPut, } from "@/app/api/scim/v2/Users/[id]/route"; import { GET as konfigGet } from "@/app/api/scim/v2/ServiceProviderConfig/route"; import { GET as schemasGet } from "@/app/api/scim/v2/Schemas/route"; import { GET as typenGet } from "@/app/api/scim/v2/ResourceTypes/route"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; /** * SCIM-Endpunkt (#029). * * Geprueft wird gegen die **Anfrageformen von authentik und Entra ID** — die * Handler werden echt gefahren, mit echten `Request`-Objekten, echten Kopfzeilen * und echtem JSON. * * **Was dieser Test nicht leisten kann, und das gehoert gesagt:** die Formen * stammen aus der Dokumentation der beiden Anbieter, nicht aus einem * Mitschnitt. Stimmt die Form nicht, stimmen Test und Wirklichkeit nicht — wie * bei #028. Die Pruefliste fuer den ersten echten Lauf steht im Issue. * * Sechs Zusagen tragen das Ganze: * * 1. Ohne Token `401`, mit fremdem Token kein Zugriff auf fremde Personen. * 2. `DELETE` deaktiviert und loescht nichts. * 3. Zweimal dasselbe `POST` ergibt `409`. * 4. Das Token steht nur als Hash in der Datenbank und in keiner Antwort. * 5. Eine SCIM-Quelle wird vom Zeitplan **nicht** abgeglichen. * 6. `PATCH active` deaktiviert und aktiviert wieder. */ const BASIS = "http://127.0.0.1:3300/api/scim/v2"; /* ------------------------------------------------------------------ */ /* Aufgezeichnete Anfrageformen */ /* ------------------------------------------------------------------ */ /** * So schickt **authentik** einen neuen Benutzer. * * Kennzeichen: `primary: true` an der Mailadresse, die Erweiterung als voll * ausgeschriebener Schema-Schluessel, `active` ist dabei. */ const AUTHENTIK_POST = { schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], userName: "mhuber@firma.example", externalId: "ak-7f3e1c", displayName: "Maria Huber", name: { givenName: "Maria", familyName: "Huber" }, emails: [{ value: "maria.huber@firma.example", type: "work", primary: true }], active: true, [SCHEMA_ENTERPRISE]: { department: "Buchhaltung" }, }; /** * So schickt **Entra ID** einen neuen Benutzer. * * Kennzeichen: `emails` **ohne** `primary`, `title` auf oberster Ebene, und * `externalId` ist die Objekt-ID des Mandanten. */ const ENTRA_POST = { schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], userName: "ademir@firma.example", externalId: "11111111-2222-3333-4444-555555555555", displayName: "Ali Demir", name: { givenName: "Ali", familyName: "Demir" }, emails: [{ value: "ali.demir@firma.example", type: "work" }], title: "Techniker", active: true, [SCHEMA_ENTERPRISE]: { department: "IT" }, }; /** So deaktiviert Entra ID ein Konto: `Replace` mit grossem R. */ const ENTRA_PATCH_AUS = { schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], Operations: [{ op: "Replace", path: "active", value: "False" }], }; /** So aktiviert authentik wieder: kleines `replace`, echter Bool. */ const AUTHENTIK_PATCH_AN = { schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], Operations: [{ op: "replace", path: "active", value: true }], }; /** * So aktiviert **Entra ID** wieder: `"True"` als **Zeichenkette**. * * Derselbe Anbieter, derselbe Pfad, und der Wert ist kein Bool. Das ist die * gefaehrlichere Haelfte des Paares: beim Deaktivieren kommt mit einem reinen * `value === true` zufaellig das Richtige heraus, beim Aktivieren nicht. */ const ENTRA_PATCH_AN = { schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], Operations: [{ op: "Replace", path: "active", value: "True" }], }; /** So leert authentik ein Feld: `remove` ohne `value`. */ const AUTHENTIK_PATCH_ABTEILUNG_WEG = { schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], Operations: [ { op: "remove", path: "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department" }, ], }; /* ------------------------------------------------------------------ */ /* Die reinen Teile */ /* ------------------------------------------------------------------ */ describe("Eine eingehende Benutzer-Darstellung lesen", () => { it("liest die Form von authentik", () => { expect(benutzerLesen(AUTHENTIK_POST)).toEqual({ externeId: "ak-7f3e1c", upn: "mhuber@firma.example", vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber", mail: "maria.huber@firma.example", abteilung: "Buchhaltung", titel: null, aktiv: true, vorgesetzterScimId: null, }); }); it("liest die Form von Entra ID", () => { const gelesen = benutzerLesen(ENTRA_POST); // `emails` ohne `primary` – der erste Eintrag gewinnt. expect(gelesen.mail).toBe("ali.demir@firma.example"); expect(gelesen.titel).toBe("Techniker"); expect(gelesen.abteilung).toBe("IT"); }); it("nimmt bei mehreren Adressen die primäre", () => { expect( mailAus([ { value: "privat@example.com", type: "home" }, { value: "arbeit@firma.example", type: "work", primary: true }, ]), ).toBe("arbeit@firma.example"); // Ohne Kennzeichnung die erste – so schickt es Entra ID. expect(mailAus([{ value: "erste@firma.example" }, { value: "zweite@firma.example" }])).toBe( "erste@firma.example", ); expect(mailAus(undefined)).toBeNull(); expect(mailAus("keine Liste")).toBeNull(); }); it("macht aus fehlenden Feldern null und nicht leere Zeichenketten", () => { /* * Der Unterschied entscheidet beim Aktualisieren: `null` heisst bei `PATCH` * „nicht mitgeschickt“ und bleibt stehen, `""` wäre eine Löschung. */ const gelesen = benutzerLesen({ userName: "nur@upn.example" }); expect(gelesen.vorname).toBeNull(); expect(gelesen.mail).toBeNull(); expect(gelesen.aktiv).toBeNull(); }); it("verträgt einen Rumpf, der gar kein Objekt ist", () => { expect(benutzerLesen(null).upn).toBeNull(); expect(benutzerLesen("Unsinn").upn).toBeNull(); }); }); describe("Der Filter", () => { it("liest userName eq", () => { expect(benutzernameAusFilter('userName eq "mhuber@firma.example"')).toBe("mhuber@firma.example"); // Anbieter schreiben es unterschiedlich. expect(benutzernameAusFilter(' username EQ "a@b.c" ')).toBe("a@b.c"); }); it("gibt undefined ohne Filter", () => { expect(benutzernameAusFilter(null)).toBeUndefined(); expect(benutzernameAusFilter(" ")).toBeUndefined(); }); it("gibt null bei einem Filter, den wir nicht verstehen", () => { /* * **Der wichtigste Fall.** Ihn zu ignorieren hiesse, alle Personen zu * liefern — und der Anbieter schloesse daraus, die gesuchte sei darunter. * Er legte sie dann nicht an. */ expect(benutzernameAusFilter('emails[type eq "work"].value co "@"')).toBeNull(); expect(benutzernameAusFilter('userName sw "m"')).toBeNull(); expect(benutzernameAusFilter('userName eq "a" and active eq true')).toBeNull(); }); }); describe("PatchOp", () => { const vorher = { externeId: "x", upn: "a@b.c", vorname: "Ada", nachname: "Lovelace", anzeigename: "Ada Lovelace", mail: "ada@b.c", abteilung: "IT", titel: "Analytikerin", aktiv: true, vorgesetzterScimId: null, }; it("liest Operations unabhängig von der Schreibweise des op", () => { expect(patchLesen(ENTRA_PATCH_AUS)).toEqual([{ op: "replace", path: "active", value: "False" }]); expect(patchLesen(AUTHENTIK_PATCH_AN)).toEqual([{ op: "replace", path: "active", value: true }]); }); it("weist einen Rumpf ohne Operations ab", () => { expect(patchLesen({ schemas: [] })).toBeNull(); expect(patchLesen({ Operations: "keine Liste" })).toBeNull(); expect(patchLesen({ Operations: [{ op: "unbekannt", path: "active" }] })).toBeNull(); }); it("deaktiviert über active – auch mit der Zeichenkette „False“", () => { /* * Entra ID schickt `"False"` als **Text**. Ein `value === false` haette das * als „wahr“ gelesen und das Konto aktiv gelassen — der Fehler, der am * teuersten ist, weil er aussieht wie nichts. */ const { eingabe } = patchAnwenden(patchLesen(ENTRA_PATCH_AUS) ?? [], vorher); expect(eingabe.aktiv).toBe(false); // Und die übrigen Felder bleiben stehen. expect(eingabe.vorname).toBe("Ada"); expect(eingabe.mail).toBe("ada@b.c"); }); it("aktiviert wieder – auch mit der Zeichenkette „True“", () => { const { eingabe } = patchAnwenden(patchLesen(AUTHENTIK_PATCH_AN) ?? [], { ...vorher, aktiv: false }); expect(eingabe.aktiv).toBe(true); /* * **Die Haelfte, die wirklich weh tut.** Entra ID schickt denselben Pfad mit * `"True"` als Text. Ein `value === true` laesst das Konto deaktiviert — und * das Deaktivieren funktionierte trotzdem, der Fehler waere also nur in * einer Richtung sichtbar. */ const entra = patchAnwenden(patchLesen(ENTRA_PATCH_AN) ?? [], { ...vorher, aktiv: false }); expect(entra.eingabe.aktiv).toBe(true); }); it("kennt die Pfade, die die Anbieter schicken", () => { const { eingabe } = patchAnwenden( [ { op: "replace", path: "name.givenName", value: "Augusta" }, { op: "replace", path: 'emails[type eq "work"].value', value: "neu@b.c" }, { op: "replace", path: `${SCHEMA_ENTERPRISE}:department`, value: "Vertrieb" }, { op: "remove", path: "title", value: undefined }, ], vorher, ); expect(eingabe.vorname).toBe("Augusta"); expect(eingabe.mail).toBe("neu@b.c"); expect(eingabe.abteilung).toBe("Vertrieb"); expect(eingabe.titel).toBeNull(); }); it("übergeht einen unbekannten Pfad, statt die Zuweisung anzuhalten", () => { /* * Nachsicht an der richtigen Stelle: Anbieter schicken Felder, die * Laufzettel nicht fuehrt. Ein `400` darauf braechte die ganze Zuweisung zum * Stehen — wegen `preferredLanguage`. */ const { eingabe, uebergangen } = patchAnwenden( [{ op: "replace", path: "preferredLanguage", value: "de-DE" }], vorher, ); expect(uebergangen).toEqual(["preferredLanguage"]); expect(eingabe).toEqual(vorher); }); it("liest einen Schritt ohne path als ganzes Objekt", () => { const { eingabe } = patchAnwenden( [{ op: "replace", path: undefined, value: { displayName: "Neuer Name" } }], vorher, ); expect(eingabe.anzeigename).toBe("Neuer Name"); expect(eingabe.vorname).toBe("Ada"); }); }); describe("Der Vorgesetzte", () => { it("liest alle drei Formen, die die Anbieter schicken", () => { // RFC 7643: ein Objekt mit `value`. expect(vorgesetzterAus({ value: "mit-7", displayName: "Chefin" })).toBe("mit-7"); // Nur ein Verweis – das letzte Pfadstueck ist die Kennung. expect(vorgesetzterAus({ $ref: "https://laufzettel.firma.de/api/scim/v2/Users/mit-7" })).toBe("mit-7"); /* * Die nackte Zeichenkette ist nicht standardkonform, kommt aber vor. Sie * nicht zu lesen hiesse, dass ein Vorgesetzter **stillschweigend** fehlt — * und das faellt an einer Oberflaeche, die ihn nur anzeigt, nie auf. */ expect(vorgesetzterAus("mit-7")).toBe("mit-7"); expect(vorgesetzterAus(undefined)).toBeNull(); expect(vorgesetzterAus({})).toBeNull(); }); it("liest manager aus der Erweiterung", () => { const gelesen = benutzerLesen({ userName: "a@b.c", [SCHEMA_ENTERPRISE]: { department: "IT", manager: { value: "mit-7" } }, }); expect(gelesen.vorgesetzterScimId).toBe("mit-7"); }); }); describe("Die Antwortformen", () => { const person = { id: "mit-1", externeId: "ak-1", upn: "a@b.c", vorname: "Ada", nachname: "Lovelace", anzeigename: "Ada Lovelace", mail: "ada@b.c", abteilung: "IT", titel: "Analytikerin", aktiv: true, vorgesetzterId: null, erstelltAm: new Date("2026-10-01T08:00:00Z"), geaendertAm: new Date("2026-10-02T09:00:00Z"), }; it("stellt eine Person nach RFC 7644 dar", () => { const dargestellt = personDarstellen(person, BASIS); expect(dargestellt["id"]).toBe("mit-1"); expect(dargestellt["userName"]).toBe("a@b.c"); expect(dargestellt["externalId"]).toBe("ak-1"); expect(dargestellt["active"]).toBe(true); expect(dargestellt[SCHEMA_ENTERPRISE]).toEqual({ department: "IT" }); expect(dargestellt["meta"]).toMatchObject({ resourceType: "User", location: `${BASIS}/Users/mit-1`, }); }); it("zählt bei einer Liste ab 1 und nicht ab 0", () => { // `startIndex` ist nach RFC 1-basiert. Ein 0 hier laesst manche Anbieter die // erste Seite zweimal holen. const liste = listeDarstellen([person], BASIS); expect(liste["startIndex"]).toBe(1); expect(liste["totalResults"]).toBe(1); expect(liste["itemsPerPage"]).toBe(1); }); it("schreibt den Status eines Fehlers als Zeichenkette", () => { // Auch das steht so im RFC – als Zahl weisen manche Anbieter die Antwort ab. expect(fehlerDarstellen(409, "schon da", "uniqueness")).toEqual({ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"], status: "409", scimType: "uniqueness", detail: "schon da", }); }); }); describe("Das Token", () => { it("trägt ein erkennbares Präfix und ist lang genug", () => { const token = tokenErzeugen(); expect(token.startsWith(TOKEN_PRAEFIX)).toBe(true); expect(token.length).toBeGreaterThan(40); }); }); /* ------------------------------------------------------------------ */ /* Mit Datenbank */ /* ------------------------------------------------------------------ */ async function aufbau() { 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 adminZeile = await testPrisma.benutzer.create({ data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" }, }); const technikerZeile = await testPrisma.benutzer.create({ data: { email: "tina@firma.example", name: "Tina", rolle: "TECHNIKER" }, }); const admin = { id: adminZeile.id, rolle: adminZeile.rolle }; const technikerin = { id: technikerZeile.id, rolle: technikerZeile.rolle }; const quelle = await testPrisma.verzeichnisquelle.create({ data: { kundeId: eigene.id, name: "authentik", art: "SCIM", aktiv: true, attributZuordnung: {} }, }); const { token } = await scimTokenErzeugen(quelle.id, admin); return { eigene, quelle, token, admin, technikerin }; } function anfrage(token: string | undefined, pfad = "/Users", init: RequestInit = {}): Request { return new Request(`http://127.0.0.1:3300/api/scim/v2${pfad}`, { ...init, headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(init.body ? { "content-type": SCIM_TYP } : {}), ...(init.headers ?? {}), }, }); } const kennung = (id: string) => ({ params: Promise.resolve({ id }) }); describe.runIf(datenbankVerfuegbar)("Das Token in der Datenbank", () => { beforeEach(async () => { await datenbankLeeren(); }); it("steht nur als Hash da und nie im Klartext", async () => { const { quelle, token } = await aufbau(); const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } }); expect(zeile.scimTokenHash).toMatch(/^\$argon2/u); expect(zeile.scimTokenHash).not.toContain(token); expect(zeile.scimTokenGesetztAm).toBeInstanceOf(Date); }); it("erscheint in keinem Protokolleintrag", async () => { const { quelle, token } = await aufbau(); const protokoll = await testPrisma.protokoll.findMany({ where: { entitaetId: quelle.id } }); expect(JSON.stringify(protokoll)).not.toContain(token); expect(protokoll.some((p) => p.aktion.includes("SCIM-Token erzeugt"))).toBe(true); }); it("findet die Quelle wieder und ein falsches nicht", async () => { const { quelle, token } = await aufbau(); expect((await quelleZuToken(token))?.id).toBe(quelle.id); expect(await quelleZuToken(`${TOKEN_PRAEFIX}voelligFalsch`)).toBeUndefined(); // Ohne Präfix wird gar nicht erst gegen die Hashes geprüft. expect(await quelleZuToken("ohne-praefix")).toBeUndefined(); }); it("gilt nach dem Widerrufen nicht mehr", async () => { const { quelle, token, admin } = await aufbau(); await scimTokenWiderrufen(quelle.id, admin); expect(await quelleZuToken(token)).toBeUndefined(); const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } }); expect(zeile.scimTokenHash).toBeNull(); expect(zeile.scimTokenGesetztAm).toBeNull(); }); it("ersetzt ein vorhandenes, statt ein zweites zu erlauben", async () => { const { quelle, token: erstes, admin } = await aufbau(); const { token: zweites } = await scimTokenErzeugen(quelle.id, admin); expect(zweites).not.toBe(erstes); expect(await quelleZuToken(erstes)).toBeUndefined(); expect((await quelleZuToken(zweites))?.id).toBe(quelle.id); }); it("gibt kein Token für eine LDAP-Quelle", async () => { const { eigene, admin } = await aufbau(); const ldap = await testPrisma.verzeichnisquelle.create({ data: { kundeId: eigene.id, name: "AD", art: "LDAP", url: "ldaps://dc.firma.test", bindDn: "cn=x", bindPasswortVerschluesselt: "v1.egal", basisDn: "dc=firma", filter: "(objectClass=user)", attributZuordnung: {}, }, }); await expect(scimTokenErzeugen(ldap.id, admin)).rejects.toThrow(/Art LDAP/); }); it("verlangt das Recht verwaltung", async () => { // Ein Token ist der Schluessel zum Verzeichnis eines Kunden. Es zu erzeugen // ist Verwaltung, nicht Technik. const { quelle, technikerin } = await aufbau(); await expect(scimTokenErzeugen(quelle.id, technikerin)).rejects.toThrow(/verwaltung/); await expect(scimTokenWiderrufen(quelle.id, technikerin)).rejects.toThrow(/verwaltung/); }); }); describe.runIf(datenbankVerfuegbar)("Der Endpunkt", () => { beforeEach(async () => { await datenbankLeeren(); }); it("weist ohne Token ab, und ohne Unterschied zu einem falschen", async () => { await aufbau(); const ohne = await usersGet(anfrage(undefined)); expect(ohne.status).toBe(401); expect(ohne.headers.get("www-authenticate")).toContain("Bearer"); expect(ohne.headers.get("content-type")).toContain(SCIM_TYP); const falsch = await usersGet(anfrage(`${TOKEN_PRAEFIX}falsch`)); expect(falsch.status).toBe(401); }); it("weist ein Token ab, dessen Quelle nicht mehr aktiv ist", async () => { const { quelle, token } = await aufbau(); await testPrisma.verzeichnisquelle.update({ where: { id: quelle.id }, data: { aktiv: false } }); expect((await usersGet(anfrage(token))).status).toBe(401); }); it("legt eine Person an – in der Form von authentik", async () => { const { quelle, token } = await aufbau(); const antwort = await usersPost( anfrage(token, "/Users", { method: "POST", body: JSON.stringify(AUTHENTIK_POST) }), ); expect(antwort.status).toBe(201); expect(antwort.headers.get("location")).toContain("/Users/"); const daten = (await antwort.json()) as Record; expect(daten["userName"]).toBe("mhuber@firma.example"); expect(daten["active"]).toBe(true); const zeile = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { quelleId: quelle.id } }); expect(zeile).toMatchObject({ upn: "mhuber@firma.example", externeId: "ak-7f3e1c", vorname: "Maria", nachname: "Huber", abteilung: "Buchhaltung", aktiv: true, herkunft: "ABGLEICH", }); // Der Kunde kommt aus der Quelle – das Token ist der einzige Mandantenbeweis. expect(zeile.kundeId).toBe(quelle.kundeId); }); it("legt eine Person an – in der Form von Entra ID", async () => { const { token } = await aufbau(); const antwort = await usersPost( anfrage(token, "/Users", { method: "POST", body: JSON.stringify(ENTRA_POST) }), ); expect(antwort.status).toBe(201); const zeile = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { upn: "ademir@firma.example" } }); expect(zeile.mail).toBe("ali.demir@firma.example"); expect(zeile.titel).toBe("Techniker"); expect(zeile.abteilung).toBe("IT"); }); it("antwortet beim zweiten gleichen POST mit 409 und legt nichts an", async () => { const { token } = await aufbau(); await usersPost(anfrage(token, "/Users", { method: "POST", body: JSON.stringify(AUTHENTIK_POST) })); const zweite = await usersPost( anfrage(token, "/Users", { method: "POST", body: JSON.stringify(AUTHENTIK_POST) }), ); expect(zweite.status).toBe(409); expect((await zweite.json())["scimType"]).toBe("uniqueness"); expect(await testPrisma.mitarbeiter.count()).toBe(1); }); it("findet eine Person über den Filter userName eq", async () => { const { token } = await aufbau(); await usersPost(anfrage(token, "/Users", { method: "POST", body: JSON.stringify(AUTHENTIK_POST) })); const gefunden = await usersGet( anfrage(token, `/Users?filter=${encodeURIComponent('userName eq "mhuber@firma.example"')}`), ); const liste = (await gefunden.json()) as Record; expect(liste["totalResults"]).toBe(1); // Und jemanden, den es nicht gibt, findet es nicht – das ist die Frage vor // dem Anlegen, und eine falsche Antwort hier erzeugt Dubletten. const leer = await usersGet( anfrage(token, `/Users?filter=${encodeURIComponent('userName eq "niemand@firma.example"')}`), ); expect((await leer.json())["totalResults"]).toBe(0); }); it("weist einen Filter ab, den es nicht versteht", async () => { const { token } = await aufbau(); const antwort = await usersGet( anfrage(token, `/Users?filter=${encodeURIComponent('emails[type eq "work"].value co "@"')}`), ); expect(antwort.status).toBe(400); expect((await antwort.json())["scimType"]).toBe("invalidFilter"); }); it("deaktiviert über PATCH active und aktiviert wieder", async () => { const { token } = await aufbau(); const angelegt = await usersPost( anfrage(token, "/Users", { method: "POST", body: JSON.stringify(AUTHENTIK_POST) }), ); const id = String(((await angelegt.json()) as Record)["id"]); const aus = await userPatch( anfrage(token, `/Users/${id}`, { method: "PATCH", body: JSON.stringify(ENTRA_PATCH_AUS) }), kennung(id), ); expect(aus.status).toBe(200); expect((await aus.json())["active"]).toBe(false); let zeile = await testPrisma.mitarbeiter.findUniqueOrThrow({ where: { id } }); expect(zeile.aktiv).toBe(false); // Die übrigen Felder stehen noch – ein PATCH leert nichts. expect(zeile.vorname).toBe("Maria"); expect(zeile.abteilung).toBe("Buchhaltung"); const an = await userPatch( anfrage(token, `/Users/${id}`, { method: "PATCH", body: JSON.stringify(AUTHENTIK_PATCH_AN) }), kennung(id), ); expect((await an.json())["active"]).toBe(true); zeile = await testPrisma.mitarbeiter.findUniqueOrThrow({ where: { id } }); expect(zeile.aktiv).toBe(true); }); it("leert ein Feld bei PATCH remove wirklich", async () => { /* * `remove` ist kein Sonderfall, sondern Alltag: jemand nimmt beim Anbieter * die Abteilung aus dem Profil, und der schickt genau das. Bleibt der alte * Wert stehen, zeigt Laufzettel dauerhaft eine Abteilung, die niemand mehr * pflegt — und niemand merkt es, weil ein Wert da ist. */ const { token } = await aufbau(); const angelegt = await usersPost( anfrage(token, "/Users", { method: "POST", body: JSON.stringify(AUTHENTIK_POST) }), ); const id = String(((await angelegt.json()) as Record)["id"]); const antwort = await userPatch( anfrage(token, `/Users/${id}`, { method: "PATCH", body: JSON.stringify(AUTHENTIK_PATCH_ABTEILUNG_WEG), }), kennung(id), ); expect(antwort.status).toBe(200); const zeile = await testPrisma.mitarbeiter.findUniqueOrThrow({ where: { id } }); expect(zeile.abteilung).toBeNull(); // Und nur das – der Rest steht noch. expect(zeile.vorname).toBe("Maria"); expect(zeile.aktiv).toBe(true); }); it("aktiviert über PATCH auch, wenn Entra ID „True“ als Text schickt", async () => { const { token } = await aufbau(); const angelegt = await usersPost( anfrage(token, "/Users", { method: "POST", body: JSON.stringify(AUTHENTIK_POST) }), ); const id = String(((await angelegt.json()) as Record)["id"]); await userPatch( anfrage(token, `/Users/${id}`, { method: "PATCH", body: JSON.stringify(ENTRA_PATCH_AUS) }), kennung(id), ); const an = await userPatch( anfrage(token, `/Users/${id}`, { method: "PATCH", body: JSON.stringify(ENTRA_PATCH_AN) }), kennung(id), ); expect((await an.json())["active"]).toBe(true); expect((await testPrisma.mitarbeiter.findUniqueOrThrow({ where: { id } })).aktiv).toBe(true); }); it("ersetzt bei PUT die ganze Darstellung", async () => { const { token } = await aufbau(); const angelegt = await usersPost( anfrage(token, "/Users", { method: "POST", body: JSON.stringify(AUTHENTIK_POST) }), ); const id = String(((await angelegt.json()) as Record)["id"]); // Ohne `title` und ohne Abteilung: beides wird geleert, das ist PUT. const antwort = await userPut( anfrage(token, `/Users/${id}`, { method: "PUT", body: JSON.stringify({ schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], userName: "mhuber@firma.example", displayName: "Maria Huber-Neu", name: { givenName: "Maria", familyName: "Huber-Neu" }, active: true, }), }), kennung(id), ); expect(antwort.status).toBe(200); const zeile = await testPrisma.mitarbeiter.findUniqueOrThrow({ where: { id } }); expect(zeile.nachname).toBe("Huber-Neu"); expect(zeile.abteilung).toBeNull(); }); it("setzt bei DELETE aktiv = false und löscht nichts", async () => { const { token } = await aufbau(); const angelegt = await usersPost( anfrage(token, "/Users", { method: "POST", body: JSON.stringify(AUTHENTIK_POST) }), ); const id = String(((await angelegt.json()) as Record)["id"]); const antwort = await userDelete(anfrage(token, `/Users/${id}`, { method: "DELETE" }), kennung(id)); expect(antwort.status).toBe(204); /* * **Die Regel des ganzen Projekts.** An einer Person hängen Läufe und * Geräte; ein archivierter Lauf muss auch in zehn Jahren noch sagen können, * für wen er lief. */ const zeile = await testPrisma.mitarbeiter.findUniqueOrThrow({ where: { id } }); expect(zeile.aktiv).toBe(false); expect(await testPrisma.mitarbeiter.count()).toBe(1); // Und der Vorgang steht im Protokoll, mit der Quelle als Urheber. const eintrag = await testPrisma.protokoll.findFirstOrThrow({ where: { entitaetId: id, aktion: { contains: "DELETE" } }, }); expect(eintrag.benutzerId).toBeNull(); expect(eintrag.aktion).toContain("authentik"); }); it("lässt ein fremdes Token nicht an die Personen einer anderen Quelle", async () => { const { eigene, token, admin } = await aufbau(); const angelegt = await usersPost( anfrage(token, "/Users", { method: "POST", body: JSON.stringify(AUTHENTIK_POST) }), ); const id = String(((await angelegt.json()) as Record)["id"]); // Eine zweite SCIM-Quelle mit eigenem Token. const zweite = await testPrisma.verzeichnisquelle.create({ data: { kundeId: eigene.id, name: "Entra", art: "SCIM", aktiv: true, attributZuordnung: {} }, }); const { token: fremd } = await scimTokenErzeugen(zweite.id, admin); // Dieselbe Kennung, anderes Token: nicht gefunden – nicht „verboten“, denn // ob es die Person gibt, ist für diese Quelle keine Auskunft. expect((await userGet(anfrage(fremd, `/Users/${id}`), kennung(id))).status).toBe(404); expect( ( await userPatch( anfrage(fremd, `/Users/${id}`, { method: "PATCH", body: JSON.stringify(ENTRA_PATCH_AUS) }), kennung(id), ) ).status, ).toBe(404); expect((await userDelete(anfrage(fremd, `/Users/${id}`, { method: "DELETE" }), kennung(id))).status).toBe( 404, ); // Und die Liste der fremden Quelle ist leer. expect(((await (await usersGet(anfrage(fremd))).json()) as Record)["totalResults"]).toBe( 0, ); // Die Person ist unverändert aktiv. expect((await testPrisma.mitarbeiter.findUniqueOrThrow({ where: { id } })).aktiv).toBe(true); }); it("übernimmt eine von Hand angelegte Person statt eine Dublette zu erzeugen", async () => { const { eigene, quelle, token } = await aufbau(); const vonHand = await testPrisma.mitarbeiter.create({ data: { kundeId: eigene.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber", upn: "mhuber@firma.example", }, }); await usersPost(anfrage(token, "/Users", { method: "POST", body: JSON.stringify(AUTHENTIK_POST) })); expect(await testPrisma.mitarbeiter.count()).toBe(1); const zeile = await testPrisma.mitarbeiter.findUniqueOrThrow({ where: { id: vonHand.id } }); expect(zeile.quelleId).toBe(quelle.id); expect(zeile.externeId).toBe("ak-7f3e1c"); }); it("verknüpft den Vorgesetzten, sobald beide zugewiesen sind", async () => { const { token } = await aufbau(); // Erst die Chefin, dann die Mitarbeiterin mit `manager` auf deren Kennung. const chefin = await usersPost( anfrage(token, "/Users", { method: "POST", body: JSON.stringify({ ...AUTHENTIK_POST, userName: "chefin@firma.example", externalId: "ak-chef" }), }), ); const chefinId = String(((await chefin.json()) as Record)["id"]); const antwort = await usersPost( anfrage(token, "/Users", { method: "POST", body: JSON.stringify({ ...AUTHENTIK_POST, [SCHEMA_ENTERPRISE]: { department: "Buchhaltung", manager: { value: chefinId } }, }), }), ); const id = String(((await antwort.json()) as Record)["id"]); expect((await testPrisma.mitarbeiter.findUniqueOrThrow({ where: { id } })).vorgesetzterId).toBe(chefinId); // Und die Antwort nennt ihn wieder, so wie RFC 7643 es vorsieht. const gelesen = (await (await userGet(anfrage(token, `/Users/${id}`), kennung(id))).json()) as Record< string, Record> >; expect(gelesen[SCHEMA_ENTERPRISE]?.["manager"]?.["value"]).toBe(chefinId); }); it("lässt einen unbekannten Vorgesetzten leer, statt die Zuweisung scheitern zu lassen", async () => { /* * Der Anbieter schickt die Leute in beliebiger Reihenfolge. Wer an einer * noch unbekannten Kennung scheiterte, liesse die halbe Belegschaft * draussen — nachgezogen wird beim naechsten `PATCH`. */ const { token } = await aufbau(); const antwort = await usersPost( anfrage(token, "/Users", { method: "POST", body: JSON.stringify({ ...AUTHENTIK_POST, [SCHEMA_ENTERPRISE]: { department: "Buchhaltung", manager: { value: "gibt-es-nicht" } }, }), }), ); expect(antwort.status).toBe(201); const id = String(((await antwort.json()) as Record)["id"]); expect((await testPrisma.mitarbeiter.findUniqueOrThrow({ where: { id } })).vorgesetzterId).toBeNull(); }); it("nimmt als Vorgesetzten niemanden aus einer anderen Quelle", async () => { /* * **`manager` wäre sonst ein Weg über die Quellengrenze**: eine Kennung aus * einer fremden Quelle zeigt auf jemanden, den diese hier nicht sehen darf — * und über die Vorgesetzten-Anzeige stünde dessen Name in der Oberfläche. */ const { eigene, token, admin } = await aufbau(); const zweite = await testPrisma.verzeichnisquelle.create({ data: { kundeId: eigene.id, name: "Entra", art: "SCIM", aktiv: true, attributZuordnung: {} }, }); const { token: fremd } = await scimTokenErzeugen(zweite.id, admin); const fremdePerson = await usersPost( anfrage(fremd, "/Users", { method: "POST", body: JSON.stringify({ ...AUTHENTIK_POST, userName: "fremd@firma.example", externalId: "ak-f" }), }), ); const fremdeId = String(((await fremdePerson.json()) as Record)["id"]); const antwort = await usersPost( anfrage(token, "/Users", { method: "POST", body: JSON.stringify({ ...AUTHENTIK_POST, [SCHEMA_ENTERPRISE]: { manager: { value: fremdeId } }, }), }), ); const id = String(((await antwort.json()) as Record)["id"]); expect((await testPrisma.mitarbeiter.findUniqueOrThrow({ where: { id } })).vorgesetzterId).toBeNull(); }); it("macht niemanden zu seinem eigenen Vorgesetzten", async () => { // Die Datenbank weist es ohnehin ab (`Mitarbeiter_vorgesetzter_nicht_selbst`); // hier scheitert deshalb nicht die ganze Zuweisung daran. const { token } = await aufbau(); const angelegt = await usersPost( anfrage(token, "/Users", { method: "POST", body: JSON.stringify(AUTHENTIK_POST) }), ); const id = String(((await angelegt.json()) as Record)["id"]); const antwort = await userPatch( anfrage(token, `/Users/${id}`, { method: "PATCH", body: JSON.stringify({ schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], Operations: [ { op: "replace", path: "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager", value: { value: id }, }, ], }), }), kennung(id), ); expect(antwort.status).toBe(200); expect((await testPrisma.mitarbeiter.findUniqueOrThrow({ where: { id } })).vorgesetzterId).toBeNull(); }); it("vermerkt die letzte Zustellung", async () => { const { quelle, token } = await aufbau(); expect( (await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } })) .letzteScimZustellungAm, ).toBeNull(); await usersPost(anfrage(token, "/Users", { method: "POST", body: JSON.stringify(AUTHENTIK_POST) })); expect( (await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } })) .letzteScimZustellungAm, ).toBeInstanceOf(Date); }); it("antwortet auf kaputtes JSON mit 400", async () => { const { token } = await aufbau(); const antwort = await usersPost(anfrage(token, "/Users", { method: "POST", body: "{kein json" })); expect(antwort.status).toBe(400); }); }); describe.runIf(datenbankVerfuegbar)("Die Beschreibungs-Endpunkte", () => { beforeEach(async () => { await datenbankLeeren(); }); it("meldet, was wirklich geht – und was nicht", async () => { const { token } = await aufbau(); const daten = (await (await konfigGet(anfrage(token, "/ServiceProviderConfig"))).json()) as Record< string, Record >; expect(daten["patch"]?.["supported"]).toBe(true); /* * Die **Verneinungen** sind das Entscheidende: ein Anbieter, der `bulk` für * unterstützt hält, schickt Anfragen, die niemand beantwortet. */ expect(daten["bulk"]?.["supported"]).toBe(false); expect(daten["sort"]?.["supported"]).toBe(false); expect(daten["filter"]?.["supported"]).toBe(false); }); it("beschreibt genau die Attribute, die Laufzettel führt", async () => { const { token } = await aufbau(); const daten = (await (await schemasGet(anfrage(token, "/Schemas"))).json()) as Record; expect(daten["totalResults"]).toBe(2); const alsText = JSON.stringify(daten); expect(alsText).toContain("userName"); expect(alsText).toContain("department"); /* * `manager` muss darin stehen, sonst schickt der Anbieter es nicht — und der * Vorgesetzte bliebe leer, ohne Fehlermeldung auf einer der beiden Seiten. */ expect(alsText).toContain("manager"); // Nichts, was wir nicht führen. expect(alsText).not.toContain("addresses"); expect(alsText).not.toContain("phoneNumbers"); }); it("nennt nur User und keine Group", async () => { const { token } = await aufbau(); const daten = (await (await typenGet(anfrage(token, "/ResourceTypes"))).json()) as Record< string, unknown >; expect(daten["totalResults"]).toBe(1); expect(JSON.stringify(daten)).not.toContain('"Group"'); }); it("verlangt auch hier ein Token", async () => { await aufbau(); expect((await konfigGet(anfrage(undefined, "/ServiceProviderConfig"))).status).toBe(401); expect((await schemasGet(anfrage(undefined, "/Schemas"))).status).toBe(401); expect((await typenGet(anfrage(undefined, "/ResourceTypes"))).status).toBe(401); }); }); describe.runIf(datenbankVerfuegbar)("Die Quelle in der Verwaltung", () => { beforeEach(async () => { await datenbankLeeren(); }); it("lässt sich mit der Art SCIM anlegen – ohne Zugangsdaten", async () => { const { admin } = await aufbau(); const quelle = await quelleAnlegen({ name: "authentik Push", art: "SCIM", aktiv: true }, admin); expect(quelle.art).toBe("SCIM"); // Kein Feld der anderen Arten belegt – die Datenbank verlangt das auch. expect(quelle.url).toBeNull(); expect(quelle.basisDn).toBeNull(); expect(quelle.tenantId).toBeNull(); expect(quelle.clientSecretVorhanden).toBe(false); // Und ohne Token: das entsteht danach. expect(quelle.scimTokenVorhanden).toBe(false); expect(quelle.letzteScimZustellungAm).toBeNull(); }); it("weist LDAP-Felder an einer SCIM-Quelle ab, statt sie stillschweigend zu schlucken", async () => { const { admin } = await aufbau(); await expect( quelleAnlegen({ name: "Krumm", art: "SCIM", basisDn: "dc=firma", aktiv: true }, admin), ).rejects.toThrow(/weder LDAP- noch Graph-Zugangsdaten/); }); it("trägt das Token in keiner Sicht – auch nicht als Hash", async () => { /* * **Die Zusage dieses Meilensteins.** Das Token ist der einzige * Mandantenbeweis, den ein Push mitbringt; es geht genau einmal aus dem * Dienst heraus. Geprüft wird nicht ein Feld, sondern die **ganze** * serialisierte Sicht — ein später ergänztes Feld fällt damit auch auf. */ const { quelle, token, admin } = await aufbau(); const zeile = await testPrisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } }); for (const sicht of [ JSON.stringify(await quellenListe(admin)), JSON.stringify(await quelleHolen(quelle.id, admin)), ]) { expect(sicht).not.toContain(token); expect(sicht).not.toContain(zeile.scimTokenHash); expect(sicht).not.toContain("scimTokenHash"); } // Dass überhaupt eines gilt, steht aber drin – sonst wäre die Oberfläche blind. expect(JSON.stringify(await quellenListe(admin))).toContain("scimTokenVorhanden"); }); it("lehnt „Verbindung testen“ an einer SCIM-Quelle mit einer Erklärung ab", async () => { // Nicht mit „unbekannter Fehler“: es ist kein Fehler, sondern eine Art, die // anders arbeitet. const { quelle, admin } = await aufbau(); await expect(verbindungTesten(quelle.id, admin)).rejects.toThrow(/keine Verbindung/); }); it("lehnt „Jetzt abgleichen“ ab, statt still die Belegschaft zu deaktivieren", async () => { /* * Der gefährliche Weg: ein Abgleich, der null Einträge liest und `ERFOLG` * meldet, würde über die Abschaltregel alle deaktivieren. Deshalb ein Wurf * an der Weiche und kein leeres Ergebnis (#016). */ const { eigene, quelle, admin } = await aufbau(); const person = await testPrisma.mitarbeiter.create({ data: { kundeId: eigene.id, quelleId: quelle.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber", upn: "mhuber@firma.example", externeId: "ak-1", }, }); const ergebnis = await abgleichAusfuehren(quelle.id, admin); expect(ergebnis.status).toBe("FEHLER"); expect(ergebnis.fehler).toMatch(/beliefert/); expect(ergebnis.deaktiviert).toBe(0); expect((await testPrisma.mitarbeiter.findUniqueOrThrow({ where: { id: person.id } })).aktiv).toBe(true); }); it("lässt die Art nicht nachträglich wechseln", async () => { // Daran hängt, was die dauerhafte Kennung bedeutet. const { quelle, admin } = await aufbau(); await expect(quelleBearbeiten(quelle.id, { art: "LDAP" }, admin)).rejects.toThrow( /lässt sich nicht ändern/, ); }); }); describe.runIf(datenbankVerfuegbar)("Der Zeitplan", () => { beforeEach(async () => { await datenbankLeeren(); }); it("gleicht eine SCIM-Quelle nicht ab", async () => { /* * Bei SCIM holt Laufzettel nicht, es bekommt. Ohne diese Bedingung hätte der * Zeitplan jede Minute versucht zu lesen, wäre an der Weiche gescheitert und * hätte „kann noch nicht abgeglichen werden“ an die Quelle geschrieben — * dauerhaft ein Fehler, der keiner ist, und sichtbar in der Oberfläche. */ const { eigene } = await aufbau(); const ldap = await testPrisma.verzeichnisquelle.create({ data: { kundeId: eigene.id, name: "AD", art: "LDAP", aktiv: true, url: "ldaps://dc.firma.test", bindDn: "cn=x", bindPasswortVerschluesselt: "v1.egal", basisDn: "dc=firma", filter: "(objectClass=user)", attributZuordnung: {}, }, }); const faellig = await faelligeQuellen(); expect(faellig.map((q) => q.id)).toEqual([ldap.id]); }); });