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,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();
|
||||
});
|
||||
Reference in New Issue
Block a user