Files
Laufzettel/tests/e2e/rollen.spec.ts
T
Florian WildeandClaude Opus 5 10e272654f
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
feat(doku): Anleitung, PDF und Ende-zu-Ende-Abnahme
Neun Kapitel unter docs/anleitung/ fuer Technik und Verwaltung, mit 35
Bildern aus `pnpm docs:screenshots` und einem Fehlerbehebungskapitel aus
echten Meldungen. `pnpm docs:pdf` macht daraus 72 Seiten A4 — hell
gedruckt, mit Deckblatt, Inhaltsverzeichnis und Seitenzahlen, ohne neue
Abhaengigkeit.

Die Ablaeufe pruefen ihre Voraussetzungen, bevor der erste startet:
antwortet ein gebauter Stand, steht der Seed-Bestand, und liest der
Server dieselbe Datenbank wie der Testprozess. Jeder Ablauf legt an, was
er braucht, und raeumt es weg. Vier neue kommen dazu: Hauptablauf,
Onboarding mit Vergleich der Zwischenablage, Rollen und 390 px auf
achtzehn Seiten.

Drei Fehler kamen dabei heraus. Das Projekt „handy" pruefte 412 px statt
der verlangten 390. Ein angemeldeter Benutzer ohne Recht erfuhr nie,
warum er abgewiesen wurde — die Meldung stand auf einer Seite, die ihn
sofort weiterleitet; jetzt gibt es /kein-zugriff. Und der
Verzeichnis-Ablauf schrieb die Attributzuordnung der ersten vorhandenen
Quelle um, in einer echten Installation also der produktiven.

101 Ablaeufe in INTERN und 97 in DIENSTLEISTUNG, beide ohne Fehlschlag.

Closes #18

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 11:03:02 +02:00

270 lines
11 KiB
TypeScript
Raw 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 { 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();
});