feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s

Quelle anlegen, Verbindung testen, nach Zeitplan oder auf Knopfdruck
abgleichen. Gelesen wird seitenweise mit searchPaginated, objectGUID
kommt als Puffer und wird gemischt-endian zur GUID umsortiert. Wer im
Verzeichnis verschwindet, wird hier inaktiv und nie geloescht.

Drei Regeln verhindern, dass eine kaputte Eingabe wie "alle sind
gegangen" aussieht: laesst sich kein einziger Eintrag zuordnen, wird
niemand deaktiviert; uebersprungene Eintraege werden am dn erkannt und
ausgenommen; eine von Hand angelegte Person wird uebernommen statt
verdoppelt.

Der Zeitplan laeuft ueber node-cron im selben Prozess, gestartet aus
src/instrumentation.ts, und nimmt keine eigene Sperre - die liegt in der
Datenbank, damit zwei Instanzen sich einig sind.

Closes #16

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-26 02:21:08 +02:00
co-authored by Claude Opus 5
parent ab5777a57e
commit 16059e7846
57 changed files with 6834 additions and 61 deletions
+87
View File
@@ -0,0 +1,87 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
/**
* 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.
*
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`).
* Im Entwicklungsmodus haengt React sich hier nicht an, und dann tut kein Knopf
* etwas (siehe CLAUDE.md, „Oberflaeche pruefen“).
*/
async function anmelden(seite: Page) {
const email = process.env["SEED_ADMIN_EMAIL"];
const passwort = process.env["SEED_ADMIN_PASSWORD"];
if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen.");
await seite.goto("/anmelden");
await seite.fill('input[name="email"]', email);
await seite.fill('input[name="passwort"]', passwort);
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
}
test.describe("Verzeichnis", () => {
test("macht die Attributzuordnung bedienbar und prüft sie", async ({ page }) => {
test.slow();
const quelle = await prisma.verzeichnisquelle.findFirst({ orderBy: { name: "asc" } });
test.skip(quelle === null, "Keine Verzeichnisquelle im Bestand.");
if (!quelle) return;
const vorher = quelle.attributZuordnung;
await anmelden(page);
await page.goto("/admin/verzeichnis");
await page.locator("summary", { hasText: "Einstellungen bearbeiten" }).first().click();
await page.locator("summary", { hasText: "Attributzuordnung anpassen" }).first().click();
const kennung = page.locator('input[name="attr.externeId"]').first();
await expect(kennung).toBeVisible();
await expect(kennung).toHaveValue("objectGUID");
// Eine Klammer darf nicht in eine LDAP-Anfrage wandern.
const titel = page.locator('input[name="attr.titel"]').first();
await titel.fill("(objectClass=*)");
await page.getByRole("button", { name: "Speichern" }).first().click();
await expect(page.locator('[role="alert"]').first()).toContainText("kein Attributname");
// Und eine gute Eingabe steht danach wirklich in der Datenbank.
await titel.fill("personalTitle");
await page.getByRole("button", { name: "Speichern" }).first().click();
await expect(page.locator('[role="status"]').last()).toContainText("gespeichert");
const nachher = await prisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
expect((nachher.attributZuordnung as Record<string, string>)["titel"]).toBe("personalTitle");
// Neu geladen steht sie auch im Formular – sonst waere sie nur gespeichert,
// aber nicht bearbeitbar.
await page.reload();
await page.locator("summary", { hasText: "Einstellungen bearbeiten" }).first().click();
await page.locator("summary", { hasText: "Attributzuordnung anpassen" }).first().click();
await expect(page.locator('input[name="attr.titel"]').first()).toHaveValue("personalTitle");
await prisma.verzeichnisquelle.update({
where: { id: quelle.id },
data: { attributZuordnung: vorher === null ? {} : vorher },
});
});
test("zeigt das Abgleich-Log und trägt am Handy ohne horizontales Scrollen", async ({ page }) => {
await anmelden(page);
await page.goto("/admin/verzeichnis");
const log = page.locator("summary", { hasText: "Abgleich-Log" }).first();
if ((await log.count()) > 0) {
await log.click();
await expect(page.locator("details", { has: log })).toBeVisible();
}
const masse = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
sichtbar: document.documentElement.clientWidth,
}));
expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
});
});