Files
Laufzettel/tests/e2e/uebersicht-druck.spec.ts
T
Florian WildeandClaude Opus 5 f3950847f3
Prüfung / pruefung (push) Waiting to run
Prüfung / e2e (push) Blocked by required conditions
feat(laeufe): Uebergabeprotokoll als PDF (#041)
Neben "Drucken" steht am Lauf "Als PDF", im Archiv an jedem Treffer "Protokoll
als PDF" — dasselbe Blatt als fertige Datei, zum Verschicken oder Abheften.
Unten darauf jetzt zwei Unterschriftsfelder mit Zeile fuer Ort, Datum und Namen;
wo eine Vier-Augen-Freigabe erteilt wurde, steht sie mit im Kopf.

Erzeugt wird es, indem der Server seine eigene Druckansicht ueber 127.0.0.1
laedt und druckt — mit dem Sitzungscookie des Anfragenden. Damit gibt es genau
eine Vorlage fuer Papier und Datei, und fuer das PDF gilt dieselbe Rechte- und
Mandantenpruefung wie fuer die Seite. Ein zweites Layout waere billiger und
wuerde frueher oder spaeter abweichen, ohne dass es jemandem auffaellt.

Ein eigener Browser-Kontext je PDF, kein geteilter: er traegt die fremde
Sitzung, und ein geteilter gaebe sie an das naechste PDF weiter. Gespeichert
wird nichts — ein abgelegtes PDF waere eine zweite Wahrheit neben dem Lauf.
Ein erzeugtes Passwort steht auch hier nie darauf, geprueft am extrahierten
Text des fertigen PDF und nicht an den Bytes: der Inhalt ist komprimiert, dort
nichts zu finden bewiese nichts.

Chromium bleibt optional, und die gemessene Zahl ist der Grund: das
Laufzeitbild waechst von 370 MB auf 1,52 GB, Alpines chromium kostet allein
1,13 GB. Er kommt nur mit `--build-arg MIT_PDF=1` beziehungsweise
`deploy/install.sh --mit-pdf` mit. Fehlt er, erscheint der Knopf gar nicht
erst — ein Knopf, der zuverlaessig in eine Fehlermeldung fuehrt, ist schlimmer
als keiner. Ein direkter Aufruf bekommt 503 mit einer Meldung, die sagt, was
fehlt und wo es steht; ein leeres PDF gibt es in keinem Fall.

Meine Schaetzung lag um das Dreieinhalbfache daneben: "+328 MB" war das
.apk-Paket, nicht das Bild, und stand schon im Dockerfile-Kommentar, bevor ich
gebaut hatte. Jetzt steht die gemessene Zahl an drei Stellen.

1545 Vitest-Tests (vorher 1526): 15 in tests/pdf.test.ts, die den Handler echt
fahren, 3 in tests/pdf-grenze.test.ts fuer die Obergrenze mit gestelltem
Browser, einer in tests/archiv.test.ts. Dazu drei E2E-Ablaeufe; volle Suite
gruen mit 118 bestanden. Ein Blatt wurde wirklich erzeugt und angesehen.

Sechs Gegenproben, alle angeschlagen — darunter ein echtes Passwort auf dem
Blatt und die Freigabe des Zaehlers aus dem finally genommen, der Fall, in dem
ein Fehlschlag die PDF-Erzeugung bis zum Neustart leise stilllegt.

Closes #41

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-25 11:14:25 +01:00

335 lines
14 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 { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import {
PASSWORT_GROSS,
PASSWORT_IM_DRUCK,
PASSWORT_KLEIN,
PASSWORT_LAENGE,
PASSWORT_SONDER,
PASSWORT_ZIFFERN,
} from "@/lib/passwort";
import { anmelden } from "./helfer";
/**
* Uebersicht und Druckansicht am gebauten Stand (#015).
*
* Beide Seiten leben von ihrer Darstellung: die Uebersicht sortiert nach
* Dringlichkeit, die Druckansicht gehoert auf Papier. Was davon stimmt, zeigt
* erst der Browser — die Lesemodelle dahinter pruefen `tests/uebersicht.test.ts`
* und `tests/druckansicht.test.ts`.
*
* Voraussetzungen prueft `tests/e2e/global-setup.ts`. Jeden Lauf, den diese
* Ablaeufe brauchen, legen sie selbst an und raeumen ihn hinterher weg. Vorher
* holten sie sich mit `findFirstOrThrow` irgendeinen vorhandenen Lauf und
* erwarteten, dass „Zuletzt bearbeitet“ gefuellt ist — gegen eine frische
* Datenbank scheiterte beides, das eine mit „No record was found“, das andere
* mit einem Zeitablauf auf einen Verweis, den es nicht gab (#018).
*/
const STEMPEL = Date.now();
/** Fortlaufend, damit die Laeufe eines Durchgangs sich nicht verwechseln. */
let laufende = 0;
/** Name und Titel tragen diesen Praefix – nur daran raeumt der Nachlauf auf. */
const PRAEFIX = "ZZ E2E Druck";
/**
* Ein Lauf aus einer Vorlage, die `{{generiertes_passwort}}` wirklich enthaelt.
*
* Direkt ueber Prisma: geprueft werden soll die **Druckansicht**, nicht der Weg
* zum Lauf — den prueft `tests/e2e/laeufe.spec.ts` vom Anlegen bis zum Archiv.
*/
async function laufMitPasswortschritt(): Promise<{ id: string; titel: string }> {
laufende += 1;
const marke = `${PRAEFIX} ${STEMPEL}-${laufende}`;
// Ueber `istEigeneOrganisation` und nicht ueber den ersten Treffer: im Modus
// DIENSTLEISTUNG waere das ein beliebiger Demo-Kunde.
const kunde = await prisma.kunde.findFirstOrThrow({
where: { istEigeneOrganisation: true },
select: { id: true },
});
const vorlage = await prisma.vorlage.create({ data: { name: marke, kategorie: "ZZ Test" } });
const version = await prisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
});
const abschnitt = await prisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Konto" },
});
await prisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Konto angelegt",
typ: "BEFEHL",
konfig: { shell: "powershell", befehl: 'New-ADUser -Password "{{generiertes_passwort}}"' },
},
});
await prisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
});
const lauf = await prisma.lauf.create({
data: { vorlagenVersionId: version.id, kundeId: kunde.id, titel: marke },
});
return { id: lauf.id, titel: marke };
}
test.describe("Übersicht", () => {
test("zeigt die vier Blöcke in ihrer Reihenfolge", async ({ page }) => {
await anmelden(page);
const ueberschriften = await page.getByRole("heading", { level: 2 }).allInnerTexts();
expect(ueberschriften).toEqual(["Überfällig", "Mir zugewiesen", "Zuletzt bearbeitet", "Schnellstart"]);
});
test("führt von einer Zeile in den Lauf", async ({ page }) => {
/*
* Der Ablauf bringt die Zeile mit, auf die er klickt. „Zuletzt bearbeitet“
* sortiert nach `geaendertAm` absteigend, der eben angelegte Lauf steht also
* oben — und weil der Klick auf **seinen** Titel geht, prueft der Test einen
* Weg und nicht die Datenlage.
*/
const lauf = await laufMitPasswortschritt();
await anmelden(page);
const zuletzt = page
.locator("section")
.filter({ has: page.getByRole("heading", { name: "Zuletzt bearbeitet" }) });
await zuletzt.getByRole("link", { name: new RegExp(lauf.titel) }).click();
await page.waitForURL(`**/laeufe/${lauf.id}`);
});
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
test.skip(!isMobile, "Nur im Projekt „handy“.");
await anmelden(page);
const masse = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
sichtbar: document.documentElement.clientWidth,
}));
expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
});
});
test.describe("Druckansicht", () => {
test("ist hell, ohne Navigation und passt auf A4", async ({ page }) => {
test.skip(test.info().project.name !== "desktop", "Das Seitenformat hängt nicht am Gerät.");
// Der eigene Lauf statt `findFirstOrThrow`: gegen eine frische Datenbank gab
// es keinen, und die Meldung sprach von „No record was found“ (#018).
const lauf = await laufMitPasswortschritt();
await anmelden(page);
await page.goto(`/drucken/lauf/${lauf.id}`);
// Keine Hülle: weder Seitenleiste noch der Scannen-Knopf gehören aufs Blatt.
await expect(page.getByRole("navigation", { name: "Hauptnavigation" })).toHaveCount(0);
await expect(page.getByRole("link", { name: "Scannen" })).toHaveCount(0);
// Hell heisst hell: der Grund des Belegs ist keine dunkle Fläche.
const grund = await page
.locator(".druck-hell")
.first()
.evaluate((element) => getComputedStyle(element).backgroundColor);
expect(grund).toBe("rgb(255, 255, 255)");
/*
* A4 kommt aus `@page`, und nur ein echtes PDF zeigt, ob die Regel greift:
* 210 × 297 mm sind 595 × 842 Punkte.
*/
const pdf = await page.pdf({ preferCSSPageSize: true });
const text = pdf.toString("latin1");
const treffer = /\/MediaBox\s*\[\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\]/u.exec(text);
expect(treffer).not.toBeNull();
expect(Number(treffer?.[3]) - Number(treffer?.[1])).toBeCloseTo(595, 0);
expect(Number(treffer?.[4]) - Number(treffer?.[2])).toBeCloseTo(842, 0);
});
test("ist vom Lauf aus erreichbar und erzeugt kein Passwort", async ({ page }) => {
/*
* Der Lauf bringt seine eigene Vorlage mit — eine **mit**
* `{{generiertes_passwort}}` im Befehl. Vorher lief dieser Test gegen
* NB-001, dessen Vorlage den Platzhalter gar nicht enthält: er hätte auch
* dann bestanden, wenn die Druckansicht Passwörter erzeugte (Reviewbefund
* zu #015).
*/
const lauf = await laufMitPasswortschritt();
await anmelden(page);
await page.goto(`/laeufe/${lauf.id}`);
await page.getByRole("link", { name: "Drucken" }).click();
await page.waitForURL(/\/drucken\/lauf\//);
const blatt = await page.locator("body").innerText();
expect(blatt).toContain("[Passwort – nicht gespeichert]");
expect(blatt).not.toContain("generiertes_passwort");
/*
* Und der zweite Beweis, der ohne Kenntnis des Textes auskommt: ein
* erzeugtes Passwort wäre bei jedem Aufbau ein anderes. Sind beide Blätter
* Zeichen für Zeichen gleich, kann keines darin stecken.
*/
await page.reload();
const nochmal = await page.locator("body").innerText();
expect(nochmal.replace(/Gedruckt am[^\n]*/u, "")).toBe(blatt.replace(/Gedruckt am[^\n]*/u, ""));
});
});
/**
* Ob `pdftotext` da ist – ohne es laesst sich der Text eines PDF nicht lesen.
*
* Der Inhalt eines PDF ist komprimiert. In den rohen Bytes nach einem Passwort
* zu suchen und nichts zu finden wuerde **nichts** beweisen: ein Passwort, das
* drinsteht, stuende dort auch nicht im Klartext. Entweder richtig extrahieren
* oder den Test ueberspringen und das laut sagen – wie bei `ldap` und `mailpit`
* im Commit-Tor.
*/
const MIT_PDFTOTEXT = (() => {
try {
execFileSync("pdftotext", ["-v"], { stdio: "ignore" });
return true;
} catch {
process.stderr.write(
"\n[uebersicht-druck] pdftotext fehlt – der Text des PDF wird nicht geprueft.\n" +
" Auf Debian: apt-get install poppler-utils\n\n",
);
return false;
}
})();
/** Der Text eines PDF, ueber `pdftotext`. */
function pdfText(bytes: Buffer): string {
const ordner = mkdtempSync(join(tmpdir(), "laufzettel-pdf-"));
try {
const datei = join(ordner, "blatt.pdf");
writeFileSync(datei, bytes);
execFileSync("pdftotext", ["-layout", datei, join(ordner, "blatt.txt")]);
return readFileSync(join(ordner, "blatt.txt"), "utf8");
} finally {
rmSync(ordner, { recursive: true, force: true });
}
}
test.describe("Das PDF-Übergabeprotokoll", () => {
/**
* Holt das PDF eines Laufs oder ueberspringt den Ablauf.
*
* **Der Knopf ist das Signal.** Er erscheint nur, wenn der Server Chromium
* findet (#041); fehlt er, ist das keine Stoerung, sondern eine Installation
* ohne PDF-Erzeugung. Dann hat dieser Ablauf nichts zu pruefen und sagt es.
*/
async function pdfHolen(seite: import("@playwright/test").Page, laufId: string): Promise<Buffer> {
const knopf = seite.getByRole("link", { name: "Als PDF" });
test.skip(
(await knopf.count()) === 0,
"Dieser Server hat kein Chromium – der PDF-Knopf erscheint deshalb nicht. " +
"Siehe docs/betrieb/pdf.md.",
);
const antwort = await seite.request.get(`/api/laeufe/${laufId}/protokoll.pdf`);
expect(antwort.status(), await antwort.text()).toBe(200);
expect(antwort.headers()["content-type"]).toBe("application/pdf");
const bytes = await antwort.body();
// Ein PDF, das keines ist, waere ein leeres Blatt mit richtigem Kopf.
expect(bytes.subarray(0, 5).toString("latin1")).toBe("%PDF-");
return bytes;
}
test("entsteht aus dem Lauf heraus und traegt das ganze Blatt", async ({ page }) => {
test.skip(test.info().project.name !== "desktop", "Das Blatt haengt nicht am Geraet.");
const lauf = await laufMitPasswortschritt();
await anmelden(page);
await page.goto(`/laeufe/${lauf.id}`);
const bytes = await pdfHolen(page, lauf.id);
test.skip(!MIT_PDFTOTEXT, "Ohne pdftotext ist der Text des PDF nicht lesbar.");
const text = pdfText(bytes);
// Titel, Vorlage mit Version, der Abschnitt und der Schritt – das Blatt ist
// vollstaendig und nicht nur ein gueltiges PDF.
expect(text).toContain(lauf.titel);
expect(text).toContain("Version 1");
expect(text).toContain("Konto");
expect(text).toContain("Konto angelegt");
// Und das Unterschriftenfeld, das dieses Blatt zum Uebergabeprotokoll macht.
expect(text).toContain("Übergeben von");
expect(text).toContain("Übernommen von");
});
test("traegt kein erzeugtes Passwort", async ({ page }) => {
/*
* **Die Zusage, die am teuersten waere, wenn sie bricht.** Die Vorlage
* dieses Laufs enthaelt `{{generiertes_passwort}}` im Befehl; auf dem Blatt
* muss der Ersatztext stehen und nirgends ein Passwort.
*
* Geprueft wird am extrahierten Text und nicht an den Bytes – siehe
* `MIT_PDFTOTEXT`.
*/
test.skip(test.info().project.name !== "desktop", "Das Blatt haengt nicht am Geraet.");
const lauf = await laufMitPasswortschritt();
await anmelden(page);
await page.goto(`/laeufe/${lauf.id}`);
const bytes = await pdfHolen(page, lauf.id);
test.skip(!MIT_PDFTOTEXT, "Ohne pdftotext ist der Text des PDF nicht lesbar.");
const text = pdfText(bytes);
expect(text).toContain(PASSWORT_IM_DRUCK);
// Der Platzhalter selbst darf auch nicht stehenbleiben.
expect(text).not.toContain("generiertes_passwort");
/*
* Und der Beweis, der ohne Kenntnis des Textes auskommt: irgendeine Folge
* aus genau den Zeichen, aus denen `passwortErzeugen` baut, in voller
* Laenge. Ein echtes Passwort traegt immer eines aus jeder Gruppe, also
* verlangt das Muster das auch – sonst traefe es Fliesstext.
*/
const alphabet = PASSWORT_GROSS + PASSWORT_KLEIN + PASSWORT_ZIFFERN + PASSWORT_SONDER;
/*
* Der Bindestrich geht ans Ende der Zeichenklasse, wo er sich selbst meint.
* Ihn zu maskieren ist der naheliegende Weg und war der falsche: in einer
* Vorlagenzeichenkette wird aus `\\-` ein `\-`, und mit dem `u`-Schalter
* ist das eine ungueltige Maskierung – der Ausdruck wirft dann schon beim
* Bauen. Keines der uebrigen Sonderzeichen hat in einer Klasse Bedeutung.
*/
const klasse = alphabet.replace("-", "") + "-";
const kandidaten = (text.match(new RegExp(`[${klasse}]{${PASSWORT_LAENGE}}`, "gu")) ?? []).filter(
// Eine Kennung aus der Datenbank ist 25 Zeichen aus einem aehnlichen
// Alphabet; sie steht im Dateinamen und ist kein Passwort.
(treffer) => !lauf.id.includes(treffer),
);
expect(kandidaten, `Passwortverdacht auf dem Blatt: ${kandidaten.join(", ")}`).toEqual([]);
});
test("gibt den Lauf eines fremden Kunden nicht heraus", async ({ page }) => {
/*
* Dieselbe Zusage wie an der Druckansicht, nur ueber den neuen Weg. Ein
* Endpunkt, der ein ganzes Blatt ausgibt, waere der bequemste Zugang zu
* einem fremden Lauf.
*/
test.skip(test.info().project.name !== "desktop", "Die Mandantengrenze haengt nicht am Geraet.");
await anmelden(page);
const antwort = await page.request.get("/api/laeufe/gibtesnicht/protokoll.pdf");
expect(antwort.status()).toBe(404);
expect(antwort.headers()["content-type"]).toContain("application/json");
});
});
test.afterAll(async () => {
const laeufe = await prisma.lauf.findMany({
where: { titel: { startsWith: PRAEFIX } },
select: { id: true },
});
const kennungen = laeufe.map((lauf) => lauf.id);
if (kennungen.length > 0) {
await prisma.schrittergebnis.deleteMany({ where: { laufId: { in: kennungen } } });
await prisma.protokoll.deleteMany({
where: { OR: [{ laufId: { in: kennungen } }, { entitaetId: { in: kennungen } }] },
});
await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } });
}
// Die Vorlagen zuletzt: an ihnen hingen gerade noch die Laeufe.
await prisma.vorlage.deleteMany({ where: { name: { startsWith: PRAEFIX } } });
await prisma.$disconnect();
});