Files
Laufzettel/tests/pdf.test.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

376 lines
16 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Betriebsmodus, VersionStatus } from "@/generated/prisma/enums";
import { RechteFehler } from "@/server/auth/sitzungspflicht";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Das PDF-Übergabeprotokoll (#041).
*
* **Zwei Teile, und der zweite ist der wichtigere.** Oben stehen die reinen
* Helfer — wo Chromium liegt, wie viele PDFs gleichzeitig entstehen dürfen. Die
* brauchen keine Datenbank und keinen Browser.
*
* Unten wird der **Route Handler echt gefahren**, mit echten `Request`-Objekten
* und echter Datenbank, wie bei den SCIM-Handlern und bei `/api/anhaenge`. Nur
* die Sitzung wird gestellt und die Browser-Erzeugung ersetzt: ein Chromium je
* Testfall zu starten kostet Sekunden und prüft nichts, was hier zur Debatte
* steht. Dass aus einer echten Druckansicht ein echtes PDF wird, prüft
* `tests/e2e/pdf.spec.ts` im Browser — der einzige Ort, an dem das geht.
*
* Die Prüfung, die diese Datei vor allem festhält: **ohne Sichtbarkeit des Laufs
* startet gar kein Browser.** Sonst wäre ein PDF-Versuch ein Weg
* herauszufinden, ob es einen Lauf mit dieser Kennung gibt — und zwar einer, der
* keine Spur hinterlässt, weil nichts ins Protokoll kommt.
*/
/* Der Handler wird echt gefahren; nur Sitzung und Browser werden gestellt. */
vi.mock("@/server/auth/sitzungspflicht", async (original) => {
const echt = await original<typeof import("@/server/auth/sitzungspflicht")>();
return { ...echt, rechtErforderlich: vi.fn() };
});
vi.mock("@/server/pdf", async (original) => {
const echt = await original<typeof import("@/server/pdf")>();
// `PdfFehler` bleibt echt – der Handler unterscheidet daran die Antwort.
return { ...echt, seiteAlsPdf: vi.fn() };
});
const { rechtErforderlich } = await import("@/server/auth/sitzungspflicht");
const { PdfFehler, seiteAlsPdf, chromiumPfad, maxPdfs, pdfLeerlaufMs, pdfVerfuegbar, eigeneBasis } =
await import("@/server/pdf");
const { GET } = await import("@/app/api/laeufe/[id]/protokoll.pdf/route");
const kennung = (id: string) => ({ params: Promise.resolve({ id }) });
const anfrage = (kopf: Record<string, string> = {}) =>
new Request("http://127.0.0.1:3300/api/laeufe/egal/protokoll.pdf", { headers: kopf });
/** Ein winziges, gültiges PDF – es wird nur durchgereicht. */
const BLATT = new Uint8Array(Buffer.from("%PDF-1.4\n1 0 obj\n<<>>\nendobj\n%%EOF\n", "utf8"));
describe("Wo Chromium liegt", () => {
const VORHER = process.env["CHROMIUM_PFAD"];
afterEach(() => {
if (VORHER === undefined) delete process.env["CHROMIUM_PFAD"];
else process.env["CHROMIUM_PFAD"] = VORHER;
});
it("nimmt einen gesetzten Pfad, der auch existiert", async () => {
// Eine Datei, die es auf jedem System gibt, auf dem diese Suite läuft.
process.env["CHROMIUM_PFAD"] = process.execPath;
await expect(chromiumPfad()).resolves.toBe(process.execPath);
});
it("meldet einen gesetzten Pfad, der ins Leere zeigt – und sucht nicht weiter", async () => {
/*
* **Der Unterschied, der zählt.** Wer `CHROMIUM_PFAD` eingetragen hat, hat
* eine Absicht; still auf `/usr/bin/chromium` auszuweichen würde heißen,
* dass ein Tippfehler unbemerkt bleibt und ein anderer Browser läuft als
* der gemeinte.
*/
process.env["CHROMIUM_PFAD"] = "/gibt/es/nicht/chromium";
await expect(chromiumPfad()).rejects.toThrow(PdfFehler);
await expect(chromiumPfad()).rejects.toThrow(/gibt\/es\/nicht/);
// Und der Verweis auf die Stelle, wo es steht.
await expect(chromiumPfad()).rejects.toThrow(/docs\/betrieb\/pdf\.md/);
});
it("sagt der Oberfläche ohne Ausnahme ab, wenn der Pfad falsch steht", async () => {
/*
* `pdfVerfuegbar` entscheidet, ob der Knopf erscheint. Ein falsch gesetztes
* `CHROMIUM_PFAD` darf die Lauf-Seite nicht mitnehmen – dort wäre der Fehler
* an der falschen Stelle sichtbar.
*/
process.env["CHROMIUM_PFAD"] = "/gibt/es/nicht/chromium";
await expect(pdfVerfuegbar()).resolves.toBe(false);
process.env["CHROMIUM_PFAD"] = process.execPath;
await expect(pdfVerfuegbar()).resolves.toBe(true);
});
});
describe("Die Grenzen der PDF-Erzeugung", () => {
const VORHER_MAX = process.env["PDF_MAX_GLEICHZEITIG"];
const VORHER_LEER = process.env["PDF_LEERLAUF_MS"];
const VORHER_PORT = process.env["PORT"];
afterEach(() => {
for (const [name, wert] of [
["PDF_MAX_GLEICHZEITIG", VORHER_MAX],
["PDF_LEERLAUF_MS", VORHER_LEER],
["PORT", VORHER_PORT],
] as const) {
if (wert === undefined) delete process.env[name];
else process.env[name] = wert;
}
});
it("hält an der Vorgabe fest, wo nichts oder Unsinn eingestellt ist", () => {
delete process.env["PDF_MAX_GLEICHZEITIG"];
expect(maxPdfs()).toBe(2);
delete process.env["PDF_LEERLAUF_MS"];
expect(pdfLeerlaufMs()).toBe(60_000);
/*
* Eine `0` oder ein Wort würde die Erzeugung ganz abschalten
* beziehungsweise den Browser sofort schließen. Beides wäre eine
* Fehlkonfiguration, die aussieht wie ein kaputtes Programm.
*/
for (const unsinn of ["0", "-1", "viele", ""]) {
process.env["PDF_MAX_GLEICHZEITIG"] = unsinn;
expect(maxPdfs()).toBe(2);
}
for (const unsinn of ["0", "10", "sofort", ""]) {
process.env["PDF_LEERLAUF_MS"] = unsinn;
expect(pdfLeerlaufMs()).toBe(60_000);
}
});
it("nimmt vernünftige Werte an und schneidet Bruchteile ab", () => {
process.env["PDF_MAX_GLEICHZEITIG"] = "4";
expect(maxPdfs()).toBe(4);
process.env["PDF_MAX_GLEICHZEITIG"] = "3.7";
expect(maxPdfs()).toBe(3);
process.env["PDF_LEERLAUF_MS"] = "5000";
expect(pdfLeerlaufMs()).toBe(5000);
});
it("ruft den eigenen Port auf, nicht die öffentliche Adresse", () => {
/*
* **Mit Absicht nicht `APP_URL`.** Hinter einem Reverse Proxy wäre das ein
* Weg nach draußen und zurück, mit TLS, Proxy-Regeln und einer zweiten
* Stelle, an der etwas klemmen kann.
*/
process.env["PORT"] = "3300";
expect(eigeneBasis()).toBe("http://127.0.0.1:3300");
delete process.env["PORT"];
expect(eigeneBasis()).toBe("http://127.0.0.1:3000");
});
});
async function aufbau() {
const kunde = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
const admin = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
});
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Vorbereitung", position: 0 },
});
await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Datensicherung geprüft", position: 0 },
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
const lauf = await testPrisma.lauf.create({
data: { vorlagenVersionId: version.id, titel: "Ein Lauf", kundeId: kunde.id },
});
return { kunde, admin: { id: admin.id, rolle: admin.rolle }, laufId: lauf.id };
}
describe.runIf(datenbankVerfuegbar)("Das PDF über seine Adresse", () => {
beforeEach(async () => {
await datenbankLeeren();
vi.mocked(rechtErforderlich).mockReset();
vi.mocked(seiteAlsPdf).mockReset();
});
it("weist ohne Anmeldung ab, bevor ein Browser startet", async () => {
const { laufId } = await aufbau();
vi.mocked(rechtErforderlich).mockRejectedValue(new RechteFehler("Nicht angemeldet."));
const antwort = await GET(anfrage(), kennung(laufId));
expect(antwort.status).toBe(401);
// Und kein Chromium hat angefangen zu laufen.
expect(seiteAlsPdf).not.toHaveBeenCalled();
});
it("weist auch ab, wenn die Rolle das Recht lesen nicht hat", async () => {
// `MITARBEITER` hat seit #035 kein `lesen`; `selbstauskunft` ist dessen Ersatz.
const { laufId } = await aufbau();
vi.mocked(rechtErforderlich).mockRejectedValue(new RechteFehler("Dafür fehlt dir die Berechtigung."));
expect((await GET(anfrage(), kennung(laufId))).status).toBe(401);
expect(seiteAlsPdf).not.toHaveBeenCalled();
});
it("liefert das Blatt mit den Kopfzeilen eines PDF", async () => {
const { admin, laufId } = await aufbau();
vi.mocked(rechtErforderlich).mockResolvedValue({ benutzer: admin } as never);
vi.mocked(seiteAlsPdf).mockResolvedValue(BLATT);
const antwort = await GET(anfrage(), kennung(laufId));
expect(antwort.status).toBe(200);
expect(antwort.headers.get("content-type")).toBe("application/pdf");
expect(antwort.headers.get("content-length")).toBe(String(BLATT.byteLength));
expect(antwort.headers.get("x-content-type-options")).toBe("nosniff");
/*
* `inline` und ein Dateiname **ohne** Titel: ein Titel enthält Leerzeichen,
* Umlaute und Schrägstriche, und ein `Content-Disposition` mit rohem Titel
* ist eine Einladung zum Kopfzeilen-Einschub.
*/
expect(antwort.headers.get("content-disposition")).toBe(`inline; filename="laufzettel-${laufId}.pdf"`);
expect(antwort.headers.get("content-disposition")).not.toContain("Ein Lauf");
// Ein Blatt gehört einem Kunden; ein öffentlicher Cache gäbe es weiter.
expect(antwort.headers.get("cache-control")).toContain("private");
expect(Buffer.from(await antwort.arrayBuffer()).equals(Buffer.from(BLATT))).toBe(true);
});
it("druckt dieselbe Seite, die die Druckansicht zeigt – mit der Sitzung des Anfragenden", async () => {
/*
* **Es gibt genau eine Vorlage für das Blatt.** Würde hier ein zweites
* Layout entstehen, liefen Papier und PDF auseinander, und niemand merkte
* es, bis ein Kunde zwei verschiedene Blätter nebeneinander legt.
*
* Das Cookie muss mit, weil die Druckansicht ihr Recht selbst prüft. Damit
* gilt für das PDF dieselbe Rechte- und Mandantenprüfung wie für die Seite.
*/
const { admin, laufId } = await aufbau();
vi.mocked(rechtErforderlich).mockResolvedValue({ benutzer: admin } as never);
vi.mocked(seiteAlsPdf).mockResolvedValue(BLATT);
await GET(anfrage({ cookie: "laufzettel_sitzung=abc" }), kennung(laufId));
expect(seiteAlsPdf).toHaveBeenCalledWith(`/drucken/lauf/${laufId}`, "laufzettel_sitzung=abc");
});
it("gibt das Blatt eines fremden Kunden nicht heraus – und startet dafür keinen Browser", async () => {
/*
* Der Techniker ist auf einen anderen Kunden beschränkt; die Kennung des
* Laufs kennt er. Ohne diese Prüfung reicht die Kennung — und das PDF wäre
* der bequemste Weg an einen fremden Lauf, weil es den ganzen Inhalt auf
* einem Blatt ausgibt.
*/
const vorher = process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "kunde-";
try {
const { laufId } = await aufbau();
await testPrisma.einstellung.update({
where: { id: "global" },
data: { modus: Betriebsmodus.DIENSTLEISTUNG },
});
const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FRD" } });
const techniker = await testPrisma.benutzer.create({
data: { email: "tina@firma.example", name: "Tina", rolle: "TECHNIKER" },
});
await testPrisma.benutzerKunde.create({
data: { benutzerId: techniker.id, kundeId: fremd.id },
});
vi.mocked(rechtErforderlich).mockResolvedValue({
benutzer: { id: techniker.id, rolle: techniker.rolle },
} as never);
const antwort = await GET(anfrage(), kennung(laufId));
/*
* `404` und nicht `403`: nicht vorhanden und nicht sichtbar sind dieselbe
* Antwort. Die Unterscheidung wäre schon eine Auskunft über fremde Kunden.
*/
expect(antwort.status).toBe(404);
expect(seiteAlsPdf).not.toHaveBeenCalled();
const daten = JSON.stringify(await antwort.json());
expect(daten).not.toContain("Ein Lauf");
expect(daten).not.toContain("Eigene Organisation");
} finally {
if (vorher === undefined) delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
else process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = vorher;
}
});
it("antwortet 404 für eine Kennung, die es nicht gibt", async () => {
const { admin } = await aufbau();
vi.mocked(rechtErforderlich).mockResolvedValue({ benutzer: admin } as never);
const antwort = await GET(anfrage(), kennung("gibtesnicht"));
expect(antwort.status).toBe(404);
expect(seiteAlsPdf).not.toHaveBeenCalled();
});
it("sagt verständlich, wenn Chromium fehlt – und verweist auf die Betriebsdoku", async () => {
/*
* **Eine Meldung, kein stilles leeres PDF** (Akzeptanzkriterium von #041).
* `503` und nicht `500`: es ist kein Fehler in diesem Code, sondern ein
* Dienst, der hier nicht eingerichtet ist.
*/
const { admin, laufId } = await aufbau();
vi.mocked(rechtErforderlich).mockResolvedValue({ benutzer: admin } as never);
vi.mocked(seiteAlsPdf).mockRejectedValue(
new PdfFehler(
"Auf diesem Server ist kein Chromium installiert, und ohne ihn entsteht kein PDF. " +
"Die Druckansicht funktioniert weiterhin: dort im Browser „in eine Datei drucken“. " +
"Wie Chromium dazukommt, steht in docs/betrieb/pdf.md.",
),
);
const antwort = await GET(anfrage(), kennung(laufId));
expect(antwort.status).toBe(503);
const { fehler } = (await antwort.json()) as { fehler: string };
expect(fehler).toContain("kein Chromium");
// Der Weg, der trotzdem funktioniert, und die Stelle, wo das Nachlesen steht.
expect(fehler).toContain("Druckansicht");
expect(fehler).toContain("docs/betrieb/pdf.md");
});
it("sagt verständlich, wenn gerade zu viele PDFs entstehen", async () => {
const { admin, laufId } = await aufbau();
vi.mocked(rechtErforderlich).mockResolvedValue({ benutzer: admin } as never);
vi.mocked(seiteAlsPdf).mockRejectedValue(
new PdfFehler(
"Es entstehen gerade 2 PDFs gleichzeitig, und mehr sind nicht eingestellt. " +
"Bitte einen Augenblick warten und noch einmal versuchen.",
),
);
const antwort = await GET(anfrage(), kennung(laufId));
expect(antwort.status).toBe(503);
expect(((await antwort.json()) as { fehler: string }).fehler).toContain("noch einmal versuchen");
});
it("verrät bei einem unerwarteten Fehler nichts über den Server", async () => {
/*
* Eine rohe Ausnahme kann Pfade und Zugangsdaten nennen (#025). Sie gehört
* ins Protokoll des Prozesses, nicht in die Antwort.
*/
const { admin, laufId } = await aufbau();
vi.mocked(rechtErforderlich).mockResolvedValue({ benutzer: admin } as never);
vi.mocked(seiteAlsPdf).mockRejectedValue(
new Error("connect ECONNREFUSED /var/run/postgres/.s.PGSQL.5432 als laufzettel"),
);
const protokoll = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
const antwort = await GET(anfrage(), kennung(laufId));
expect(antwort.status).toBe(500);
const roh = JSON.stringify(await antwort.json());
expect(roh).not.toContain("ECONNREFUSED");
expect(roh).not.toContain("/var/run");
expect(roh).not.toContain("laufzettel");
// Aber es steht im Protokoll des Dienstes, sonst sucht niemand.
expect(protokoll).toHaveBeenCalled();
expect(protokoll.mock.calls.flat().join(" ")).toContain("ECONNREFUSED");
} finally {
protokoll.mockRestore();
}
});
});