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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d30bb6d235
commit
f3950847f3
@@ -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 } },
|
||||
|
||||
Reference in New Issue
Block a user