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(); return { ...echt, rechtErforderlich: vi.fn() }; }); vi.mock("@/server/pdf", async (original) => { const echt = await original(); // `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 = {}) => 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(); } }); });