From 2e8681abf58bec9c666205477c2f797213f92872 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 4 Oct 2026 16:11:28 +0200 Subject: [PATCH] fix(gesundheit): Fehlergrund nicht mehr unangemeldet herausgeben /api/gesundheit steht in der Allowlist des Proxys, damit ein Healthcheck kein Cookie braucht. Eine Meldung von Prisma oder pg nennt aber Host, Port, Benutzernamen und Datenbanknamen -- fuer einen Pfad, der ohne Anmeldung antwortet, ist das zu viel. Nach aussen bleibt {"status":"fehler"} mit 503, damit Docker und der Reverse Proxy weiter richtig entscheiden. Die Begruendung geht ins Protokoll des Prozesses, nach demselben Muster wie in src/server/actions/verzeichnis.ts. Fuenf Tests, darunter einer fuer die andere Haelfte der Zusage: dass die Begruendung wirklich geschrieben und nicht verschluckt wird. Gegenprobe gefahren -- mit der alten Fassung werden drei davon rot. Closes #39 --- docs/betrieb/inbetriebnahme.md | 2 +- docs/betrieb/reverse-proxy.md | 5 + docs/issues/025-gesundheit-fehlermeldung.md | 14 +-- src/app/api/gesundheit/route.ts | 20 +++- tests/gesundheit.test.ts | 102 ++++++++++++++++++++ 5 files changed, 132 insertions(+), 11 deletions(-) create mode 100644 tests/gesundheit.test.ts diff --git a/docs/betrieb/inbetriebnahme.md b/docs/betrieb/inbetriebnahme.md index 65b1461..48e77df 100644 --- a/docs/betrieb/inbetriebnahme.md +++ b/docs/betrieb/inbetriebnahme.md @@ -249,7 +249,7 @@ Datei nicht neu. Änderungen an `APP_VERSION` oder `GIT_SHA` sind Build-Argument |---|---| | `APP_SECRET fehlt in der .env` beim `up` | Genau das. Der Stand bricht absichtlich ab, statt mit einem Standardwert zu starten. Gilt ebenso für `APP_URL` und `POSTGRES_PASSWORD`. | | `web` startet nicht, `migrate` steht auf `exited (1)` | `docker compose logs migrate`. Meist erreicht die Migration die Datenbank nicht — oder du hast `POSTGRES_PASSWORD` nach dem ersten Start geändert. Das Passwort steckt dann noch im Volume. | -| `/api/gesundheit` antwortet mit 503 | Die Anwendung läuft, die Datenbank antwortet nicht. `docker compose ps` — ist `postgres` gesund? | +| `/api/gesundheit` antwortet mit 503 | Die Anwendung läuft, die Datenbank antwortet nicht. `docker compose ps` — ist `postgres` gesund? Die Antwort nennt absichtlich keinen Grund, weil der Pfad ohne Anmeldung erreichbar ist; der Grund steht in `docker compose logs web`. | | Der Seed bricht mittendrin ab | Bis zur Abnahme von #019 fehlte dem Einmal-Dienst `APP_SECRET`, und das Bind-Passwort der Beispiel-Verzeichnisquelle ließ sich nicht verschlüsseln — abgebrochen wurde erst, nachdem die Vorlagen schon standen. Steht dein `docker-compose.yml` auf dem aktuellen Stand, ist das behoben; ein erneuter Lauf holt den Rest nach. | | Nach dem Login wieder auf der Anmeldeseite | Das Cookie ist `Secure`, die Verbindung aber `http`. `APP_URL` auf `https://…` setzen und HTTPS über den Proxy anbieten. | | Die QR-Codes zeigen auf `localhost:3000` | `APP_URL` ist nicht oder falsch gesetzt. Die Oberfläche warnt unter **Admin** und in der Etikettenansicht davor. Aufkleber, die so gedruckt wurden, sind wertlos — bevor du weiterdruckst, `APP_URL` richtigstellen und `docker compose up -d`. | diff --git a/docs/betrieb/reverse-proxy.md b/docs/betrieb/reverse-proxy.md index 4c9c7eb..12d2b7b 100644 --- a/docs/betrieb/reverse-proxy.md +++ b/docs/betrieb/reverse-proxy.md @@ -203,6 +203,11 @@ schlimmsten Fall, macht den Unterpfad aber nicht betriebsfähig. antwortet `{"status":"ok"}` oder mit Status 503. Das ist der richtige Pfad für die Überwachung des Proxys; `/` leitet ohne Cookie auf die Anmeldeseite um und sagt nichts über den Zustand. +Im Fehlerfall sagt er nur `{"status":"fehler"}` und **nicht, warum** — der Pfad ist ohne Anmeldung +erreichbar, und eine Datenbankmeldung nennt Host, Port, Benutzernamen und Datenbanknamen. Die +Begründung steht im Protokoll des Prozesses: `docker compose logs web` beziehungsweise +`journalctl -u laufzettel -n 50`. + **Die Kamera braucht HTTPS.** Der QR-Scanner in der Anwendung benutzt die Kamera des Geräts, und Browser geben sie nur über eine gesicherte Verbindung frei (`localhost` ausgenommen). Ohne TLS scannt am Handy niemand — das ist der praktische Grund, warum der Proxy nicht optional ist. diff --git a/docs/issues/025-gesundheit-fehlermeldung.md b/docs/issues/025-gesundheit-fehlermeldung.md index a472f58..5d312a5 100644 --- a/docs/issues/025-gesundheit-fehlermeldung.md +++ b/docs/issues/025-gesundheit-fehlermeldung.md @@ -1,10 +1,10 @@ # #025 Healthcheck gibt die rohe Datenbankfehlermeldung heraus -**Status:** offen +**Status:** erledigt **Im Tracker:** [#39](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/39) **Meilenstein:** v0.2.0 **Labels:** phase-2, backend -**Geschätzt:** 1 h **Tatsächlich:** – +**Geschätzt:** 1 h **Tatsächlich:** 25 min ## Ziel @@ -23,13 +23,13 @@ Aufgefallen beim Abnahme-Review zu [#019](019-betrieb-release.md). ## Aufgaben -- [ ] Nach außen nur `{ "status": "fehler" }` mit Status 503 -- [ ] Die Begründung serverseitig protokollieren, damit sie beim Suchen nicht fehlt -- [ ] Prüfen, ob `docs/betrieb/` irgendwo die Meldung als Diagnosehilfe nennt — dann auf das Log +- [x] Nach außen nur `{ "status": "fehler" }` mit Status 503 +- [x] Die Begründung serverseitig protokollieren, damit sie beim Suchen nicht fehlt +- [x] Prüfen, ob `docs/betrieb/` irgendwo die Meldung als Diagnosehilfe nennt — dann auf das Log verweisen ## Akzeptanzkriterien -- [ ] Test: bei nicht erreichbarer Datenbank enthält die Antwort weder Host noch Port noch +- [x] Test: bei nicht erreichbarer Datenbank enthält die Antwort weder Host noch Port noch Benutzernamen -- [ ] Test: der Statuscode bleibt 503, damit Docker und der Reverse Proxy weiter richtig entscheiden +- [x] Test: der Statuscode bleibt 503, damit Docker und der Reverse Proxy weiter richtig entscheiden diff --git a/src/app/api/gesundheit/route.ts b/src/app/api/gesundheit/route.ts index 65ce8c8..4a5b597 100644 --- a/src/app/api/gesundheit/route.ts +++ b/src/app/api/gesundheit/route.ts @@ -1,12 +1,26 @@ import { prisma } from "@/lib/db"; -/** Healthcheck fuer Docker und den Reverse Proxy. Prueft, ob die Datenbank antwortet. */ +/** + * Healthcheck fuer Docker und den Reverse Proxy. Prueft, ob die Datenbank antwortet. + * + * Dieser Pfad ist absichtlich ohne Anmeldung erreichbar — er steht in der + * Allowlist von `src/proxy.ts`, damit ein Healthcheck kein Cookie braucht. + * Deshalb geht die **Begruendung** eines Fehlers nicht hinaus: eine Meldung von + * Prisma oder `pg` nennt typischerweise Host, Port, Benutzernamen und + * Datenbanknamen. Nach aussen genuegt, **ob** es geht; warum nicht, steht im + * Protokoll des Prozesses (`docker compose logs web`, + * `journalctl -u laufzettel`). Dasselbe Muster wie in + * `src/server/actions/verzeichnis.ts` (#039). + */ export async function GET(): Promise { try { await prisma.$queryRaw`SELECT 1`; return Response.json({ status: "ok" }); } catch (fehler) { - const meldung = fehler instanceof Error ? fehler.message : "unbekannter Fehler"; - return Response.json({ status: "fehler", meldung }, { status: 503 }); + console.error( + "Healthcheck fehlgeschlagen:", + fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : "unbekannter Fehler", + ); + return Response.json({ status: "fehler" }, { status: 503 }); } } diff --git a/tests/gesundheit.test.ts b/tests/gesundheit.test.ts new file mode 100644 index 0000000..5272087 --- /dev/null +++ b/tests/gesundheit.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Der Healthcheck (#039). + * + * Dieser Pfad ist der einzige, der ohne Anmeldung **und** mit Datenbankzugriff + * antwortet. Geprueft wird deshalb nicht, dass er funktioniert — das tun der + * Compose-Stand, `install.sh --test` und jeder e2e-Vorlauf —, sondern dass er im + * Fehlerfall **nichts verraet**. Die Datenbank wird dafuer ersetzt: eine echte + * Verbindung laesst sich nicht so kaputtmachen, dass die Meldung reproduzierbar + * Host, Port und Benutzernamen enthaelt. + */ + +const abfrage = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + get $queryRaw() { + return abfrage; + }, + }, +})); + +/** + * So sieht die Meldung wirklich aus, wenn `pg` nicht durchkommt — sie nennt + * alles, was ein Angreifer ueber die Datenbank wissen will. + */ +const ECHTE_PG_MELDUNG = + "Can't reach database server at `db-prod.firma.local:5432`. " + + "Authentication failed against database server, the provided database credentials for " + + "`laufzettel_prod` are not valid. Database `laufzettel_betrieb` does not exist."; + +const GEHEIMNISSE = [ + "db-prod.firma.local", + "5432", + "laufzettel_prod", + "laufzettel_betrieb", + "Authentication failed", +]; + +async function antwort() { + // Nach jedem Zuruecksetzen der Module neu geholt, damit der Mock greift. + const { GET } = await import("@/app/api/gesundheit/route"); + return GET(); +} + +describe("Healthcheck", () => { + let protokoll: ReturnType; + + beforeEach(() => { + vi.resetModules(); + abfrage.mockReset(); + protokoll = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + protokoll.mockRestore(); + }); + + it("antwortet mit ok, wenn die Datenbank antwortet", async () => { + abfrage.mockResolvedValue([{ "1": 1 }]); + const a = await antwort(); + expect(a.status).toBe(200); + await expect(a.json()).resolves.toEqual({ status: "ok" }); + }); + + it("gibt im Fehlerfall weder Host noch Port noch Benutzernamen heraus", async () => { + abfrage.mockRejectedValue(new Error(ECHTE_PG_MELDUNG)); + const a = await antwort(); + + const text = await a.text(); + for (const geheimnis of GEHEIMNISSE) { + expect(text).not.toContain(geheimnis); + } + expect(JSON.parse(text)).toEqual({ status: "fehler" }); + }); + + it("behaelt 503, damit Docker und der Reverse Proxy richtig entscheiden", async () => { + abfrage.mockRejectedValue(new Error(ECHTE_PG_MELDUNG)); + expect((await antwort()).status).toBe(503); + }); + + it("schreibt die Begruendung ins Protokoll, statt sie zu verschlucken", async () => { + abfrage.mockRejectedValue(new Error(ECHTE_PG_MELDUNG)); + await antwort(); + + expect(protokoll).toHaveBeenCalledTimes(1); + const geschrieben = protokoll.mock.calls[0]?.join(" ") ?? ""; + expect(geschrieben).toContain("Healthcheck fehlgeschlagen"); + // Ohne das waere die Meldung nur weg, nicht aufgehoben. + expect(geschrieben).toContain("db-prod.firma.local:5432"); + }); + + it("nennt auch eine Ausnahme ohne Error-Klasse, ohne sie auszugeben", async () => { + abfrage.mockRejectedValue("nur ein Text"); + const a = await antwort(); + + expect(a.status).toBe(503); + await expect(a.json()).resolves.toEqual({ status: "fehler" }); + expect(protokoll.mock.calls[0]?.join(" ")).toContain("unbekannter Fehler"); + }); +});