Fix #44: Speicherfehler erklären und die Ablage von selbst prüfen #52

Merged
florian.wilde merged 1 commits from fix/44-speicherfehler-melden into main 2026-09-01 14:31:52 +00:00
10 changed files with 607 additions and 11 deletions
+17 -1
View File
@@ -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
+26 -5
View File
@@ -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' } },
)
}
+30
View File
@@ -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()
+129
View File
@@ -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
}
+154
View File
@@ -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<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
View File
@@ -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())
}
+5 -1
View File
@@ -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)
}
+4 -1
View File
@@ -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.'),
}
}
}
+105
View File
@@ -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)
})
})
+112
View File
@@ -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.')
})
})