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
+367
View File
@@ -0,0 +1,367 @@
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<string> {
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<string, string> },
});
}
await prisma.$disconnect();
});