From 92c78a92ff92db2e8a9402b2fb1f010d94045f82 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Tue, 1 Sep 2026 16:31:03 +0200 Subject: [PATCH] Speicherfehler erklaeren und die Ablage von selbst pruefen Beim Ablegen eines Schreibens stand in der Oberflaeche "Das Schreiben konnte nicht abgelegt werden." Bei einer Fehlersuche am 31.08.2026 lagen hinter genau diesem Satz nacheinander DREI verschiedene Ursachen: ein fehlender Ablagebereich, ein Endpunkt, der auf einen fremden Anbieter zeigte, und eine Verschluesselung, die der Speicher nicht anbietet. Ohne Zugang zum Serverlog waere die Suche aussichtslos gewesen. Jetzt uebersetzt src/lib/speicher/fehler.ts die bekannten Faelle in Saetze, die sagen, was zu tun ist - fehlender Ablagebereich mit Verweis auf Schritt 6 der Installation, abgelehnte Zugangsdaten mit den Namen der beiden Variablen, nicht unterstuetzte Verschluesselung mit dem Hinweis auf S3_SSE, und nicht erreichbarer Speicher mit der Zusicherung, dass die Eingaben bleiben. Durchgereicht wird dabei nichts. Fremde Fehlertexte tragen Hostnamen, Eimernamen und Anfragekennungen - das gehoert ins Log, nicht auf den Bildschirm eines Betreuers. Uebersetzt werden nur die bekannten Faelle; alles andere bleibt bei der allgemeinen Meldung. Dazu eine Bereitschaftspruefung: Sie schreibt eine winzige Datei, liest sie zurueck und raeumt sie weg - und beantwortet damit erreichbar, Ablagebereich vorhanden und Schreibrecht auf einmal. Das Ergebnis steht in /api/gesundheit und als Zeile im Log. ## Warum nicht instrumentation.ts Der naheliegende Ort waere der Starthaken von Next. 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. Das war ausprobiert, nicht vermutet. Die Pruefung haengt deshalb an der Gesundheitsabfrage. Der Healthcheck ruft sie ab 40 Sekunden nach dem Start alle 30 Sekunden auf; die erste Pruefung passiert also von selbst kurz nach dem Hochfahren. Eine Schonfrist von fuenf Minuten verhindert, dass dabei alle 30 Sekunden eine Probedatei in den Objektspeicher geht, und gemeldet wird nur bei einer Aenderung. Ein gestoerter Speicher fuehrt ausdruecklich NICHT zu 503. Sonst startete der Healthcheck den Behaelter neu und machte die ganze Akte unerreichbar - wegen eines Teils, den ein Neustart nicht repariert. Gegen drei Mutationen geprueft: NotImplemented ohne Textpruefung, alte Reihenfolge bei der Codeermittlung, Erklaerung nicht benutzt - jede wird rot. Refs #44 --- docs/INSTALLATION.md | 18 +++- src/app/api/gesundheit/route.ts | 31 +++++-- src/lib/speicher/datei.ts | 30 +++++++ src/lib/speicher/fehler.ts | 129 ++++++++++++++++++++++++++ src/lib/speicher/pruefung.ts | 154 ++++++++++++++++++++++++++++++++ src/lib/speicher/s3.ts | 28 +++++- src/server/actions/dokumente.ts | 6 +- src/server/actions/schreiben.ts | 5 +- tests/speicher-pruefung.test.ts | 105 ++++++++++++++++++++++ tests/speicherfehler.test.ts | 112 +++++++++++++++++++++++ 10 files changed, 607 insertions(+), 11 deletions(-) create mode 100644 src/lib/speicher/fehler.ts create mode 100644 src/lib/speicher/pruefung.ts create mode 100644 tests/speicher-pruefung.test.ts create mode 100644 tests/speicherfehler.test.ts diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 9bf54fd..11b3b8f 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -322,7 +322,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.11.1","commit":"...", +# "speicher":{"status":"bereit","treiber":"s3"}} ``` Kommt hier nichts, sieh in die Logs: @@ -405,6 +406,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 diff --git a/src/app/api/gesundheit/route.ts b/src/app/api/gesundheit/route.ts index 57365c2..619395c 100644 --- a/src/app/api/gesundheit/route.ts +++ b/src/app/api/gesundheit/route.ts @@ -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' } }, + ) } diff --git a/src/lib/speicher/datei.ts b/src/lib/speicher/datei.ts index 415cdd3..b44c869 100644 --- a/src/lib/speicher/datei.ts +++ b/src/lib/speicher/datei.ts @@ -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 { + const pfad = probepfad(schluessel) + await mkdir(dirname(pfad), { recursive: true }) + await writeFile(pfad, inhalt, { mode: 0o600 }) +} + +export async function rohLesen(schluessel: string): Promise { + return readFile(probepfad(schluessel)) +} + +export async function rohEntfernen(schluessel: string): Promise { + await rm(probepfad(schluessel), { force: true }) +} + /** Nur fuer Wartungsskripte: das Ablageverzeichnis. */ export function ablageverzeichnis(): string { return wurzel() diff --git a/src/lib/speicher/fehler.ts b/src/lib/speicher/fehler.ts new file mode 100644 index 0000000..116a69b --- /dev/null +++ b/src/lib/speicher/fehler.ts @@ -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 +} diff --git a/src/lib/speicher/pruefung.ts b/src/lib/speicher/pruefung.ts new file mode 100644 index 0000000..610edd6 --- /dev/null +++ b/src/lib/speicher/pruefung.ts @@ -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.11.1 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 { + 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 { + 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 +} diff --git a/src/lib/speicher/s3.ts b/src/lib/speicher/s3.ts index d9c0dd7..bd04975 100644 --- a/src/lib/speicher/s3.ts +++ b/src/lib/speicher/s3.ts @@ -174,10 +174,32 @@ export async function objekteAuflisten(): Promise { 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 { + 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 { + 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 { - 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()) } diff --git a/src/server/actions/dokumente.ts b/src/server/actions/dokumente.ts index d0bb2e3..43fd693 100644 --- a/src/server/actions/dokumente.ts +++ b/src/server/actions/dokumente.ts @@ -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) } diff --git a/src/server/actions/schreiben.ts b/src/server/actions/schreiben.ts index bed8633..8180562 100644 --- a/src/server/actions/schreiben.ts +++ b/src/server/actions/schreiben.ts @@ -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.'), + } } } diff --git a/tests/speicher-pruefung.test.ts b/tests/speicher-pruefung.test.ts new file mode 100644 index 0000000..8c46fda --- /dev/null +++ b/tests/speicher-pruefung.test.ts @@ -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.11.1 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) + }) +}) diff --git a/tests/speicherfehler.test.ts b/tests/speicherfehler.test.ts new file mode 100644 index 0000000..8e57ec6 --- /dev/null +++ b/tests/speicherfehler.test.ts @@ -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.') + }) +}) -- 2.54.0