promote(api): dev → uat — /api/readyz auth bypass fix + /health/ready revert (GRO-2687)
CI / Lint & Typecheck (pull_request) Successful in 24s
CI / Test (pull_request) Successful in 31s
CI / Build & Push Docker Images (pull_request) Successful in 53s
CI / Lint & Typecheck (push) Successful in 20s
CI / Test (push) Successful in 22s
CI / Build & Push Docker Images (push) Successful in 36s
CI / Lint & Typecheck (pull_request) Successful in 24s
CI / Test (pull_request) Successful in 31s
CI / Build & Push Docker Images (pull_request) Successful in 53s
CI / Lint & Typecheck (push) Successful in 20s
CI / Test (push) Successful in 22s
CI / Build & Push Docker Images (push) Successful in 36s
QA approved (Lint Roller, review #5061). Phase 2 self-merge per SDLC. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit was merged in pull request #239.
This commit is contained in:
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -65,17 +65,6 @@ 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" }));
|
||||||
// /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)
|
// /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)
|
// Distinct from /health so a dropped schema triggers an alert without cycling pods (GRO-2678)
|
||||||
app.get("/api/readyz", async (c) => {
|
app.get("/api/readyz", async (c) => {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ if (process.env.AUTH_DISABLED === "true") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
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();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user