feat(api): add DB-touching /api/readyz so schema loss cannot hide behind /health (GRO-2678)
CI / Lint & Typecheck (pull_request) Successful in 19s
CI / Test (pull_request) Successful in 21s
CI / Build & Push Docker Images (pull_request) Successful in 31s
CI / Lint & Typecheck (push) Successful in 20s
CI / Test (push) Successful in 22s
CI / Build & Push Docker Images (push) Successful in 34s
CI / Lint & Typecheck (pull_request) Successful in 19s
CI / Test (pull_request) Successful in 21s
CI / Build & Push Docker Images (pull_request) Successful in 31s
CI / Lint & Typecheck (push) Successful in 20s
CI / Test (push) Successful in 22s
CI / Build & Push Docker Images (push) Successful in 34s
This commit was merged in pull request #231.
This commit is contained in:
@@ -65,8 +65,11 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the
|
|||||||
| # | Scenario | Steps | Expected |
|
| # | Scenario | Steps | Expected |
|
||||||
|---|----------|-------|----------|
|
|---|----------|-------|----------|
|
||||||
| TC-API-0.1 | Unauthenticated health check | GET /api/health | 200 OK, `{"status":"ok"}` |
|
| 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-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
|
### 4.1 Authentication
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
// ─── Mock db module ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let selectImpl: () => Promise<unknown>;
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -65,6 +65,17 @@ app.use(
|
|||||||
app.get("/health", (c) => c.json({ status: "ok" }));
|
app.get("/health", (c) => c.json({ status: "ok" }));
|
||||||
// /api/health: used by Gateway HTTPRoute (/api/* → API pod)
|
// /api/health: used by Gateway HTTPRoute (/api/* → API pod)
|
||||||
app.get("/api/health", (c) => c.json({ status: "ok" }));
|
app.get("/api/health", (c) => c.json({ status: "ok" }));
|
||||||
|
// /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
|
// Public booking routes — no auth required, must be registered before auth middleware
|
||||||
app.route("/api/book", bookRouter);
|
app.route("/api/book", bookRouter);
|
||||||
|
|||||||
Reference in New Issue
Block a user