Files
Laufzettel/tests/e2e/onboarding.spec.ts
florian.wilde e1f244e4e8
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(protokoll): Protokoll ansehen, mit Filtern und Mandantensicht (#45)
2026-10-04 16:06:26 +00:00

382 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 in der Protokoll-Ansicht -------------------------------
*
* Seit #023 ist das Protokoll sichtbar, und die Seite zeigt `vorher` und
* `nachher` als Text. Die Abfrage oben belegt, dass dort nichts steht; dieser
* Griff belegt, dass die **Ansicht** nichts hinzuerfindet — etwa aus einer
* aufgelösten Vorschau.
*/
await page.goto("/admin/protokoll");
const protokollSeite = (await page.locator("body").innerText()).replace(/\s+/gu, " ");
expect(protokollSeite).not.toContain(passwort);
// Gegenprobe: die Seite ist überhaupt da und zeigt Einträge.
await expect(page.getByRole("heading", { name: "Protokoll", level: 1 })).toBeVisible();
// ---- 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();
});