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
This commit is contained in:
@@ -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`. |
|
| `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. |
|
| `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. |
|
| 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. |
|
| 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`. |
|
| 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`. |
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
**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
|
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.
|
scannt am Handy niemand — das ist der praktische Grund, warum der Proxy nicht optional ist.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# #025 Healthcheck gibt die rohe Datenbankfehlermeldung heraus
|
# #025 Healthcheck gibt die rohe Datenbankfehlermeldung heraus
|
||||||
|
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Im Tracker:** [#39](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/39)
|
**Im Tracker:** [#39](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/39)
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** phase-2, backend
|
**Labels:** phase-2, backend
|
||||||
**Geschätzt:** 1 h **Tatsächlich:** –
|
**Geschätzt:** 1 h **Tatsächlich:** 25 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
@@ -23,13 +23,13 @@ Aufgefallen beim Abnahme-Review zu [#019](019-betrieb-release.md).
|
|||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
|
|
||||||
- [ ] Nach außen nur `{ "status": "fehler" }` mit Status 503
|
- [x] Nach außen nur `{ "status": "fehler" }` mit Status 503
|
||||||
- [ ] Die Begründung serverseitig protokollieren, damit sie beim Suchen nicht fehlt
|
- [x] 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] Prüfen, ob `docs/betrieb/` irgendwo die Meldung als Diagnosehilfe nennt — dann auf das Log
|
||||||
verweisen
|
verweisen
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## 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
|
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
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
import { prisma } from "@/lib/db";
|
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<Response> {
|
export async function GET(): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
await prisma.$queryRaw`SELECT 1`;
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
return Response.json({ status: "ok" });
|
return Response.json({ status: "ok" });
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
const meldung = fehler instanceof Error ? fehler.message : "unbekannter Fehler";
|
console.error(
|
||||||
return Response.json({ status: "fehler", meldung }, { status: 503 });
|
"Healthcheck fehlgeschlagen:",
|
||||||
|
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : "unbekannter Fehler",
|
||||||
|
);
|
||||||
|
return Response.json({ status: "fehler" }, { status: 503 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user