Serve surfaces/shell's built Dashboard from apps/api on the same origin as /api (ADR 0044), exactly as the display host is served: a closed set of files read once at startup, never a static directory, never a path built from request input. - The shell exports @ekklesia/shell/dashboard-assets (the exact built index.html and script URLs), emits the script at assets/ekklesia-dashboard/app.js, and index.html names that absolute path so dev and production serve the same URLs. - GET / and GET /assets/ekklesia-dashboard/app.js answer from a frozen snapshot: no-cache with a strong ETag and 304 on match. Source maps are never served; every other path is the ordinary sanitized 404. - The page sets nosniff, X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none' (that directive only), and Referrer-Policy: no-referrer, on 200 and 304 alike. - A missing or empty shell build fails startup with a fixed DashboardHostAssetError before any module code is imported. - The If-None-Match comparison moves to conditional-request.ts, shared with the display host routes. - The emailed-link token stripping moves from browser/main.tsx into consumeAuthLanding (location and history injected) so it is tested; behavior is unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
374 lines
13 KiB
TypeScript
374 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 { testDashboardHostAssets } from "./support/dashboard-host-assets.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(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
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);
|
|
});
|
|
});
|