Compare commits
10
Commits
88e9202c2f
...
v1.12.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5db3baac0 | ||
|
|
274d7666ea | ||
|
|
890e00fb5c | ||
|
|
042e63812b | ||
|
|
53dde70f26 | ||
|
|
92c78a92ff | ||
|
|
c210f8bff0 | ||
|
|
5c6fac59b2 | ||
|
|
ea34294fa7 | ||
|
|
810716e4e4 |
+13
-3
@@ -103,7 +103,17 @@ 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=
|
||||
@@ -131,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:-}
|
||||
|
||||
+154
-13
@@ -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,22 +376,37 @@ 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 Verschluesselung?** Brauchst du nicht. Die Anwendung
|
||||
> **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
|
||||
@@ -395,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
|
||||
@@ -887,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": {
|
||||
|
||||
@@ -100,7 +100,7 @@ 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.11.1.
|
||||
# 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}"
|
||||
@@ -109,7 +109,7 @@ 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.11.1 enthalten nur die"
|
||||
echo " Sicherungen aus Fassungen vor v1.12.0 enthalten nur die"
|
||||
echo " Datenbank. Die Akte ist dann vollstaendig, die Dateien"
|
||||
echo " dahinter fehlen."
|
||||
else
|
||||
|
||||
@@ -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' } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+25
-3
@@ -174,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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,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