feat(doku): Anleitung, PDF und Ende-zu-Ende-Abnahme (#36)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #36.
This commit is contained in:
2026-09-26 09:04:15 +00:00
parent 9bd198a531
commit fbbb634e56
104 changed files with 4527 additions and 299 deletions
+93 -39
View File
@@ -1,6 +1,10 @@
import { expect, test, type Page } from "@playwright/test";
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).
@@ -10,73 +14,108 @@ import { prisma } from "@/lib/db";
* 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“).
* 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.
*/
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"]')]);
/*
* 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();
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 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 = page.locator('input[name="attr.externeId"]').first();
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 = page.locator('input[name="attr.titel"]').first();
const titel = meine.locator('input[name="attr.titel"]');
await titel.fill("(objectClass=*)");
await page.getByRole("button", { name: "Speichern" }).first().click();
await expect(page.locator('[role="alert"]').first()).toContainText("kein Attributname");
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 page.getByRole("button", { name: "Speichern" }).first().click();
await expect(page.locator('[role="status"]').last()).toContainText("gespeichert");
await meine.getByRole("button", { name: "Speichern" }).click();
await expect(meine.locator('[role="status"]').last()).toContainText("gespeichert");
const nachher = await prisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } });
const nachher = await prisma.verzeichnisquelle.findFirstOrThrow({ where: { name: QUELLEN_NAME } });
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 },
});
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 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 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,
@@ -85,3 +124,18 @@ test.describe("Verzeichnis", () => {
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();
});