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