diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 5f0bda8..a0a5b5f 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -65,8 +65,11 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the | # | Scenario | Steps | Expected | |---|----------|-------|----------| | TC-API-0.1 | Unauthenticated health check | GET /api/health | 200 OK, `{"status":"ok"}` | +| TC-API-0.2 | DB-touching readiness check — healthy (GRO-2678) | GET /api/readyz | 200 OK, `{"status":"ready"}` | +| TC-API-0.3 | DB-touching readiness check — response body safe | GET /api/readyz and inspect body | Body contains only `status` and (on error) `check` fields — no raw SQL, driver messages, or stack traces | > **Note (GRO-1544):** Health endpoint registered on `api` basePath before auth middleware at `/api/health`. The old path `/health` was incorrect (routed to web pod via HTTPRoute `/*` rule). +> **Note (GRO-2678):** `/api/readyz` is a separate DB-touching endpoint for monitoring. It is intentionally NOT used for K8s liveness/readiness probes — those remain DB-less to avoid pod cycling on transient DB blips. ### 4.1 Authentication diff --git a/src/__tests__/health-ready.test.ts b/src/__tests__/health-ready.test.ts new file mode 100644 index 0000000..6a8ea88 --- /dev/null +++ b/src/__tests__/health-ready.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// ─── Mock db module ─────────────────────────────────────────────────────────── + +let selectImpl: () => Promise; + +vi.mock("@groombook/db", () => { + const staff = new Proxy( + { _name: "staff" }, + { + get(_target, prop) { + if (prop === "_name") return "staff"; + return { table: "staff", column: prop }; + }, + } + ); + + return { + getDb: () => ({ + select: (_fields: unknown) => ({ + from: (_table: unknown) => ({ + limit: (_n: number) => selectImpl(), + }), + }), + }), + staff, + }; +}); + +// ─── Build test app ─────────────────────────────────────────────────────────── + +async function makeApp() { + const { getDb, staff } = await import("@groombook/db"); + + const app = new Hono(); + app.get("/health/ready", async (c) => { + try { + await getDb().select({ id: staff.id }).from(staff).limit(1); + return c.json({ status: "ready" }, 200); + } catch (err) { + const pgCode = (err as Record).code ?? "unknown"; + console.error("[health/ready] DB check failed:", pgCode); + return c.json({ status: "degraded" }, 503); + } + }); + return app; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("GET /health/ready", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("returns 200 {status:'ready'} when DB query succeeds", async () => { + selectImpl = () => Promise.resolve([{ id: "staff-1" }]); + + const app = await makeApp(); + const res = await app.request("/health/ready", { method: "GET" }); + const body = (await res.json()) as Record; + + expect(res.status).toBe(200); + expect(body.status).toBe("ready"); + }); + + it("returns 503 {status:'degraded'} when DB query throws (schema dropped)", async () => { + const schemaErr = Object.assign(new Error("relation \"staff\" does not exist"), { code: "42P01" }); + selectImpl = () => Promise.reject(schemaErr); + + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const app = await makeApp(); + const res = await app.request("/health/ready", { method: "GET" }); + const body = (await res.json()) as Record; + + expect(res.status).toBe(503); + expect(body.status).toBe("degraded"); + + // Must not leak SQL error details in the response body + expect(JSON.stringify(body)).not.toContain("42P01"); + expect(JSON.stringify(body)).not.toContain("relation"); + + consoleSpy.mockRestore(); + }); + + it("returns 503 {status:'degraded'} on any DB connection error", async () => { + selectImpl = () => Promise.reject(new Error("ECONNREFUSED")); + + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const app = await makeApp(); + const res = await app.request("/health/ready", { method: "GET" }); + const body = (await res.json()) as Record; + + expect(res.status).toBe(503); + expect(body.status).toBe("degraded"); + + consoleSpy.mockRestore(); + }); +}); diff --git a/src/__tests__/readyz.test.ts b/src/__tests__/readyz.test.ts new file mode 100644 index 0000000..f2f5fde --- /dev/null +++ b/src/__tests__/readyz.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// ─── Mock db module ─────────────────────────────────────────────────────────── + +let selectImpl: () => Promise; + +vi.mock("@groombook/db", () => { + const staff = new Proxy( + { _name: "staff" }, + { + get(_target, prop) { + if (prop === "_name") return "staff"; + return { table: "staff", column: prop }; + }, + } + ); + + return { + getDb: () => ({ + select: (_fields: unknown) => ({ + from: (_table: unknown) => ({ + limit: (_n: number) => selectImpl(), + }), + }), + }), + staff, + }; +}); + +// ─── Build test app ─────────────────────────────────────────────────────────── + +async function makeApp() { + // Import after mocks are in place + const { getDb, staff } = await import("@groombook/db"); + + const app = new Hono(); + app.get("/api/readyz", async (c) => { + try { + await getDb().select({ id: staff.id }).from(staff).limit(1); + return c.json({ status: "ready" }, 200); + } catch (err) { + console.error("[readyz] DB check failed:", err); + return c.json({ status: "degraded", check: "db" }, 503); + } + }); + return app; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("GET /api/readyz", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("returns 200 {status:'ready'} when DB query succeeds", async () => { + selectImpl = () => Promise.resolve([{ id: "staff-1" }]); + + const app = await makeApp(); + const res = await app.request("/api/readyz", { method: "GET" }); + const body = (await res.json()) as Record; + + expect(res.status).toBe(200); + expect(body.status).toBe("ready"); + }); + + it("returns 503 {status:'degraded',check:'db'} when DB query throws", async () => { + selectImpl = () => Promise.reject(new Error("42P01: relation staff does not exist")); + + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const app = await makeApp(); + const res = await app.request("/api/readyz", { method: "GET" }); + const body = (await res.json()) as Record; + + expect(res.status).toBe(503); + expect(body.status).toBe("degraded"); + expect(body.check).toBe("db"); + + // Raw SQL / driver error must NOT appear in the response body + expect(JSON.stringify(body)).not.toContain("42P01"); + expect(JSON.stringify(body)).not.toContain("relation"); + + consoleSpy.mockRestore(); + }); +}); diff --git a/src/index.ts b/src/index.ts index e08a2ff..99a66fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -65,6 +65,28 @@ app.use( app.get("/health", (c) => c.json({ status: "ok" })); // /api/health: used by Gateway HTTPRoute (/api/* → API pod) app.get("/api/health", (c) => c.json({ status: "ok" })); +// /health/ready: DB-touching readiness probe — K8s removes pod from endpoints when schema is dropped (GRO-2689) +app.get("/health/ready", async (c) => { + try { + await getDb().select({ id: staff.id }).from(staff).limit(1); + return c.json({ status: "ready" }, 200); + } catch (err) { + const pgCode = (err as Record).code ?? "unknown"; + console.error("[health/ready] DB check failed:", pgCode); + return c.json({ status: "degraded" }, 503); + } +}); +// /api/readyz: DB-touching deep health check consumed by monitoring (not K8s probes) +// Distinct from /health so a dropped schema triggers an alert without cycling pods (GRO-2678) +app.get("/api/readyz", async (c) => { + try { + await getDb().select({ id: staff.id }).from(staff).limit(1); + return c.json({ status: "ready" }, 200); + } catch (err) { + console.error("[readyz] DB check failed:", err); + return c.json({ status: "degraded", check: "db" }, 503); + } +}); // Public booking routes — no auth required, must be registered before auth middleware app.route("/api/book", bookRouter);