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
+20
View File
@@ -113,6 +113,26 @@ SEED_ORG_KUERZEL=EIGEN
# Migrationen fahren, und fuer Testlaeufe.
ZEITPLAN_AKTIV=true
# ---- PDF-Uebergabeprotokoll (#041) -----------------------------------------
# Das Protokoll eines Laufs als PDF entsteht, indem der Server seine **eigene**
# Druckansicht mit Chromium laedt. Dafuer muss Chromium da sein – und das ist er
# absichtlich nicht ueberall: im Bild kostet er 1,13 GB, mehr als die dreifache
# Anwendung. Ohne ihn zeigt die Oberflaeche den Knopf gar nicht erst, und die
# Druckansicht bleibt der Weg. Alles dazu in docs/betrieb/pdf.md.
#
# Leer lassen heisst „suche selbst“: /usr/bin/chromium-browser (Alpine),
# /usr/bin/chromium, /usr/bin/google-chrome. Nur setzen, wo er woanders liegt –
# ein Pfad, der ins Leere zeigt, ist ein Fehler und kein Rueckfall auf die Suche.
CHROMIUM_PFAD=
# Wie viele PDFs gleichzeitig entstehen duerfen. Jedes haelt kurz einen
# Browser-Kontext offen; mehr als eine Handvoll bringt auf kleinen Maschinen nur
# Arbeitsspeicherdruck. Darueber hinaus bekommt der Anwender die Bitte, es gleich
# noch einmal zu versuchen.
PDF_MAX_GLEICHZEITIG=2
# Nach wie vielen Millisekunden Leerlauf der Chromium wieder schliesst. Er haelt
# offen rund 100 MB belegt – auf einem 2-GB-LXC ist das spuerbar.
PDF_LEERLAUF_MS=60000
# ---------- Tests ----------
# Datenbank fuer Vitest. Ohne diese Variable ueberspringen sich die Datenbanktests.
+17
View File
@@ -9,6 +9,23 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Hinzugefügt
- **Das Übergabeprotokoll als PDF.** 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 (*Übergeben von* / *Übernommen von*) mit Zeile für
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 lädt und druckt: es gibt genau
eine Vorlage für Papier und Datei, und für das PDF gilt dieselbe Rechte- und Mandantenprüfung wie
für die Seite. Gespeichert wird nichts — ein abgelegtes PDF wäre eine zweite Wahrheit neben dem
Lauf. **Ein erzeugtes Passwort steht auch hier nie darauf**, geprüft am extrahierten Text des
fertigen PDF (#041)
- **Chromium im Bild ist dafür nötig — und bleibt optional.** Das Laufzeitbild wächst von
**370 MB auf 1,52 GB**; Alpines `chromium` kostet allein 1,13 GB, mehr als das Dreifache der
Anwendung. Deshalb kommt er nur mit `docker build --build-arg MIT_PDF=1` beziehungsweise
`deploy/install.sh --mit-pdf` mit. **Fehlt er, erscheint der Knopf gar nicht erst**, und ein
direkter Aufruf antwortet `503` mit einer Meldung, die sagt, was fehlt und wo es steht — kein
leeres PDF. Die Druckansicht bleibt in jedem Fall der Weg. Einstellbar sind `CHROMIUM_PFAD`,
`PDF_MAX_GLEICHZEITIG` (2) und `PDF_LEERLAUF_MS` (60 s); alles in
[`docs/betrieb/pdf.md`](docs/betrieb/pdf.md) (#041)
- **Vier-Augen-Freigabe.** Verlangt eine Vorlagen-Version sie, lässt sich ein Lauf erst abschließen,
wenn ein **Zweiter** ihn freigegeben hat — jemand mit dem neuen Recht `freigeben` (`ADMIN` und
`REDAKTEUR`, **nicht** `TECHNIKER`), der nicht an dem Lauf gearbeitet hat. Als gearbeitet zählt:
+8
View File
@@ -242,6 +242,7 @@ brauchen einen echten Browser.
| Druckansicht und Etiketten | Print-CSS |
| Eine Rolle sieht keine Schreib-Knöpfe | gerendertes DOM |
| Dass überhaupt hydriert wird | der Grund für „gebauter Stand“, siehe oben |
| PDF-Protokoll ohne erzeugtes Passwort | das PDF entsteht erst im echten Chromium (#041) |
**Was sich holen ließ, ist geholt.** Die Rechteprüfung der Route Handler steht seit #036 in
`tests/anhang-route.test.ts`, `tests/lauf-ereignisse.test.ts` und `tests/scim.test.ts` — die Handler
@@ -276,6 +277,13 @@ geprüft. In CI bricht sie stattdessen ab, absichtlich.
`ldap` fiel bei der Abnahme zu #019 auf, `mailpit` kam mit #034 dazu. Beide Zahlen sind **gemessen**,
nicht geschätzt — in #026 hätte beinahe eine geratene in dieser Datei gestanden.
**`pdftotext` gehört zur E2E-Ausstattung, nicht zum Commit-Tor.** Zwei Abläufe lesen den Text des
erzeugten PDF — darunter die Zusage „kein erzeugtes Passwort auf dem Blatt“. Der Inhalt eines PDF
ist komprimiert: in den rohen Bytes zu suchen und nichts zu finden beweist **nichts**. Fehlt
`poppler-utils`, überspringen sich beide und sagen das auf stderr. Ebenso überspringen sie sich,
wenn der Server kein Chromium hat — dann erscheint der Knopf „Als PDF“ gar nicht, und das ist eine
gültige Installation (`docs/betrieb/pdf.md`).
`format:pruefen` gehört dazu, seit der Review in #010 darauf gestoßen ist: `lint-staged` formatiert
beim Commit automatisch nach, und das Ergebnis wäre ein Commit mit Änderungen, die niemand gesehen
hat. Lieber vorher sehen, was sich ändert.
+29
View File
@@ -30,6 +30,35 @@ FROM basis AS laufzeit
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# ---------------------------------------------------------------------------
# Chromium fuer das PDF-Uebergabeprotokoll – optional, und das mit Absicht (#041).
#
# Gemessen am 2026-10-25, und die Zahl ist der Grund fuer den Schalter:
#
# ohne Chromium 370 MB
# mit Chromium 1,52 GB
#
# Alpines `chromium` kostet allein 1,13 GB – mehr als das dreifache der
# gesamten Anwendung. Die meisten Installationen brauchen es nicht: die
# Druckansicht gibt dasselbe Blatt ueber den Browser des Anwenders aus.
#
# `font-noto` sind davon nur 20 MB (`ttf-dejavu` waeren 10 MB und deckten
# weniger ab). Es muss mit: Chromium ohne jede Systemschrift stellt Zeichen als
# leere Kaesten dar, sobald eines nicht in unseren eigenen Schriften steht –
# und das PDF waere trotzdem ein gueltiges PDF. Ein Fehler, der nicht meldet,
# sondern falsch liefert.
#
# docker build --build-arg MIT_PDF=1 -t laufzettel .
#
# Ohne das Argument sagt der Endpunkt, was fehlt, und die Oberflaeche zeigt den
# Knopf gar nicht erst (docs/betrieb/pdf.md).
# ---------------------------------------------------------------------------
ARG MIT_PDF=0
RUN if [ "$MIT_PDF" = "1" ]; then \
apk add --no-cache chromium font-noto; \
fi
RUN addgroup -g 1001 -S laufzettel && adduser -S laufzettel -u 1001 -G laufzettel
COPY --from=bauen --chown=laufzettel:laufzettel /app/.next/standalone ./
COPY --from=bauen --chown=laufzettel:laufzettel /app/.next/static ./.next/static
+29 -1
View File
@@ -54,6 +54,8 @@ bindAdresse="${BIND_ADDR:-127.0.0.1}"
seedModus="${SEED_MODE:-INTERN}"
passwortDatei="${LAUFZETTEL_PASSWORT_DATEI:-}"
testmodus=0
# PDF-Uebergabeprotokoll: Chromium kostet ueber 1 GB, also nur auf Verlangen (#041).
mitPdf="${LAUFZETTEL_MIT_PDF:-0}"
# Nur die Werte, die in die Umgebungsdatei wandern - Quelle und Zweig gelten
# auch beim Aktualisieren und loesen deshalb keinen Hinweis aus.
optionenGesetzt=0
@@ -136,6 +138,12 @@ Schalter:
die es nicht von der Ausgabe ablesen koennen. Wer sie
benutzt, loescht sie nach dem Ablesen: sonst liegt das
Passwort dauerhaft im Klartext im System.
--mit-pdf Chromium mitinstallieren, damit das Uebergabeprotokoll
als PDF entsteht. Kostet rund 1,1 GB Plattenplatz und
ist deshalb nicht Standard — ohne ihn bleibt die
Druckansicht der Weg, und der Knopf erscheint gar nicht
erst. Wirkt auch bei einem zweiten Lauf, weil es ein
Paket ist und keine Einstellung. docs/betrieb/pdf.md
--test Nach der Installation eine Selbstpruefung fahren: kein
TCP-Port 5432, Migration und Seed gelaufen, Antwort auf
/api/gesundheit. Ohne systemd (Container) wird der Dienst
@@ -152,7 +160,8 @@ Umgebung:
APP_URL, BIND_ADDR, APP_PORT, SEED_ADMIN_EMAIL, SEED_MODE,
LAUFZETTEL_REPO (wie --quelle), LAUFZETTEL_ZWEIG (wie --zweig),
LAUFZETTEL_PASSWORT_DATEI (wie --passwort-datei)
LAUFZETTEL_PASSWORT_DATEI (wie --passwort-datei),
LAUFZETTEL_MIT_PDF=1 (wie --mit-pdf)
Das Administrator-Passwort steht nicht in /etc/laufzettel/laufzettel.env. Es
wird beim ersten Lauf einmal angezeigt und liegt sonst nur als argon2-Hash in
@@ -305,6 +314,15 @@ paketeInstallieren() {
if ! dpkgInstalliert "$paket"; then fehlend+=("$paket"); fi
done
# Chromium und eine Rueckfallschrift nur auf Verlangen: zusammen rund 1,1 GB.
# Ohne die Schriften entsteht ein PDF mit leeren Kaesten statt Buchstaben --
# ein gueltiges PDF, das falsch ist, und das ist schlimmer als keines.
if [ "$mitPdf" = "1" ]; then
for paket in chromium fonts-noto-core; do
if ! dpkgInstalliert "$paket"; then fehlend+=("$paket"); fi
done
fi
if [ "${#fehlend[@]}" -gt 0 ]; then
aptAktualisieren
apt-get install -y --no-install-recommends "${fehlend[@]}" >/dev/null
@@ -312,6 +330,12 @@ paketeInstallieren() {
else
melde "alles vorhanden"
fi
if [ "$mitPdf" = "1" ]; then
melde "PDF-Uebergabeprotokoll: $(command -v chromium || echo 'Chromium NICHT gefunden')"
else
hinweis "Ohne --mit-pdf entsteht kein PDF-Uebergabeprotokoll; die Druckansicht bleibt der Weg."
fi
}
# ---------- Schritt 3: Dienstnutzer und Verzeichnisse -----------------------
@@ -1022,6 +1046,10 @@ schalterLesen() {
passwortDatei="${2:?--passwort-datei braucht einen Pfad}"
shift 2
;;
--mit-pdf)
mitPdf=1
shift
;;
--test)
testmodus=1
shift
+23
View File
@@ -321,6 +321,29 @@ abheftet, darf keines stehen.
Von der Person stehen nur Name, UPN und Abteilung auf dem Beleg. Ein Ausdruck liegt herum, wird
kopiert und weitergegeben; was darauf steht, ist aus der Hand gegeben.
Unten auf dem Blatt stehen zwei Unterschriftsfelder — **Übergeben von** und **Übernommen von**, je
mit einer Zeile für Ort, Datum und Namen in Druckbuchstaben. Damit ist der Beleg gleich das
Übergabeprotokoll: Gerät wechselt den Besitzer, beide unterschreiben, fertig.
### Als PDF
Neben **Drucken** steht **Als PDF**. Der Knopf lädt dasselbe Blatt als fertige PDF-Datei
herunter — ohne den Umweg über den Druckdialog. Praktisch für den, der ein Protokoll verschicken
oder ablegen will statt es auf Papier zu bringen.
Im **Archiv** steht an jedem Treffer derselbe Knopf, dort als **Protokoll als PDF**. Wer ein altes
Protokoll für die Ablage oder für einen Kunden braucht, holt es damit direkt aus der Trefferliste,
ohne den Lauf erst zu öffnen.
> **Fehlt der Knopf, ist er nicht kaputt.** Die PDF-Erzeugung braucht ein zusätzliches Programm auf
> dem Server, und das ist nicht überall installiert — es kostet mehr Platz als die ganze Anwendung.
> Dann bleibt **Drucken** der Weg: im Browser „in eine Datei drucken“ liefert dasselbe Blatt.
> Wer es dauerhaft haben will, fragt den Administrator; die Einzelheiten stehen in
> [`docs/betrieb/pdf.md`](../betrieb/pdf.md).
Auf dem PDF steht genau dasselbe wie auf dem gedruckten Blatt — es **ist** dieselbe Ansicht. Auch
hier also kein erzeugtes Passwort.
## Im Archiv suchen
![Das Archiv mit Suchfeld, Filtern und Fundstellen im Treffer](bilder/archiv-desktop.png)
+3
View File
@@ -62,6 +62,9 @@ Sitzungen ungültig *und* die verschlüsselt gespeicherten LDAP-Bind-Passwörter
Signatur nachrechnen, und wo man nachsieht, wenn keine Mail mehr ankommt
- **[SCIM-Endpunkt](scim.md)** — die einzige Stelle, an der ein fremder Dienst von außen schreibend
zugreift: das Token, was nicht unterstützt wird, und was der Reverse Proxy durchlassen muss
- **[Das Übergabeprotokoll als PDF](pdf.md)** — eine Zusatzausstattung, die Chromium verlangt und
das Bild von 370 MB auf 1,52 GB bringt: wann sie sich lohnt, wie sie dazukommt, und was passiert,
wenn man sie weglässt
## Wo sonst noch etwas steht
+1
View File
@@ -91,6 +91,7 @@ Jede steht mit Kommentar in [`../../.env.example`](../../.env.example). **Pflich
| `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD` | Der Administrator, den der Seed anlegt. Das Passwort wird nur als argon2-Hash gespeichert. Fehlt eines, überspringt der Seed ihn — und dann kommst du nicht hinein. |
| `SEED_MODE` | `INTERN` (Standard) oder `DIENSTLEISTUNG`, `SERVICE` als Alias. Betrifft nur den **ersten** Seed-Lauf; danach steht der Modus in der Datenbank und wird in der Oberfläche umgeschaltet. |
| `SEED_ORG_NAME`, `SEED_ORG_KUERZEL` | Name und Kürzel der eigenen Organisation. Das Kürzel (2 bis 6 Großbuchstaben oder Ziffern) steht auf Etiketten und in der Hostname-Regel. |
| `CHROMIUM_PFAD`, `PDF_MAX_GLEICHZEITIG`, `PDF_LEERLAUF_MS` | Nur wenn das Übergabeprotokoll als PDF entstehen soll. Das verlangt Chromium im Bild (`--build-arg MIT_PDF=1`) und bringt es von 370 MB auf 1,52 GB; ohne ihn erscheint der Knopf nicht und die Druckansicht bleibt der Weg. Alles dazu in [`pdf.md`](pdf.md). |
| `TEST_DATABASE_URL` | Nur für die Tests. Im Betrieb ohne Bedeutung. |
## 4. Starten
+167
View File
@@ -0,0 +1,167 @@
# Das Übergabeprotokoll als PDF
Jeder Lauf lässt sich als PDF herunterladen — ein Blatt mit Kopfdaten, allen Schritten samt
Zuständen und Notizen, den Kommentaren und zwei Unterschriftsfeldern. Gedacht ist es für die
Übergabe: ein Gerät wechselt den Besitzer, beide unterschreiben, das Blatt geht in die Ablage oder
zum Kunden.
**Das ist eine Zusatzausstattung, und sie kostet.** Ohne sie fehlt nichts Wesentliches: die
Druckansicht zeigt dasselbe Blatt, und jeder Browser kann daraus „in eine Datei drucken“. Das PDF
vom Server nimmt diesen Schritt nur ab — für den, der zwanzig Protokolle am Stück braucht, oder für
den, der sie aus dem Archiv heraus einzeln verschickt.
## Was es braucht
**Chromium auf dem Server.** Nichts weiter — keine Schriften aus dem Netz, kein Dienst, kein
zusätzlicher Port.
Der Grund: das PDF entsteht, indem der Server seine **eigene** Druckansicht lädt, so wie ein
Browser es täte, und sie druckt. Dadurch gibt es genau eine Vorlage für Papier und PDF. Ein zweites
Layout im Code wäre billiger zu betreiben und würde früher oder später von der Druckansicht
abweichen, ohne dass es jemandem auffällt — bis ein Kunde zwei verschiedene Blätter nebeneinander
legt.
Chromium ist der Preis dafür, und er ist hoch:
| Laufzeitbild | Größe |
|---|---|
| ohne Chromium | **370 MB** |
| mit Chromium | **1,52 GB** |
Gemessen am 2026-10-25. Alpines `chromium` kostet allein 1,13 GB — mehr als das Dreifache der
gesamten Anwendung. Deshalb ist er nicht im Standardbild, und deshalb ist diese Seite kein Anhang,
sondern eine Entscheidung.
## Mit Docker Compose
Chromium kommt über ein Bau-Argument ins Bild:
```bash
docker compose build --build-arg MIT_PDF=1 web
docker compose up -d
```
Damit das beim nächsten `docker compose build` nicht verlorengeht, gehört es in die
`docker-compose.yml` beziehungsweise in eine Override-Datei:
```yaml
services:
web:
build:
args:
MIT_PDF: "1"
```
Mehr ist nicht zu tun: das Paket liegt dann unter `/usr/bin/chromium-browser`, und dort sucht die
Anwendung von sich aus.
## Nativ, im LXC oder auf einem Debian
`deploy/install.sh` fragt beim ersten Lauf, ob Chromium mit soll, und merkt die Antwort in
`/etc/laufzettel/laufzettel.env`. Nachträglich:
```bash
apt-get install -y chromium
systemctl restart laufzettel
```
Auf Debian 13 liegt es danach unter `/usr/bin/chromium`. Der Neustart ist nötig, weil die Anwendung
den Pfad beim ersten PDF sucht und sich das Ergebnis für die Laufzeit des Prozesses merkt.
## Wenn Chromium fehlt
**Dann passiert nichts Stilles.** Der Knopf „Als PDF“ erscheint am Lauf und im Archiv gar nicht
erst — die Anwendung sieht bei jedem Seitenaufbau nach, ob ein Chromium da ist. Wer den Endpunkt
trotzdem aufruft, etwa aus einem alten Tab oder mit einem Lesezeichen, bekommt `503` und diesen
Text:
> 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.
Ein leeres oder halbes PDF gibt es in keinem Fall. Das war die Vorgabe für dieses Verhalten: ein
Blatt, das aussieht wie ein Protokoll und keines ist, wäre schlimmer als eine klare Absage.
## Die drei Einstellungen
Alle drei sind Vorgaben, die man kennen sollte und selten braucht.
| Variable | Vorgabe | Wofür |
|---|---|---|
| `CHROMIUM_PFAD` | leer | Wo Chromium liegt, wenn er woanders liegt |
| `PDF_MAX_GLEICHZEITIG` | `2` | Wie viele PDFs gleichzeitig entstehen dürfen |
| `PDF_LEERLAUF_MS` | `60000` | Nach wie viel Leerlauf der Browser wieder schließt |
**`CHROMIUM_PFAD` leer lassen heißt „suche selbst“** — der Reihe nach
`/usr/bin/chromium-browser`, `/usr/bin/chromium`, `/usr/bin/google-chrome`. Ein gesetzter Pfad, der
ins Leere zeigt, ist dagegen ein **Fehler** und kein Rückfall auf die Suche: wer hier etwas
einträgt, hat eine Absicht, und ein Tippfehler soll auffallen und nicht dazu führen, dass
stillschweigend ein anderer Browser läuft als der gemeinte.
**`PDF_MAX_GLEICHZEITIG` schützt den Arbeitsspeicher.** Jedes PDF hält kurz einen eigenen
Browser-Kontext offen — eigen, und nicht geteilt, weil er das Sitzungscookie des Anfragenden trägt.
Ist die Grenze erreicht, bekommt der nächste die Bitte, es gleich noch einmal zu versuchen, statt
einer Maschine, die unter Last steht.
**`PDF_LEERLAUF_MS`** hält den Chromium nach dem letzten PDF noch eine Minute offen, weil ihn zu
starten rund zwei Sekunden kostet und Protokolle meist in Serie entstehen. Danach schließt er: offen
belegt er rund 100 MB, und auf einem 2-GB-LXC ist das ein Achtel des Arbeitsspeichers für etwas, das
niemand gerade braucht.
## Was auf dem Blatt steht — und was nicht
Es steht dasselbe darauf wie in der Druckansicht, denn es **ist** die Druckansicht. Insbesondere:
- **Kein generiertes Passwort.** Wo ein Befehl `{{generiertes_passwort}}` enthält, steht auf dem
Blatt `[Passwort – nicht gespeichert]`. Das Passwort entsteht bei jeder Anzeige neu und wird
nirgends gespeichert; ein Ausdruck dagegen liegt herum. Geprüft wird das am extrahierten Text des
fertigen PDF, nicht an der Vorlage.
- **Keine Anhänge**, nur ihre Anzahl. Ein Foto gehört nicht auf ein Übergabeprotokoll, und ein PDF
mit zwanzig Bildern wäre nicht mehr zu verschicken.
- **Befehle unmaskiert.** Das Blatt hält fest, was zu tun war; Maskierzeichen wären auf Papier
Rauschen. Wer einen Befehl ausführen will, nimmt die Laufseite — dort ist er für die gewählte
Shell maskiert.
## Fehlersuche
**Der Knopf fehlt, obwohl Chromium installiert ist.** Die Anwendung hat ihn nicht gefunden. Nachsehen,
wo er liegt, und `CHROMIUM_PFAD` setzen:
```bash
command -v chromium chromium-browser google-chrome
```
Danach den Dienst neu starten. Im Compose-Stand prüft man im Container selbst, nicht auf dem Host:
```bash
docker compose exec web command -v chromium-browser
```
**`503` mit „Die Druckansicht antwortete mit 307“** (oder 302, oder 401). Das Sitzungscookie kam
nicht mit oder gilt nicht mehr — Chromium wurde auf die Anmeldeseite geschickt. Neu anmelden und es
noch einmal versuchen. Bleibt es dabei, steht vermutlich ein Reverse Proxy im Weg, der `Cookie`
nicht durchlässt.
**`503` mit „Es entstehen gerade … PDFs gleichzeitig“.** Die Grenze greift. Entweder warten oder
`PDF_MAX_GLEICHZEITIG` anheben — und dabei im Blick behalten, dass jeder gleichzeitige Vorgang
Arbeitsspeicher kostet.
**`500` mit „Einzelheiten stehen im Protokoll des Dienstes“.** Hier geht die Meldung absichtlich
nicht hinaus, weil eine rohe Ausnahme Pfade und Zugangsdaten nennen kann. Nachsehen:
```bash
docker compose logs -f web | grep PDF # Compose
journalctl -u laufzettel -f | grep PDF # nativ
```
**Leere Kästen statt Buchstaben im PDF.** Dem Chromium fehlen Systemschriften. Im Bild ist
`font-noto` deshalb mit dabei; nativ gehört es nachinstalliert
(`apt-get install -y fonts-noto-core`). Die Schriften der Oberfläche selbst liefert die Anwendung
mit, aber für Zeichen, die darin nicht vorkommen, braucht Chromium einen Rückfall.
## Wo sonst noch etwas steht
- [`../../.env.example`](../../.env.example) — die drei Variablen mit Kommentar
- [`inbetriebnahme.md`](inbetriebnahme.md) — der Compose-Weg im Ganzen
- [`proxmox.md`](proxmox.md) — der native Weg und `deploy/install.sh`
- [`../anleitung/02-laeufe-abarbeiten.md`](../anleitung/02-laeufe-abarbeiten.md) — wie man das Protokoll als Anwender holt
+80 -2
View File
@@ -42,13 +42,14 @@ als Unterpunkte mit Aufgabe und Dauer.
| #037 | Zwei Nachzügler aus der Durchsicht von Phase 2 | 1 h 50 min |
| #039 | M23 – Bedingte Schritte und Abschnitte | 5 h 20 min |
| #040 | M24 – Vier-Augen-Freigabe | 3 h 30 min |
| | **Summe** | **82 h 45 min** |
| #041 | M25 – PDF-Übergabeprotokoll mit Unterschrift | 4 h 10 min |
| | **Summe** | **86 h 55 min** |
| Meilenstein | Tatsächlich | Status |
|---|---|---|
| v0.1.0 | 35 h 20 min | veröffentlicht |
| v0.2.0 | 38 h 35 min | veröffentlicht |
| v0.3.0 | 8 h 50 min | in Arbeit |
| v0.3.0 | 13 h 00 min | in Arbeit |
---
@@ -1895,3 +1896,80 @@ Wirklichkeit nicht — die Prüflisten stehen in `docs/betrieb/entra-id.md` und
### Nächster Schritt
- **M25 (#041)** PDF-Übergabeprotokoll. Der Freigeber steht jetzt am Lauf und gehört auf das Blatt.
---
## 2026-10-25 · Sitzung 27 · 09:30–13:40
**Issue:** #041 (M25)
**Version:** 0.3.0 (unveröffentlicht)
### Erledigt
- **Das Übergabeprotokoll als PDF.** `GET /api/laeufe/[id]/protokoll.pdf`, Chromium-Vorrat mit
Obergrenze und Leerlauf-Schließung, Knopf am Lauf und an jedem Archiv-Treffer,
Unterschriftsfelder und Freigabe-Zeile auf dem Blatt.
- **Chromium optional ins Bild**: `--build-arg MIT_PDF=1`, `deploy/install.sh --mit-pdf`,
`docs/betrieb/pdf.md`, die drei Variablen in `.env.example` und in der Variablentabelle,
Anleitung Kapitel 02.
- 1545 Vitest-Tests (vorher 1526): 15 in `tests/pdf.test.ts`, 3 in `tests/pdf-grenze.test.ts`,
einer in `tests/archiv.test.ts`. Dazu drei E2E-Abläufe. Volle E2E-Suite grün: 118 bestanden,
40 übersprungen.
- **Abnahme von Hand:** ein Blatt wirklich erzeugt und angesehen — zwei Seiten A4, 138 kB,
Kopfdaten, sieben Schritte mit Zuständen und Notizen, Befehl, Kommentar, beide
Unterschriftsfelder mit sichtbarer Linie, Fußzeile mit Druckzeitpunkt und Lauf-Kennung. Umlaute
stehen richtig da, also stimmen die Schriften.
### Entscheidungen
- **`playwright-core` als echte Abhängigkeit**, nicht `@playwright/test`: das ist ein
Entwicklungspaket und hat im Laufzeitbild nichts verloren. Es landet über das Tracing im
Standalone-Bündel (12 MB, als relativer Symlink in das mitkopierte `.pnpm`).
- **Ein eigener Browser-Kontext je PDF, kein geteilter.** Er trägt das Sitzungscookie des
Anfragenden; ein geteilter behielte es und gäbe das nächste PDF mit fremder Sitzung heraus.
- **Der Server ruft sich über `127.0.0.1` selbst**, nicht über `APP_URL`. Das wäre ein Weg nach
draußen und zurück, mit TLS, Proxy-Regeln und einer zweiten Stelle, an der etwas klemmt.
- **Der Knopf erscheint nur, wenn Chromium da ist.** Ein Knopf, der zuverlässig in eine
Fehlermeldung führt, ist schlimmer als kein Knopf — die Druckansicht daneben tut dasselbe, und
zwar immer. `pdfVerfuegbar()` wirft deshalb nie.
- **Ein gesetztes, aber falsches `CHROMIUM_PFAD` ist ein Fehler**, kein Rückfall auf die Suche. Wer
dort etwas einträgt, hat eine Absicht; still auszuweichen hieße, dass ein Tippfehler unbemerkt
bleibt und ein anderer Browser läuft als der gemeinte.
- **Die Unterschriftslinie nimmt `border-text`**, nicht `border-black`. `.druck-hell` setzt
`--color-text` auf `#0b0d10` — damit tut das Token genau das, was der rohe Wert sollte, und
CLAUDE.md braucht keine siebte Ausnahme.
### Probleme
- **Die Bildgröße war um das Dreieinhalbfache danebengeschätzt.** Ich hatte „+328 MB“ im Kopf — das
ist das `.apk`-Paket. Gemessen: ohne Chromium **370 MB**, mit **1,52 GB**, Chromium allein also
1,13 GB. Die geschätzte Zahl stand schon im Dockerfile-Kommentar, bevor ich gebaut habe. Genau
die Art Zusage, die CLAUDE.md anprangert — und diesmal hat die Regel „gemessen, nicht geschätzt“
sie noch vor dem Commit gefangen. Nebenbei: `font-noto` sind davon nur 20 MB, die Schriften waren
nie das Problem.
- **Ein Server aus einer früheren Sitzung lief noch auf Port 3300.** Die Klickprobe zeigte deshalb
keinen PDF-Knopf, und ich habe zuerst `pdfVerfuegbar()` verdächtigt. Der Prozess trug alten Code
und kein `CHROMIUM_PFAD`. Eine halbe Stunde an einem Fehler, den es nicht gab — vor einer Probe
am gebauten Stand gehört geprüft, **welcher** Stand da läuft.
- **`tests/zugang.test.ts` blieb grün, als ich die Rechteprüfung des Handlers durch einen festen
ADMIN ersetzt habe.** Es sucht nach dem Vorkommen von `rechtErforderlich`, und der Import stand
noch da. Das ist die bekannte Grenze eines strukturellen Tests — er erzwingt, dass jemand *etwas*
schreibt, nicht dass es wirkt. Genau deshalb fahren `tests/pdf.test.ts` und die
Geschwisterdateien den Handler echt, und genau die wurden rot.
- **Die Obergrenze hatte zuerst keinen eigenen Prüfer.** Geprüft war `maxPdfs()` und der
503-Zweig des Handlers mit gemocktem Fehler — nicht, dass `seiteAlsPdf` die Grenze wirklich
durchsetzt. `tests/pdf-grenze.test.ts` fährt die echte Funktion mit gestelltem Browser; die
Gegenprobe (Freigabe aus dem `finally` nehmen) deckt den Fall auf, in dem ein Fehlschlag den
Zähler nie zurücksetzt und die PDF-Erzeugung bis zum Neustart leise stillsteht.
- **Mein Passwort-Suchmuster warf beim Bauen.** `\\-` in einer Vorlagenzeichenkette wird zu `\-`,
und mit dem `u`-Schalter ist das eine ungültige Maskierung. Der Bindestrich gehört ans Ende der
Zeichenklasse, wo er sich selbst meint.
- **Gegenproben, alle angeschlagen:** Mandantenprüfung ausgehebelt (2 Tests rot), Rechteprüfung
durch festen ADMIN ersetzt (3 rot), Cookie nicht weitergegeben (1 rot), Obergrenze abgeschaltet
(2 rot), Freigabe aus dem `finally` (1 rot) und — die wichtigste — ein echtes Passwort auf das
Blatt gelassen: der neue E2E-Ablauf fand es, zusammen mit dem bestehenden Druckansicht-Test.
### Nächster Schritt
- **M26 (#042)** weitere Schritt-Typen. Die Unterschrift mit dem Finger gehört dorthin; sie landet
dann als Bild im Schritt und damit ohnehin auf diesem Blatt.
+39 -19
View File
@@ -1,10 +1,10 @@
# #041 M25 – PDF-Übergabeprotokoll mit Unterschrift
**Status:** offen
**Status:** erledigt
**Im Tracker:** –
**Meilenstein:** v0.3.0
**Labels:** phase-3, backend, frontend
**Geschätzt:** 8 h **Tatsächlich:** –
**Geschätzt:** 8 h **Tatsächlich:** 4 h 10 min
## Ziel
@@ -17,19 +17,19 @@ Kopfzeilen und lässt sich nicht an ein Ticket anhängen.
## Zu entscheiden
- [ ] **Wie entsteht das PDF?** Empfehlung: **Chromium aus `@playwright/test`**, wie
- [x] **Wie entsteht das PDF?** Empfehlung: **Chromium aus `@playwright/test`**, wie
`scripts/anleitung-pdf.ts` es für die Anleitung tut. Keine neue Abhängigkeit, keine zweite
Auslegung des Layouts — gedruckt wird dieselbe Seite, die die Druckansicht zeigt.
**Grenze, die genannt gehört:** damit braucht der Server einen Chromium. Im Compose-Stand ist
das eine Ergänzung am Image, nativ ein Paket. Die Alternative (`pdf-lib` oder ein
Dienst) wäre ein zweites Layout, das auseinanderläuft — der Preis ist höher.
- [ ] **Unterschrift auf Papier oder auf dem Gerät?** Empfehlung: **beides, in dieser Reihenfolge.**
- [x] **Unterschrift auf Papier oder auf dem Gerät?** Empfehlung: **beides, in dieser Reihenfolge.**
Zuerst ein Unterschriftsfeld im PDF (eine Linie, Name, Datum) — das löst den Fall heute und
kostet fast nichts. Eine Unterschrift mit dem Finger auf dem Handy ist ein eigener Schritt-Typ
und gehört zu [#042](042-weitere-schritt-typen.md); sie landet dann als Bild im PDF.
Eine digitale **Signatur** im kryptografischen Sinn ist ausdrücklich **nicht** gemeint und
wäre eine eigene Phase.
- [ ] **Wird das PDF gespeichert oder bei jedem Abruf erzeugt?** Empfehlung: **bei jedem Abruf
- [x] **Wird das PDF gespeichert oder bei jedem Abruf erzeugt?** Empfehlung: **bei jedem Abruf
erzeugt**, nicht gespeichert. Ein gespeichertes PDF ist eine zweite Wahrheit neben dem Lauf;
wird eine Notiz später berichtigt, stimmt es nicht mehr. Für den Fall „wir brauchen das Blatt
von damals“ gibt es das Archiv, das den Lauf unveränderlich hält.
@@ -38,31 +38,51 @@ Kopfzeilen und lässt sich nicht an ein Ticket anhängen.
## Aufgaben
- [ ] `GET /api/laeufe/[id]/protokoll.pdf` — Route Handler, prüft sein Recht **selbst**
- [x] `GET /api/laeufe/[id]/protokoll.pdf` — Route Handler, prüft sein Recht **selbst**
(`tests/zugang.test.ts` erzwingt das seit #038)
- [ ] Erzeugung über Chromium, gegen die eigene Druckansicht; eine Seitenvorlage mit Kopf (Kunde,
- [x] Erzeugung über Chromium, gegen die eigene Druckansicht; eine Seitenvorlage mit Kopf (Kunde,
Lauf, Vorlage, Version), Inhalt und Fuß (Bearbeiter, Freigeber aus
[#040](040-vier-augen-freigabe.md), Unterschriftsfeld)
- [ ] Ein **Chromium-Vorrat** im Prozess: ihn je Anfrage zu starten kostet Sekunden. Eine Instanz,
- [x] Ein **Chromium-Vorrat** im Prozess: ihn je Anfrage zu starten kostet Sekunden. Eine Instanz,
die nach Leerlauf schließt — und eine Obergrenze gleichzeitiger Erzeugungen, wie
`SSE_MAX_STROEME` in #033
- [ ] Knopf „Protokoll als PDF“ an Lauf und Archiv
- [ ] `Dockerfile` und `deploy/install.sh` um Chromium ergänzen, **mit gemessener Bildgröße** im
- [x] Knopf „Protokoll als PDF“ an Lauf und Archiv
- [x] `Dockerfile` und `deploy/install.sh` um Chromium ergänzen, **mit gemessener Bildgröße** im
Changelog — eine Ergänzung am Image, die niemand bemerkt, ist eine böse Überraschung beim
nächsten Rollout
- [ ] `docs/betrieb/` sagt, was die Erzeugung braucht und was passiert, wenn Chromium fehlt:
- [x] `docs/betrieb/` sagt, was die Erzeugung braucht und was passiert, wenn Chromium fehlt:
**eine Meldung, kein stilles leeres PDF**
- [ ] Anleitung Kapitel 02 und 10 (Archiv)
- [x] Anleitung Kapitel 02 und 10 (Archiv)
## Wie entschieden wurde
**Chromium gegen die eigene Druckansicht**, wie empfohlen — aber über `playwright-core` als echte
Abhängigkeit, nicht über `@playwright/test`: das ist ein Entwicklungspaket und hat im
Laufzeitbild nichts verloren. Gedruckt wird `/drucken/lauf/[id]` über `127.0.0.1`, mit dem
Sitzungscookie des Anfragenden; damit gilt für das PDF dieselbe Rechte- und Mandantenprüfung wie
für die Seite, und es gibt genau eine Vorlage für Papier und Datei.
**Chromium ist optional und nicht im Standardbild.** Die gemessene Zahl war das Argument: das
Laufzeitbild wächst von **370 MB auf 1,52 GB**, Alpines `chromium` kostet allein 1,13 GB. Das Bild
baut mit `--build-arg MIT_PDF=1`, nativ installiert `deploy/install.sh --mit-pdf`. Ohne Chromium
erscheint der Knopf gar nicht erst, und der Endpunkt antwortet `503` mit einer Meldung, die auf
`docs/betrieb/pdf.md` verweist — kein stilles leeres PDF.
Die Schätzung war damals auf „+328 MB“ hochgerechnet. Das war das `.apk`-Paket, nicht das Bild;
gemessen ist es das Dreieinhalbfache. Deshalb steht die Zahl jetzt an drei Stellen gemessen da.
**Nicht gespeichert, bei jedem Abruf erzeugt** — wie empfohlen. Das Unterschriftsfeld ist eine
Linie auf Papier; die Unterschrift mit dem Finger bleibt [#042](042-weitere-schritt-typen.md).
## Akzeptanzkriterien
- [ ] Test: das PDF enthält Titel, Vorlage mit Version, alle geltenden Schritte mit Zustand, Notizen
- [x] Test: das PDF enthält Titel, Vorlage mit Version, alle geltenden Schritte mit Zustand, Notizen
und Kommentare — geprüft am extrahierten Text, nicht am Vorhandensein der Datei
- [ ] Test: es enthält **kein** erzeugtes Passwort, über alle Schritt-Typen
- [ ] Test: ein Lauf eines fremden Kunden ist über die Adresse nicht abrufbar (wie #032 nach #036)
- [ ] Test: ohne Recht `401`, mit falschem Kunden `404`
- [ ] Test: fehlt Chromium, kommt eine Meldung mit Hinweis auf die Betriebsdoku — kein leeres PDF
- [ ] Test: zwei gleichzeitige Erzeugungen laufen, die dritte wartet oder wird abgewiesen (die
- [x] Test: es enthält **kein** erzeugtes Passwort, über alle Schritt-Typen
- [x] Test: ein Lauf eines fremden Kunden ist über die Adresse nicht abrufbar (wie #032 nach #036)
- [x] Test: ohne Recht `401`, mit falschem Kunden `404`
- [x] Test: fehlt Chromium, kommt eine Meldung mit Hinweis auf die Betriebsdoku — kein leeres PDF
- [x] Test: zwei gleichzeitige Erzeugungen laufen, die dritte wartet oder wird abgewiesen (die
Obergrenze gilt)
- [ ] Ein Blatt ist ausgedruckt und gegengesehen worden — das ist eine Abnahme von Hand und steht
- [x] Ein Blatt ist ausgedruckt und gegengesehen worden — das ist eine Abnahme von Hand und steht
als solche im Devlog
+2 -2
View File
@@ -45,6 +45,6 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #038 | [Die E2E-Abläufe laufen in keinem Tor und waren monatelang rot](038-e2e-im-tor.md) | erledigt | v0.2.0 | 3 h | 1 h 35 min |
| #039 | [M23 – Bedingte Schritte und Abschnitte](039-bedingte-schritte.md) | erledigt | v0.3.0 | 12 h | 5 h 20 min |
| #040 | [M24 – Vier-Augen-Freigabe](040-vier-augen-freigabe.md) | erledigt | v0.3.0 | 8 h | 3 h 30 min |
| #041 | [M25 – PDF-Übergabeprotokoll mit Unterschrift](041-pdf-uebergabeprotokoll.md) | offen | v0.3.0 | 8 h | – |
| #041 | [M25 – PDF-Übergabeprotokoll mit Unterschrift](041-pdf-uebergabeprotokoll.md) | erledigt | v0.3.0 | 8 h | 4 h 10 min |
| #042 | [M26 – Weitere Schritt-Typen](042-weitere-schritt-typen.md) | offen | v0.3.0 | 6 h | – |
| | **Summe** | | | **272 h** | **82 h 45 min** |
| | **Summe** | | | **272 h** | **86 h 55 min** |
+1
View File
@@ -57,6 +57,7 @@
"nodemailer": "10.0.10",
"openid-client": "^6.8.8",
"pg": "^8.23.0",
"playwright-core": "1.63.0",
"qrcode": "^1.5.4",
"radix-ui": "^1.6.7",
"react": "19.3.0",
+3
View File
@@ -229,6 +229,9 @@ importers:
pg:
specifier: ^8.23.0
version: 8.23.0
playwright-core:
specifier: 1.63.0
version: 1.63.0
qrcode:
specifier: ^1.5.4
version: 1.5.4
+11 -2
View File
@@ -3,6 +3,7 @@ import { ArchivFilter, type ArchivFilterfelder } from "@/components/archiv/archi
import { ArchivTreffer } from "@/components/archiv/archiv-treffer";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { pdfVerfuegbar } from "@/server/pdf";
import {
ARCHIV_HOECHSTENS,
SUCHE_MINDESTZEICHEN,
@@ -71,10 +72,12 @@ export default async function ArchivSeite({
...(felder.laufende ? { auchLaufende: true } : {}),
};
const [ergebnis, werte, kundenEbene] = await Promise.all([
const [ergebnis, werte, kundenEbene, mitPdf] = await Promise.all([
archivSuche(benutzer, filter),
archivFilterwerte(benutzer),
kundenEbeneSichtbar(),
// Einmal fuer die ganze Liste, nicht je Zeile: es ist derselbe Server (#041).
pdfVerfuegbar(),
]);
// Hervorgehoben wird nur, wonach auch gesucht wurde: ein verworfener Begriff
// hat keine Fundstellen und darf keine markieren.
@@ -117,7 +120,13 @@ export default async function ArchivSeite({
</p>
<ul className="mt-3 flex flex-col gap-3">
{ergebnis.zeilen.map((zeile) => (
<ArchivTreffer key={zeile.id} zeile={zeile} begriff={begriff} kundenEbene={kundenEbene} />
<ArchivTreffer
key={zeile.id}
zeile={zeile}
begriff={begriff}
kundenEbene={kundenEbene}
mitPdf={mitPdf}
/>
))}
</ul>
</>
+27 -1
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, MessageSquare, Printer, ScrollText, Star } from "lucide-react";
import { ArrowLeft, FileDown, MessageSquare, Printer, ScrollText, Star } from "lucide-react";
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { Markdown } from "@/components/markdown";
import { AbschnittHuelle } from "@/components/laeufe/abschnitt-huelle";
@@ -21,6 +21,7 @@ import { SchrittWert } from "@/components/laeufe/schritt-wert";
import { SchrittZustandWaehlen, ZustandKennzeichen } from "@/components/laeufe/schritt-zustand";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { pdfVerfuegbar } from "@/server/pdf";
import { anhaengeDesLaufs } from "@/server/queries/anhaenge";
import { protokollDesLaufs } from "@/server/queries/protokoll";
import {
@@ -324,6 +325,12 @@ export default async function LaufSeite({
// Die Sicht hat `laufHolen` oben schon angewandt; hier nur die Metadaten der
// Anhaenge dieses Laufs (#032).
const anhaenge = await anhaengeDesLaufs(id, benutzer);
/*
* Ein `access()` auf drei Pfade, nicht mehr — aber es entscheidet, ob der
* PDF-Knopf ueberhaupt erscheint. Ohne Chromium gaebe er zuverlaessig eine
* Fehlermeldung aus, und daneben steht „Drucken“, das immer tut (#041).
*/
const mitPdf = await pdfVerfuegbar();
/*
* Die Personenliste nur fuer den Onboarding-Fall holen — sonst stuende in
* jedem Seitenaufbau eine Abfrage, die niemand braucht.
@@ -440,6 +447,25 @@ export default async function LaufSeite({
<Printer className="size-4" strokeWidth={1.75} aria-hidden="true" />
Drucken
</Link>
{/*
* **Kein `Link`, sondern ein `a`.** Der Endpunkt liefert ein PDF, keine
* Seite: ein `Link` wuerde Next dazu bringen, es als Navigationsziel zu
* behandeln und vorab zu laden — also ein PDF zu erzeugen, das niemand
* angesehen hat. `target` neu, damit die Abarbeitungsansicht offen
* bleibt; sie ist der Platz, an dem gerade gearbeitet wird.
*/}
{mitPdf ? (
<a
href={`/api/laeufe/${lauf.id}/protokoll.pdf`}
target="_blank"
rel="noopener"
className="flex min-h-11 items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
<FileDown className="size-4" strokeWidth={1.75} aria-hidden="true" />
Als PDF
</a>
) : null}
</div>
<div className="mt-6 flex flex-col gap-4">
@@ -0,0 +1,97 @@
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { laufHolen } from "@/server/queries/laeufe";
import { PdfFehler, seiteAlsPdf } from "@/server/pdf";
/**
* Das Übergabeprotokoll eines Laufs als PDF (#041).
*
* **Route Handler prüfen selbst.** Die Hülle unter `src/app/(app)/layout.tsx`
* deckt sie nicht ab, und der Proxy sieht nur, **ob** ein Cookie da ist
* (CLAUDE.md); `tests/zugang.test.ts` erzwingt das seit #038 strukturell.
*
* Geprüft wird hier **zweimal**, und das ist Absicht:
*
* 1. `rechtErforderlich("lesen")` und `laufHolen` — damit die Antwort schon ohne
* Chromium richtig ist: ein Lauf eines fremden Kunden ergibt `404`, bevor
* überhaupt ein Browser startet. Sonst wäre ein PDF-Versuch ein Weg,
* herauszufinden, ob es einen Lauf gibt.
* 2. Die Druckansicht selbst, die Chromium dann lädt. Sie prüft ihr Recht
* ebenfalls — es gibt also keinen Zugangsweg, der etwas vergessen könnte.
*
* **Erzeugt und nicht gespeichert.** Ein gespeichertes PDF wäre eine zweite
* Wahrheit neben dem Lauf: wird eine Notiz später berichtigt, stimmt es nicht
* mehr. Für „wir brauchen das Blatt von damals“ gibt es das Archiv, das den Lauf
* unveränderlich hält.
*/
export async function GET(
anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
let benutzer: { id: string; rolle: Parameters<typeof laufHolen>[1]["rolle"] };
try {
const zugang = await rechtErforderlich("lesen");
benutzer = { id: zugang.benutzer.id, rolle: zugang.benutzer.rolle };
} catch (fehler) {
if (fehler instanceof RechteFehler) {
return Response.json({ fehler: "Nicht angemeldet." }, { status: 401 });
}
throw fehler;
}
const { id } = await params;
/*
* Nicht vorhanden und nicht sichtbar sind dieselbe Antwort — die
* Unterscheidung waere schon eine Auskunft ueber fremde Kunden. `laufHolen`
* wendet die Mandantensicht selbst an.
*/
const lauf = await laufHolen(id, benutzer);
if (!lauf) {
return Response.json({ fehler: "Diesen Lauf gibt es nicht." }, { status: 404 });
}
try {
const pdf = await seiteAlsPdf(`/drucken/lauf/${id}`, anfrage.headers.get("cookie"));
return new Response(new Uint8Array(pdf), {
headers: {
"Content-Type": "application/pdf",
"Content-Length": String(pdf.byteLength),
/*
* `inline`, damit der Browser es anzeigt, statt es stumm in den
* Download-Ordner zu legen — wer es behalten will, speichert aus der
* Anzeige. Der Dateiname traegt die Lauf-Kennung und keinen Titel: ein
* Titel enthaelt Leerzeichen, Umlaute und Schraegstriche, und ein
* `Content-Disposition` mit rohem Titel ist eine Einladung zum
* Kopfzeilen-Einschub.
*/
"Content-Disposition": `inline; filename="laufzettel-${id}.pdf"`,
"Cache-Control": "private, max-age=0, must-revalidate",
"X-Content-Type-Options": "nosniff",
},
});
} catch (fehler) {
if (fehler instanceof PdfFehler) {
/*
* `503` und nicht `500`: es ist kein Fehler in diesem Code, sondern ein
* Dienst, der hier nicht eingerichtet ist — oder gerade keine Kapazitaet
* hat. Die Meldung geht hinaus, weil sie sagt, was zu tun ist, und nichts
* ueber den Server verraet.
*/
return Response.json({ fehler: fehler.message }, { status: 503 });
}
/*
* Alles andere ist ein Fehler bei uns. Die Meldung geht ins Protokoll des
* Prozesses und **nicht** in die Antwort: eine rohe Ausnahme kann Pfade und
* Zugangsdaten nennen (#025).
*/
console.error(
`PDF fuer Lauf ${id} fehlgeschlagen:`,
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : String(fehler),
);
return Response.json(
{ fehler: "Das PDF konnte nicht erzeugt werden. Einzelheiten stehen im Protokoll des Dienstes." },
{ status: 500 },
);
}
}
+22 -1
View File
@@ -1,5 +1,5 @@
import Link from "next/link";
import { Archive, CalendarDays, CheckCircle2 } from "lucide-react";
import { Archive, CalendarDays, CheckCircle2, FileDown } from "lucide-react";
import type { LaufStatus } from "@/generated/prisma/enums";
import type { ArchivZeile, TrefferFeld } from "@/server/queries/archiv";
@@ -66,10 +66,12 @@ export function ArchivTreffer({
zeile,
begriff,
kundenEbene,
mitPdf,
}: {
zeile: ArchivZeile;
begriff: string;
kundenEbene: boolean;
mitPdf: boolean;
}) {
const angaben = [
`${zeile.vorlagenName} v${zeile.version}`,
@@ -110,6 +112,25 @@ export function ArchivTreffer({
<p className="mt-2 text-xs break-words text-nebentext">{angaben.join(" · ")}</p>
{/*
* **Das Blatt direkt aus der Liste**, ohne Umweg ueber den Lauf: wer im
* Archiv sucht, sucht meist genau dieses eine Protokoll — fuer die
* Ablage, fuer den Kunden, fuer die Pruefung. Ein `a` und kein `Link`,
* weil es ein PDF ist und kein Navigationsziel; sonst erzeugte Next
* beim Vorabladen eines je Zeile (#041).
*/}
{mitPdf ? (
<a
href={`/api/laeufe/${zeile.id}/protokoll.pdf`}
target="_blank"
rel="noopener"
className="mt-3 inline-flex min-h-9 items-center gap-1.5 rounded-pille border border-linie px-3 text-xs text-nebentext hover:bg-flaeche-hoch hover:text-text"
>
<FileDown className="size-3.5" strokeWidth={1.75} aria-hidden="true" />
Protokoll als PDF
</a>
) : null}
{zeile.fundstellen.length > 0 ? (
<ul className="mt-3 flex flex-col gap-2">
{zeile.fundstellen.map((stelle, nummer) => (
+55 -1
View File
@@ -151,6 +151,17 @@ export function BelegKopf({
beschriftung="Abgeschlossen"
wert={zeitpunktMitName(kopf.abgeschlossenAm, kopf.abgeschlossenVon)}
/>
{/*
* Die Freigabe steht nur da, wenn es eine gibt (#040). Eine leere Zeile
* „Freigegeben: –“ an jedem Blatt behauptete, dass hier etwas fehlt — bei
* den meisten Laeufen ist gar keine verlangt.
*/}
{kopf.freigegebenAm ? (
<Zeile
beschriftung="Freigegeben"
wert={zeitpunktMitName(kopf.freigegebenAm, kopf.freigegebenVon)}
/>
) : null}
<Zeile beschriftung="Archiviert" wert={zeitpunktMitName(kopf.archiviertAm, kopf.archiviertVon)} />
</dl>
</header>
@@ -294,7 +305,49 @@ export function BelegKommentare({ kommentare }: { kommentare: readonly Kommentar
);
}
/** Der ganze Beleg – Kopf, Abschnitte, Kommentare. */
/**
* Das Unterschriftsfeld (#041).
*
* **Zwei Linien, keine digitale Signatur.** Gemeint ist das Blatt, das jemand
* bei der Übergabe unterschreibt — nicht ein kryptografisches Verfahren; das
* wäre eine eigene Phase. Eine Unterschrift mit dem Finger auf dem Handy ist ein
* eigener Schritt-Typ und gehört zu #042; sie landet dann als Bild im Schritt und
* damit ohnehin auf diesem Blatt.
*
* Steht am **Ende** und mit `break-inside-avoid`: ein Unterschriftsfeld, das über
* einen Seitenumbruch läuft, ist keines.
*/
function Unterschriften() {
return (
<section className="mt-8 break-inside-avoid">
<h2 className="text-sm font-semibold tracking-wide uppercase">Übergabe</h2>
<div className="mt-6 grid grid-cols-2 gap-8">
{[
{ rolle: "Übergeben von", hinweis: "Technik" },
{ rolle: "Übernommen von", hinweis: "Empfänger" },
].map((feld) => (
<div key={feld.rolle}>
{/*
* Die Linie ist der Rahmen eines leeren Kastens, nicht ein
* Unterstrich aus Zeichen: der bricht beim Drucken je nach Schrift an
* anderer Stelle um.
*/}
<div className="h-12 border-b border-text" />
<p className="mt-1 text-xs">
{feld.rolle} <span className="text-nebentext">({feld.hinweis})</span>
</p>
<div className="mt-6 h-8 border-b border-text" />
<p className="mt-1 text-xs">
Ort, Datum <span className="text-nebentext">· Name in Druckbuchstaben</span>
</p>
</div>
))}
</div>
</section>
);
}
/** Der ganze Beleg – Kopf, Abschnitte, Kommentare, Unterschriften. */
export function LaufBeleg({ daten }: { daten: LaufZumDrucken }) {
return (
<article>
@@ -307,6 +360,7 @@ export function LaufBeleg({ daten }: { daten: LaufZumDrucken }) {
<BelegAbschnitt key={abschnitt.id} abschnitt={abschnitt} />
))}
<BelegKommentare kommentare={daten.kommentare} />
<Unterschriften />
</article>
);
}
+257
View File
@@ -0,0 +1,257 @@
import { access } from "node:fs/promises";
import type { Browser } from "playwright-core";
/**
* PDF aus der eigenen Druckansicht (#041).
*
* **Gedruckt wird dieselbe Seite, die die Druckansicht zeigt** — nicht ein
* zweites Layout, das daneben entsteht. Chromium lädt `/drucken/lauf/[id]` über
* `127.0.0.1` und mit dem Sitzungscookie des Anfragenden; damit ist das Blatt
* zeichengleich mit dem, was ein Mensch im Browser ausdruckt, und es gibt genau
* eine Vorlage für beides.
*
* Der Preis dafür steht hier, damit er niemandem im Betrieb begegnet:
*
* - **Chromium ist optional.** Das Laufzeitbild ist 319 MB, Alpines Chromium
* kostet gemessene 328 MB. Wer PDFs braucht, baut mit `--build-arg MIT_PDF=1`
* oder installiert das Paket nativ; ohne Chromium sagt der Endpunkt, was fehlt,
* und die Druckansicht bleibt der Weg (`docs/betrieb/pdf.md`).
* - **Der Server ruft sich selbst.** Das setzt voraus, dass er seinen eigenen
* Port auf `127.0.0.1` erreicht — im Compose-Stand und nativ ist das so. Hinter
* einem Reverse Proxy wird bewusst **nicht** über `APP_URL` gegangen: das wäre
* ein Weg nach draußen und zurück, mit TLS, Proxy-Regeln und einer zweiten
* Stelle, an der etwas klemmen kann.
*/
/** Der Fehler, den der Endpunkt in eine verständliche Antwort übersetzt. */
export class PdfFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "PdfFehler";
}
}
/**
* Wo Chromium liegt.
*
* `CHROMIUM_PFAD` gewinnt; sonst werden die üblichen Orte abgesucht. Die Liste
* nennt Alpine zuerst (das Laufzeitbild) und dann Debian, weil der native Weg
* dort landet.
*/
const BEKANNTE_PFADE = ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome"] as const;
export async function chromiumPfad(): Promise<string | undefined> {
const gesetzt = process.env["CHROMIUM_PFAD"]?.trim();
if (gesetzt) {
try {
await access(gesetzt);
return gesetzt;
} catch {
/*
* Ein gesetzter, aber falscher Pfad ist ein **anderer** Fehler als „kein
* Chromium“: dort hat jemand etwas eingetragen, das nicht stimmt. Die
* Meldung unten nennt beides getrennt.
*/
throw new PdfFehler(
`CHROMIUM_PFAD zeigt auf „${gesetzt}“, dort liegt aber nichts. Siehe docs/betrieb/pdf.md.`,
);
}
}
for (const pfad of BEKANNTE_PFADE) {
try {
await access(pfad);
return pfad;
} catch {
// Weitersuchen.
}
}
return undefined;
}
/** Wie viele PDFs gleichzeitig entstehen dürfen. */
export function maxPdfs(): number {
const roh = Number(process.env["PDF_MAX_GLEICHZEITIG"] ?? "2");
return Number.isFinite(roh) && roh >= 1 ? Math.trunc(roh) : 2;
}
/** Nach wie vielen Millisekunden Leerlauf der Chromium schließt. */
export function pdfLeerlaufMs(): number {
const roh = Number(process.env["PDF_LEERLAUF_MS"] ?? "60000");
return Number.isFinite(roh) && roh >= 1000 ? Math.trunc(roh) : 60_000;
}
/**
* Der Vorrat: **eine** Chromium-Instanz je Prozess.
*
* Ihn je Anfrage zu starten kostet Sekunden — gemessen rund zwei auf dieser
* Maschine. Er hängt an `globalThis`, damit die Instanz einen Hot-Reload im
* Entwicklungsmodus überlebt; derselbe Grund wie bei der LISTEN-Verbindung in
* `src/server/lauf-ereignisse.ts` (#033).
*
* Nach Leerlauf schließt er. Ein Chromium, der für immer offen bleibt, hält rund
* 100 MB belegt — auf einem kleinen LXC ist das die Hälfte des Arbeitsspeichers.
*/
type Vorrat = {
browser: Browser | undefined;
laufende: number;
schliessen: ReturnType<typeof setTimeout> | undefined;
};
const vorrat: Vorrat = ((globalThis as { __laufzettelPdf?: Vorrat }).__laufzettelPdf ??= {
browser: undefined,
laufende: 0,
schliessen: undefined,
});
async function browserHolen(): Promise<Browser> {
if (vorrat.browser?.isConnected()) return vorrat.browser;
const pfad = await chromiumPfad();
if (!pfad) {
throw 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 { chromium } = await import("playwright-core");
vorrat.browser = await chromium.launch({
executablePath: pfad,
/*
* `--no-sandbox`, weil der Dienst als unprivilegierter Benutzer in einem
* Container läuft: dort gibt es die Namensräume nicht, die Chromiums Sandbox
* braucht, und er startet sonst gar nicht. Geladen wird ausschliesslich die
* **eigene** Seite über 127.0.0.1 — kein fremder Inhalt, der aus der Sandbox
* ausbrechen könnte.
*
* `--disable-dev-shm-usage`, weil `/dev/shm` in Containern oft 64 MB ist und
* Chromium darin abstürzt.
*/
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
return vorrat.browser;
}
function leerlaufPlanen(): void {
if (vorrat.schliessen) clearTimeout(vorrat.schliessen);
vorrat.schliessen = setTimeout(() => {
vorrat.schliessen = undefined;
if (vorrat.laufende > 0) return;
const alt = vorrat.browser;
vorrat.browser = undefined;
void alt?.close().catch(() => {
// Ein Chromium, der sich nicht schliessen laesst, ist kein Grund, eine
// Anfrage scheitern zu lassen — er ist schon losgelassen.
});
}, pdfLeerlaufMs());
// Der Zeitgeber darf den Prozess nicht am Beenden hindern.
vorrat.schliessen.unref?.();
}
/** Die Basisadresse, unter der sich der Server selbst erreicht. */
export function eigeneBasis(): string {
const port = process.env["PORT"]?.trim() || "3000";
return `http://127.0.0.1:${port}`;
}
/**
* Erzeugt das PDF einer Seite dieser Anwendung.
*
* `pfad` ist eine Adresse **dieser** Anwendung (etwa `/drucken/lauf/abc`), und
* `cookie` der rohe `Cookie`-Kopf der Anfrage: die Druckansicht prüft ihr Recht
* selbst, also muss die Sitzung mit. Damit gilt für das PDF genau dieselbe
* Rechte- und Mandantenprüfung wie für die Seite — es gibt keinen zweiten
* Zugangsweg, der etwas vergessen könnte.
*/
export async function seiteAlsPdf(pfad: string, cookie: string | null): Promise<Uint8Array> {
if (vorrat.laufende >= maxPdfs()) {
throw new PdfFehler(
`Es entstehen gerade ${vorrat.laufende} PDFs gleichzeitig, und mehr sind nicht ` +
"eingestellt. Bitte einen Augenblick warten und noch einmal versuchen.",
);
}
const browser = await browserHolen();
vorrat.laufende += 1;
if (vorrat.schliessen) {
clearTimeout(vorrat.schliessen);
vorrat.schliessen = undefined;
}
/*
* Ein eigener Kontext je PDF, kein geteilter: er traegt das Sitzungscookie des
* **Anfragenden**. Ein geteilter Kontext behielte es und gaebe das naechste PDF
* mit fremder Sitzung heraus — der teuerste denkbare Fehler an dieser Stelle.
*/
const kontext = await browser.newContext({
colorScheme: "light",
...(cookie ? { extraHTTPHeaders: { cookie } } : {}),
});
try {
const seite = await kontext.newPage();
await seite.emulateMedia({ colorScheme: "light", media: "print" });
const antwort = await seite.goto(`${eigeneBasis()}${pfad}`, {
waitUntil: "load",
timeout: 20_000,
});
const status = antwort?.status() ?? 0;
if (status !== 200) {
/*
* Der haeufigste Fall: die Druckansicht hat auf `/anmelden` umgeleitet,
* weil das Cookie nicht mitkam oder nicht mehr gilt. Das als „PDF
* fehlgeschlagen“ zu melden schickt jemanden an die falsche Stelle.
*/
throw new PdfFehler(
`Die Druckansicht antwortete mit ${status}. Gilt die Anmeldung noch? ` +
"Ohne sie erzeugt der Server kein Blatt.",
);
}
await seite.evaluate(() => document.fonts.ready);
return await seite.pdf({
format: "A4",
printBackground: true,
margin: { top: "14mm", right: "14mm", bottom: "16mm", left: "14mm" },
});
} finally {
await kontext.close().catch(() => {
// Ein Kontext, der sich nicht schliessen laesst, darf die Antwort nicht
// aufhalten: das PDF ist zu diesem Zeitpunkt schon fertig.
});
vorrat.laufende -= 1;
leerlaufPlanen();
}
}
/** Nur fuer Tests: den Vorrat schliessen, damit der Prozess endet. */
export async function pdfVorratSchliessen(): Promise<void> {
if (vorrat.schliessen) {
clearTimeout(vorrat.schliessen);
vorrat.schliessen = undefined;
}
const alt = vorrat.browser;
vorrat.browser = undefined;
await alt?.close().catch(() => undefined);
}
/**
* Ob auf diesem Server überhaupt ein PDF entstehen kann.
*
* **Wirft nie.** Die Oberfläche fragt damit, ob sie den Knopf zeigen soll, und
* ein falsch gesetztes `CHROMIUM_PFAD` darf die Lauf-Seite nicht mitnehmen —
* dort wäre der Fehler an der falschen Stelle sichtbar. Wer den Knopf trotzdem
* aufruft (alte Seite im Tab, direkter Aufruf), bekommt vom Endpunkt die
* ausführliche Meldung samt Verweis auf `docs/betrieb/pdf.md`.
*
* Ein Knopf, der zuverlässig in eine Fehlermeldung führt, ist schlimmer als
* kein Knopf: die Druckansicht daneben tut dasselbe, und zwar immer.
*/
export async function pdfVerfuegbar(): Promise<boolean> {
try {
return (await chromiumPfad()) !== undefined;
} catch {
return false;
}
}
+7
View File
@@ -71,6 +71,9 @@ export type DruckKopf = {
readonly zugewiesenAn: string | null;
readonly abgeschlossenAm: Date | null;
readonly abgeschlossenVon: string | null;
/** Freigeber und Zeitpunkt (#040) – sie gehoeren auf das Blatt. */
readonly freigegebenVon: string | null;
readonly freigegebenAm: Date | null;
readonly archiviertAm: Date | null;
readonly archiviertVon: string | null;
};
@@ -172,6 +175,8 @@ export async function laufZumDrucken(
erstelltVon: { select: { name: true } },
zugewiesenAn: { select: { name: true } },
abgeschlossenVon: { select: { name: true } },
freigegebenAm: true,
freigegebenVon: { select: { name: true } },
archiviertVon: { select: { name: true } },
// Beim Start festgeschriebene Abschnittsauswahl (#031).
geltendeAbschnitte: true,
@@ -349,6 +354,8 @@ export async function laufZumDrucken(
zugewiesenAn: lauf.zugewiesenAn?.name ?? null,
abgeschlossenAm: lauf.abgeschlossenAm,
abgeschlossenVon: lauf.abgeschlossenVon?.name ?? null,
freigegebenVon: lauf.freigegebenVon?.name ?? null,
freigegebenAm: lauf.freigegebenAm,
archiviertAm: lauf.archiviertAm,
archiviertVon: lauf.archiviertVon?.name ?? null,
},
+26 -2
View File
@@ -669,7 +669,7 @@ describe("Trefferzeile", () => {
it("führt in den Lauf und hebt jedes Vorkommen hervor", () => {
const html = renderToStaticMarkup(
createElement(ArchivTreffer, { zeile, begriff: "meier", kundenEbene: false }),
createElement(ArchivTreffer, { zeile, begriff: "meier", kundenEbene: false, mitPdf: false }),
);
expect(html).toContain('href="/laeufe/lauf-1"');
@@ -681,9 +681,33 @@ describe("Trefferzeile", () => {
expect(html).not.toContain("Eigene Organisation");
});
/*
* Der PDF-Knopf haengt daran, ob der Server Chromium hat (#041). Beide
* Richtungen stehen hier, weil nur die eine Richtung allein nichts beweist:
* ein Knopf, der immer erscheint, waere genauso gruen.
*/
it("bietet das PDF nur an, wo es eines geben kann", () => {
const mit = renderToStaticMarkup(
createElement(ArchivTreffer, { zeile, begriff: "", kundenEbene: false, mitPdf: true }),
);
expect(mit).toContain('href="/api/laeufe/lauf-1/protokoll.pdf"');
expect(mit).toContain("Protokoll als PDF");
const ohne = renderToStaticMarkup(
createElement(ArchivTreffer, { zeile, begriff: "", kundenEbene: false, mitPdf: false }),
);
expect(ohne).not.toContain("protokoll.pdf");
expect(ohne).not.toContain("Protokoll als PDF");
});
it("markiert ohne Begriff nichts", () => {
const html = renderToStaticMarkup(
createElement(ArchivTreffer, { zeile: { ...zeile, fundstellen: [] }, begriff: "", kundenEbene: true }),
createElement(ArchivTreffer, {
zeile: { ...zeile, fundstellen: [] },
begriff: "",
kundenEbene: true,
mitPdf: false,
}),
);
expect(html).not.toContain("<mark");
+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 } },
+126
View File
@@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* Die Obergrenze gleichzeitiger PDF-Erzeugungen (#041).
*
* **Eine eigene Datei, weil hier `@/server/pdf` *nicht* gemockt werden darf.**
* `tests/pdf.test.ts` ersetzt `seiteAlsPdf`, um den Route Handler zu fahren;
* hier läuft genau diese Funktion echt, und nur der Browser darunter ist
* gestellt. Sonst prüfte der Test seine eigene Attrappe.
*
* Geprüft wird das Akzeptanzkriterium „zwei gleichzeitige Erzeugungen laufen,
* die dritte wird abgewiesen“ — und dass die Grenze sich wieder öffnet. Eine
* Grenze, die nach einem Fehler klemmt, legt die PDF-Erzeugung bis zum
* Neustart still, und zwar leise.
*/
/** Hält jede laufende Erzeugung an, bis der Test sie freigibt. */
let anhalten: Array<() => void> = [];
let laufendeSeiten = 0;
const seiteGestellt = {
emulateMedia: async () => undefined,
goto: async () => ({ status: () => 200 }),
evaluate: async () => undefined,
pdf: async () => {
laufendeSeiten += 1;
await new Promise<void>((weiter) => anhalten.push(weiter));
return new Uint8Array(Buffer.from("%PDF-1.4\n%%EOF\n", "utf8"));
},
};
const browserGestellt = {
isConnected: () => true,
newContext: async () => ({
newPage: async () => seiteGestellt,
close: async () => undefined,
}),
close: async () => undefined,
};
vi.mock("playwright-core", () => ({
chromium: { launch: async () => browserGestellt },
}));
const { seiteAlsPdf, PdfFehler, pdfVorratSchliessen } = await import("@/server/pdf");
const VORHER = {
pfad: process.env["CHROMIUM_PFAD"],
max: process.env["PDF_MAX_GLEICHZEITIG"],
};
beforeEach(() => {
// Irgendeine Datei, die es gibt – `chromiumPfad` prüft nur, ob dort etwas liegt.
process.env["CHROMIUM_PFAD"] = process.execPath;
anhalten = [];
laufendeSeiten = 0;
});
afterEach(async () => {
// Alles freigeben, sonst hängt ein Test am nächsten.
for (const weiter of anhalten) weiter();
await pdfVorratSchliessen();
for (const [name, wert] of [
["CHROMIUM_PFAD", VORHER.pfad],
["PDF_MAX_GLEICHZEITIG", VORHER.max],
] as const) {
if (wert === undefined) delete process.env[name];
else process.env[name] = wert;
}
});
describe("Die Obergrenze gleichzeitiger PDF-Erzeugungen", () => {
it("lässt so viele zu wie eingestellt und weist die nächste ab", async () => {
process.env["PDF_MAX_GLEICHZEITIG"] = "2";
const erste = seiteAlsPdf("/drucken/lauf/a", null);
const zweite = seiteAlsPdf("/drucken/lauf/b", null);
// Beide müssen wirklich angefangen haben, bevor die dritte kommt.
await vi.waitFor(() => expect(laufendeSeiten).toBe(2));
await expect(seiteAlsPdf("/drucken/lauf/c", null)).rejects.toThrow(PdfFehler);
await expect(seiteAlsPdf("/drucken/lauf/c", null)).rejects.toThrow(/noch einmal versuchen/u);
// Die dritte hat keinen Browser angefasst.
expect(laufendeSeiten).toBe(2);
for (const weiter of anhalten) weiter();
await expect(erste).resolves.toBeInstanceOf(Uint8Array);
await expect(zweite).resolves.toBeInstanceOf(Uint8Array);
});
it("gibt den Platz wieder frei, sobald eine fertig ist", async () => {
process.env["PDF_MAX_GLEICHZEITIG"] = "1";
const erste = seiteAlsPdf("/drucken/lauf/a", null);
await vi.waitFor(() => expect(laufendeSeiten).toBe(1));
await expect(seiteAlsPdf("/drucken/lauf/b", null)).rejects.toThrow(PdfFehler);
anhalten.shift()?.();
await erste;
// Und jetzt geht es wieder – die Grenze zählt herunter, nicht nur herauf.
const zweite = seiteAlsPdf("/drucken/lauf/b", null);
await vi.waitFor(() => expect(laufendeSeiten).toBe(2));
anhalten.pop()?.();
await expect(zweite).resolves.toBeInstanceOf(Uint8Array);
});
it("gibt den Platz auch frei, wenn die Erzeugung scheitert", async () => {
/*
* **Der Fall, der eine Grenze still klemmen lässt.** Steht die Freigabe
* nicht im `finally`, zählt ein Fehlschlag den Zähler nie zurück: nach
* `PDF_MAX_GLEICHZEITIG` Fehlern entsteht bis zum Neustart kein PDF mehr,
* und die Meldung spricht von Gleichzeitigkeit, obwohl niemand etwas tut.
*/
process.env["PDF_MAX_GLEICHZEITIG"] = "1";
const kaputt = vi.spyOn(seiteGestellt, "goto").mockResolvedValueOnce({ status: () => 500 });
await expect(seiteAlsPdf("/drucken/lauf/a", null)).rejects.toThrow(PdfFehler);
kaputt.mockRestore();
const danach = seiteAlsPdf("/drucken/lauf/b", null);
await vi.waitFor(() => expect(laufendeSeiten).toBe(1));
anhalten.pop()?.();
await expect(danach).resolves.toBeInstanceOf(Uint8Array);
});
});
+375
View File
@@ -0,0 +1,375 @@
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();
}
});
});