import { expect, test, type Page } from "@playwright/test"; import "dotenv/config"; import { prisma } from "@/lib/db"; import { PASSWORT_IM_DRUCK, PASSWORT_LAENGE } from "@/lib/passwort"; import { anmelden } from "./helfer"; /** * Der Onboarding-Ablauf (#018). * * Er prüft die Kette, an der im Auftrag das meiste hängt: aus einem Namen werden * über die Namensregeln UPN, Anmeldename und Mail-Adresse, aus der gewählten * Abteilung werden Ziel-OU und Standardgruppen, und aus allem zusammen entsteht * ein `New-ADUser`-Befehl, den ein Techniker auf einem Domänencontroller * ausführt. * * Zwei Zusagen werden hier eingelöst, und beide nur hier: * * 1. **Der Kopier-Knopf liefert zeichengenau das, was auf dem Bildschirm * steht.** Geprüft wird über die Zwischenablage, nicht über das erwartete * Ergebnis: ein Vergleich gegen einen selbst zusammengebauten Text prüfte, ob * der Test rechnen kann, nicht ob der Knopf richtig kopiert. Ein Befehl, der * beim Kopieren etwas verliert — eine Zeile, ein Fluchtzeichen —, tut auf dem * Domänencontroller etwas anderes als er liest. * 2. **`{{generiertes_passwort}}` wird nie gespeichert.** Nicht im * `Schrittergebnis`, nicht im `Kommentar`, nicht im `Protokoll` und nicht im * Ausdruck. Das ist die härteste Zusage des Auftrags, und sie lässt sich nur * so prüfen: das angezeigte Passwort auslesen und danach in der Datenbank * danach suchen. */ const STEMPEL = Date.now(); const TITEL = `ZZ E2E Onboarding ${STEMPEL}`; const VORLAGE = "Mitarbeiter-Onboarding"; const ABTEILUNG = "Buchhaltung"; /* * Der Name der neuen Person. Kein „ZZ E2E“ davor: aus ihm entstehen Anmeldenamen * nach den Namensregeln, und ein Leerzeichen im Vornamen wäre eine Prüfung der * Regeln und nicht des Ablaufs. Angelegt wird ohnehin nichts — die Person steht * beim Onboarding noch nicht im Verzeichnis, sie ist nur Freitext am Lauf. */ const VORNAME = "Zoe"; const NACHNAME = "Testfall"; /** * Was die Abteilung in die Befehle speist – dieselben Werte wie im Seed * (`prisma/seed-daten/stammdaten.ts`). */ const ZIEL_OU = "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local"; const GRUPPEN = "G-Buchhaltung, G-Alle"; /** * Die Namensregeln, die dieser Ablauf braucht — er setzt sie selbst. * * Er darf sie nicht voraussetzen: `Einstellung.namensregeln` sind Betriebsdaten, * ein Administrator ändert sie, und ein anderer Ablauf hat sie schon einmal * geleert. Stehen sie leer, antwortet „Namen aus den Regeln vorschlagen“ mit * „Für diesen Kunden sind keine Namensregeln hinterlegt“ — und der Ablauf * scheiterte an einer Voraussetzung statt an einem Fehler. Die Werte sind * dieselben, die `prisma/seed-daten/grundbestand.ts` anlegt. */ const REGELN = { upn: "{v}.{nachname}@firma.example", mail: "{v}.{nachname}@firma.example", sam: "{v}{nachname}", }; /** Was vor dem Ablauf in der Einstellung stand – wird hinterher zurückgelegt. */ let regelnVorher: unknown = null; /** * Der Kunde, dem die Abteilung „Buchhaltung“ gehört. * * Im Modus DIENSTLEISTUNG fragt der Start-Dialog nach einem Kunden, und die * Datenbank verlangt, dass es **derselbe** ist wie der der gewählten Abteilung: * `Lauf_abteilung_gleicher_kunde` lässt keine Abteilung eines fremden Kunden zu. * Ein „nimm den ersten Kunden“ wäre dort nicht ungenau, sondern rot. */ let abteilungsKunde = ""; /** Die Adresse des Laufs, den der erste Ablauf anlegt. */ let laufPfad = ""; test.describe.configure({ mode: "serial" }); test.beforeAll(async () => { const abteilung = await prisma.abteilung.findFirst({ where: { name: ABTEILUNG }, select: { zielOu: true, standardGruppen: true, kunde: { select: { name: true } } }, }); if (!abteilung) { throw new Error( `Die Abteilung „${ABTEILUNG}“ fehlt. Sie kommt aus dem Seed — pnpm db:seed laufen lassen.`, ); } abteilungsKunde = abteilung.kunde.name; // Lieber hier scheitern als später an einem Befehl, dessen -Path nicht stimmt: // die Erwartung unten und die Datenbank müssen dasselbe sagen. expect(abteilung.zielOu).toBe(ZIEL_OU); expect(abteilung.standardGruppen.join(", ")).toBe(GRUPPEN); const einstellung = await prisma.einstellung.findUniqueOrThrow({ where: { id: "global" }, select: { namensregeln: true }, }); regelnVorher = einstellung.namensregeln; await prisma.einstellung.update({ where: { id: "global" }, data: { namensregeln: REGELN } }); }); /** Der Befehl eines Schritts, genau so wie er im `pre` steht – ohne jede Normalisierung. */ async function befehlstext(seite: Page, enthaelt: string): Promise { const zeile = seite.getByRole("listitem").filter({ hasText: enthaelt }); const text = await zeile .locator("pre.mono") .first() .evaluate((feld) => feld.textContent ?? ""); return text; } /** Das Passwort aus dem `New-ADUser`-Befehl. */ function passwortAus(befehl: string): string { const treffer = /ConvertTo-SecureString "([^"]+)"/u.exec(befehl); if (!treffer?.[1]) { throw new Error(`Im Befehl steht kein Passwort. Der Befehl lautet:\n${befehl}`); } return treffer[1]; } test.describe("Onboarding", () => { test("schlägt Namen vor, füllt OU und Gruppen und kopiert den Befehl zeichengenau", async ({ page, context, }) => { test.setTimeout(180_000); /* * Ohne dieses Recht wirft `navigator.clipboard.writeText` in Chromium, und * der Knopf fängt das ab (der Text bleibt markierbar) — der Ablauf sähe einen * Knopf, der scheinbar funktioniert, und eine leere Zwischenablage. */ await context.grantPermissions(["clipboard-read", "clipboard-write"]); await anmelden(page); await page.goto("/laeufe/starten"); // ---- Vorlage am Namen wählen ------------------------------------------- const optionen = await page .locator('select[name="vorlagenVersionId"] option') .evaluateAll((liste) => liste .filter((eintrag): eintrag is HTMLOptionElement => eintrag instanceof HTMLOptionElement) .map((eintrag) => ({ wert: eintrag.value, text: eintrag.textContent ?? "" })), ); const treffer = optionen.find((eintrag) => eintrag.text.includes(VORLAGE)); if (!treffer) { throw new Error( `Die Vorlage „${VORLAGE}“ steht nicht zur Auswahl. Zur Wahl stehen: ` + `${optionen.map((eintrag) => eintrag.text).join(", ")}`, ); } await page.selectOption('select[name="vorlagenVersionId"]', treffer.wert); const angaben = page.locator("fieldset"); await expect(angaben).toBeVisible(); // ---- Vorname und Nachname eintragen ------------------------------------ /* * Der Vorschlag entsteht aus dem **Freitextnamen**, nicht aus den * Vorlagenvariablen: beim Onboarding steht die Person noch nicht im * Verzeichnis, und der Dialog fragt genau dafür nach einem Namen. */ await page.getByLabel("… oder Name als Freitext").fill(`${VORNAME} ${NACHNAME}`); await angaben.getByLabel(/^Kontotyp/u).selectOption("Active Directory"); await angaben.getByLabel(/^Vorname/u).fill(VORNAME); await angaben.getByLabel(/^Nachname/u).fill(NACHNAME); await angaben.getByLabel(/^Abteilung/u).fill(ABTEILUNG); await angaben.getByLabel(/^Eintrittsdatum/u).fill("2026-10-01"); /* * Im Modus DIENSTLEISTUNG verlangt der Dialog einen Kunden — und es muss der * der gewählten Abteilung sein, sonst weist die Datenbank den Lauf ab * (`Lauf_abteilung_gleicher_kunde`). * * Gewählt wird er **vor** dem Namensvorschlag: die Namensregeln können je * Kunde eigene sein, und ohne Kunden antwortet die Aktion mit „Im Modus * Dienstleistung muss ein Kunde gewählt werden“ — die Felder blieben leer, * und der Test läse das als fehlende Regel. */ const kundenWahl = page.locator('select[name="kundeId"]'); if ((await kundenWahl.count()) > 0) await kundenWahl.selectOption({ label: abteilungsKunde }); // ---- Namen aus den Regeln vorschlagen ---------------------------------- await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click(); /* * Die Zahl am Ende ist kein Zufall: trägt die Datenbank den Namensstamm * schon, hängt die Regel eine Unterscheidung an. Ob mit oder ohne entscheidet * der Bestand, deshalb steht hier ein Muster und keine Zeichenkette. */ await expect(angaben.getByLabel(/^UPN/u)).toHaveValue(/^z\.testfall\d*@firma\.example$/u); await expect(angaben.getByLabel(/^Anmeldename/u)).toHaveValue(/^ztestfall\d*$/u); await expect(angaben.getByLabel(/^Mail-Adresse/u)).toHaveValue(/^z\.testfall\d*@firma\.example$/u); const upn = await angaben.getByLabel(/^UPN/u).inputValue(); const sam = await angaben.getByLabel(/^Anmeldename/u).inputValue(); const mail = await angaben.getByLabel(/^Mail-Adresse/u).inputValue(); // Ziel-OU und Gruppen bleiben **leer**: sie sollen aus der Abteilung kommen. await expect(angaben.getByLabel(/^Ziel-OU/u)).toHaveValue(""); await expect(angaben.getByLabel(/^Gruppen/u)).toHaveValue(""); // ---- Abteilung im aufklappbaren Teil wählen ---------------------------- const weitere = page.locator("details").filter({ hasText: "Titel, Ticket" }); await weitere.locator("summary").click(); await weitere.locator('input[name="titel"]').fill(TITEL); await weitere.locator('select[name="abteilungId"]').selectOption({ label: ABTEILUNG }); await page.getByRole("button", { name: "Lauf anlegen" }).click(); await page.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 60_000 }); laufPfad = new URL(page.url()).pathname; const laufId = laufPfad.split("/").pop() as string; // ---- Der fertige New-ADUser-Befehl ------------------------------------- const anlegen = page.getByRole("listitem").filter({ hasText: "New-ADUser" }); await expect(anlegen).toHaveCount(1); const befehl = await befehlstext(page, "New-ADUser"); expect(befehl).toContain(`New-ADUser -Name "${VORNAME} ${NACHNAME}"`); expect(befehl).toContain(`-GivenName "${VORNAME}"`); expect(befehl).toContain(`-Surname "${NACHNAME}"`); expect(befehl).toContain(`-SamAccountName "${sam}"`); expect(befehl).toContain(`-UserPrincipalName "${upn}"`); expect(befehl).toContain(`-EmailAddress "${mail}"`); expect(befehl).toContain(`-Department "${ABTEILUNG}"`); // Das Abnahmekriterium, wörtlich: die Ziel-OU kommt aus der Abteilung. expect(befehl).toContain(`-Path "${ZIEL_OU}"`); // Und kein Platzhalter ist stehen geblieben. expect(befehl).not.toContain("{{"); // Der Gruppen-Befehl trägt die Standardgruppen der Abteilung. const gruppenBefehl = await befehlstext(page, "Add-ADGroupMember"); expect(gruppenBefehl).toContain(`"${GRUPPEN}".Split(",")`); expect(gruppenBefehl).toContain(`-Members "${sam}"`); // ---- Genau ein generiertes Passwort ------------------------------------ const passwort = passwortAus(befehl); expect(passwort).toHaveLength(PASSWORT_LAENGE); /* * Ein Passwort, nicht zwei: wer `{{generiertes_passwort}}` zweimal schreibt, * meint dasselbe. Zwei verschiedene hießen — das Konto bekommt A, der * Benutzer erfährt B. */ expect(befehl.split(passwort)).toHaveLength(2); // Und der Hinweis steht daneben, weil es nirgends gespeichert wird. await expect(anlegen.getByText(/Passwort sofort in den Passwortmanager/)).toBeVisible(); // ---- Der Kern: die Zwischenablage, zeichengenau ------------------------ await anlegen.getByRole("button", { name: "Befehl kopieren" }).click(); await expect(anlegen.getByRole("button", { name: "Kopiert." })).toBeVisible(); const zwischenablage = await page.evaluate(() => navigator.clipboard.readText()); // `toBe` und nicht `toContain`: ein verlorenes Fluchtzeichen oder ein // umgewandelter Zeilenumbruch ändert den Befehl, ohne ihn falsch aussehen zu // lassen. expect(zwischenablage).toBe(befehl); // ---- Das Passwort steht nirgends in der Datenbank ---------------------- /* * Erst einen Schritt speichern: das ist der Vorgang, bei dem etwas in * `Schrittergebnis` und `Protokoll` geschrieben wird. Ohne ihn prüfte der * folgende Abschnitt leere Tabellen. */ await anlegen.getByRole("button", { name: "erledigt", exact: true }).click(); // Auf die Datenbank warten, nicht auf den Bildschirm: der Protokolleintrag // entsteht in derselben Transaktion, und genau den soll die Suche unten sehen. await expect .poll(async () => prisma.schrittergebnis.count({ where: { laufId, zustand: "ERLEDIGT" } }), { timeout: 30_000, message: "Der Schritt „Konto anlegen“ steht nicht als erledigt in der Datenbank.", }) .toBeGreaterThan(0); const ergebnisse = await prisma.schrittergebnis.findMany({ where: { laufId }, select: { wert: true, notiz: true }, }); const kommentare = await prisma.kommentar.findMany({ where: { laufId }, select: { rumpf: true } }); const protokolle = await prisma.protokoll.findMany({ where: { OR: [{ laufId }, { entitaetId: laufId }] }, select: { aktion: true, vorher: true, nachher: true }, }); expect(JSON.stringify({ ergebnisse, kommentare, protokolle })).not.toContain(passwort); /* * Und zur Sicherheit über **alle** Zeilen, nicht nur die dieses Laufs: ein * Passwort, das an einer ganz anderen Stelle landet, wäre genauso schlimm. * `strpos` statt `LIKE`: das Alphabet enthält `_`, und das ist in `LIKE` ein * Platzhalter — ein Treffer wäre dann Zufall und ein Nicht-Treffer nichts wert. */ const spuren = await prisma.$queryRaw< readonly { readonly ergebnisse: bigint; readonly kommentare: bigint; readonly protokoll: bigint }[] >` SELECT (SELECT count(*) FROM "Schrittergebnis" WHERE strpos(coalesce("notiz", '') || coalesce("wert"::text, ''), ${passwort}) > 0) AS "ergebnisse", (SELECT count(*) FROM "Kommentar" WHERE strpos("rumpf", ${passwort}) > 0) AS "kommentare", (SELECT count(*) FROM "Protokoll" WHERE strpos("aktion" || coalesce("vorher"::text, '') || coalesce("nachher"::text, ''), ${passwort}) > 0) AS "protokoll" `; expect({ ergebnisse: Number(spuren[0]?.ergebnisse), kommentare: Number(spuren[0]?.kommentare), protokoll: Number(spuren[0]?.protokoll), }).toEqual({ ergebnisse: 0, kommentare: 0, protokoll: 0 }); // ---- Auch nicht auf dem Ausdruck --------------------------------------- await page.goto(`/drucken/lauf/${laufId}`); const blatt = (await page.locator("body").innerText()).replace(/\s+/gu, " "); expect(blatt).not.toContain(passwort); expect(blatt).toContain(PASSWORT_IM_DRUCK); }); test("erzeugt bei jedem Aufbau ein anderes Passwort", async ({ page }) => { /* * Die Kehrseite der Zusage, und sie gehört geprüft: weil nichts gespeichert * wird, gibt es auch keinen Weg, dasselbe Passwort noch einmal zu erfahren. * Wer es nicht sofort weitergibt, hat es verloren — und der Techniker muss * sich darauf verlassen können, dass das nicht heimlich anders ist. */ expect(laufPfad, "Der vorige Ablauf hat keinen Lauf angelegt.").not.toBe(""); await anmelden(page); await page.goto(laufPfad); const erstes = passwortAus(await befehlstext(page, "New-ADUser")); await page.reload(); const zweites = passwortAus(await befehlstext(page, "New-ADUser")); expect(zweites).not.toBe(erstes); expect(zweites).toHaveLength(PASSWORT_LAENGE); }); }); test.afterAll(async () => { const laeufe = await prisma.lauf.findMany({ where: { titel: { startsWith: "ZZ E2E Onboarding" } }, select: { id: true }, }); const kennungen = laeufe.map((lauf) => lauf.id); if (kennungen.length > 0) { await prisma.kommentar.deleteMany({ where: { laufId: { in: kennungen } } }); await prisma.schrittergebnis.deleteMany({ where: { laufId: { in: kennungen } } }); // `Protokoll.laufId` steht auf `Restrict` – sonst bleibt der Lauf stehen. await prisma.protokoll.deleteMany({ where: { OR: [{ laufId: { in: kennungen } }, { entitaetId: { in: kennungen } }] }, }); await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } }); } // Die Namensregeln zurücklegen, wie sie waren – sie sind Betriebsdaten. if (regelnVorher !== null) { await prisma.einstellung.update({ where: { id: "global" }, data: { namensregeln: regelnVorher as Record }, }); } await prisma.$disconnect(); });