Fix #44: Speicherfehler erklären und die Ablage von selbst prüfen #52
+17
-1
@@ -322,7 +322,8 @@ betreuungs-cockpit-sicherung-1 Up
|
|||||||
```bash
|
```bash
|
||||||
# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast)
|
# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast)
|
||||||
curl -s http://127.0.0.1:3000/api/gesundheit
|
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:
|
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
|
## Schritt 7 — In den Reverse Proxy eintragen
|
||||||
|
|
||||||
Der Proxy steht auf einem **anderen Server**. Damit sind zwei Dinge zu
|
Der Proxy steht auf einem **anderen Server**. Damit sind zwei Dinge zu
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { db } from '@/lib/db'
|
import { db } from '@/lib/db'
|
||||||
import { COMMIT, VERSION } from '@/lib/version'
|
import { COMMIT, VERSION } from '@/lib/version'
|
||||||
|
import { speicherZustand } from '@/lib/speicher/pruefung'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Betriebsbereitschaft fuer den Healthcheck in docker-compose.
|
* Betriebsbereitschaft fuer den Healthcheck in docker-compose.
|
||||||
*
|
*
|
||||||
* Bewusst ohne Anmeldung, aber ohne jede Auskunft ueber Inhalte: geprueft
|
* 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
|
* Die Version steht dabei: sie wird beim Aktualisieren gebraucht, um zu
|
||||||
* sehen, ob der neue Stand tatsaechlich laeuft. Das ist keine Auskunft
|
* sehen, ob der neue Stand tatsaechlich laeuft. Das ist keine Auskunft
|
||||||
@@ -19,14 +25,29 @@ export const dynamic = 'force-dynamic'
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
await db.$queryRaw`SELECT 1`
|
await db.$queryRaw`SELECT 1`
|
||||||
return NextResponse.json(
|
|
||||||
{ status: 'bereit', version: VERSION, commit: COMMIT || undefined },
|
|
||||||
{ headers: { 'Cache-Control': 'no-store' } },
|
|
||||||
)
|
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ status: 'datenbank nicht erreichbar', version: VERSION },
|
{ status: 'datenbank nicht erreichbar', version: VERSION },
|
||||||
{ status: 503, headers: { 'Cache-Control': 'no-store' } },
|
{ 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))
|
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. */
|
/** Nur fuer Wartungsskripte: das Ablageverzeichnis. */
|
||||||
export function ablageverzeichnis(): string {
|
export function ablageverzeichnis(): string {
|
||||||
return wurzel()
|
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.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
@@ -174,10 +174,32 @@ export async function objekteAuflisten(): Promise<string[]> {
|
|||||||
return gefunden
|
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. */
|
/** Der Inhalt, wie er im Eimer liegt - ohne den Umschlag abzunehmen. */
|
||||||
export async function rohLesen(schluessel: string): Promise<Buffer> {
|
export async function rohLesen(schluessel: string): Promise<Buffer> {
|
||||||
const geprueft = schluesselErfordern(schluessel)
|
const antwort = await s3().send(new GetObjectCommand({ Bucket: eimer(), Key: schluessel }))
|
||||||
const antwort = await s3().send(new GetObjectCommand({ Bucket: eimer(), Key: geprueft }))
|
if (!antwort.Body) throw new Error(`Objekt ${schluessel} ist leer.`)
|
||||||
if (!antwort.Body) throw new Error(`Objekt ${geprueft} ist leer.`)
|
|
||||||
return Buffer.from(await antwort.Body.transformToByteArray())
|
return Buffer.from(await antwort.Body.transformToByteArray())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { bekannteAbsender } from '@/lib/dokumente/suche'
|
|||||||
import { erkennungsvorschlaege, type Erkennungsergebnis } from '@/lib/dokumente/erkennung'
|
import { erkennungsvorschlaege, type Erkennungsergebnis } from '@/lib/dokumente/erkennung'
|
||||||
import { ocrAnstossen } from '@/lib/dokumente/ocr-nachlauf'
|
import { ocrAnstossen } from '@/lib/dokumente/ocr-nachlauf'
|
||||||
import { ablegenMitTyp, DateiAbgelehnt, entfernen, signierteUrl } from '@/lib/speicher'
|
import { ablegenMitTyp, DateiAbgelehnt, entfernen, signierteUrl } from '@/lib/speicher'
|
||||||
|
import { speicherfehlerMeldung } from '@/lib/speicher/fehler'
|
||||||
import {
|
import {
|
||||||
DokumentMetadatenSchema,
|
DokumentMetadatenSchema,
|
||||||
FristUebernehmenSchema,
|
FristUebernehmenSchema,
|
||||||
@@ -217,7 +218,10 @@ export async function dokumenteHochladen(
|
|||||||
const meldung =
|
const meldung =
|
||||||
fehler instanceof DateiAbgelehnt
|
fehler instanceof DateiAbgelehnt
|
||||||
? fehler.message
|
? 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)) {
|
if (!(fehler instanceof DateiAbgelehnt)) {
|
||||||
console.error('[dokumente] Upload gescheitert:', fehler)
|
console.error('[dokumente] Upload gescheitert:', fehler)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { db, nichtGeloescht } from '@/lib/db'
|
|||||||
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
|
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
|
||||||
import { protokollieren } from '@/lib/audit'
|
import { protokollieren } from '@/lib/audit'
|
||||||
import { ablegenMitTyp } from '@/lib/speicher'
|
import { ablegenMitTyp } from '@/lib/speicher'
|
||||||
|
import { speicherfehlerMeldung } from '@/lib/speicher/fehler'
|
||||||
import { heuteAlsTag } from '@/lib/fristen/datum'
|
import { heuteAlsTag } from '@/lib/fristen/datum'
|
||||||
import { tagAusText } from '@/lib/finanzen/datum'
|
import { tagAusText } from '@/lib/finanzen/datum'
|
||||||
import {
|
import {
|
||||||
@@ -228,7 +229,9 @@ export async function schreibenAblegen(
|
|||||||
const verweigert = zugriffsfehler(ausnahme)
|
const verweigert = zugriffsfehler(ausnahme)
|
||||||
if (verweigert) return verweigert
|
if (verweigert) return verweigert
|
||||||
console.error('[schreiben] Ablegen fehlgeschlagen:', ausnahme)
|
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,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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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.')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user