feat(laeufe): Uebergabeprotokoll als PDF (#041)
Prüfung / pruefung (push) Waiting to run
Prüfung / e2e (push) Blocked by required conditions

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>
This commit is contained in:
Florian Wilde
2026-10-25 11:14:25 +01:00
co-authored by Claude Opus 5
parent d30bb6d235
commit f3950847f3
25 changed files with 1577 additions and 31 deletions
+152
View File
@@ -1,6 +1,18 @@
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";
/**
@@ -163,6 +175,146 @@ test.describe("Druckansicht", () => {
});
});
/**
* 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 } },