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>
142 lines
6.2 KiB
TypeScript
142 lines
6.2 KiB
TypeScript
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<string, string>)["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();
|
||
});
|