Files
Ekklesia/apps/api/tests/health-routes.test.ts
T
ekklesiaandClaude Opus 5.5 aaec9789c4
CI / verify (pull_request) Successful in 45s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 1m42s
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]>
2026-10-01 16:47:01 -05:00

372 lines
13 KiB
TypeScript

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);
});
});