feat(doku): Anleitung, PDF und Ende-zu-Ende-Abnahme
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

Neun Kapitel unter docs/anleitung/ fuer Technik und Verwaltung, mit 35
Bildern aus `pnpm docs:screenshots` und einem Fehlerbehebungskapitel aus
echten Meldungen. `pnpm docs:pdf` macht daraus 72 Seiten A4 — hell
gedruckt, mit Deckblatt, Inhaltsverzeichnis und Seitenzahlen, ohne neue
Abhaengigkeit.

Die Ablaeufe pruefen ihre Voraussetzungen, bevor der erste startet:
antwortet ein gebauter Stand, steht der Seed-Bestand, und liest der
Server dieselbe Datenbank wie der Testprozess. Jeder Ablauf legt an, was
er braucht, und raeumt es weg. Vier neue kommen dazu: Hauptablauf,
Onboarding mit Vergleich der Zwischenablage, Rollen und 390 px auf
achtzehn Seiten.

Drei Fehler kamen dabei heraus. Das Projekt „handy" pruefte 412 px statt
der verlangten 390. Ein angemeldeter Benutzer ohne Recht erfuhr nie,
warum er abgewiesen wurde — die Meldung stand auf einer Seite, die ihn
sofort weiterleitet; jetzt gibt es /kein-zugriff. Und der
Verzeichnis-Ablauf schrieb die Attributzuordnung der ersten vorhandenen
Quelle um, in einer echten Installation also der produktiven.

101 Ablaeufe in INTERN und 97 in DIENSTLEISTUNG, beide ohne Fehlschlag.

Closes #18

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-26 11:03:02 +02:00
co-authored by Claude Opus 5
parent 9bd198a531
commit 10e272654f
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();
});