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>
335 lines
14 KiB
TypeScript
335 lines
14 KiB
TypeScript
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();
|
||
});
|