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.') + }) +})