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 { 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(); });