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