feat(doku): Anleitung, PDF und Ende-zu-Ende-Abnahme (#36)
This commit was merged in pull request #36.
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user