Compare commits

..

4 Commits

Author SHA1 Message Date
Flea Flicker 9227cf4883 fix(api): add /api/readyz to authMiddleware bypass (GRO-2687 UAT fix)
CI / Test (push) Successful in 19s
CI / Lint & Typecheck (push) Successful in 24s
CI / Lint & Typecheck (pull_request) Successful in 20s
CI / Test (pull_request) Successful in 25s
CI / Build & Push Docker Images (push) Successful in 58s
CI / Build & Push Docker Images (pull_request) Successful in 29s
2026-08-09 09:54:35 +00:00
Flea Flicker 8f5a069e77 fix(api): add /api/readyz to authMiddleware bypass list (GRO-2687)
CI / Lint & Typecheck (pull_request) Successful in 19s
CI / Test (pull_request) Successful in 21s
CI / Build & Push Docker Images (pull_request) Successful in 55s
/api/readyz was returning 401 Unauthorized in UAT (GRO-2692 Shedward
regression). The authMiddleware for /api/* did not whitelist /api/readyz,
causing every unauthenticated probe request to be rejected.

Add /api/readyz to the bypass condition alongside /api/auth/* and
/api/health. Add readyz-auth-bypass.test.ts confirming the route passes
through the middleware without auth and that non-whitelisted paths still
block (503 when auth not configured).

Closes GRO-2692 regression (UAT fail).
2026-08-09 09:52:37 +00:00
Flea Flicker 07717afd01 revert(api): remove /health/ready — superseded by /api/readyz (GRO-2689)
CI / Lint & Typecheck (push) Successful in 19s
CI / Test (push) Successful in 21s
CI / Build & Push Docker Images (push) Successful in 43s
2026-08-09 09:50:43 +00:00
Flea Flicker d8f6981be1 revert(api): remove /health/ready — superseded by /api/readyz (GRO-2689)
CI / Lint & Typecheck (pull_request) Successful in 18s
CI / Test (pull_request) Successful in 25s
CI / Build & Push Docker Images (pull_request) Successful in 53s
CTO architectural ruling (PR #235 review): K8s readiness/liveness probes
must remain DB-less to prevent transient DB blips from cycling pods. The
/api/readyz endpoint (GRO-2687) is the canonical DB-health signal for the
monitoring layer and satisfies the GRO-2678 detection-gap requirement.

Removes:
- GET /health/ready route from src/index.ts
- src/__tests__/health-ready.test.ts

Refs GRO-2689, GRO-2687, GRO-2678
2026-08-09 09:48:10 +00:00
4 changed files with 45 additions and 114 deletions
-102
View File
@@ -1,102 +0,0 @@
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();
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect, vi } from "vitest";
import { Hono } from "hono";
// Mock auth lib so getAuth() throws — non-bypass paths hit the 503 "not configured" guard.
vi.mock("../lib/auth.js", () => ({
getAuth: () => {
throw new Error("auth not configured");
},
initAuth: vi.fn(),
getActiveProviders: vi.fn(() => []),
}));
describe("authMiddleware bypass: /api/readyz", () => {
it("serves /api/readyz without auth (bypass before auth check)", async () => {
// Ensure AUTH_DISABLED is not set so the bypass is exercised, not AUTH_DISABLED shortcut.
const prev = process.env.AUTH_DISABLED;
delete process.env.AUTH_DISABLED;
const { authMiddleware } = await import("../middleware/auth.js");
const app = new Hono();
app.use("/api/*", authMiddleware);
app.get("/api/readyz", (c) => c.json({ status: "ok" }, 200));
const res = await app.request("/api/readyz", { method: "GET" });
expect(res.status).toBe(200);
if (prev !== undefined) process.env.AUTH_DISABLED = prev;
});
it("blocks non-whitelisted /api/* paths when auth is not configured", async () => {
const prev = process.env.AUTH_DISABLED;
delete process.env.AUTH_DISABLED;
const { authMiddleware } = await import("../middleware/auth.js");
const app = new Hono();
app.use("/api/*", authMiddleware);
app.get("/api/staff", (c) => c.json({ ok: true }, 200));
const res = await app.request("/api/staff", { method: "GET" });
expect(res.status).toBe(503);
if (prev !== undefined) process.env.AUTH_DISABLED = prev;
});
});
-11
View File
@@ -65,17 +65,6 @@ 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<string, unknown>).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) => {
+1 -1
View File
@@ -23,7 +23,7 @@ if (process.env.AUTH_DISABLED === "true") {
}
export const authMiddleware: MiddlewareHandler = async (c, next) => {
if (c.req.path.startsWith("/api/auth/") || c.req.path === "/api/health") {
if (c.req.path.startsWith("/api/auth/") || c.req.path === "/api/health" || c.req.path === "/api/readyz") {
await next();
return;
}