feat(api): add unauthenticated health endpoints
Add GET /api/v1/health/live and GET /api/v1/health/ready per ADR 0044.
- live returns a fixed 200 {"status":"live"} whenever the process serves.
- ready returns 200 {"status":"ready"} only when module startup has
completed (and shutdown has not begun) and a database ping succeeds
within 2s; otherwise a fixed 503 {"code":"NOT_READY"}.
- The ping runs on the shared pool as one simple-protocol message with
a transaction-local statement_timeout, so PostgreSQL cancels a stalled
statement; the pool discards the connection on failure.
- At most one ping is ever unsettled: probes share its outcome and, once
its deadline passes, answer 503 immediately until it settles.
- Both routes set Cache-Control: no-store, accept GET only (405 with
Allow: GET otherwise, HEAD included), never resolve a principal, and
are mounted in app.ts beside, never inside, /api/v1/modules/.
- createApiApp gains a required `health` option; startApi supplies it
and clears module readiness when shutdown begins.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d593c10f1d
commit
aaec9789c4
@@ -0,0 +1,371 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
type ModuleDatabase,
|
||||
type ModuleRouteHandler,
|
||||
type ModuleRuntimeServices,
|
||||
parsePermissionId,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationEvaluator,
|
||||
type AuthorizationStore,
|
||||
createPermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleUiPublicationRegistry,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import {
|
||||
type ApiHealthOptions,
|
||||
HEALTH_LIVE_PATH,
|
||||
HEALTH_READY_PATH,
|
||||
READINESS_DATABASE_TIMEOUT_MS,
|
||||
} from "../src/health-routes.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const modulePermission = parsePermissionId("health.status.read");
|
||||
|
||||
/** What a real driver failure can carry, and what must therefore never reach a response. */
|
||||
const DRIVER_FAILURE_DETAIL =
|
||||
"connect ECONNREFUSED postgresql://ekklesia:[email protected]:5432/ekklesia SELECT 1";
|
||||
|
||||
function fakeRuntime(): EkklesiaIdentityRuntime {
|
||||
return {
|
||||
handleAuthRequest: vi.fn(),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||
};
|
||||
}
|
||||
|
||||
function fakeStore(): AuthorizationStore {
|
||||
return {
|
||||
createGrant: vi.fn(),
|
||||
grantPlatformOwner: vi.fn(),
|
||||
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||
revokeGrant: vi.fn(),
|
||||
revokePlatformOwner: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function fakeModuleServices(): ModuleRuntimeServices {
|
||||
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||
}
|
||||
|
||||
function healthOptions(overrides: Partial<ApiHealthOptions> = {}) {
|
||||
return {
|
||||
checkDatabase: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||
isModuleStartupComplete: vi.fn<() => boolean>().mockReturnValue(true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildApp(
|
||||
health: ApiHealthOptions,
|
||||
moduleRoutes: readonly RegisteredModuleHttpRoute[] = Object.freeze([]),
|
||||
) {
|
||||
const evaluator: AuthorizationEvaluator = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
};
|
||||
const runtime = fakeRuntime();
|
||||
const app = createApiApp({
|
||||
authorization: {
|
||||
evaluator,
|
||||
registry: createPermissionRegistry([
|
||||
{ baseline: "public", description: "Test permission.", id: modulePermission },
|
||||
]),
|
||||
store: fakeStore(),
|
||||
},
|
||||
moduleRoutes,
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: fakeModuleServices(),
|
||||
moduleUi: createModuleUiPublicationRegistry([]),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
health,
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
return { app, evaluator, runtime };
|
||||
}
|
||||
|
||||
/** Resolves once every already-queued microtask has run, using the real `setImmediate` — the only
|
||||
* timer functions these tests fake are `setTimeout` and `clearTimeout`. */
|
||||
function drainMicrotasks(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
/** Whether `promise` settled with no fake time advanced at all: "promptly", made exact. */
|
||||
async function settlesWithoutWaiting(promise: Response | Promise<Response>): Promise<boolean> {
|
||||
let settled = false;
|
||||
void Promise.resolve(promise).then(
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
);
|
||||
await drainMicrotasks();
|
||||
return settled;
|
||||
}
|
||||
|
||||
async function expectNotReady(response: Response): Promise<void> {
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await response.json()).toEqual({ code: "NOT_READY" });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("GET /api/v1/health/live", () => {
|
||||
it("returns 200 with a fixed body and Cache-Control: no-store", async () => {
|
||||
const health = healthOptions();
|
||||
const { app } = buildApp(health);
|
||||
|
||||
const response = await app.request(HEALTH_LIVE_PATH);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(response.headers.get("content-type")).toMatch(/^application\/json/u);
|
||||
expect(await response.json()).toEqual({ status: "live" });
|
||||
});
|
||||
|
||||
it("does not consult the database or module startup, so it stays 200 while readiness is 503", async () => {
|
||||
const health = healthOptions({
|
||||
checkDatabase: vi.fn().mockRejectedValue(new Error(DRIVER_FAILURE_DETAIL)),
|
||||
isModuleStartupComplete: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
const { app } = buildApp(health);
|
||||
|
||||
const response = await app.request(HEALTH_LIVE_PATH);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(health.checkDatabase).not.toHaveBeenCalled();
|
||||
expect(health.isModuleStartupComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/v1/health/ready", () => {
|
||||
it("returns 200 with a fixed body when the database answers and module startup completed", async () => {
|
||||
const health = healthOptions();
|
||||
const { app } = buildApp(health);
|
||||
|
||||
const response = await app.request(HEALTH_READY_PATH);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await response.json()).toEqual({ status: "ready" });
|
||||
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns a fixed 503 when the database query fails, carrying nothing from the failure", async () => {
|
||||
const health = healthOptions({
|
||||
checkDatabase: vi.fn().mockRejectedValue(new Error(DRIVER_FAILURE_DETAIL)),
|
||||
});
|
||||
const { app } = buildApp(health);
|
||||
|
||||
const response = await app.request(HEALTH_READY_PATH);
|
||||
const text = await response.clone().text();
|
||||
|
||||
await expectNotReady(response);
|
||||
for (const fragment of ["ECONNREFUSED", "hunter2", "db.internal", "SELECT", "Error"]) {
|
||||
expect(text).not.toContain(fragment);
|
||||
}
|
||||
});
|
||||
|
||||
it("treats a check that throws synchronously exactly like one that rejects", async () => {
|
||||
const health = healthOptions({
|
||||
checkDatabase: vi.fn(() => {
|
||||
throw new Error(DRIVER_FAILURE_DETAIL);
|
||||
}),
|
||||
});
|
||||
const { app } = buildApp(health);
|
||||
|
||||
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||
});
|
||||
|
||||
it("returns 503 without touching the database before module startup completed", async () => {
|
||||
const health = healthOptions({ isModuleStartupComplete: vi.fn().mockReturnValue(false) });
|
||||
const { app } = buildApp(health);
|
||||
|
||||
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||
expect(health.checkDatabase).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 503 when shutdown began while the database query was in flight", async () => {
|
||||
let startupComplete = true;
|
||||
const health = healthOptions({
|
||||
checkDatabase: vi.fn(async () => {
|
||||
startupComplete = false;
|
||||
}),
|
||||
isModuleStartupComplete: () => startupComplete,
|
||||
});
|
||||
const { app } = buildApp(health);
|
||||
|
||||
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||
});
|
||||
|
||||
it("starts a fresh check for each sequential probe rather than caching a success", async () => {
|
||||
const health = healthOptions();
|
||||
const { app } = buildApp(health);
|
||||
|
||||
await app.request(HEALTH_READY_PATH);
|
||||
await app.request(HEALTH_READY_PATH);
|
||||
|
||||
expect(health.checkDatabase).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reports ready for a slow query that still answers inside the bound", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
let answer: () => void = () => undefined;
|
||||
const health = healthOptions({
|
||||
checkDatabase: vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
answer = resolve;
|
||||
}),
|
||||
),
|
||||
});
|
||||
const { app } = buildApp(health);
|
||||
|
||||
const pending = app.request(HEALTH_READY_PATH);
|
||||
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS - 1);
|
||||
answer();
|
||||
|
||||
expect((await pending).status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readiness never piles up database checks", () => {
|
||||
it("keeps exactly one check in flight for a query that never settles, while every probe answers 503 promptly", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
const health = healthOptions({
|
||||
checkDatabase: vi.fn(() => new Promise<void>(() => undefined)),
|
||||
});
|
||||
const { app } = buildApp(health);
|
||||
|
||||
// Probes arriving inside the first check's window share it and its single deadline.
|
||||
const first = app.request(HEALTH_READY_PATH);
|
||||
const concurrent = app.request(HEALTH_READY_PATH);
|
||||
await drainMicrotasks();
|
||||
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS);
|
||||
await expectNotReady(await first);
|
||||
await expectNotReady(await concurrent);
|
||||
|
||||
// After the deadline the query is still unsettled: no new check starts, and each probe answers
|
||||
// without waiting out another bound.
|
||||
for (let probe = 0; probe < 20; probe += 1) {
|
||||
const pending = app.request(HEALTH_READY_PATH);
|
||||
expect(await settlesWithoutWaiting(pending)).toBe(true);
|
||||
await expectNotReady(await pending);
|
||||
}
|
||||
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts a new check only once the timed-out query has actually settled", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
let settleHungQuery: (error: Error) => void = () => undefined;
|
||||
const checkDatabase = vi
|
||||
.fn<() => Promise<void>>()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
settleHungQuery = reject;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(undefined);
|
||||
const { app } = buildApp(healthOptions({ checkDatabase }));
|
||||
|
||||
const first = app.request(HEALTH_READY_PATH);
|
||||
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS);
|
||||
await expectNotReady(await first);
|
||||
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||
expect(checkDatabase).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The database cancels the abandoned statement; the next probe may finally ask again.
|
||||
settleHungQuery(new Error(DRIVER_FAILURE_DETAIL));
|
||||
await drainMicrotasks();
|
||||
|
||||
const recovered = await app.request(HEALTH_READY_PATH);
|
||||
expect(recovered.status).toBe(200);
|
||||
expect(checkDatabase).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("health route boundaries", () => {
|
||||
it.each([
|
||||
HEALTH_LIVE_PATH,
|
||||
HEALTH_READY_PATH,
|
||||
])("%s accepts GET only, answering every other method with a fixed 405 and never checking readiness", async (path) => {
|
||||
const health = healthOptions();
|
||||
const { app } = buildApp(health);
|
||||
|
||||
for (const method of ["HEAD", "POST", "PUT", "PATCH", "DELETE"]) {
|
||||
const response = await app.request(path, { method });
|
||||
expect(response.status, method).toBe(405);
|
||||
expect(response.headers.get("allow")).toBe("GET");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await response.text()).toBe("");
|
||||
}
|
||||
expect(health.checkDatabase).not.toHaveBeenCalled();
|
||||
expect(health.isModuleStartupComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
HEALTH_LIVE_PATH,
|
||||
HEALTH_READY_PATH,
|
||||
])("%s is unauthenticated: it never resolves a principal or evaluates authorization, even with a cookie", async (path) => {
|
||||
const { app, evaluator, runtime } = buildApp(healthOptions());
|
||||
|
||||
const response = await app.request(path, {
|
||||
headers: { Cookie: "better-auth.session_token=anything", Origin: trustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
|
||||
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cannot be shadowed by a module, even one whose id is "health"', async () => {
|
||||
const moduleHandler = vi.fn<ModuleRouteHandler>(
|
||||
async () => new Response("module", { status: 200 }),
|
||||
);
|
||||
const moduleRoutes = ["/live", "/ready"].map(
|
||||
(path) =>
|
||||
Object.freeze({
|
||||
bodyMode: "buffered",
|
||||
handler: moduleHandler,
|
||||
maxBodyBytes: 1024,
|
||||
method: "GET",
|
||||
moduleId: "health",
|
||||
path,
|
||||
permission: modulePermission,
|
||||
}) as RegisteredModuleHttpRoute,
|
||||
);
|
||||
const { app } = buildApp(healthOptions(), moduleRoutes);
|
||||
|
||||
expect(await (await app.request(HEALTH_LIVE_PATH)).json()).toEqual({ status: "live" });
|
||||
expect(await (await app.request(HEALTH_READY_PATH)).json()).toEqual({ status: "ready" });
|
||||
expect(moduleHandler).not.toHaveBeenCalled();
|
||||
|
||||
// The module's own routes still live where every module route lives, and only there.
|
||||
const moduleResponse = await app.request("/api/v1/modules/health/ready");
|
||||
expect(await moduleResponse.text()).toBe("module");
|
||||
expect(moduleHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user