Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5db3baac0 | ||
|
|
274d7666ea | ||
|
|
890e00fb5c | ||
|
|
042e63812b | ||
|
|
53dde70f26 | ||
|
|
92c78a92ff | ||
|
|
c210f8bff0 | ||
|
|
5c6fac59b2 | ||
|
|
ea34294fa7 | ||
|
|
810716e4e4 | ||
|
|
88e9202c2f | ||
|
|
c0cb29bf53 | ||
|
|
dbc24d965d | ||
|
|
4d826ef55f | ||
|
|
6e6f022234 | ||
|
|
b3823094d6 |
+26
-3
@@ -103,12 +103,35 @@ VERTRAUE_PROXY=true
|
||||
# --- Objektspeicher (S3-kompatibel, EU!) ------------------------------------
|
||||
# Wird ab Etappe 3 verwendet. Beispiel: Hetzner Object Storage, Falkenstein.
|
||||
# ACHTUNG: Mit dem Anbieter muss ein AV-Vertrag nach Art. 28 DSGVO bestehen.
|
||||
S3_ENDPOINT=https://fsn1.your-objectstorage.com
|
||||
# Bleibt AUSKOMMENTIERT, solange das mitgelieferte MinIO benutzt wird.
|
||||
#
|
||||
# Die docker-compose.yml setzt dann von selbst http://minio:9000 - aber
|
||||
# nur, wenn die Variable leer ist. Steht hier ein Wert, gewinnt er, und
|
||||
# die Anwendung spricht mit einem fremden Host statt mit dem Speicher
|
||||
# nebenan. Genau das ist am 31.08.2026 passiert: Jede Ablage scheiterte
|
||||
# monatelang mit "NoSuchBucket", und die Zugangs-ID ging bei jedem Versuch
|
||||
# nach draussen.
|
||||
#
|
||||
# Nur setzen, wenn ein EXTERNER Objektspeicher benutzt wird, z.B.:
|
||||
# S3_ENDPOINT=https://fsn1.your-objectstorage.com
|
||||
S3_REGION=eu-central
|
||||
S3_BUCKET=betreuungs-cockpit
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
|
||||
# Serverseitige Verschluesselung durch den Anbieter.
|
||||
#
|
||||
# Bleibt leer, und das ist die richtige Voreinstellung: Die Anwendung
|
||||
# verschluesselt jede Datei selbst, bevor sie den Server verlaesst. Eine
|
||||
# zweite Lage durch den Anbieter schuetzt vor nichts, was der eigene
|
||||
# Umschlag nicht schon abdeckt.
|
||||
#
|
||||
# Das mitgelieferte MinIO kann sie ohnehin nicht - es laeuft ohne KMS und
|
||||
# lehnt jede Ablage mit "NotImplemented" ab, sobald hier etwas steht.
|
||||
#
|
||||
# Nur setzen, wenn ein externer Anbieter sie ausdruecklich verlangt:
|
||||
# S3_SSE=AES256
|
||||
|
||||
# --- E-Mail-Erinnerungen (optional, ab Etappe 2) ----------------------------
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
@@ -118,5 +141,5 @@ SMTP_FROM="Betreuungs-Cockpit <cockpit@example.de>"
|
||||
|
||||
# --- Erstanmeldung ----------------------------------------------------------
|
||||
# Wird nur vom Seed benutzt, um den ersten Betreuer-Zugang anzulegen.
|
||||
SEED_ADMIN_EMAIL=fw@wildeflorian.de
|
||||
SEED_ADMIN_PASSWORT=bitte-beim-ersten-login-aendern
|
||||
SEED_ADMIN_EMAIL=
|
||||
SEED_ADMIN_PASSWORT=
|
||||
|
||||
@@ -11,6 +11,14 @@ dist/
|
||||
next-env.d.ts
|
||||
*.tsbuildinfo
|
||||
|
||||
# Eigene Anpassungen der Installation.
|
||||
#
|
||||
# Docker Compose liest diese Datei automatisch zusaetzlich zur
|
||||
# docker-compose.yml. Sie ist der vorgesehene Ort fuer Aenderungen, die ein
|
||||
# Update ueberleben sollen: update.sh prueft nur versionierte Dateien
|
||||
# (git status --untracked-files=no) und laesst sie deshalb in Ruhe.
|
||||
docker-compose.override.yml
|
||||
|
||||
# Umgebung - enthaelt Schluessel, gehoert NIE ins Repo
|
||||
.env
|
||||
.env.local
|
||||
|
||||
+82
-1
@@ -26,6 +26,86 @@ Wiederherstellung*.
|
||||
|
||||
_Noch nichts._
|
||||
|
||||
## [1.12.0] – 2026-09-01
|
||||
|
||||
Diese Fassung geht aus einer Fehlersuche im laufenden Betrieb hervor. Ein
|
||||
Schreiben ließ sich nicht in der Akte ablegen — und hinter derselben
|
||||
Meldung lagen nacheinander drei verschiedene Ursachen. Beim Nachsehen kam
|
||||
ein vierter Punkt dazu, der schwerer wiegt als alle anderen zusammen.
|
||||
|
||||
### Nach dem Update: einmal die Sicherung prüfen
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose exec sicherung /bin/sh /sicherung.sh
|
||||
```
|
||||
|
||||
Erwartet werden drei Zeilen: `Datenbank gesichert`, `Dokumente gesichert`
|
||||
und `Selbstpruefung bestanden - Datenbank und Dokumente.`
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Deine Dokumente waren in keiner Sicherung.** Der Sicherungsdienst
|
||||
machte täglich ein `pg_dump` und sonst nichts. Nach einem Plattenschaden
|
||||
wäre die Akte vollständig wiederhergestellt worden — mit allen
|
||||
Einträgen, Verweisen und Prüfsummen, aber ohne eine einzige Datei
|
||||
dahinter. Die Prüfsumme in der Akte hätte dann ein Dokument belegt, das
|
||||
es nicht mehr gibt.
|
||||
|
||||
Je Lauf entstehen jetzt zwei verschlüsselte Dateien mit demselben
|
||||
Zeitstempel — der Datenbankstand und ein Archiv der Dokumente. Beide mit
|
||||
Prüfsumme, beide in der Aufbewahrung, beide in der Selbstprüfung.
|
||||
Schlägt der Dokumententeil fehl, bricht der Lauf ab und räumt den
|
||||
angefangenen Datenbankstand weg: Eine Sicherung, die nur die halbe
|
||||
Wahrheit enthält, aber vollständig aussieht, ist schlimmer als gar
|
||||
keine.
|
||||
|
||||
- **Der mitgelieferte Objektspeicher konnte keine Dokumente aufnehmen.**
|
||||
Die Anwendung verlangte bei jeder Ablage serverseitige Verschlüsselung,
|
||||
das mitgelieferte MinIO kann sie ohne Zusatzdienst nicht. Wer der
|
||||
Installationsanleitung folgte, landete zwangsläufig dort. Sie ist jetzt
|
||||
abschaltbar und standardmäßig aus — gebraucht wird sie nicht, weil die
|
||||
Anwendung seit 1.11.0 selbst verschlüsselt.
|
||||
|
||||
- **„Das Schreiben konnte nicht abgelegt werden" sagte nichts.** Jetzt
|
||||
steht dort, was los ist: fehlender Ablagebereich mit Verweis auf den
|
||||
Installationsschritt, abgelehnte Zugangsdaten, nicht erreichbarer
|
||||
Speicher. Und in jedem Fall, ob deine Eingaben erhalten bleiben.
|
||||
|
||||
- **Zwei Einstellungen erreichten die Anwendung nie.**
|
||||
`ERSTEINRICHTUNG_GEHEIMNIS` und `VERTRAUE_PROXY` standen zwar in der
|
||||
Anleitung, wurden aber nicht an den Behälter durchgereicht. Bei
|
||||
`VERTRAUE_PROXY` fiel das nicht auf: Die Anmeldesperre zählte still eine
|
||||
Dimension weniger.
|
||||
|
||||
- **Die Beispielkonfiguration zeigte auf einen fremden Anbieter.**
|
||||
`S3_ENDPOINT` war mit einer externen Adresse vorbelegt und hebelte damit
|
||||
den richtigen Standard aus. Die Zeile ist jetzt auskommentiert.
|
||||
|
||||
### Neu
|
||||
|
||||
- **Die Anwendung prüft ihre Dateiablage selbst.** Sie schreibt eine
|
||||
winzige Datei, liest sie zurück und räumt sie weg — und beantwortet
|
||||
damit auf einmal, ob der Speicher erreichbar ist, der Ablagebereich
|
||||
existiert und geschrieben werden darf. Das Ergebnis steht unter
|
||||
`/api/gesundheit` und im Log. Bisher fiel eine kaputte Konfiguration
|
||||
erst auf, wenn jemand ein Dokument ablegen wollte.
|
||||
|
||||
- **Ein neuer Abschnitt in der Anleitung: „Wo deine Daten liegen".**
|
||||
Die drei Orte, der Pfad auf der Platte, welche Schlüssel unverzichtbar
|
||||
sind — und wie man prüft, ob zu jedem Akteneintrag auch eine Datei
|
||||
gehört.
|
||||
|
||||
### Geändert
|
||||
|
||||
- **Ausschlussfristen warnen nur noch, wenn sie näherrücken.** Auf dem
|
||||
Dashboard standen dauerhaft drei rote Kästen mit Warndreieck — für
|
||||
Aufwandspauschalen, die 2027, 2028 und 2029 ablaufen. Eine Warnung, die
|
||||
monatelang dasteht und nichts von einem will, stumpft ab; man übersieht
|
||||
die vierte, die etwas will. Ab drei Monaten vor Ablauf meldet sich eine
|
||||
Frist wieder, Versäumtes immer. In der Fristenliste stehen sie
|
||||
unverändert weiter.
|
||||
|
||||
## [1.11.0] – 2026-08-24
|
||||
|
||||
Diese Fassung geht aus einer Sicherheitsprüfung hervor. Sie ändert nichts
|
||||
@@ -1265,7 +1345,8 @@ Was davon wann angegangen wird, steht in [`docs/ROADMAP.md`](docs/ROADMAP.md).
|
||||
|
||||
---
|
||||
|
||||
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.11.0...HEAD
|
||||
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.12.0...HEAD
|
||||
[1.12.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.11.0...v1.12.0
|
||||
[1.11.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.10.2...v1.11.0
|
||||
[1.10.2]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.10.1...v1.10.2
|
||||
[1.10.1]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.10.0...v1.10.1
|
||||
|
||||
@@ -93,6 +93,15 @@ services:
|
||||
AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET fehlt in der .env}
|
||||
AUTH_URL: ${AUTH_URL:?AUTH_URL fehlt in der .env}
|
||||
AUTH_TRUST_HOST: 'true'
|
||||
# Ohne diesen Wert laesst sich der erste Zugang nicht anlegen - siehe
|
||||
# src/server/actions/ersteinrichtung.ts. Leer heisst: gesperrt.
|
||||
ERSTEINRICHTUNG_GEHEIMNIS: ${ERSTEINRICHTUNG_GEHEIMNIS:-}
|
||||
# Bewusst OHNE Vorgabe "true": Die strengere Einstellung bleibt die
|
||||
# Voreinstellung. Wer einen Reverse Proxy davorstellt - der
|
||||
# dokumentierte Betrieb tut das -, sagt es in der .env ausdruecklich.
|
||||
# Ohne den Wert zaehlt die Anmeldesperre nur nach E-Mail-Adresse.
|
||||
VERTRAUE_PROXY: ${VERTRAUE_PROXY:-}
|
||||
VERTRAUE_PROXY_STUFEN: ${VERTRAUE_PROXY_STUFEN:-}
|
||||
ENC_KEY_V1: ${ENC_KEY_V1:?ENC_KEY_V1 fehlt in der .env - ohne ihn sind die verschluesselten Felder nicht lesbar}
|
||||
# Bleibt leer, solange nicht rotiert wird. Die Anwendung ueberspringt
|
||||
# leere Schluesselvariablen - siehe src/lib/env.ts.
|
||||
@@ -110,6 +119,9 @@ services:
|
||||
S3_BUCKET: ${S3_BUCKET:-betreuungs-cockpit}
|
||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID}
|
||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY}
|
||||
# Leer lassen. Das mitgelieferte MinIO laeuft ohne KMS und lehnt jede
|
||||
# Ablage ab, sobald hier etwas steht - siehe .env.example.
|
||||
S3_SSE: ${S3_SSE:-}
|
||||
UPLOAD_MAX_BYTES: ${UPLOAD_MAX_BYTES:-26214400}
|
||||
OCR_AKTIV: ${OCR_AKTIV:-true}
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
@@ -155,9 +167,20 @@ services:
|
||||
PGDATABASE: ${POSTGRES_DB:-betreuungs_cockpit}
|
||||
SICHERUNG_PASSWORT: ${SICHERUNG_PASSWORT:?SICHERUNG_PASSWORT fehlt in der .env}
|
||||
SICHERUNG_AUFBEWAHRUNG_TAGE: ${SICHERUNG_AUFBEWAHRUNG_TAGE:-30}
|
||||
SICHERUNG_DOKUMENTE_QUELLE: /dokumente
|
||||
TZ: Europe/Berlin
|
||||
volumes:
|
||||
- ./backups:/backups
|
||||
# Die Dokumente, ausdruecklich NUR LESEND.
|
||||
#
|
||||
# Der Sicherungsdienst laeuft unbeaufsichtigt per cron. Ein Dienst,
|
||||
# der die Daten zerstoeren kann, die er sichern soll, ist ein Risiko
|
||||
# ohne Gegenwert - ein Fehler im Skript oder ein kompromittierter
|
||||
# Behaelter wuerde beides auf einmal treffen.
|
||||
#
|
||||
# Zurueckspielen geschieht deshalb nicht hierher, sondern in ein
|
||||
# Verzeichnis unter ./backups; siehe scripts/wiederherstellung.sh.
|
||||
- minio-daten:/dokumente:ro
|
||||
- ./scripts/sicherung.sh:/sicherung.sh:ro
|
||||
- ./scripts/wiederherstellung.sh:/wiederherstellung.sh:ro
|
||||
entrypoint: ['/bin/sh', '-c']
|
||||
|
||||
+199
-20
@@ -34,6 +34,7 @@ durchgeht — dann weißt du im Fehlerfall genau, wo es hakt.
|
||||
- [Schritt 9 — Sicherung prüfen](#schritt-9--sicherung-prüfen)
|
||||
- [Schritt 10 — Wartungslauf einrichten](#schritt-10--wartungslauf-einrichten)
|
||||
- [Schritt 11 — Schlüssel in Sicherheit bringen](#schritt-11--schlüssel-in-sicherheit-bringen)
|
||||
- [Wo deine Daten liegen](#wo-deine-daten-liegen)
|
||||
- [Aktualisieren](#aktualisieren)
|
||||
- [Wenn etwas nicht klappt](#wenn-etwas-nicht-klappt)
|
||||
- [Abnahmeliste](#abnahmeliste)
|
||||
@@ -184,6 +185,13 @@ cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
> **Die `.env` allein genügt nicht.** Die `docker-compose.yml` reicht die
|
||||
> Werte einzeln an den Behälter durch — sie hat kein `env_file:`. Ein
|
||||
> selbst ergänzter Wert, der dort nicht aufgeführt ist, erreicht die
|
||||
> Anwendung nicht, auch wenn er in der `.env` steht. Alle hier
|
||||
> beschriebenen Werte sind eingetragen; ein Test wacht darüber
|
||||
> (`tests/umgebung-durchreichen.test.ts`).
|
||||
|
||||
Diese Werte **musst** du setzen:
|
||||
|
||||
```bash
|
||||
@@ -211,6 +219,9 @@ INDEX_KEY=<aus Schritt 2>
|
||||
# --- Objektspeicher ---
|
||||
S3_ACCESS_KEY_ID=<aus Schritt 2>
|
||||
S3_SECRET_ACCESS_KEY=<aus Schritt 2>
|
||||
# S3_ENDPOINT bleibt AUSKOMMENTIERT, solange du das mitgelieferte MinIO
|
||||
# benutzt. Nur dann setzt die docker-compose.yml von selbst
|
||||
# http://minio:9000 - steht dort ein Wert, gewinnt er.
|
||||
|
||||
# --- Sicherung ---
|
||||
SICHERUNG_PASSWORT=<aus Schritt 2>
|
||||
@@ -312,7 +323,8 @@ betreuungs-cockpit-sicherung-1 Up
|
||||
```bash
|
||||
# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast)
|
||||
curl -s http://127.0.0.1:3000/api/gesundheit
|
||||
# {"status":"bereit","version":"1.10.2","commit":"..."}
|
||||
# {"status":"bereit","version":"1.12.0","commit":"...",
|
||||
# "speicher":{"status":"bereit","treiber":"s3"}}
|
||||
```
|
||||
|
||||
Kommt hier nichts, sieh in die Logs:
|
||||
@@ -364,20 +376,42 @@ nichts kaputtmacht.
|
||||
Der Objektspeicher braucht einen Ablagebereich für die Dokumente:
|
||||
|
||||
```bash
|
||||
set -a && source .env && set +a
|
||||
|
||||
docker compose exec minio mc alias set lokal \
|
||||
http://localhost:9000 "$S3_ACCESS_KEY_ID" "$S3_SECRET_ACCESS_KEY"
|
||||
|
||||
docker compose exec minio mc mb lokal/betreuungs-cockpit
|
||||
docker compose exec minio sh -c '
|
||||
mc alias set lokal http://localhost:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null &&
|
||||
mc mb "lokal/$S3_BUCKET" 2>/dev/null || true
|
||||
mc ls lokal
|
||||
'
|
||||
```
|
||||
|
||||
### Kontrolle
|
||||
Es muss eine Zeile mit dem Namen deines Ablagebereichs erscheinen —
|
||||
standardmäßig `betreuungs-cockpit/`.
|
||||
|
||||
```bash
|
||||
docker compose exec minio mc ls lokal
|
||||
# [...] betreuungs-cockpit/
|
||||
```
|
||||
> **Warum der Befehl so umständlich aussieht.** Er holt die Zugangsdaten
|
||||
> aus den Variablen, die im MinIO-Behälter ohnehin stehen. Das hat drei
|
||||
> Gründe, und jeder davon hat schon einmal Zeit gekostet:
|
||||
>
|
||||
> 1. **Kein Passwort in der Befehlszeile** — es stünde sonst in der
|
||||
> Verlaufsdatei deiner Shell.
|
||||
> 2. **Kein `source .env`** — exportierte Werte haben Vorrang vor der
|
||||
> `.env`-Datei und überstimmen danach *jedes* `docker compose up`. Wer
|
||||
> später die `.env` ändert und neu startet, sieht keine Wirkung und
|
||||
> sucht den Fehler überall, nur nicht in der eigenen Shell.
|
||||
> 3. **Der Alias überlebt kein Neuerstellen.** `mc alias set` schreibt
|
||||
> nach `/root/.mc` **im Behälter**. Nach jedem `docker compose up -d
|
||||
> minio` ist er weg, und `mc ls lokal` liefert dann kommentarlos nichts
|
||||
> — man hält seine Dokumente für verschwunden. Deshalb setzt jeder
|
||||
> Aufruf den Alias selbst.
|
||||
>
|
||||
> Der Bucket-Name kommt aus `$S3_BUCKET` und ist nicht fest verdrahtet:
|
||||
> Sonst zeigen Anwendung und Ablagebereich aneinander vorbei, sobald
|
||||
> jemand den Namen ändert.
|
||||
|
||||
> **Serverseitige Verschlüsselung?** Brauchst du nicht. Die Anwendung
|
||||
> verschlüsselt jede Datei selbst, bevor sie den Server verlässt — mit
|
||||
> `ENC_KEY_V1`, der den Anbieter nie erreicht. Das mitgelieferte MinIO
|
||||
> läuft ohne KMS und würde jede Ablage ablehnen, sobald `S3_SSE` in der
|
||||
> `.env` steht. Setz den Wert nur, wenn ein externer Anbieter ihn
|
||||
> ausdrücklich verlangt.
|
||||
|
||||
> **Externer Objektspeicher statt MinIO?** Wenn du einen S3-Dienst in der
|
||||
> EU benutzt (z. B. Hetzner Object Storage, Falkenstein), streich den
|
||||
@@ -388,6 +422,21 @@ docker compose exec minio mc ls lokal
|
||||
|
||||
---
|
||||
|
||||
> **Der Abschnitt `speicher`** sagt, ob die Dateiablage benutzbar ist:
|
||||
> erreichbar, Ablagebereich vorhanden, Schreibrecht da. Steht dort
|
||||
> `gestoert`, lässt sich kein Dokument ablegen — alles andere funktioniert
|
||||
> weiter. Die Erklärung steht im Log:
|
||||
>
|
||||
> ```bash
|
||||
> docker compose logs app | grep '\[speicher\]'
|
||||
> ```
|
||||
>
|
||||
> Geprüft wird beim ersten Aufruf dieser Adresse und danach höchstens alle
|
||||
> fünf Minuten. Der Healthcheck ruft sie ohnehin auf, die Zeile steht also
|
||||
> kurz nach dem Start im Log.
|
||||
|
||||
---
|
||||
|
||||
## Schritt 7 — In den Reverse Proxy eintragen
|
||||
|
||||
Der Proxy steht auf einem **anderen Server**. Damit sind zwei Dinge zu
|
||||
@@ -731,13 +780,19 @@ Erwartete Ausgabe, in dieser Reihenfolge:
|
||||
|
||||
```
|
||||
Sicherung beginnt: /backups/cockpit_2026-08-22_1430.dump.enc
|
||||
Sicherung fertig (1.2M)
|
||||
Selbstpruefung bestanden.
|
||||
Datenbank gesichert (1.2M)
|
||||
Dokumente gesichert (18M)
|
||||
Selbstpruefung bestanden - Datenbank und Dokumente.
|
||||
```
|
||||
|
||||
Die **Selbstprüfung** ist der eigentliche Punkt: Das Skript entschlüsselt
|
||||
die eben geschriebene Datei sofort wieder. Schlägt das fehl, endet der
|
||||
Lauf mit einem Fehler statt mit einem stillen Scheinerfolg.
|
||||
beide Dateien sofort wieder und liest sie einmal durch. Schlägt das fehl,
|
||||
endet der Lauf mit einem Fehler statt mit einem stillen Scheinerfolg.
|
||||
|
||||
Fehlt die Zeile `Dokumente gesichert`, ist das Ablageverzeichnis nicht in
|
||||
den Sicherungsdienst eingehängt. Das Skript bricht dann ab und räumt den
|
||||
angefangenen Datenbankstand wieder weg — eine Sicherung, die nur die halbe
|
||||
Wahrheit enthält, aber vollständig aussieht, wäre schlimmer als gar keine.
|
||||
|
||||
### Und jetzt einmal zurückspielen
|
||||
|
||||
@@ -760,6 +815,25 @@ docker compose exec db psql -U cockpit probe \
|
||||
docker compose exec db dropdb -U cockpit probe
|
||||
```
|
||||
|
||||
Beim Zurückspielen packt das Skript auch das Dokumentenarchiv aus — nach
|
||||
`backups/dokumente-wiederhergestellt_<datum>/`. An ihren Platz schreibt es
|
||||
die Dateien **nicht**: Der Sicherungsdienst hat auf das Ablageverzeichnis
|
||||
bewusst nur Lesezugriff, und in ein laufendes MinIO hineinzuschreiben würde
|
||||
dessen Datenbestand beschädigen. Wie der letzte Handgriff aussieht, gibt
|
||||
das Skript am Ende selbst aus.
|
||||
|
||||
Für die Übung genügt ein Blick in das ausgepackte Verzeichnis:
|
||||
|
||||
```bash
|
||||
find backups/dokumente-wiederhergestellt_*/ -type f | head
|
||||
rm -rf backups/dokumente-wiederhergestellt_*
|
||||
```
|
||||
|
||||
> **Im Ernstfall gehört beides zusammen.** Eine Datenbank ohne die
|
||||
> zugehörigen Dateien ergibt eine Akte, deren Prüfsummen Dokumente
|
||||
> belegen, die es nicht mehr gibt. Spiel immer das Paar mit demselben
|
||||
> Zeitstempel zurück.
|
||||
|
||||
### Kontrolle
|
||||
|
||||
```bash
|
||||
@@ -767,12 +841,18 @@ ls -lh backups/
|
||||
# cockpit_....dump.enc und cockpit_....dump.enc.sha256
|
||||
```
|
||||
|
||||
> **Was das Backup nicht enthält:** den Schlüssel `ENC_KEY_V1` und die
|
||||
> Dateien im Objektspeicher. Für die Dateien:
|
||||
> **Was das Backup enthält:** die Datenbank *und* die Dokumente. Je Lauf
|
||||
> entstehen zwei verschlüsselte Dateien mit demselben Zeitstempel:
|
||||
>
|
||||
> ```bash
|
||||
> docker compose exec minio mc mirror lokal/betreuungs-cockpit /backups/dokumente
|
||||
> ```
|
||||
> cockpit_2026-09-01_0315.dump.enc <- Akte, Fristen, Buchungen
|
||||
> dokumente_2026-09-01_0315.tar.gz.enc <- Bescheide, Schreiben, Belege
|
||||
> ```
|
||||
>
|
||||
> **Was es nicht enthält:** den Schlüssel `ENC_KEY_V1`. Ohne ihn bleiben
|
||||
> auch aus einer vollständig zurückgespielten Sicherung die Diagnosen,
|
||||
> IBANs, Notizen und Dokumente unlesbar. Er gehört an die zwei Orte aus
|
||||
> Schritt 11.
|
||||
>
|
||||
> Und kopier die Sicherungen regelmäßig **weg vom Server** — ein Backup,
|
||||
> das neben dem Original liegt, hilft bei einem Serverausfall nicht.
|
||||
@@ -849,6 +929,105 @@ Denk an die letzten Pflichten:
|
||||
|
||||
---
|
||||
|
||||
## Wo deine Daten liegen
|
||||
|
||||
Drei Orte, und keiner davon ist ein Ordner, den du einfach öffnest.
|
||||
|
||||
| Was | Wo | In der täglichen Sicherung? |
|
||||
|---|---|---|
|
||||
| **Dokumente** (Bescheide, Schreiben, Belege) | Docker-Volume `minio-daten` | ja |
|
||||
| **Datenbank** (Akte, Fristen, Buchungen) | Docker-Volume `postgres-daten` | ja |
|
||||
| **Sicherungen** | `./backups` neben der `docker-compose.yml` | — |
|
||||
|
||||
### Den Pfad auf der Platte finden
|
||||
|
||||
```bash
|
||||
docker volume inspect betreuungs-cockpit_minio-daten --format '{{.Mountpoint}}'
|
||||
# /var/lib/docker/volumes/betreuungs-cockpit_minio-daten/_data
|
||||
```
|
||||
|
||||
Heißt dein Projektverzeichnis anders, heißen auch die Volumes anders —
|
||||
Docker stellt den Verzeichnisnamen voran:
|
||||
|
||||
```bash
|
||||
docker volume ls | grep -E "minio-daten|postgres-daten"
|
||||
```
|
||||
|
||||
> **Dort hineinzusehen bringt nichts.** MinIO legt jedes Dokument als
|
||||
> eigenes Verzeichnis mit Metadatei und Datenteilen ab, und der Inhalt ist
|
||||
> verschlüsselt. Du fändest dort keine anklickbaren PDFs, sondern
|
||||
> Fragmente. Wer die Dokumente sehen will, nimmt die Anwendung.
|
||||
|
||||
### Nachsehen, was abgelegt ist
|
||||
|
||||
```bash
|
||||
docker compose exec minio sh -c '
|
||||
mc alias set lokal http://localhost:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null &&
|
||||
mc ls --recursive "lokal/$S3_BUCKET"
|
||||
'
|
||||
```
|
||||
|
||||
Je Dokument eine Zeile unter `betreute/<id>/dokumente/<uuid>.pdf`.
|
||||
|
||||
### Prüfen, ob Akte und Speicher übereinstimmen
|
||||
|
||||
Die wichtigste Kontrolle: Zu **jedem** Eintrag in der Akte muss eine Datei
|
||||
gehören. Fehlt sie, belegt eine Prüfsumme ein Dokument, das es nicht mehr
|
||||
gibt — gegenüber dem Betreuungsgericht der schlechteste Zustand.
|
||||
|
||||
```bash
|
||||
docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -c "SELECT \"objektSchluessel\" FROM dokument WHERE \"geloeschtAm\" IS NULL ORDER BY 1;"' | sort > /tmp/erwartet.txt
|
||||
|
||||
docker compose exec minio sh -c '
|
||||
mc alias set lokal http://localhost:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null &&
|
||||
mc ls --recursive "lokal/$S3_BUCKET"
|
||||
' | awk '{print $NF}' | sort > /tmp/vorhanden.txt
|
||||
|
||||
echo "In der Akte: $(wc -l < /tmp/erwartet.txt) Im Speicher: $(wc -l < /tmp/vorhanden.txt)"
|
||||
echo "--- Fehlt im Speicher (Handlungsbedarf): ---"
|
||||
comm -23 /tmp/erwartet.txt /tmp/vorhanden.txt
|
||||
echo "--- Verwaist im Speicher (harmlos): ---"
|
||||
comm -13 /tmp/erwartet.txt /tmp/vorhanden.txt
|
||||
```
|
||||
|
||||
Beide Zahlen gleich und beide Listen leer — dann passt es.
|
||||
|
||||
Ein Eintrag unter *„Fehlt im Speicher"* ist ein Akteneintrag ohne Datei.
|
||||
Er entsteht, wenn eine Ablage scheitert. Solche Einträge löschen und das
|
||||
Dokument neu anlegen.
|
||||
|
||||
### Ohne welche Schlüssel nichts davon lesbar ist
|
||||
|
||||
| Schlüssel | Ohne ihn |
|
||||
|---|---|
|
||||
| `ENC_KEY_V1` | Diagnosen, IBANs, Notizen und alle Dokumente bleiben unlesbar — auch aus jeder Sicherung |
|
||||
| `SICHERUNG_PASSWORT` | keine Sicherung lässt sich öffnen |
|
||||
| `MINIO_KMS_SECRET_KEY` | nur falls gesetzt: MinIO gibt die Objekte nicht mehr heraus |
|
||||
|
||||
Alle gehören an die zwei Orte aus Schritt 11, und keiner davon ist dieser
|
||||
Server.
|
||||
|
||||
### Eigene Anpassungen, die ein Update überleben
|
||||
|
||||
Änderungen an der `docker-compose.yml` legt `scripts/update.sh` bei jedem
|
||||
Update per `git stash` beiseite — sie sind versioniert. Wer etwas dauerhaft
|
||||
ändern will, legt stattdessen eine **`docker-compose.override.yml`**
|
||||
daneben:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
EIGENER_WERT: ${EIGENER_WERT:-}
|
||||
```
|
||||
|
||||
Docker Compose liest sie automatisch mit. `update.sh` prüft nur
|
||||
versionierte Dateien (`git status --untracked-files=no`) und lässt sie
|
||||
deshalb unangetastet.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Aktualisieren
|
||||
|
||||
```bash
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "betreuungs-cockpit",
|
||||
"version": "1.11.0",
|
||||
"version": "1.12.0",
|
||||
"private": true,
|
||||
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
|
||||
"engines": {
|
||||
|
||||
+81
-18
@@ -1,6 +1,6 @@
|
||||
#!/bin/sh
|
||||
# =============================================================================
|
||||
# Taegliche Sicherung der Datenbank
|
||||
# Taegliche Sicherung: Datenbank UND Dokumente
|
||||
# =============================================================================
|
||||
#
|
||||
# Laeuft im Behaelter "sicherung" per cron um 03:15 Uhr. Von Hand:
|
||||
@@ -10,17 +10,44 @@
|
||||
# Was passiert:
|
||||
# 1. pg_dump im eigenen Format (-Fc), damit einzelne Tabellen
|
||||
# wiederherstellbar bleiben.
|
||||
# 2. Verschluesselung mit AES-256 und PBKDF2 aus SICHERUNG_PASSWORT.
|
||||
# 2. Die abgelegten Dokumente als Archiv.
|
||||
# 3. Beides verschluesselt mit AES-256 und PBKDF2 aus SICHERUNG_PASSWORT.
|
||||
# Das Backup liegt oft auf fremdem Speicher - unverschluesselt waere
|
||||
# es ein Datenleck mit Ansage.
|
||||
# 3. SHA-256-Pruefsumme daneben.
|
||||
# 4. Staende, die aelter sind als SICHERUNG_AUFBEWAHRUNG_TAGE, entfernen.
|
||||
# 4. SHA-256-Pruefsumme je Datei.
|
||||
# 5. Staende, die aelter sind als SICHERUNG_AUFBEWAHRUNG_TAGE, entfernen.
|
||||
# Der jeweils erste Stand eines Monats bleibt stehen.
|
||||
#
|
||||
# WICHTIG: Dieses Backup enthaelt die verschluesselten Felder, aber NICHT
|
||||
# den Schluessel ENC_KEY_V1. Ohne ihn sind Diagnosen, IBANs und Notizen
|
||||
# auch aus einer wiederhergestellten Datenbank nicht lesbar. Den Schluessel
|
||||
# getrennt sichern - Passwortmanager oder ausgedruckt im Ordner.
|
||||
# ## Warum die Dokumente dazugehoeren
|
||||
#
|
||||
# Bis v1.11.0 sicherte dieses Skript nur die Datenbank. Nach einem
|
||||
# Plattenschaden waere die Akte vollstaendig wiederhergestellt worden -
|
||||
# mit allen Eintraegen, Verweisen und Pruefsummen, aber ohne eine einzige
|
||||
# Datei dahinter. Die Pruefsumme in der Akte haette dann ein Dokument
|
||||
# belegt, das es nicht mehr gibt. Gegenueber dem Betreuungsgericht ist das
|
||||
# der schlechteste denkbare Zustand.
|
||||
#
|
||||
# ## Warum das Ablageverzeichnis roh eingepackt wird
|
||||
#
|
||||
# Der naheliegende Weg waere "mc mirror" aus dem MinIO-Client. Der ist in
|
||||
# diesem Behaelter aber nicht vorhanden und muesste bei jedem Lauf aus dem
|
||||
# Netz geholt werden - eine Internetabhaengigkeit im Sicherungspfad ist
|
||||
# genau das, was man dort nicht will.
|
||||
#
|
||||
# Stattdessen wird das Datenverzeichnis direkt eingepackt. Das ist
|
||||
# vertretbar, weil MinIO ein Objekt erst vollstaendig unter .minio.sys/tmp
|
||||
# schreibt und dann an seinen Platz umbenennt: Ein laufender Upload liegt
|
||||
# also nie halbfertig im Archiv, er fehlt hoechstens. Bei einem Lauf um
|
||||
# 03:15 Uhr auf einem Einzelplatzsystem ist auch das unwahrscheinlich.
|
||||
#
|
||||
# Derselbe Weg deckt den Dateitreiber mit ab - dort liegen die Dateien
|
||||
# ebenfalls einfach im Verzeichnis.
|
||||
#
|
||||
# WICHTIG: Dieses Backup enthaelt die verschluesselten Felder und die
|
||||
# verschluesselten Dateien, aber NICHT den Schluessel ENC_KEY_V1. Ohne ihn
|
||||
# sind Diagnosen, IBANs, Notizen und Dokumente auch aus einer
|
||||
# wiederhergestellten Sicherung nicht lesbar. Den Schluessel getrennt
|
||||
# sichern - Passwortmanager oder ausgedruckt im Ordner.
|
||||
# =============================================================================
|
||||
|
||||
set -eu
|
||||
@@ -30,6 +57,11 @@ AUFBEWAHRUNG="${SICHERUNG_AUFBEWAHRUNG_TAGE:-30}"
|
||||
ZEITSTEMPEL="$(date +%Y-%m-%d_%H%M)"
|
||||
TAG_IM_MONAT="$(date +%d)"
|
||||
DATEI="$ZIEL/cockpit_${ZEITSTEMPEL}.dump.enc"
|
||||
DOKUMENTE_DATEI="$ZIEL/dokumente_${ZEITSTEMPEL}.tar.gz.enc"
|
||||
|
||||
# Wo die Dokumente liegen. Der Behaelter bekommt das Ablageverzeichnis
|
||||
# eingehaengt - siehe docker-compose.yml, Dienst "sicherung".
|
||||
DOKUMENTE_QUELLE="${SICHERUNG_DOKUMENTE_QUELLE:-/dokumente}"
|
||||
|
||||
if [ -z "${SICHERUNG_PASSWORT:-}" ]; then
|
||||
echo "$(date -Iseconds) FEHLER: SICHERUNG_PASSWORT ist nicht gesetzt. Abbruch." >&2
|
||||
@@ -50,17 +82,39 @@ pg_dump --format=custom --compress=6 --no-owner --no-privileges \
|
||||
GROESSE="$(du -h "$DATEI" | cut -f1)"
|
||||
sha256sum "$DATEI" > "$DATEI.sha256"
|
||||
|
||||
# Der jeweils erste Stand eines Monats bekommt eine Markierung und bleibt
|
||||
# beim Aufraeumen verschont.
|
||||
if [ "$TAG_IM_MONAT" = "01" ]; then
|
||||
touch "$DATEI.monatsstand"
|
||||
echo "$(date -Iseconds) Datenbank gesichert ($GROESSE)"
|
||||
|
||||
# --- Dokumente ---------------------------------------------------------------
|
||||
# Schlaegt dieser Teil fehl, bricht der ganze Lauf ab (set -e). Das ist
|
||||
# Absicht: Eine Sicherung, die nur die halbe Wahrheit enthaelt, aber
|
||||
# vollstaendig aussieht, ist schlimmer als gar keine - man verlaesst sich
|
||||
# darauf und merkt es erst im Ernstfall.
|
||||
if [ ! -d "$DOKUMENTE_QUELLE" ]; then
|
||||
echo "$(date -Iseconds) FEHLER: Das Ablageverzeichnis $DOKUMENTE_QUELLE ist nicht eingehaengt." >&2
|
||||
echo " Ohne die Dokumente ist die Sicherung unvollstaendig." >&2
|
||||
echo " Siehe docker-compose.yml, Dienst \"sicherung\"." >&2
|
||||
rm -f "$DATEI" "$DATEI.sha256"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$(date -Iseconds) Sicherung fertig ($GROESSE)"
|
||||
# "tar" ohne Leitung an einen Verbraucher, der frueh schliesst - sonst
|
||||
# SIGPIPE, siehe die Anmerkung zur Selbstpruefung weiter unten.
|
||||
tar -czf - -C "$DOKUMENTE_QUELLE" . | openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt -pass env:SICHERUNG_PASSWORT -out "$DOKUMENTE_DATEI"
|
||||
|
||||
DOK_GROESSE="$(du -h "$DOKUMENTE_DATEI" | cut -f1)"
|
||||
sha256sum "$DOKUMENTE_DATEI" > "$DOKUMENTE_DATEI.sha256"
|
||||
|
||||
echo "$(date -Iseconds) Dokumente gesichert ($DOK_GROESSE)"
|
||||
|
||||
# Der jeweils erste Stand eines Monats bekommt eine Markierung und bleibt
|
||||
# beim Aufraeumen verschont. Beide Teile, sonst faellt die Haelfte weg.
|
||||
if [ "$TAG_IM_MONAT" = "01" ]; then
|
||||
touch "$DATEI.monatsstand" "$DOKUMENTE_DATEI.monatsstand"
|
||||
fi
|
||||
|
||||
# --- Aufraeumen --------------------------------------------------------------
|
||||
ENTFERNT=0
|
||||
for alt in "$ZIEL"/cockpit_*.dump.enc; do
|
||||
for alt in "$ZIEL"/cockpit_*.dump.enc "$ZIEL"/dokumente_*.tar.gz.enc; do
|
||||
[ -e "$alt" ] || continue
|
||||
[ -e "$alt.monatsstand" ] && continue
|
||||
|
||||
@@ -87,9 +141,18 @@ KOPF="$(openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
|
||||
-pass env:SICHERUNG_PASSWORT -in "$DATEI" 2>/dev/null \
|
||||
| dd bs=1 count=5 2>/dev/null || true)"
|
||||
|
||||
if [ "$KOPF" = "PGDMP" ]; then
|
||||
echo "$(date -Iseconds) Selbstpruefung bestanden."
|
||||
else
|
||||
echo "$(date -Iseconds) FEHLER: Die Sicherung laesst sich nicht entschluesseln!" >&2
|
||||
if [ "$KOPF" != "PGDMP" ]; then
|
||||
echo "$(date -Iseconds) FEHLER: Der Datenbankstand laesst sich nicht entschluesseln!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Dasselbe fuer die Dokumente: entschluesseln und das Archiv auflisten
|
||||
# lassen. "tar -tzf" liest die ganze Datei und meldet jeden Schaden - das
|
||||
# ist bei den Groessenordnungen hier bezahlbar und sagt mehr aus als ein
|
||||
# Blick auf die ersten Bytes.
|
||||
if ! openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 -pass env:SICHERUNG_PASSWORT -in "$DOKUMENTE_DATEI" 2>/dev/null | tar -tzf - >/dev/null 2>&1; then
|
||||
echo "$(date -Iseconds) FEHLER: Das Dokumentenarchiv laesst sich nicht lesen!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$(date -Iseconds) Selbstpruefung bestanden - Datenbank und Dokumente."
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
# ausdruecklich nach, weil dabei der aktuelle Datenbestand ueberschrieben
|
||||
# wird.
|
||||
#
|
||||
# ZWEI DINGE, DIE MAN VORHER WISSEN MUSS:
|
||||
# DREI DINGE, DIE MAN VORHER WISSEN MUSS:
|
||||
#
|
||||
# 1. Die Sicherung enthaelt die anwendungsseitig verschluesselten Felder,
|
||||
# aber NICHT den Schluessel. Ohne den passenden ENC_KEY_Vn aus der .env
|
||||
@@ -20,6 +20,14 @@
|
||||
# das hier einmal im Jahr auf einer Testdatenbank aus, bevor du es im
|
||||
# Ernstfall zum ersten Mal brauchst. Siehe README, Abschnitt
|
||||
# "Wiederherstellung ueben".
|
||||
#
|
||||
# 3. Die Dokumente werden NICHT selbsttaetig an ihren Platz geschrieben.
|
||||
# Dieses Skript packt sie nach ./backups aus und sagt, wie es
|
||||
# weitergeht. Grund: Der Sicherungsdienst hat auf das Ablageverzeichnis
|
||||
# bewusst nur Lesezugriff (siehe docker-compose.yml), und in ein
|
||||
# laufendes MinIO hineinzuschreiben wuerde dessen Datenbestand
|
||||
# beschaedigen. Der letzte Handgriff bleibt beim Menschen - er ist ein
|
||||
# Einzeiler und im Klartext beschrieben.
|
||||
# =============================================================================
|
||||
|
||||
set -eu
|
||||
@@ -87,7 +95,44 @@ openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
|
||||
| pg_restore --clean --if-exists --no-owner --no-privileges \
|
||||
--dbname="${PGDATABASE:-postgres}" --exit-on-error
|
||||
|
||||
echo "$(date -Iseconds) Wiederherstellung abgeschlossen."
|
||||
echo "$(date -Iseconds) Datenbank wiederhergestellt."
|
||||
|
||||
# --- Dokumente ---------------------------------------------------------------
|
||||
# Zu "cockpit_2026-09-01_0315.dump.enc" gehoert
|
||||
# "dokumente_2026-09-01_0315.tar.gz.enc" - gleicher Zeitstempel, anderer
|
||||
# Name. Fehlt sie, stammt die Sicherung aus einer Fassung vor v1.12.0.
|
||||
ZEITSTEMPEL="$(basename "$DATEI" | sed -n 's/^cockpit_\(.*\)\.dump\.enc$/\1/p')"
|
||||
DOKUMENTE_DATEI="$(dirname "$DATEI")/dokumente_${ZEITSTEMPEL}.tar.gz.enc"
|
||||
AUSPACKZIEL="$(dirname "$DATEI")/dokumente-wiederhergestellt_${ZEITSTEMPEL}"
|
||||
|
||||
if [ ! -f "$DOKUMENTE_DATEI" ]; then
|
||||
echo ""
|
||||
echo "WARNUNG: Zu dieser Sicherung gibt es kein Dokumentenarchiv."
|
||||
echo " Erwartet wurde: $DOKUMENTE_DATEI"
|
||||
echo " Sicherungen aus Fassungen vor v1.12.0 enthalten nur die"
|
||||
echo " Datenbank. Die Akte ist dann vollstaendig, die Dateien"
|
||||
echo " dahinter fehlen."
|
||||
else
|
||||
if [ -f "$DOKUMENTE_DATEI.sha256" ]; then
|
||||
echo "Pruefsumme des Dokumentenarchivs wird geprueft ..."
|
||||
if sha256sum -c "$DOKUMENTE_DATEI.sha256" >/dev/null 2>&1; then
|
||||
echo " in Ordnung."
|
||||
else
|
||||
echo "FEHLER: Die Pruefsumme des Dokumentenarchivs stimmt nicht." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$AUSPACKZIEL"
|
||||
openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
|
||||
-pass env:SICHERUNG_PASSWORT -in "$DOKUMENTE_DATEI" \
|
||||
| tar -xzf - -C "$AUSPACKZIEL"
|
||||
|
||||
ANZAHL="$(find "$AUSPACKZIEL" -type f | wc -l)"
|
||||
echo "$(date -Iseconds) Dokumente ausgepackt: $ANZAHL Dateien nach"
|
||||
echo " $AUSPACKZIEL"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Naechste Schritte:"
|
||||
echo " 1. Die SQL-Skripte erneut ausfuehren (Volltextsuche und Audit-Schutz):"
|
||||
@@ -95,6 +140,21 @@ echo " docker compose exec app npm run db:sql"
|
||||
echo " 2. Pruefen, ob ENC_KEY_V1 in der .env zu diesem Bestand passt -"
|
||||
echo " ohne den richtigen Schluessel bleiben die verschluesselten"
|
||||
echo " Felder unlesbar."
|
||||
echo " 3. In der Anwendung eine betreute Person oeffnen und nachsehen, ob"
|
||||
echo " die Diagnosen im Klartext erscheinen. Erst dann ist die"
|
||||
echo " 3. Die Dokumente an ihren Platz bringen. Dazu MinIO anhalten, das"
|
||||
echo " Datenverzeichnis ersetzen und wieder starten - auf dem Host,"
|
||||
echo " nicht in diesem Behaelter:"
|
||||
echo ""
|
||||
echo " docker compose stop minio app"
|
||||
echo " docker run --rm \\"
|
||||
echo " -v betreuungs-cockpit_minio-daten:/ziel \\"
|
||||
echo " -v \"\$PWD/backups:/quelle:ro\" alpine \\"
|
||||
echo " sh -c 'rm -rf /ziel/* /ziel/..?* && cp -a /quelle/dokumente-wiederhergestellt_*/. /ziel/'"
|
||||
echo " docker compose up -d"
|
||||
echo ""
|
||||
echo " Steht mehr als ein ausgepacktes Verzeichnis unter ./backups,"
|
||||
echo " vorher das richtige auswaehlen statt des Sternchens."
|
||||
echo ""
|
||||
echo " 4. In der Anwendung eine betreute Person oeffnen und nachsehen, ob"
|
||||
echo " die Diagnosen im Klartext erscheinen. Dann ein Dokument"
|
||||
echo " herunterladen. Erst wenn beides klappt, ist die"
|
||||
echo " Wiederherstellung wirklich gelungen."
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { COMMIT, VERSION } from '@/lib/version'
|
||||
import { speicherZustand } from '@/lib/speicher/pruefung'
|
||||
|
||||
/**
|
||||
* Betriebsbereitschaft fuer den Healthcheck in docker-compose.
|
||||
*
|
||||
* Bewusst ohne Anmeldung, aber ohne jede Auskunft ueber Inhalte: geprueft
|
||||
* wird nur, ob die Datenbank antwortet.
|
||||
* wird, ob Datenbank und Dateiablage antworten.
|
||||
*
|
||||
* Nur die Datenbank entscheidet ueber 503. Ein gestoerter Speicher wird
|
||||
* gemeldet, laesst den Behaelter aber laufen - sonst wuerde der
|
||||
* Healthcheck ihn neu starten, und die ganze Akte waere weg wegen eines
|
||||
* Teils, den ein Neustart nicht repariert.
|
||||
*
|
||||
* Die Version steht dabei: sie wird beim Aktualisieren gebraucht, um zu
|
||||
* sehen, ob der neue Stand tatsaechlich laeuft. Das ist keine Auskunft
|
||||
@@ -19,14 +25,29 @@ export const dynamic = 'force-dynamic'
|
||||
export async function GET() {
|
||||
try {
|
||||
await db.$queryRaw`SELECT 1`
|
||||
return NextResponse.json(
|
||||
{ status: 'bereit', version: VERSION, commit: COMMIT || undefined },
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
)
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ status: 'datenbank nicht erreichbar', version: VERSION },
|
||||
{ status: 503, headers: { 'Cache-Control': 'no-store' } },
|
||||
)
|
||||
}
|
||||
|
||||
// Der Speicher wird mitgeprueft, aber er entscheidet NICHT ueber die
|
||||
// Betriebsbereitschaft: Faellt er aus, funktioniert alles ausser dem
|
||||
// Ablegen von Dateien. Ein 503 wuerde den Behaelter neu starten lassen
|
||||
// und die ganze Akte unerreichbar machen - wegen eines Teils, den ein
|
||||
// Neustart nicht repariert.
|
||||
const speicher = await speicherZustand()
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'bereit',
|
||||
version: VERSION,
|
||||
commit: COMMIT || undefined,
|
||||
speicher: speicher.ok
|
||||
? { status: 'bereit', treiber: speicher.treiber }
|
||||
: { status: 'gestoert', treiber: speicher.treiber, art: speicher.art },
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,42 @@ export const WARTEZEIT_DRINGEND_TAGE = 21
|
||||
/** Wie viele Ausschlussfristen zeigt die Warnung hoechstens? */
|
||||
const AUSSCHLUSSFRISTEN_MAX = 3
|
||||
|
||||
/**
|
||||
* Wie weit im Voraus eine Ausschlussfrist die Warnung ausloest.
|
||||
*
|
||||
* Frueher gab es diese Grenze nicht: Der Warnblock fragte nur, OB etwas
|
||||
* eine Ausschlussfrist ist, nie WANN sie faellig wird. Auf dem Dashboard
|
||||
* standen deshalb dauerhaft drei rote Kaesten mit Warndreieck - fuer
|
||||
* Aufwandspauschalen, die 2027, 2028 und 2029 ablaufen. Die Pflichtfristen
|
||||
* werden bewusst drei Jahre im Voraus angelegt (jahreVoraus in
|
||||
* pflichtfristen.ts), und alle landeten ungefiltert in der Warnung.
|
||||
*
|
||||
* Das ist nicht nur laestig, sondern gefaehrlich: Eine Warnung, die
|
||||
* monatelang dasteht und nichts von einem will, stumpft ab. Man uebersieht
|
||||
* die vierte, die etwas will - und bei § 1878 BGB ist das die
|
||||
* Jahrespauschale, die ersatzlos erlischt.
|
||||
*
|
||||
* 90 Tage, weil die kuerzeste hier vorkommende Ausschlussfrist sechs
|
||||
* Monate Zeit laesst. Drei Monate Vorlauf sind reichlich, und solange
|
||||
* nichts ansteht, bleibt der Kasten leer - dann bedeutet er etwas.
|
||||
*
|
||||
* Verschwinden tut nichts: Was ausserhalb des Horizonts liegt, steht
|
||||
* weiterhin in der Fristenliste.
|
||||
*/
|
||||
const AUSSCHLUSSFRIST_HORIZONT_TAGE = 90
|
||||
|
||||
/**
|
||||
* Gehoert diese Frist in den hervorgehobenen Warnblock?
|
||||
*
|
||||
* Abgelaufenes immer - eine versaeumte Ausschlussfrist bleibt eine
|
||||
* schlechte Nachricht, auch wenn sie lange zurueckliegt. Sonst nur, was
|
||||
* innerhalb des Horizonts faellig wird.
|
||||
*/
|
||||
function warntJetzt(eintrag: { ausschlussfrist: boolean; tage: number }): boolean {
|
||||
if (!eintrag.ausschlussfrist) return false
|
||||
return eintrag.tage < 0 || eintrag.tage <= AUSSCHLUSSFRIST_HORIZONT_TAGE
|
||||
}
|
||||
|
||||
/**
|
||||
* Datumsfelder ohne Uhrzeit (@db.Date) kommen als UTC-Mitternacht aus der
|
||||
* Datenbank. Wer sie unbesehen mit einem lokalen "heute" vergleicht,
|
||||
@@ -126,7 +162,7 @@ export function fristenEinordnen(fristen: FristRoh[], heute: Date): FristenAmpel
|
||||
dieseWoche: eintraege.filter((e) => e.stufe === 'orange'),
|
||||
diesenMonat: eintraege.filter((e) => e.stufe === 'gelb'),
|
||||
weitereOffen: eintraege.filter((e) => e.stufe === 'gruen').length,
|
||||
ausschlussfristen: eintraege.filter((e) => e.ausschlussfrist).slice(0, AUSSCHLUSSFRISTEN_MAX),
|
||||
ausschlussfristen: eintraege.filter(warntJetzt).slice(0, AUSSCHLUSSFRISTEN_MAX),
|
||||
gesamtOffen: eintraege.length,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,26 @@ export const env = {
|
||||
bucket: pflicht('S3_BUCKET'),
|
||||
accessKeyId: pflicht('S3_ACCESS_KEY_ID'),
|
||||
secretAccessKey: pflicht('S3_SECRET_ACCESS_KEY'),
|
||||
|
||||
/**
|
||||
* Serverseitige Verschluesselung - nur, wenn ausdruecklich gewuenscht.
|
||||
*
|
||||
* Frueher stand im Treiber fest ServerSideEncryption: 'AES256'. Das
|
||||
* mitgelieferte MinIO laeuft ohne KMS und lehnt das mit
|
||||
* "NotImplemented" ab: Es liess sich kein einziges Dokument ablegen,
|
||||
* und wer der INSTALLATION.md folgte, landete zwangslaeufig dort.
|
||||
*
|
||||
* Gebraucht wird sie ohnehin nicht mehr. Seit v1.11.0 verschluesselt
|
||||
* die Anwendung jede Datei selbst, bevor sie den Server verlaesst
|
||||
* (src/lib/speicher/umschlag.ts). Serverseitige Verschluesselung
|
||||
* schuetzt vor nichts, was der eigene Umschlag nicht schon abdeckt -
|
||||
* sie ist hoechstens eine zweite Lage fuer Anbieter, die sie
|
||||
* anbieten.
|
||||
*
|
||||
* Leer bedeutet aus. Wer sie will, setzt S3_SSE=AES256 oder den Wert,
|
||||
* den sein Anbieter erwartet.
|
||||
*/
|
||||
serverseitigeVerschluesselung: process.env.S3_SSE?.trim() || null,
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -93,6 +93,36 @@ export async function signierteUrl(
|
||||
return dateiRoutePfad(zugriffTokenErzeugen(schluessel, sekunden))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Bereitschaftspruefung
|
||||
// -----------------------------------------------------------------------------
|
||||
//
|
||||
// Ohne Umschlag und ohne die strenge Schluesselpruefung: Der Probeschluessel
|
||||
// gehoert zu keiner betreuten Person und faellt durch das Muster.
|
||||
|
||||
function probepfad(schluessel: string): string {
|
||||
const basis = wurzel()
|
||||
const ziel = resolve(basis, ...schluessel.split('/'))
|
||||
if (ziel !== basis && !ziel.startsWith(basis + sep)) {
|
||||
throw new Error('Der Ablagepfad läge außerhalb des Dokumentenverzeichnisses.')
|
||||
}
|
||||
return ziel
|
||||
}
|
||||
|
||||
export async function rohSchreiben(schluessel: string, inhalt: Buffer): Promise<void> {
|
||||
const pfad = probepfad(schluessel)
|
||||
await mkdir(dirname(pfad), { recursive: true })
|
||||
await writeFile(pfad, inhalt, { mode: 0o600 })
|
||||
}
|
||||
|
||||
export async function rohLesen(schluessel: string): Promise<Buffer> {
|
||||
return readFile(probepfad(schluessel))
|
||||
}
|
||||
|
||||
export async function rohEntfernen(schluessel: string): Promise<void> {
|
||||
await rm(probepfad(schluessel), { force: true })
|
||||
}
|
||||
|
||||
/** Nur fuer Wartungsskripte: das Ablageverzeichnis. */
|
||||
export function ablageverzeichnis(): string {
|
||||
return wurzel()
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'server-only'
|
||||
|
||||
/**
|
||||
* Speicherfehler in Sätze übersetzen, mit denen jemand etwas anfangen kann.
|
||||
*
|
||||
* Anlass ist eine Fehlersuche vom 31.08.2026. Beim Ablegen eines Schreibens
|
||||
* stand in der Oberfläche:
|
||||
*
|
||||
* Das Schreiben konnte nicht abgelegt werden.
|
||||
*
|
||||
* Dahinter lagen nacheinander DREI verschiedene Ursachen — ein fehlender
|
||||
* Ablagebereich, ein falsch konfigurierter Endpunkt und eine
|
||||
* Verschlüsselung, die der Speicher nicht anbietet. Jedes Mal derselbe
|
||||
* Satz. Die Suche dauerte entsprechend, und ohne Zugang zum Serverlog wäre
|
||||
* sie aussichtslos gewesen.
|
||||
*
|
||||
* ## Was hier NICHT passiert
|
||||
*
|
||||
* Es wird nichts durchgereicht, was der Speicher sagt. Fremde
|
||||
* Fehlertexte können Hostnamen, Eimernamen und Anfragekennungen
|
||||
* enthalten - das gehört ins Log, nicht auf den Bildschirm eines
|
||||
* Betreuers. Übersetzt werden nur die Fälle, die wir kennen; alles andere
|
||||
* bleibt bei der allgemeinen Meldung des Aufrufers.
|
||||
*
|
||||
* Die technische Ursache steht weiterhin vollständig im Log.
|
||||
*/
|
||||
|
||||
/** Ein erkannter Speicherfehler samt Sprachfassung. */
|
||||
export type Speicherfehler = {
|
||||
/** Kurzform für Protokoll und Gesundheitsauskunft. */
|
||||
art: 'kein-ablagebereich' | 'verschluesselung' | 'zugang' | 'nicht-erreichbar'
|
||||
/** Was in der Oberfläche stehen soll. */
|
||||
meldung: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt den Fehlercode heraus.
|
||||
*
|
||||
* Die Reihenfolge ist wichtig und war zuerst falsch: "name" traegt bei
|
||||
* einem gewoehnlichen Node-Fehler den Wert "Error" - eine nichtssagende
|
||||
* Zeichenkette, die aber nicht leer ist. Stuende sie vorn, kaeme der
|
||||
* eigentliche Code aus "code" (ECONNREFUSED und Verwandte) nie zum Zug.
|
||||
*
|
||||
* Deshalb: erst "Code" (AWS-SDK), dann "code" (Node-Systemaufruf), und
|
||||
* "name" nur als letzte Zuflucht.
|
||||
*/
|
||||
function code(fehler: unknown): string {
|
||||
if (typeof fehler !== 'object' || fehler === null) return ''
|
||||
const mitCode = fehler as { Code?: unknown; code?: unknown; name?: unknown }
|
||||
for (const wert of [mitCode.Code, mitCode.code, mitCode.name]) {
|
||||
if (typeof wert === 'string' && wert !== '' && wert !== 'Error') return wert
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function nachricht(fehler: unknown): string {
|
||||
return fehler instanceof Error ? fehler.message : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt die häufigen Speicherfehler.
|
||||
*
|
||||
* @returns null, wenn es keiner der bekannten Fälle ist - dann bleibt es
|
||||
* bei der allgemeinen Meldung des Aufrufers.
|
||||
*/
|
||||
export function speicherfehlerErkennen(fehler: unknown): Speicherfehler | null {
|
||||
const kennung = code(fehler)
|
||||
const text = nachricht(fehler)
|
||||
|
||||
if (kennung === 'NoSuchBucket') {
|
||||
return {
|
||||
art: 'kein-ablagebereich',
|
||||
meldung:
|
||||
'Im Objektspeicher fehlt der Ablagebereich für die Dokumente. Er wird bei der ' +
|
||||
'Installation einmalig angelegt — siehe INSTALLATION.md, Schritt 6. Bis dahin lässt ' +
|
||||
'sich nichts ablegen; deine Eingaben gehen aber nicht verloren.',
|
||||
}
|
||||
}
|
||||
|
||||
// MinIO ohne KMS antwortet so, sobald S3_SSE gesetzt ist.
|
||||
if (kennung === 'NotImplemented' && /encryption/i.test(text)) {
|
||||
return {
|
||||
art: 'verschluesselung',
|
||||
meldung:
|
||||
'Der Objektspeicher unterstützt die eingestellte serverseitige Verschlüsselung nicht. ' +
|
||||
'Der mitgelieferte Speicher braucht sie auch nicht — die Anwendung verschlüsselt selbst. ' +
|
||||
'Nimm S3_SSE aus der .env heraus.',
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
kennung === 'AccessDenied' ||
|
||||
kennung === 'SignatureDoesNotMatch' ||
|
||||
kennung === 'InvalidAccessKeyId'
|
||||
) {
|
||||
return {
|
||||
art: 'zugang',
|
||||
meldung:
|
||||
'Die Zugangsdaten zum Objektspeicher werden abgelehnt. Prüfe S3_ACCESS_KEY_ID und ' +
|
||||
'S3_SECRET_ACCESS_KEY in der .env — und ob sie zu dem Speicher gehören, auf den ' +
|
||||
'S3_ENDPOINT zeigt.',
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN', 'TimeoutError'].includes(kennung) ||
|
||||
/ECONNREFUSED|ENOTFOUND|ETIMEDOUT/.test(text)
|
||||
) {
|
||||
return {
|
||||
art: 'nicht-erreichbar',
|
||||
meldung:
|
||||
'Der Objektspeicher antwortet nicht. Läuft er, und zeigt S3_ENDPOINT auf die richtige ' +
|
||||
'Adresse? Deine Eingaben gehen nicht verloren.',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Bequemer Aufruf für die Stellen, die eine Meldung brauchen.
|
||||
*
|
||||
* @param allgemein Was stehen soll, wenn der Fehler keiner der bekannten
|
||||
* ist - z.B. "Das Schreiben konnte nicht abgelegt werden."
|
||||
*/
|
||||
export function speicherfehlerMeldung(fehler: unknown, allgemein: string): string {
|
||||
const erkannt = speicherfehlerErkennen(fehler)
|
||||
return erkannt ? erkannt.meldung : allgemein
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import 'server-only'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { env } from '@/lib/env'
|
||||
import { speicherfehlerErkennen } from '@/lib/speicher/fehler'
|
||||
|
||||
/**
|
||||
* Ist die Dateiablage benutzbar?
|
||||
*
|
||||
* Bis v1.12.0 fiel eine kaputte Speicherkonfiguration erst auf, wenn
|
||||
* jemand ein Dokument ablegen wollte. Auf dem Livesystem waren das
|
||||
* mehrere Wochen nach der Installation - und dann mitten in der Arbeit,
|
||||
* mit einer Meldung, die nichts sagte.
|
||||
*
|
||||
* Diese Pruefung schreibt eine winzige Datei, liest sie zurueck und raeumt
|
||||
* sie weg. Sie beantwortet damit alle drei Fragen auf einmal: erreichbar,
|
||||
* Ablagebereich vorhanden, Schreibrecht da.
|
||||
*
|
||||
* ## Sie verhindert den Start nicht
|
||||
*
|
||||
* Eine Anwendung, die wegen des Objektspeichers nicht hochkommt, ist
|
||||
* schlimmer als eine, die ihn meldet: Ohne Oberflaeche sieht niemand die
|
||||
* Erklaerung, und der Rest der Akte - Fristen, Buchungen, Stammdaten -
|
||||
* waere ohne Not mit abgeschaltet.
|
||||
*/
|
||||
|
||||
export type Speicherzustand =
|
||||
| { ok: true; treiber: string }
|
||||
| { ok: false; treiber: string; art: string; meldung: string }
|
||||
|
||||
/** Der Ablageschluessel fuer die Probe. Liegt im eigenen Bereich. */
|
||||
function probeschluessel(): string {
|
||||
return `pruefung/${randomUUID()}.txt`
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt, liest und loescht eine Probedatei.
|
||||
*
|
||||
* Bewusst ueber die Treiber und nicht ueber die Fassade in index.ts: Die
|
||||
* prueft den Ablageschluessel gegen das Muster fuer Dokumente einer
|
||||
* betreuten Person, und darum geht es hier nicht.
|
||||
*/
|
||||
export async function speicherPruefen(): Promise<Speicherzustand> {
|
||||
const treiber = env.speicherTreiber
|
||||
const schluessel = probeschluessel()
|
||||
const inhalt = Buffer.from(`bereitschaftspruefung ${new Date().toISOString()}`, 'utf8')
|
||||
|
||||
try {
|
||||
if (treiber === 's3') {
|
||||
const s3 = await import('@/lib/speicher/s3')
|
||||
await s3.rohSchreiben(schluessel, inhalt)
|
||||
await s3.rohLesen(schluessel)
|
||||
await s3.rohEntfernen(schluessel)
|
||||
} else {
|
||||
const datei = await import('@/lib/speicher/datei')
|
||||
await datei.rohSchreiben(schluessel, inhalt)
|
||||
await datei.rohLesen(schluessel)
|
||||
await datei.rohEntfernen(schluessel)
|
||||
}
|
||||
return { ok: true, treiber }
|
||||
} catch (fehler) {
|
||||
const erkannt = speicherfehlerErkennen(fehler)
|
||||
return {
|
||||
ok: false,
|
||||
treiber,
|
||||
art: erkannt?.art ?? 'unbekannt',
|
||||
meldung: erkannt?.meldung ?? 'Die Dateiablage ist nicht benutzbar.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Gemerkter Zustand
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Warum die Pruefung an der Gesundheitsabfrage haengt und nicht an einem
|
||||
* Startaufruf.
|
||||
*
|
||||
* Der naheliegende Ort waere instrumentation.ts - der Haken von Next.js
|
||||
* fuer "einmal beim Hochfahren". Der wird aber auch fuer die
|
||||
* Edge-Laufzeit gebuendelt, und ueber die Treiber kaeme node:path in ein
|
||||
* Buendel, in dem es das nicht gibt: Der Build schlaegt fehl. Es gibt
|
||||
* Kunstgriffe dagegen; sie sind fragil und muessten bei jedem
|
||||
* Next-Wechsel nachgezogen werden.
|
||||
*
|
||||
* Der Healthcheck aus docker-compose ruft /api/gesundheit ohnehin ab 40
|
||||
* Sekunden nach dem Start alle 30 Sekunden auf. Die erste Pruefung
|
||||
* passiert damit von selbst kurz nach dem Hochfahren, und die Zeile steht
|
||||
* im Log, wo man sie sucht. Ohne Behaelter geschieht dasselbe beim ersten
|
||||
* Aufruf der Adresse.
|
||||
*/
|
||||
const NEU_PRUEFEN_NACH_MS = 5 * 60 * 1000
|
||||
|
||||
let gemerkt: { zustand: Speicherzustand; zeitpunkt: number } | null = null
|
||||
let zuletztGemeldet: string | null = null
|
||||
|
||||
/**
|
||||
* Der Zustand der Ablage, hoechstens alle fuenf Minuten frisch geholt.
|
||||
*
|
||||
* Ohne diese Schonfrist schriebe der Healthcheck alle 30 Sekunden eine
|
||||
* Probedatei in den Objektspeicher - unnoetiger Verkehr fuer eine Frage,
|
||||
* deren Antwort sich selten aendert.
|
||||
*/
|
||||
export async function speicherZustand(): Promise<Speicherzustand> {
|
||||
const jetzt = Date.now()
|
||||
if (gemerkt && jetzt - gemerkt.zeitpunkt < NEU_PRUEFEN_NACH_MS) {
|
||||
return gemerkt.zustand
|
||||
}
|
||||
|
||||
const zustand = await speicherPruefen()
|
||||
gemerkt = { zustand, zeitpunkt: jetzt }
|
||||
melden(zustand)
|
||||
return zustand
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt eine Zeile ins Protokoll - aber nur, wenn sich etwas geaendert
|
||||
* hat. Sonst stuende alle fuenf Minuten dieselbe Meldung im Log und
|
||||
* niemand liest sie mehr.
|
||||
*/
|
||||
function melden(zustand: Speicherzustand): void {
|
||||
const kennung = zustand.ok ? 'ok' : `fehler:${zustand.art}`
|
||||
if (kennung === zuletztGemeldet) return
|
||||
zuletztGemeldet = kennung
|
||||
|
||||
if (zustand.ok) {
|
||||
console.log(`[speicher] Ablage bereit (Treiber: ${zustand.treiber}).`)
|
||||
return
|
||||
}
|
||||
|
||||
console.error(
|
||||
`[speicher] Die Dateiablage ist NICHT benutzbar (Treiber: ${zustand.treiber}, ` +
|
||||
`${zustand.art}). ${zustand.meldung} ` +
|
||||
`Die Anwendung laeuft trotzdem weiter - alles ausser dem Ablegen von ` +
|
||||
`Dateien funktioniert.`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Nur fuer Tests: die Schonfrist ueberspringen.
|
||||
*
|
||||
* Bewusst OHNE zuletztGemeldet zurueckzusetzen - das entspricht dem, was
|
||||
* nach fuenf Minuten im Betrieb passiert: neu geprueft, aber nur bei einer
|
||||
* Aenderung gemeldet.
|
||||
*/
|
||||
export function schonfristUeberspringen(): void {
|
||||
gemerkt = null
|
||||
}
|
||||
|
||||
/** Nur fuer Tests: auch das Meldegedaechtnis leeren. */
|
||||
export function zustandVergessen(): void {
|
||||
gemerkt = null
|
||||
zuletztGemeldet = null
|
||||
}
|
||||
+36
-7
@@ -6,6 +6,7 @@ import {
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
type ServerSideEncryption,
|
||||
} from '@aws-sdk/client-s3'
|
||||
import { env } from '@/lib/env'
|
||||
import {
|
||||
@@ -33,8 +34,13 @@ import { entschluesselnBinaer, verschluesselnBinaer } from '@/lib/speicher/umsch
|
||||
* Kontoauszuege.
|
||||
*
|
||||
* Deshalb liegt jetzt derselbe Umschlag darum wie beim Dateitreiber, mit
|
||||
* einem Schluessel aus der Umgebung. Die serverseitige Verschluesselung
|
||||
* bleibt zusaetzlich stehen; sie kostet nichts.
|
||||
* einem Schluessel aus der Umgebung.
|
||||
*
|
||||
* Die serverseitige Verschluesselung ist damit ueberfluessig und wird nur
|
||||
* noch gesendet, wenn S3_SSE gesetzt ist. Im Kommentar stand hier einmal,
|
||||
* sie "kostet nichts" - das war falsch: Das mitgelieferte MinIO laeuft
|
||||
* ohne KMS und lehnte jedes PutObject mit "NotImplemented" ab. Sie kostete
|
||||
* die gesamte Funktion.
|
||||
*
|
||||
* ## Und deshalb keine presigned URLs mehr
|
||||
*
|
||||
@@ -80,6 +86,7 @@ export async function schreiben(
|
||||
_mimeTyp: string,
|
||||
): Promise<void> {
|
||||
const geprueft = schluesselErfordern(schluessel)
|
||||
const sse = env.s3.serverseitigeVerschluesselung
|
||||
await s3().send(
|
||||
new PutObjectCommand({
|
||||
Bucket: eimer(),
|
||||
@@ -91,8 +98,8 @@ export async function schreiben(
|
||||
ContentType: 'application/octet-stream',
|
||||
// Auch beim direkten Abruf niemals im Browser darstellen.
|
||||
ContentDisposition: 'attachment',
|
||||
// Serverseitige Verschluesselung, wo der Anbieter sie unterstuetzt.
|
||||
ServerSideEncryption: 'AES256',
|
||||
// Nur mitschicken, wenn konfiguriert - siehe env.s3.
|
||||
...(sse ? { ServerSideEncryption: sse as ServerSideEncryption } : {}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -167,10 +174,32 @@ export async function objekteAuflisten(): Promise<string[]> {
|
||||
return gefunden
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt Bytes ohne Umschlag ab. Nur fuer die Bereitschaftspruefung.
|
||||
*
|
||||
* Bewusst ohne schluesselErfordern(): Der Probeschluessel gehoert zu
|
||||
* keiner betreuten Person und faellt durch das Muster.
|
||||
*/
|
||||
export async function rohSchreiben(schluessel: string, inhalt: Buffer): Promise<void> {
|
||||
await s3().send(
|
||||
new PutObjectCommand({
|
||||
Bucket: eimer(),
|
||||
Key: schluessel,
|
||||
Body: inhalt,
|
||||
ContentType: 'application/octet-stream',
|
||||
ContentDisposition: 'attachment',
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** Raeumt die Probedatei wieder weg. */
|
||||
export async function rohEntfernen(schluessel: string): Promise<void> {
|
||||
await s3().send(new DeleteObjectCommand({ Bucket: eimer(), Key: schluessel }))
|
||||
}
|
||||
|
||||
/** Der Inhalt, wie er im Eimer liegt - ohne den Umschlag abzunehmen. */
|
||||
export async function rohLesen(schluessel: string): Promise<Buffer> {
|
||||
const geprueft = schluesselErfordern(schluessel)
|
||||
const antwort = await s3().send(new GetObjectCommand({ Bucket: eimer(), Key: geprueft }))
|
||||
if (!antwort.Body) throw new Error(`Objekt ${geprueft} ist leer.`)
|
||||
const antwort = await s3().send(new GetObjectCommand({ Bucket: eimer(), Key: schluessel }))
|
||||
if (!antwort.Body) throw new Error(`Objekt ${schluessel} ist leer.`)
|
||||
return Buffer.from(await antwort.Body.transformToByteArray())
|
||||
}
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
/** Muss zur "version" in der package.json passen. */
|
||||
export const VERSION = '1.11.0'
|
||||
export const VERSION = '1.12.0'
|
||||
|
||||
/**
|
||||
* Kurzer Git-Stand, beim Bauen hineingereicht:
|
||||
|
||||
@@ -11,6 +11,7 @@ import { bekannteAbsender } from '@/lib/dokumente/suche'
|
||||
import { erkennungsvorschlaege, type Erkennungsergebnis } from '@/lib/dokumente/erkennung'
|
||||
import { ocrAnstossen } from '@/lib/dokumente/ocr-nachlauf'
|
||||
import { ablegenMitTyp, DateiAbgelehnt, entfernen, signierteUrl } from '@/lib/speicher'
|
||||
import { speicherfehlerMeldung } from '@/lib/speicher/fehler'
|
||||
import {
|
||||
DokumentMetadatenSchema,
|
||||
FristUebernehmenSchema,
|
||||
@@ -217,7 +218,10 @@ export async function dokumenteHochladen(
|
||||
const meldung =
|
||||
fehler instanceof DateiAbgelehnt
|
||||
? fehler.message
|
||||
: `„${anzeigename}“ konnte nicht gespeichert werden. Bitte versuche es erneut.`
|
||||
: speicherfehlerMeldung(
|
||||
fehler,
|
||||
`„${anzeigename}“ konnte nicht gespeichert werden. Bitte versuche es erneut.`,
|
||||
)
|
||||
if (!(fehler instanceof DateiAbgelehnt)) {
|
||||
console.error('[dokumente] Upload gescheitert:', fehler)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { db, nichtGeloescht } from '@/lib/db'
|
||||
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
|
||||
import { protokollieren } from '@/lib/audit'
|
||||
import { ablegenMitTyp } from '@/lib/speicher'
|
||||
import { speicherfehlerMeldung } from '@/lib/speicher/fehler'
|
||||
import { heuteAlsTag } from '@/lib/fristen/datum'
|
||||
import { tagAusText } from '@/lib/finanzen/datum'
|
||||
import {
|
||||
@@ -228,7 +229,9 @@ export async function schreibenAblegen(
|
||||
const verweigert = zugriffsfehler(ausnahme)
|
||||
if (verweigert) return verweigert
|
||||
console.error('[schreiben] Ablegen fehlgeschlagen:', ausnahme)
|
||||
return { fehler: 'Das Schreiben konnte nicht abgelegt werden.' }
|
||||
return {
|
||||
fehler: speicherfehlerMeldung(ausnahme, 'Das Schreiben konnte nicht abgelegt werden.'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Die Installationsanleitung muss ausführbar sein, nicht nur lesbar.
|
||||
*
|
||||
* Anlass ist eine Fehlersuche vom 31.08.2026. Drei Stellen der Anleitung
|
||||
* haben dabei Zeit gekostet — keine davon war falsch formuliert, alle drei
|
||||
* waren schlicht nicht durchdacht:
|
||||
*
|
||||
* 1. `mc alias set` wurde einmalig gesetzt. Der Alias liegt in `/root/.mc`
|
||||
* **im Behälter** und ist nach jedem `docker compose up -d minio` weg.
|
||||
* `mc ls lokal` liefert dann kommentarlos nichts — man hält seine
|
||||
* Dokumente für verschwunden.
|
||||
* 2. Der Bucket-Name stand fest im Befehl, die Anwendung liest ihn aus
|
||||
* `S3_BUCKET`. Weichen sie ab, zeigen beide aneinander vorbei.
|
||||
* 3. `set -a && source .env` exportiert alle Werte in die Shell — und die
|
||||
* haben danach Vorrang vor der `.env`-Datei. Wer später etwas ändert
|
||||
* und neu startet, sieht keine Wirkung.
|
||||
*
|
||||
* Diese Prüfungen lesen den Text. Sie können die Anleitung nicht
|
||||
* ausführen, aber sie schlagen an, sobald jemand in eine der Fallen
|
||||
* zurückfällt.
|
||||
*/
|
||||
|
||||
const anleitung = readFileSync('docs/INSTALLATION.md', 'utf8')
|
||||
|
||||
/** Alle Zeilen mit einem mc-Aufruf, ohne Fließtext und Zitate. */
|
||||
function mcZeilen(): string[] {
|
||||
return anleitung
|
||||
.split('\n')
|
||||
.filter((zeile) => /\bmc (ls|mb|mirror|cp|rm|stat)\b/.test(zeile))
|
||||
.filter((zeile) => !zeile.trim().startsWith('>'))
|
||||
}
|
||||
|
||||
describe('Installationsanleitung', () => {
|
||||
it('setzt bei jedem mc-Aufruf den Alias selbst', () => {
|
||||
// Sonst funktioniert der Befehl nur, solange der Behaelter nicht neu
|
||||
// erstellt wurde - und das sagt einem niemand.
|
||||
const abschnitte = anleitung.split('```')
|
||||
const ohneAlias = abschnitte
|
||||
.filter((block) => /\bmc (ls|mb|mirror|cp|rm|stat)\b/.test(block))
|
||||
.filter((block) => !block.includes('mc alias set'))
|
||||
|
||||
expect(
|
||||
ohneAlias.length,
|
||||
'Ein mc-Befehl ohne "mc alias set" im selben Block läuft nur, solange ' +
|
||||
'der MinIO-Behälter nicht neu erstellt wurde.',
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
it('verdrahtet den Namen des Ablagebereichs nicht fest', () => {
|
||||
const festVerdrahtet = mcZeilen().filter((zeile) => /lokal\/betreuungs-cockpit/.test(zeile))
|
||||
|
||||
expect(
|
||||
festVerdrahtet,
|
||||
'Der Name gehört aus $S3_BUCKET - sonst zeigen Anwendung und ' +
|
||||
'Ablagebereich aneinander vorbei, sobald jemand ihn ändert.',
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('empfiehlt kein "source .env" mehr', () => {
|
||||
// Exportierte Werte ueberstimmen danach jedes "docker compose up".
|
||||
const zeilen = anleitung
|
||||
.split('\n')
|
||||
.filter((zeile) => !zeile.trim().startsWith('>'))
|
||||
.filter((zeile) => !zeile.trim().startsWith('#'))
|
||||
.filter((zeile) => /source \.env/.test(zeile))
|
||||
|
||||
expect(zeilen).toEqual([])
|
||||
})
|
||||
|
||||
it('erklärt trotzdem, warum es nicht empfohlen wird', () => {
|
||||
// Weglassen allein hilft nicht: Wer den Kniff von woanders kennt,
|
||||
// benutzt ihn weiter.
|
||||
expect(anleitung).toMatch(/Vorrang vor der/)
|
||||
})
|
||||
|
||||
it('sagt, wo die Daten liegen', () => {
|
||||
expect(anleitung).toMatch(/## Wo deine Daten liegen/)
|
||||
expect(anleitung).toMatch(/docker volume inspect/)
|
||||
expect(anleitung).toMatch(/minio-daten/)
|
||||
expect(anleitung).toMatch(/postgres-daten/)
|
||||
})
|
||||
|
||||
it('zeigt, wie man Akte und Speicher abgleicht', () => {
|
||||
// Die wichtigste Kontrolle: Zu jedem Akteneintrag muss eine Datei
|
||||
// gehoeren, sonst belegt eine Pruefsumme ein Dokument, das es nicht
|
||||
// mehr gibt.
|
||||
expect(anleitung).toMatch(/objektSchluessel/)
|
||||
expect(anleitung).toMatch(/comm -23/)
|
||||
})
|
||||
|
||||
it('nennt die Schlüssel, ohne die nichts mehr lesbar ist', () => {
|
||||
const abschnitt = anleitung.slice(anleitung.indexOf('## Wo deine Daten liegen'))
|
||||
for (const schluessel of ['ENC_KEY_V1', 'SICHERUNG_PASSWORT', 'MINIO_KMS_SECRET_KEY']) {
|
||||
expect(abschnitt, `${schluessel} fehlt in der Aufzählung`).toContain(schluessel)
|
||||
}
|
||||
})
|
||||
|
||||
it('weist docker-compose.override.yml als Ort für eigene Anpassungen aus', () => {
|
||||
expect(anleitung).toMatch(/docker-compose\.override\.yml/)
|
||||
expect(readFileSync('.gitignore', 'utf8')).toMatch(/docker-compose\.override\.yml/)
|
||||
})
|
||||
|
||||
it('behauptet nicht mehr, die Dokumente seien ungesichert', () => {
|
||||
// Stand bis v1.12.0 so drin und stimmt seit der Sicherung nicht mehr.
|
||||
expect(anleitung).not.toMatch(/Was das Backup nicht enthält:.*Dateien im Objektspeicher/s)
|
||||
})
|
||||
})
|
||||
+77
-1
@@ -92,9 +92,10 @@ describe('Fristen-Ampel', () => {
|
||||
[
|
||||
frist({ id: 'normal', datum: dbTag('2026-08-20') }),
|
||||
frist({
|
||||
// Weit genug weg fuer "gruen", nah genug fuer die Warnung.
|
||||
id: 'pauschale',
|
||||
titel: 'Aufwandspauschale 2026',
|
||||
datum: dbTag('2026-12-31'),
|
||||
datum: dbTag('2026-10-15'),
|
||||
ausschlussfrist: true,
|
||||
rechtsgrundlage: '§ 1878 BGB',
|
||||
}),
|
||||
@@ -103,6 +104,81 @@ describe('Fristen-Ampel', () => {
|
||||
)
|
||||
|
||||
expect(daten.ausschlussfristen.map((e) => e.id)).toEqual(['pauschale'])
|
||||
// Nicht in einem der Faelligkeitstoepfe - der Warnblock steht daneben.
|
||||
expect(daten.weitereOffen).toBe(1)
|
||||
})
|
||||
|
||||
describe('Ausschlussfristen: nur warnen, wenn es etwas zu tun gibt', () => {
|
||||
/**
|
||||
* Der Anlass: Auf dem Dashboard standen dauerhaft drei rote Kaesten mit
|
||||
* Warndreieck - Aufwandspauschalen mit Verfall 2027, 2028 und 2029. Die
|
||||
* Pflichtfristen werden bewusst drei Jahre im Voraus angelegt, und der
|
||||
* Warnblock fragte nur, OB etwas eine Ausschlussfrist ist, nie WANN sie
|
||||
* faellig wird.
|
||||
*
|
||||
* Eine Warnung, die monatelang dasteht und nichts von einem will,
|
||||
* stumpft ab. Genau davor schuetzen diese Tests.
|
||||
*/
|
||||
function pauschale(id: string, datum: string) {
|
||||
return frist({
|
||||
id,
|
||||
titel: `Aufwandspauschale ${id}`,
|
||||
datum: dbTag(datum),
|
||||
ausschlussfrist: true,
|
||||
rechtsgrundlage: '§ 1878 BGB',
|
||||
})
|
||||
}
|
||||
|
||||
it('lässt Fristen weit in der Zukunft aus der Warnung heraus', () => {
|
||||
const daten = fristenEinordnen(
|
||||
[
|
||||
pauschale('2027', '2027-05-06'),
|
||||
pauschale('2028', '2028-05-06'),
|
||||
pauschale('2029', '2029-05-06'),
|
||||
],
|
||||
HEUTE,
|
||||
)
|
||||
|
||||
expect(daten.ausschlussfristen).toEqual([])
|
||||
// Verschwunden sind sie deshalb nicht - sie stehen in der Liste.
|
||||
expect(daten.weitereOffen).toBe(3)
|
||||
expect(daten.gesamtOffen).toBe(3)
|
||||
})
|
||||
|
||||
it('warnt, sobald eine in den Horizont rückt', () => {
|
||||
// 90 Tage nach dem 19.08.2026 ist der 17.11.2026.
|
||||
const daten = fristenEinordnen([pauschale('bald', '2026-11-17')], HEUTE)
|
||||
|
||||
expect(daten.ausschlussfristen.map((e) => e.id)).toEqual(['bald'])
|
||||
})
|
||||
|
||||
it('warnt einen Tag davor noch nicht', () => {
|
||||
const daten = fristenEinordnen([pauschale('knapp-drueber', '2026-11-18')], HEUTE)
|
||||
|
||||
expect(daten.ausschlussfristen).toEqual([])
|
||||
})
|
||||
|
||||
it('warnt bei einer versäumten Frist immer, egal wie lange sie zurückliegt', () => {
|
||||
// Eine verfallene Aufwandspauschale bleibt eine schlechte Nachricht.
|
||||
// Sie stillschweigend aus der Warnung zu nehmen, waere das Gegenteil
|
||||
// von dem, was hier gewollt ist.
|
||||
const daten = fristenEinordnen([pauschale('verpasst', '2024-05-06')], HEUTE)
|
||||
|
||||
expect(daten.ausschlussfristen.map((e) => e.id)).toEqual(['verpasst'])
|
||||
})
|
||||
|
||||
it('mischt Fälliges und Fernes richtig', () => {
|
||||
const daten = fristenEinordnen(
|
||||
[
|
||||
pauschale('verpasst', '2025-05-06'),
|
||||
pauschale('bald', '2026-10-01'),
|
||||
pauschale('fern', '2029-05-06'),
|
||||
],
|
||||
HEUTE,
|
||||
)
|
||||
|
||||
expect(daten.ausschlussfristen.map((e) => e.id)).toEqual(['verpasst', 'bald'])
|
||||
})
|
||||
})
|
||||
|
||||
it('warnt bei einer Ausschlussfrist ausdrücklich vor dem ersatzlosen Verfall', () => {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Die Beispielkonfiguration darf keine Falle enthalten.
|
||||
*
|
||||
* `.env.example` wird nach `.env` kopiert und dann Zeile für Zeile
|
||||
* ausgefüllt. Was schon einen Wert hat, wird dabei leicht überlesen — und
|
||||
* genau das ist am 31.08.2026 passiert:
|
||||
*
|
||||
* S3_ENDPOINT=https://fsn1.your-objectstorage.com
|
||||
*
|
||||
* Die Zeile war als Beispiel gedacht. Sie stand aber aktiv in der `.env`,
|
||||
* und damit sprach die Anwendung monatelang mit einem fremden Anbieter
|
||||
* statt mit dem Objektspeicher nebenan. Jede Ablage scheiterte mit
|
||||
* `NoSuchBucket`, und die Zugangs-ID des lokalen Speichers ging bei jedem
|
||||
* Versuch nach draußen.
|
||||
*
|
||||
* Verschärfend: Die `docker-compose.yml` hatte längst einen brauchbaren
|
||||
* Standard (`${S3_ENDPOINT:-http://minio:9000}`). Der greift aber nur bei
|
||||
* leerer Variable — die Beispieldatei hebelte ihn aus. Der Mechanismus,
|
||||
* der den Fehler verhindern sollte, war durch die Beispieldatei
|
||||
* wirkungslos.
|
||||
*
|
||||
* Diese Tests halten fest: Ein Wert steht in der Beispieldatei nur, wenn
|
||||
* er für die mitgelieferte Zusammenstellung auch richtig ist.
|
||||
*/
|
||||
|
||||
const beispiel = readFileSync('.env.example', 'utf8')
|
||||
|
||||
/** Alle aktiven Zuweisungen - Kommentarzeilen zählen nicht. */
|
||||
function zuweisungen(): [string, string][] {
|
||||
return beispiel
|
||||
.split('\n')
|
||||
.map((zeile) => zeile.trim())
|
||||
.filter((zeile) => zeile !== '' && !zeile.startsWith('#'))
|
||||
.map((zeile) => {
|
||||
const stelle = zeile.indexOf('=')
|
||||
return [zeile.slice(0, stelle), zeile.slice(stelle + 1)] as [string, string]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechner, die zur mitgelieferten Zusammenstellung gehören. Alles andere
|
||||
* ist ein fremder Host und hat in der Beispieldatei nichts verloren.
|
||||
*/
|
||||
const EIGENE_HOSTS = ['localhost', '127.0.0.1', '0.0.0.0', 'minio', 'db']
|
||||
|
||||
describe('Beispielkonfiguration', () => {
|
||||
it('zeigt mit keinem aktiven Wert auf einen fremden Host', () => {
|
||||
const fremde = zuweisungen()
|
||||
.filter(([, wert]) => /https?:\/\//.test(wert))
|
||||
.filter(([, wert]) => {
|
||||
const treffer = wert.match(/https?:\/\/([^:/"']+)/)
|
||||
return treffer ? !EIGENE_HOSTS.includes(treffer[1]!) : false
|
||||
})
|
||||
.map(([name, wert]) => `${name}=${wert}`)
|
||||
|
||||
expect(
|
||||
fremde,
|
||||
'Diese Zeilen zeigen nach draußen. Wer die Datei kopiert und die ' +
|
||||
'Zeile übersieht, schickt seine Daten an einen fremden Rechner.',
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('lässt S3_ENDPOINT auskommentiert, damit der Compose-Standard greift', () => {
|
||||
const aktiv = zuweisungen().some(([name]) => name === 'S3_ENDPOINT')
|
||||
|
||||
expect(
|
||||
aktiv,
|
||||
'Mit gesetztem S3_ENDPOINT greift ${S3_ENDPOINT:-http://minio:9000} ' +
|
||||
'aus der docker-compose.yml nicht mehr.',
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('erklärt trotzdem, wann man S3_ENDPOINT braucht', () => {
|
||||
// Auskommentieren allein hilft nicht - dann sucht jemand mit externem
|
||||
// Speicher vergeblich nach der Einstellung.
|
||||
expect(beispiel).toMatch(/#\s*S3_ENDPOINT=/)
|
||||
expect(beispiel).toMatch(/EXTERNER Objektspeicher/)
|
||||
})
|
||||
|
||||
it('enthält keine persönliche E-Mail-Adresse', () => {
|
||||
// Stand einmal drin: die Adresse des Betreibers als Vorgabe fuer den
|
||||
// Seed. In einer mitgelieferten Beispieldatei hat sie nichts zu suchen.
|
||||
const adressen = zuweisungen()
|
||||
// Verbindungszeichenfolgen tragen ein "@" vor dem Rechnernamen -
|
||||
// das ist keine E-Mail-Adresse.
|
||||
.filter(([, wert]) => !wert.includes('://'))
|
||||
.filter(([, wert]) => /[\w.+-]+@[\w-]+\.[a-z]{2,}/i.test(wert))
|
||||
.filter(([, wert]) => !/example\.(de|com|org)/.test(wert))
|
||||
.map(([name, wert]) => `${name}=${wert}`)
|
||||
|
||||
expect(adressen).toEqual([])
|
||||
})
|
||||
|
||||
it('findet überhaupt Zuweisungen - sonst prüft der Test nichts', () => {
|
||||
expect(zuweisungen().length).toBeGreaterThan(15)
|
||||
})
|
||||
})
|
||||
@@ -132,3 +132,90 @@ describe('Skripte mit pipefail', () => {
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Die Sicherung muss die Dokumente enthalten - nicht nur die Datenbank.
|
||||
*
|
||||
* Bis v1.11.0 sicherte scripts/sicherung.sh ausschliesslich die Datenbank.
|
||||
* Nach einem Plattenschaden waere die Akte vollstaendig wiederhergestellt
|
||||
* worden - mit allen Eintraegen, Verweisen und Pruefsummen, aber ohne eine
|
||||
* einzige Datei dahinter. Die Pruefsumme in der Akte haette dann ein
|
||||
* Dokument belegt, das es nicht mehr gibt.
|
||||
*
|
||||
* Aufgefallen ist das nicht durch einen Test, sondern beim Nachsehen, wo
|
||||
* die Daten eigentlich liegen. Die folgenden Pruefungen lesen deshalb den
|
||||
* Skripttext: Sie koennen die Sicherung nicht ausfuehren - dafuer braeuchte
|
||||
* es Postgres, MinIO und einen Behaelter -, aber sie schlagen an, sobald
|
||||
* einer der Bausteine wieder herausfaellt.
|
||||
*/
|
||||
describe('Sicherung umfasst die Dokumente', () => {
|
||||
const sicherung = readFileSync('scripts/sicherung.sh', 'utf8')
|
||||
const wiederherstellung = readFileSync('scripts/wiederherstellung.sh', 'utf8')
|
||||
|
||||
it('packt das Ablageverzeichnis ein', () => {
|
||||
expect(sicherung).toMatch(/tar -czf - -C "\$DOKUMENTE_QUELLE"/)
|
||||
})
|
||||
|
||||
it('verschlüsselt das Archiv mit demselben Verfahren wie den Datenbankstand', () => {
|
||||
// Zwei Aufrufe: einer fuer den Dump, einer fuer die Dokumente.
|
||||
const verschluesselt = sicherung.match(/openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt/g)
|
||||
expect(verschluesselt).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('legt zu beiden Teilen eine Prüfsumme', () => {
|
||||
expect(sicherung).toMatch(/sha256sum "\$DATEI"/)
|
||||
expect(sicherung).toMatch(/sha256sum "\$DOKUMENTE_DATEI"/)
|
||||
})
|
||||
|
||||
it('bricht ab, wenn das Ablageverzeichnis nicht eingehängt ist', () => {
|
||||
// Eine Sicherung, die nur die halbe Wahrheit enthaelt, aber
|
||||
// vollstaendig aussieht, ist schlimmer als gar keine.
|
||||
expect(sicherung).toMatch(/if \[ ! -d "\$DOKUMENTE_QUELLE" \]/)
|
||||
expect(sicherung).toMatch(/rm -f "\$DATEI" "\$DATEI\.sha256"/)
|
||||
})
|
||||
|
||||
it('prüft am Ende beide Teile, nicht nur den Datenbankstand', () => {
|
||||
expect(sicherung).toMatch(/tar -tzf -/)
|
||||
expect(sicherung).toMatch(/Datenbank und Dokumente/)
|
||||
})
|
||||
|
||||
it('räumt alte Dokumentenarchive genauso auf wie alte Datenbankstände', () => {
|
||||
expect(sicherung).toMatch(/dokumente_\*\.tar\.gz\.enc/)
|
||||
})
|
||||
|
||||
it('verschont beim Monatsstand beide Teile', () => {
|
||||
expect(sicherung).toMatch(/touch "\$DATEI\.monatsstand" "\$DOKUMENTE_DATEI\.monatsstand"/)
|
||||
})
|
||||
|
||||
it('holt die Dokumente beim Zurückspielen mit zurück', () => {
|
||||
expect(wiederherstellung).toMatch(/DOKUMENTE_DATEI=/)
|
||||
expect(wiederherstellung).toMatch(/tar -xzf - -C "\$AUSPACKZIEL"/)
|
||||
})
|
||||
|
||||
it('sagt beim Zurückspielen deutlich, wenn kein Dokumentenarchiv dabei ist', () => {
|
||||
// Sonst haelt man eine halbe Wiederherstellung fuer eine ganze.
|
||||
expect(wiederherstellung).toMatch(/kein Dokumentenarchiv/)
|
||||
})
|
||||
|
||||
it('schreibt die Dokumente nicht selbst an ihren Platz', () => {
|
||||
// Der Sicherungsdienst hat auf das Ablageverzeichnis nur Lesezugriff.
|
||||
// In ein laufendes MinIO hineinzuschreiben wuerde dessen Datenbestand
|
||||
// beschaedigen - der letzte Handgriff bleibt beim Menschen.
|
||||
expect(wiederherstellung).toMatch(/docker compose stop minio app/)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Der Sicherungsdienst darf die Daten, die er sichert, nicht zerstoeren
|
||||
* koennen.
|
||||
*
|
||||
* Er laeuft unbeaufsichtigt per cron. Ein Fehler im Skript oder ein
|
||||
* kompromittierter Behaelter wuerde sonst Sicherung und Bestand auf einmal
|
||||
* treffen - genau der Fall, gegen den eine Sicherung da ist.
|
||||
*/
|
||||
describe('Sicherungsdienst greift nur lesend auf die Dokumente zu', () => {
|
||||
it('hängt das Ablageverzeichnis mit :ro ein', () => {
|
||||
const compose = readFileSync('docker-compose.yml', 'utf8')
|
||||
expect(compose).toMatch(/minio-daten:\/dokumente:ro/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Die Bereitschaftsprüfung der Dateiablage.
|
||||
*
|
||||
* Bis v1.12.0 fiel eine kaputte Speicherkonfiguration erst auf, wenn
|
||||
* jemand ein Dokument ablegen wollte. Auf dem Livesystem waren das
|
||||
* mehrere Wochen nach der Installation — und dann mitten in der Arbeit,
|
||||
* mit einer Meldung, die nichts sagte.
|
||||
*
|
||||
* Die Prüfung schreibt eine winzige Datei, liest sie zurück und räumt sie
|
||||
* weg. Sie beantwortet damit drei Fragen auf einmal: erreichbar,
|
||||
* Ablagebereich vorhanden, Schreibrecht da.
|
||||
*/
|
||||
|
||||
let verzeichnis = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
verzeichnis = await mkdtemp(join(tmpdir(), 'bc-pruefung-'))
|
||||
process.env.SPEICHER_TREIBER = 'datei'
|
||||
process.env.DATEI_SPEICHER_PFAD = verzeichnis
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(verzeichnis, { recursive: true, force: true })
|
||||
delete process.env.SPEICHER_TREIBER
|
||||
delete process.env.DATEI_SPEICHER_PFAD
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Bereitschaftsprüfung', () => {
|
||||
it('meldet eine benutzbare Ablage', async () => {
|
||||
const { speicherPruefen } = await import('@/lib/speicher/pruefung')
|
||||
|
||||
const zustand = await speicherPruefen()
|
||||
|
||||
expect(zustand.ok).toBe(true)
|
||||
expect(zustand.treiber).toBe('datei')
|
||||
})
|
||||
|
||||
it('lässt nichts liegen', async () => {
|
||||
// Eine Pruefung, die bei jedem Aufruf eine Datei zuruecklaesst, waere
|
||||
// nach einem Jahr Betrieb ein Verzeichnis voller Muell.
|
||||
const { speicherPruefen } = await import('@/lib/speicher/pruefung')
|
||||
|
||||
await speicherPruefen()
|
||||
await speicherPruefen()
|
||||
await speicherPruefen()
|
||||
|
||||
const inhalt = await readdir(join(verzeichnis, 'pruefung')).catch(() => [])
|
||||
expect(inhalt).toEqual([])
|
||||
})
|
||||
|
||||
it('meldet einen Fehler, statt zu werfen', async () => {
|
||||
// Der Aufrufer ist die Gesundheitsauskunft. Wirft die Pruefung, ist
|
||||
// die Auskunft weg - und mit ihr die Erklaerung.
|
||||
// Eine Datei als Ablageverzeichnis: Das Anlegen des Unterordners
|
||||
// scheitert dann zuverlaessig und auf jedem System.
|
||||
const alsDatei = join(verzeichnis, 'keine-ablage')
|
||||
await writeFile(alsDatei, 'ich bin eine Datei')
|
||||
process.env.DATEI_SPEICHER_PFAD = alsDatei
|
||||
const { speicherPruefen } = await import('@/lib/speicher/pruefung')
|
||||
|
||||
const zustand = await speicherPruefen()
|
||||
|
||||
expect(zustand.ok).toBe(false)
|
||||
if (!zustand.ok) expect(zustand.meldung.length).toBeGreaterThan(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Gemerkter Zustand', () => {
|
||||
it('prüft nicht bei jedem Aufruf neu', async () => {
|
||||
// Der Healthcheck fragt alle 30 Sekunden. Ohne Schonfrist schriebe er
|
||||
// ebenso oft eine Probedatei in den Objektspeicher.
|
||||
const modul = await import('@/lib/speicher/pruefung')
|
||||
modul.zustandVergessen()
|
||||
|
||||
const erste = await modul.speicherZustand()
|
||||
const zweite = await modul.speicherZustand()
|
||||
|
||||
expect(zweite).toBe(erste)
|
||||
})
|
||||
|
||||
it('meldet denselben Zustand nicht wieder und wieder', async () => {
|
||||
const protokoll = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const modul = await import('@/lib/speicher/pruefung')
|
||||
modul.zustandVergessen()
|
||||
|
||||
await modul.speicherZustand()
|
||||
// Wie nach fuenf Minuten: neu pruefen, aber das Meldegedaechtnis bleibt.
|
||||
modul.schonfristUeberspringen()
|
||||
await modul.speicherZustand()
|
||||
|
||||
// Zweimal geprueft, aber nur die erste Meldung - sonst stuende alle
|
||||
// fuenf Minuten dieselbe Zeile im Log und niemand liest sie mehr.
|
||||
const speicherzeilen = protokoll.mock.calls.filter((aufruf) =>
|
||||
String(aufruf[0]).startsWith('[speicher]'),
|
||||
)
|
||||
expect(speicherzeilen).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Was der S3-Treiber tatsächlich an den Objektspeicher schickt.
|
||||
*
|
||||
* Anlass: Der Treiber setzte bei jedem PutObject fest
|
||||
* `ServerSideEncryption: 'AES256'`. Das mitgelieferte MinIO läuft ohne KMS
|
||||
* und lehnte das mit `NotImplemented` ab — es ließ sich kein einziges
|
||||
* Dokument ablegen, und wer der INSTALLATION.md folgte, landete
|
||||
* zwangsläufig dort.
|
||||
*
|
||||
* Gemerkt hat das kein Test, weil in der Entwicklung der Dateitreiber
|
||||
* läuft und der S3-Pfad nie ausgeführt wurde. Diese Datei schließt genau
|
||||
* diese Lücke: Sie fängt den Befehl ab, bevor er das Netz sieht, und
|
||||
* schaut hinein.
|
||||
*/
|
||||
|
||||
process.env.S3_ENDPOINT = 'http://minio:9000'
|
||||
process.env.S3_BUCKET = 'betreuungs-cockpit'
|
||||
process.env.S3_ACCESS_KEY_ID = 'probe-zugang'
|
||||
process.env.S3_SECRET_ACCESS_KEY = 'probe-geheimnis'
|
||||
|
||||
/** Der letzte Befehl, den der Treiber abgeschickt hat. */
|
||||
let letzterBefehl: Record<string, unknown> | null = null
|
||||
|
||||
vi.mock('@aws-sdk/client-s3', () => {
|
||||
class PutObjectCommand {
|
||||
input: Record<string, unknown>
|
||||
constructor(input: Record<string, unknown>) {
|
||||
this.input = input
|
||||
}
|
||||
}
|
||||
class GetObjectCommand extends PutObjectCommand {}
|
||||
class DeleteObjectCommand extends PutObjectCommand {}
|
||||
class HeadObjectCommand extends PutObjectCommand {}
|
||||
class ListObjectsV2Command extends PutObjectCommand {}
|
||||
|
||||
class S3Client {
|
||||
async send(befehl: { input: Record<string, unknown> }) {
|
||||
letzterBefehl = befehl.input
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
}
|
||||
})
|
||||
|
||||
const SCHLUESSEL = 'betreute/cmt4ey0jm0003o001wq4rc3m9/dokumente/ba7a8c1a-d4c5-41c7-8f5f-ebfb4805b0e5.pdf'
|
||||
const INHALT = Buffer.from('%PDF-1.4 ein Bescheid', 'utf8')
|
||||
|
||||
/**
|
||||
* Der Treiber baut seinen Klienten einmal und merkt ihn sich. Damit die
|
||||
* Umgebungsvariable je Testfall neu gelesen wird, muss das Modul frisch
|
||||
* geladen werden.
|
||||
*/
|
||||
async function treiberFrisch() {
|
||||
vi.resetModules()
|
||||
return import('@/lib/speicher/s3')
|
||||
}
|
||||
|
||||
describe('S3-Treiber: serverseitige Verschlüsselung', () => {
|
||||
beforeEach(() => {
|
||||
letzterBefehl = null
|
||||
delete process.env.S3_SSE
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.S3_SSE
|
||||
})
|
||||
|
||||
it('schickt ohne S3_SSE keinen Verschlüsselungskopf mit', async () => {
|
||||
const s3 = await treiberFrisch()
|
||||
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
|
||||
|
||||
expect(letzterBefehl).not.toBeNull()
|
||||
expect(letzterBefehl).not.toHaveProperty('ServerSideEncryption')
|
||||
})
|
||||
|
||||
it('schickt ihn mit, wenn S3_SSE gesetzt ist', async () => {
|
||||
process.env.S3_SSE = 'AES256'
|
||||
const s3 = await treiberFrisch()
|
||||
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
|
||||
|
||||
expect(letzterBefehl?.ServerSideEncryption).toBe('AES256')
|
||||
})
|
||||
|
||||
it('übernimmt auch einen anderen Wert, den ein Anbieter verlangt', async () => {
|
||||
process.env.S3_SSE = 'aws:kms'
|
||||
const s3 = await treiberFrisch()
|
||||
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
|
||||
|
||||
expect(letzterBefehl?.ServerSideEncryption).toBe('aws:kms')
|
||||
})
|
||||
|
||||
it('behandelt eine leere Angabe wie "aus"', async () => {
|
||||
// Sonst schickte eine leergelassene Zeile in der .env einen leeren
|
||||
// Kopf, und der Anbieter antwortete mit einem Fehler, den niemand
|
||||
// versteht.
|
||||
process.env.S3_SSE = ' '
|
||||
const s3 = await treiberFrisch()
|
||||
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
|
||||
|
||||
expect(letzterBefehl).not.toHaveProperty('ServerSideEncryption')
|
||||
})
|
||||
})
|
||||
|
||||
describe('S3-Treiber: was sonst noch mitgeht', () => {
|
||||
beforeEach(() => {
|
||||
letzterBefehl = null
|
||||
delete process.env.S3_SSE
|
||||
})
|
||||
|
||||
it('legt den Inhalt verschlüsselt ab, nicht im Klartext', async () => {
|
||||
const s3 = await treiberFrisch()
|
||||
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
|
||||
|
||||
const koerper = letzterBefehl?.Body as Buffer
|
||||
expect(koerper.subarray(0, 4).toString('ascii')).toBe('BCD1')
|
||||
expect(koerper.includes(Buffer.from('ein Bescheid', 'utf8'))).toBe(false)
|
||||
})
|
||||
|
||||
it('meldet den Inhaltstyp nicht als PDF - dort liegt Geheimtext', async () => {
|
||||
const s3 = await treiberFrisch()
|
||||
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
|
||||
|
||||
expect(letzterBefehl?.ContentType).toBe('application/octet-stream')
|
||||
expect(letzterBefehl?.ContentDisposition).toBe('attachment')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { speicherfehlerErkennen, speicherfehlerMeldung } from '@/lib/speicher/fehler'
|
||||
|
||||
/**
|
||||
* Speicherfehler müssen sagen, was zu tun ist.
|
||||
*
|
||||
* Anlass ist eine Fehlersuche vom 31.08.2026. In der Oberfläche stand
|
||||
* dreimal hintereinander derselbe Satz — „Das Schreiben konnte nicht
|
||||
* abgelegt werden." — und dahinter lagen drei völlig verschiedene
|
||||
* Ursachen: ein fehlender Ablagebereich, ein Endpunkt, der auf einen
|
||||
* fremden Anbieter zeigte, und eine Verschlüsselung, die der Speicher
|
||||
* nicht anbietet.
|
||||
*
|
||||
* Ohne Zugang zum Serverlog wäre die Suche aussichtslos gewesen.
|
||||
*/
|
||||
|
||||
/** Baut einen Fehler, wie ihn das AWS-SDK wirft. */
|
||||
function sdkFehler(code: string, nachricht = ''): Error {
|
||||
const fehler = new Error(nachricht || code) as Error & { Code: string }
|
||||
fehler.Code = code
|
||||
return fehler
|
||||
}
|
||||
|
||||
describe('Speicherfehler erklären', () => {
|
||||
it('erkennt den fehlenden Ablagebereich und nennt den Installationsschritt', () => {
|
||||
const erkannt = speicherfehlerErkennen(sdkFehler('NoSuchBucket'))
|
||||
|
||||
expect(erkannt?.art).toBe('kein-ablagebereich')
|
||||
expect(erkannt?.meldung).toMatch(/Schritt 6/)
|
||||
})
|
||||
|
||||
it('erkennt die nicht unterstützte Verschlüsselung und nennt die Stellschraube', () => {
|
||||
const erkannt = speicherfehlerErkennen(
|
||||
sdkFehler('NotImplemented', 'Server side encryption specified but KMS is not configured'),
|
||||
)
|
||||
|
||||
expect(erkannt?.art).toBe('verschluesselung')
|
||||
expect(erkannt?.meldung).toMatch(/S3_SSE/)
|
||||
})
|
||||
|
||||
it('verwechselt ein anderes NotImplemented nicht damit', () => {
|
||||
// "NotImplemented" schicken S3-Nachbauten fuer allerlei; nur der Fall
|
||||
// mit der Verschluesselung hat eine passende Erklaerung.
|
||||
expect(speicherfehlerErkennen(sdkFehler('NotImplemented', 'Versioning not supported'))).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['AccessDenied', 'SignatureDoesNotMatch', 'InvalidAccessKeyId'])(
|
||||
'erkennt %s als Zugangsproblem',
|
||||
(code) => {
|
||||
const erkannt = speicherfehlerErkennen(sdkFehler(code))
|
||||
|
||||
expect(erkannt?.art).toBe('zugang')
|
||||
expect(erkannt?.meldung).toMatch(/S3_ACCESS_KEY_ID/)
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT'])(
|
||||
'erkennt %s als nicht erreichbar',
|
||||
(code) => {
|
||||
const fehler = new Error('connect failed') as Error & { code: string }
|
||||
fehler.code = code
|
||||
|
||||
expect(speicherfehlerErkennen(fehler)?.art).toBe('nicht-erreichbar')
|
||||
},
|
||||
)
|
||||
|
||||
it('verspricht bei einem Netzfehler, dass die Eingaben bleiben', () => {
|
||||
const fehler = new Error('connect ECONNREFUSED 172.18.0.3:9000')
|
||||
|
||||
expect(speicherfehlerErkennen(fehler)?.meldung).toMatch(/gehen nicht verloren/)
|
||||
})
|
||||
|
||||
it('gibt bei einem unbekannten Fehler null zurück', () => {
|
||||
// Dann bleibt es bei der allgemeinen Meldung des Aufrufers. Fremde
|
||||
// Fehlertexte durchzureichen waere die schlechtere Wahl.
|
||||
expect(speicherfehlerErkennen(new Error('irgendwas ging schief'))).toBeNull()
|
||||
expect(speicherfehlerErkennen(null)).toBeNull()
|
||||
expect(speicherfehlerErkennen('kaputt')).toBeNull()
|
||||
})
|
||||
|
||||
it('verrät nichts über Hostnamen, Eimer oder Anfragekennungen', () => {
|
||||
// Solche Angaben stehen in den Fehlern des SDK und gehoeren ins Log,
|
||||
// nicht auf den Bildschirm eines Betreuers.
|
||||
const fehler = sdkFehler('NoSuchBucket', 'The specified bucket does not exist') as Error & {
|
||||
BucketName: string
|
||||
RequestId: string
|
||||
}
|
||||
fehler.BucketName = 'betreuungs-cockpit'
|
||||
fehler.RequestId = '18D0FEDD87C08296'
|
||||
|
||||
const meldung = speicherfehlerErkennen(fehler)!.meldung
|
||||
|
||||
expect(meldung).not.toContain('betreuungs-cockpit')
|
||||
expect(meldung).not.toContain('18D0FEDD87C08296')
|
||||
expect(meldung).not.toContain('bucket')
|
||||
})
|
||||
})
|
||||
|
||||
describe('speicherfehlerMeldung', () => {
|
||||
it('nimmt die erkannte Erklärung, wenn es eine gibt', () => {
|
||||
const meldung = speicherfehlerMeldung(sdkFehler('NoSuchBucket'), 'Allgemeine Meldung.')
|
||||
|
||||
expect(meldung).not.toBe('Allgemeine Meldung.')
|
||||
expect(meldung).toMatch(/Ablagebereich/)
|
||||
})
|
||||
|
||||
it('fällt sonst auf die Meldung des Aufrufers zurück', () => {
|
||||
const meldung = speicherfehlerMeldung(new Error('unbekannt'), 'Allgemeine Meldung.')
|
||||
|
||||
expect(meldung).toBe('Allgemeine Meldung.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Was die Anwendung liest, muss der Behälter auch bekommen.
|
||||
*
|
||||
* Die `docker-compose.yml` zählt die Umgebungsvariablen des Dienstes `app`
|
||||
* einzeln auf — sie hat kein `env_file:`. Wer einen neuen Wert einführt,
|
||||
* ihn in `.env.example` dokumentiert und in `env.ts` ausliest, hat damit
|
||||
* noch nichts erreicht: Im Behälter kommt er nicht an.
|
||||
*
|
||||
* Genau das ist mit v1.11.0 passiert. `ERSTEINRICHTUNG_GEHEIMNIS` und
|
||||
* `VERTRAUE_PROXY` standen in der Anleitung, in der Beispieldatei und im
|
||||
* Code — nur nicht in der Compose-Datei. Die Anleitung war damit schlicht
|
||||
* falsch, und der Fehler war von außen nicht zu sehen: `VERTRAUE_PROXY`
|
||||
* fehlt still, die Anmeldesperre zählt dann eine Dimension weniger.
|
||||
*
|
||||
* Dieser Test liest beide Seiten und vergleicht sie. Er prüft kein
|
||||
* Verhalten, sondern die Verpackung — wie `skripte.test.ts`.
|
||||
*/
|
||||
|
||||
const compose = readFileSync('docker-compose.yml', 'utf8')
|
||||
const envDatei = readFileSync('src/lib/env.ts', 'utf8')
|
||||
const beispiel = readFileSync('.env.example', 'utf8')
|
||||
|
||||
/**
|
||||
* Alles, was `env.ts` über seine Helfer ausliest.
|
||||
*
|
||||
* `process.env.X` direkt wird mitgenommen, ebenso `pflicht('X')`,
|
||||
* `zahl('X', …)`, `schalter('X', …)` und `schluessel32('X')`.
|
||||
*/
|
||||
function gelesen(): Set<string> {
|
||||
const namen = new Set<string>()
|
||||
|
||||
for (const treffer of envDatei.matchAll(/process\.env\.([A-Z_0-9]+)/g)) {
|
||||
namen.add(treffer[1]!)
|
||||
}
|
||||
for (const treffer of envDatei.matchAll(
|
||||
/(?:pflicht|zahl|schalter|schluessel32)\('([A-Z_0-9]+)'/g,
|
||||
)) {
|
||||
namen.add(treffer[1]!)
|
||||
}
|
||||
|
||||
return namen
|
||||
}
|
||||
|
||||
/** Die Variablen, die der Dienst `app` durchreicht. */
|
||||
function durchgereicht(): Set<string> {
|
||||
const abschnitt = compose.slice(compose.indexOf('\n app:'), compose.indexOf('\n sicherung:'))
|
||||
const namen = new Set<string>()
|
||||
for (const treffer of abschnitt.matchAll(/^ {6}([A-Z_0-9]+):/gm)) {
|
||||
namen.add(treffer[1]!)
|
||||
}
|
||||
return namen
|
||||
}
|
||||
|
||||
/**
|
||||
* Was absichtlich nicht durchgereicht wird - jeweils mit Grund.
|
||||
*
|
||||
* Wer hier etwas ergänzt, nimmt es bewusst aus der Prüfung. Das ist
|
||||
* erlaubt, aber es soll dabeistehen, warum.
|
||||
*/
|
||||
const AUSNAHMEN = new Map<string, string>([
|
||||
['NODE_ENV', 'wird im Dienst gesetzt, kommt nicht aus der .env'],
|
||||
['NEXT_PUBLIC_GIT_COMMIT', 'Bauargument, steckt schon im Abbild'],
|
||||
['NEXT_PUBLIC_BUILD_ZEIT', 'Bauargument, steckt schon im Abbild'],
|
||||
['DATEI_SPEICHER_PFAD', 'nur fuer den Dateitreiber; im Behaelter laeuft s3'],
|
||||
['TESSERACT_BEFEHL', 'Standard "tesseract" stimmt im Abbild immer'],
|
||||
])
|
||||
|
||||
describe('Umgebungsvariablen erreichen den Behälter', () => {
|
||||
it('reicht jede Variable durch, die die Anwendung ausliest', () => {
|
||||
const fehlend = [...gelesen()]
|
||||
.filter((name) => !durchgereicht().has(name))
|
||||
.filter((name) => !AUSNAHMEN.has(name))
|
||||
// ENC_KEY_V2, V3 ... werden dynamisch gesucht; nur V1 ist Pflicht.
|
||||
.filter((name) => !/^ENC_KEY_V\d+$/.test(name))
|
||||
.sort()
|
||||
|
||||
expect(
|
||||
fehlend,
|
||||
'Diese Werte liest die Anwendung, aber die docker-compose.yml gibt sie ' +
|
||||
'dem Dienst "app" nicht mit. In der .env stehen sie dann wirkungslos.',
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('erklärt jede Ausnahme', () => {
|
||||
for (const [name, grund] of AUSNAHMEN) {
|
||||
expect(grund.length, `Ausnahme ${name} ohne Begründung`).toBeGreaterThan(10)
|
||||
}
|
||||
})
|
||||
|
||||
it('dokumentiert die beiden Werte aus v1.11.0 in der Beispieldatei', () => {
|
||||
// Sie waren der Anlass. Verschwinden sie aus .env.example, merkt es
|
||||
// sonst niemand, bis jemand eine Neuinstallation versucht.
|
||||
expect(beispiel).toMatch(/ERSTEINRICHTUNG_GEHEIMNIS=/)
|
||||
expect(beispiel).toMatch(/VERTRAUE_PROXY=/)
|
||||
})
|
||||
|
||||
it('lässt VERTRAUE_PROXY ohne Vorgabewert', () => {
|
||||
// Die strengere Einstellung bleibt die Voreinstellung. Ein Vorgabewert
|
||||
// "true" waere bequem und falsch: Ohne Proxy ist der weitergereichte
|
||||
// Kopf frei erfunden.
|
||||
expect(compose).toMatch(/VERTRAUE_PROXY: \$\{VERTRAUE_PROXY:-\}/)
|
||||
})
|
||||
|
||||
it('findet überhaupt etwas - sonst prüft der Test nichts', () => {
|
||||
// Ein stiller Ausfall der Erkennung waere schlimmer als eine Luecke:
|
||||
// Der Test staende gruen da und pruefte nichts.
|
||||
expect(gelesen().size).toBeGreaterThan(15)
|
||||
expect(durchgereicht().size).toBeGreaterThan(15)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user