import { expect, test, type Locator, type Page } from "@playwright/test"; import "dotenv/config"; import { verschluesseln } from "@/lib/geheimnisse"; import { AD_ZUORDNUNG } from "@/lib/verzeichnis"; import { prisma } from "@/lib/db"; import { STANDARD_FILTER } from "@/server/ldap/zuordnung"; import { anmelden, betriebsmodus } from "./helfer"; /** * Admin → Verzeichnis durch die Oberflaeche (#016). * * Ohne LDAP-Container: geprueft wird, was auch ohne Verzeichnis gilt — dass die * Attributzuordnung bedienbar ist, dass eine krumme Eingabe abgewiesen wird und * dass eine gute wirklich in der Datenbank landet. Was der Abgleich **tut**, * steht in `tests/abgleich.test.ts` gegen einen echten Server. * * Voraussetzungen prueft `tests/e2e/global-setup.ts`. Die Quelle, an der hier * gearbeitet wird, legt der Ablauf selbst an und loescht sie hinterher: vorher * nahm er die erste vorhandene, schaltete sich mit `test.skip` ab, wenn es keine * gab, und schrieb die Attributzuordnung einer fremden — im Zweifel produktiven * — Quelle um (#018). Angefasst wird nur der Kasten mit dem eigenen Namen. * * Die Seite gibt es nur im Modus INTERN (#008); im Modus DIENSTLEISTUNG leitet * sie um, und dann schlaegt dieser Ablauf fehl. Das ist die richtige Antwort: * die Ablaeufe pruefen den Modus aus der `.env`, und der ist INTERN. */ /* * Das Verzeichnis gibt es **nur** im Modus INTERN (#008): im Modus * DIENSTLEISTUNG leitet die Seite auf `/admin` um, und es gibt nichts zu * pruefen. Uebersprungen mit Grund — ein Ablauf, der dort rot wuerde, sagte * etwas ueber den Modus und nichts ueber das Verzeichnis. */ test.skip(betriebsmodus() === "DIENSTLEISTUNG", "Das Verzeichnis gibt es nur im Modus INTERN (#008)."); const STEMPEL = Date.now(); const QUELLEN_NAME = `ZZ E2E Verzeichnis ${STEMPEL}`; /** Der Kasten dieser Quelle – alles andere auf der Seite bleibt unberührt. */ function kasten(seite: Page): Locator { return seite.locator("li").filter({ has: seite.getByRole("heading", { name: QUELLEN_NAME }) }); } test.beforeAll(async () => { const kunde = await prisma.kunde.findFirstOrThrow({ where: { istEigeneOrganisation: true }, select: { id: true }, }); await prisma.verzeichnisquelle.create({ data: { kundeId: kunde.id, name: QUELLEN_NAME, url: "ldaps://dc-e2e.firma.local:636", bindDn: "CN=e2e,OU=Dienstkonten,DC=firma,DC=local", // Der CHECK in der Datenbank verlangt das Formatpraefix; verschluesselt wird // deshalb wirklich und nicht mit einer Zeichenkette getan. bindPasswortVerschluesselt: verschluesseln(`e2e-${STEMPEL}`), basisDn: "OU=Benutzer,DC=firma,DC=local", filter: STANDARD_FILTER, attributZuordnung: { ...AD_ZUORDNUNG }, abgleichIntervallMinuten: 60, // Kein Zeitplan: eine aktive Quelle auf einen Rechner, den es nicht gibt, // liesse den Abgleich im Hintergrund in Fehler laufen. aktiv: false, }, }); }); test.describe("Verzeichnis", () => { test("macht die Attributzuordnung bedienbar und prüft sie", async ({ page }) => { test.slow(); await anmelden(page); await page.goto("/admin/verzeichnis"); const meine = kasten(page); await expect(meine).toHaveCount(1); await meine.locator("summary", { hasText: "Einstellungen bearbeiten" }).click(); await meine.locator("summary", { hasText: "Attributzuordnung anpassen" }).click(); const kennung = meine.locator('input[name="attr.externeId"]'); await expect(kennung).toBeVisible(); await expect(kennung).toHaveValue("objectGUID"); // Eine Klammer darf nicht in eine LDAP-Anfrage wandern. const titel = meine.locator('input[name="attr.titel"]'); await titel.fill("(objectClass=*)"); await meine.getByRole("button", { name: "Speichern" }).click(); await expect(meine.locator('[role="alert"]').first()).toContainText("kein Attributname"); // Und eine gute Eingabe steht danach wirklich in der Datenbank. await titel.fill("personalTitle"); await meine.getByRole("button", { name: "Speichern" }).click(); await expect(meine.locator('[role="status"]').last()).toContainText("gespeichert"); const nachher = await prisma.verzeichnisquelle.findFirstOrThrow({ where: { name: QUELLEN_NAME } }); expect((nachher.attributZuordnung as Record)["titel"]).toBe("personalTitle"); // Neu geladen steht sie auch im Formular – sonst waere sie nur gespeichert, // aber nicht bearbeitbar. await page.reload(); const nochmal = kasten(page); await nochmal.locator("summary", { hasText: "Einstellungen bearbeiten" }).click(); await nochmal.locator("summary", { hasText: "Attributzuordnung anpassen" }).click(); await expect(nochmal.locator('input[name="attr.titel"]')).toHaveValue("personalTitle"); }); test("zeigt das Abgleich-Log und trägt am Handy ohne horizontales Scrollen", async ({ page }) => { await anmelden(page); await page.goto("/admin/verzeichnis"); const meine = kasten(page); /* * Ohne Bedingung: die eigene Quelle steht da, also steht auch ihr Log da. * Vorher hing der Klick an einem `if (count > 0)` — fehlte der Aufklapper, * bestand der Test, ohne etwas geprueft zu haben. */ const log = meine.locator("summary", { hasText: "Abgleich-Log" }); await log.click(); // Noch nie abgeglichen – und genau das sagt die Seite, statt leer zu bleiben. await expect(meine.getByText(/Noch kein Lauf/)).toBeVisible(); const masse = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, sichtbar: document.documentElement.clientWidth, })); expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar); }); }); test.afterAll(async () => { // An dieser Quelle haengt keine Person: sie war nie aktiv und hat nie // abgeglichen. Haengte eine, verweigerte der Fremdschluessel das Loeschen. const quellen = await prisma.verzeichnisquelle.findMany({ where: { name: { startsWith: "ZZ E2E Verzeichnis" } }, select: { id: true }, }); const kennungen = quellen.map((quelle) => quelle.id); if (kennungen.length > 0) { await prisma.protokoll.deleteMany({ where: { entitaetId: { in: kennungen } } }); await prisma.verzeichnisquelle.deleteMany({ where: { id: { in: kennungen } } }); } await prisma.$disconnect(); });