7679fada0a
Register GET /health/ready before the /api/* auth middleware so it is
public and reachable by K8s readinessProbe on port 3000 without auth.
On success → 200 {"status":"ready"}; on any DB/schema failure → 503
{"status":"degraded"}. Logs the pg error code; never leaks SQL in body.
A dropped schema (42P01) surfaces as non-200, closing the /health mask
that allowed the GRO-2678 incident to go undetected for ~43h.
Add health-ready.test.ts covering the 200 success path, 503 on schema
drop (42P01), and 503 on connection error; all assert no SQL leakage.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
103 lines
3.4 KiB
TypeScript
103 lines
3.4 KiB
TypeScript
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() {
|
|
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<string, unknown>).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<string, unknown>;
|
|
|
|
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<string, unknown>;
|
|
|
|
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<string, unknown>;
|
|
|
|
expect(res.status).toBe(503);
|
|
expect(body.status).toBe("degraded");
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|