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
+269
View File
@@ -0,0 +1,269 @@
import { hash } from "@node-rs/argon2";
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { geraetName, SEED_TAG, SEED_VORLAGE } from "./helfer";
/**
* Was ein `LESER` sieht und was der Server ihm verweigert (#018).
*
* Die Rechtematrix selbst prüft `tests/rollen.test.ts`, Rolle für Rolle gegen
* jedes Recht. Hier geht es um die zwei Richtungen, die eine Tabelle nicht
* prüfen kann:
*
* 1. **Die Oberfläche bietet nichts an, was der Server verbietet.** Ein Knopf,
* der zu einer Fehlermeldung führt, ist ein Versprechen, das nicht gehalten
* wird — und wer ihn sieht, hält sich für berechtigt.
* 2. **Der Server verbietet, was die Oberfläche nicht anbietet.** Das Ausblenden
* ist Bequemlichkeit, keine Absicherung: wer die Adresse kennt, ruft sie
* direkt auf. Geprüft wird deshalb jede Adresse, hinter der geschrieben wird.
*
* Der Ablauf legt seinen Leser selbst an — mit `@node-rs/argon2`, genau wie
* `prisma/seed-daten/grundbestand.ts` — und räumt ihn hinterher weg. Ein Konto
* mit Leserechten in einer Datenbank stehen zu lassen, die weiterbenutzt wird,
* wäre eine Hinterlassenschaft, die niemand erwartet.
*/
const STEMPEL = Date.now();
const LESER_EMAIL = `zz-e2e-leser-${STEMPEL}@firma.example`;
const LESER_NAME = `ZZ E2E Leser ${STEMPEL}`;
const LESER_PASSWORT = `ZZ-E2E-Leser-${STEMPEL}`;
const LAUF_TITEL = `ZZ E2E Rollen ${STEMPEL}`;
let leserId = "";
let laufId = "";
let geraetId = "";
let vorlageId = "";
test.beforeAll(async () => {
// Das Passwort wird nur als argon2-Hash gespeichert, nie im Klartext.
const leser = await prisma.benutzer.create({
data: {
email: LESER_EMAIL,
name: LESER_NAME,
rolle: "LESER",
passwortHash: await hash(LESER_PASSWORT),
aktiv: true,
},
select: { id: true },
});
leserId = leser.id;
/*
* Ein Lauf, an dem sich der Schreibschutz zeigen lässt. Angelegt über Prisma
* und nicht über die Oberfläche: hier geht es um die Rechte und nicht um den
* Weg zum Lauf — den prüft `hauptablauf.spec.ts`. Der Kunde kommt vom Gerät,
* weil ein zusammengesetzter Fremdschlüssel keinen Lauf mit dem Gerät eines
* fremden Kunden zulässt.
*/
const geraet = await prisma.geraet.findUniqueOrThrow({
where: { tagCode: SEED_TAG },
select: { id: true, kundeId: true },
});
geraetId = geraet.id;
const vorlage = await prisma.vorlage.findFirstOrThrow({
where: { name: SEED_VORLAGE },
select: {
id: true,
versionen: {
where: { status: "VEROEFFENTLICHT" },
orderBy: { version: "desc" },
take: 1,
select: { id: true },
},
},
});
vorlageId = vorlage.id;
const versionId = vorlage.versionen[0]?.id;
if (!versionId) {
throw new Error(`Die Vorlage „${SEED_VORLAGE}“ hat keine veröffentlichte Version.`);
}
const lauf = await prisma.lauf.create({
data: {
vorlagenVersionId: versionId,
kundeId: geraet.kundeId,
geraetId: geraet.id,
titel: LAUF_TITEL,
status: "IN_ARBEIT",
// `Lauf_begonnen_mit_zeitpunkt` verlangt den Zeitstempel zum Status.
gestartetAm: new Date(),
},
select: { id: true },
});
laufId = lauf.id;
});
async function alsLeserAnmelden(seite: Page): Promise<void> {
await seite.goto("/anmelden");
await seite.fill('input[name="email"]', LESER_EMAIL);
await seite.fill('input[name="passwort"]', LESER_PASSWORT);
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
}
test.describe("Ein Leser", () => {
test("sieht auf Läufen, Vorlagen und Geräten keine Schreib-Knöpfe", async ({ page }) => {
test.slow();
await alsLeserAnmelden(page);
// ---- Läufe --------------------------------------------------------------
await page.goto("/laeufe");
await expect(page.getByRole("heading", { name: "Läufe" })).toBeVisible();
await expect(page.getByRole("link", { name: "Lauf starten" })).toHaveCount(0);
// ---- Ein Lauf in Arbeit -------------------------------------------------
await page.goto(`/laeufe/${laufId}`);
await expect(page.getByRole("heading", { name: LAUF_TITEL })).toBeVisible();
for (const knopf of [
"Lauf beginnen",
"Lauf abschließen",
"archivieren",
"erledigt",
"nicht zutreffend",
"fehlgeschlagen",
"Wert speichern",
"Kommentar hinzufügen",
"Abschnitt trifft nicht zu",
]) {
await expect(
page.getByRole("button", { name: knopf, exact: true }),
`Ein Leser sieht auf dem Lauf den Knopf „${knopf}“.`,
).toHaveCount(0);
}
/*
* Und zwar wirklich keinen Schreibweg: im Inhaltsbereich steht für einen Leser
* **kein einziges** Formular. Die Aufzählung oben prüft Beschriftungen, dieses
* eine prüft, dass keine übersehen wurde. Gezählt wird in `main`, nicht auf der
* ganzen Seite — das Abmelden-Formular der Hülle steht daneben und gehört jedem.
*/
await expect(page.locator("main form")).toHaveCount(0);
// Die Angaben selbst stehen trotzdem da – lesen darf er ja.
await expect(page.getByRole("progressbar")).toBeVisible();
// ---- Vorlagen -----------------------------------------------------------
await page.goto("/vorlagen");
await expect(page.getByRole("heading", { name: "Vorlagen" })).toBeVisible();
await expect(page.getByRole("button", { name: "Vorlage anlegen" })).toHaveCount(0);
await expect(page.getByText("Vorlage aus einer Datei einspielen")).toHaveCount(0);
await page.goto(`/vorlagen/${vorlageId}`);
await expect(page.getByRole("button", { name: /bearbeiten/iu })).toHaveCount(0);
await expect(page.getByRole("button", { name: "archivieren" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "Kopfdaten" })).toHaveCount(0);
// ---- Geräte -------------------------------------------------------------
await page.goto("/geraete");
await expect(page.getByRole("heading", { name: "Geräte" })).toBeVisible();
await expect(page.getByRole("button", { name: "Gerät anlegen" })).toHaveCount(0);
await expect(page.getByText("Geräte aus einer CSV-Datei einlesen")).toHaveCount(0);
await page.goto(`/geraete/${geraetId}`);
await expect(page.getByRole("heading", { name: new RegExp(geraetName()) })).toBeVisible();
// „Vorlage zuweisen“ und die Schnellstart-Knöpfe hängen an `laeufeBearbeiten`.
await expect(page.getByRole("heading", { name: "Vorlage zuweisen" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Starten" })).toHaveCount(0);
// ---- Mitarbeiter --------------------------------------------------------
await page.goto("/mitarbeiter");
await expect(page.getByText("Person von Hand anlegen")).toHaveCount(0);
/*
* Und die Verwaltung steht gar nicht in der Navigation. Gesucht wird **in**
* `nav` und mit `exact`: auf der Personenliste steht ein „Systemadministrator“
* als Berufsbezeichnung, und ein unscharfes „Admin“ traf ihn mit.
*/
if (test.info().project.name === "handy") await page.getByLabel("Menü").click();
await expect(page.locator("nav").getByRole("link", { name: "Admin", exact: true })).toHaveCount(0);
});
test("wird abgewiesen, wenn er die Adresse direkt aufruft", async ({ page }) => {
test.slow();
await alsLeserAnmelden(page);
/*
* `/laeufe/starten` verlangt nur `lesen`, damit ein abgelaufenes Lesezeichen
* keine 500er-Seite ergibt — und schickt dann zurück zur Liste. Wer keine
* Läufe bearbeiten darf, soll die Seite nicht als vorhanden erleben.
*/
await page.goto("/laeufe/starten");
await expect(page).toHaveURL(/\/laeufe$/u);
await expect(page.getByRole("button", { name: "Lauf anlegen" })).toHaveCount(0);
/*
* Alles unter `verwaltung` weist der Server ab. Kein geworfener Fehler,
* sondern eine Weiterleitung: für jemanden, der ein altes Lesezeichen öffnet,
* wäre eine 500er-Seite die falsche Auskunft.
*
* Geprüft wird beides: dass er **nicht auf der Seite landet** und dass er
* **erfährt, warum**. Bis #018 schickte `sitzungErforderlich` ihn nach
* `/anmelden?fehler=keinrecht` — und die Anmeldeseite schickte einen bereits
* Angemeldeten sofort weiter auf `/`. Die Erklärung war damit unerreichbar,
* er stand ohne Hinweis auf der Übersicht und wusste nicht, ob er sich
* verklickt hatte oder ob etwas kaputt ist. Seitdem führt der Weg auf
* `/kein-zugriff`.
*/
for (const adresse of [
"/admin",
"/admin/benutzer",
"/admin/abteilungen",
"/admin/verzeichnis",
"/tags",
// Der Etikettenbogen **ohne** Codes zeigt den ganzen Vorrat an noch nicht
// zugeordneten Tags – dieselbe Liste wie /tags, also dasselbe Recht.
"/drucken/etiketten",
]) {
await page.goto(adresse);
const gelandet = new URL(page.url()).pathname;
expect(gelandet, `${adresse} hat einen Leser nicht abgewiesen.`).not.toBe(adresse);
expect(gelandet, `${adresse} weist ab, ohne zu sagen warum.`).toBe("/kein-zugriff");
await expect(page.getByRole("heading", { name: "Dafür fehlt dir die Berechtigung" })).toBeVisible();
// Und die Auskunft nennt die eigene Rolle, damit klar ist, woran es liegt.
// Im Absatz gesucht: „Leser“ steht auch in der Navigation und im Namen des
// Testkontos, und ein `getByText` ohne Rahmen trifft im Modus
// DIENSTLEISTUNG sechs Stellen.
await expect(page.getByRole("main").getByText("mit der Rolle")).toContainText("Leser");
}
});
test("darf lesen, was zum Lesen da ist", async ({ page }) => {
/*
* Die Gegenprobe, und sie gehört dazu: würde hier etwas abgewiesen, wäre die
* Prüfung oben nicht der Nachweis eines Rechts, sondern der eines kaputten
* Kontos. Der Zustands-Endpunkt steht mit dabei, weil ein Route Handler die
* Hülle nicht erbt und sein Recht selbst prüfen muss (CLAUDE.md, „Rechte“).
*/
await alsLeserAnmelden(page);
for (const adresse of ["/", "/laeufe", "/vorlagen", "/mitarbeiter", "/geraete", "/archiv", "/scannen"]) {
const antwort = await page.goto(adresse);
expect(antwort?.status(), `${adresse} antwortet einem Leser nicht mit 200.`).toBe(200);
expect(new URL(page.url()).pathname, `${adresse} hat einen Leser weitergeleitet.`).toBe(adresse);
}
const zustand = await page.request.get(`/api/laeufe/${laufId}/zustand`);
expect(zustand.status()).toBe(200);
// Kein Befehl in der Antwort: sonst stünde dort ein Passwort, das die Seite
// bewusst nicht von selbst neu baut.
expect(await zustand.text()).not.toContain("Rename-Computer");
});
});
test.afterAll(async () => {
if (laufId) {
await prisma.kommentar.deleteMany({ where: { laufId } });
await prisma.schrittergebnis.deleteMany({ where: { laufId } });
await prisma.protokoll.deleteMany({ where: { OR: [{ laufId }, { entitaetId: laufId }] } });
await prisma.lauf.deleteMany({ where: { id: laufId } });
}
if (leserId) {
/*
* `Protokoll.benutzerId` steht auf `Restrict`. Ein Leser schreibt nichts, was
* protokolliert wird — aber „sollte nichts geschrieben haben“ ist kein Grund,
* das Aufräumen an einem Fremdschlüssel scheitern zu lassen.
*/
await prisma.protokoll.deleteMany({ where: { benutzerId: leserId } });
await prisma.benutzer.deleteMany({ where: { id: leserId } });
}
await prisma.$disconnect();
});