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>
1112 lines
42 KiB
TypeScript
1112 lines
42 KiB
TypeScript
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<string, unknown>;
|
||
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<string, unknown>;
|
||
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<string, unknown>)["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<string, unknown>)["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<string, unknown>)["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<string, unknown>)["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<string, unknown>)["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<string, unknown>)["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<string, unknown>)["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<string, unknown>)["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<string, unknown>)["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<string, Record<string, unknown>>
|
||
>;
|
||
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<string, unknown>)["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<string, unknown>)["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<string, unknown>)["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<string, unknown>)["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<string, unknown>
|
||
>;
|
||
|
||
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<string, unknown>;
|
||
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]);
|
||
});
|
||
});
|