Files
Ekklesia/apps/api/tests/lifecycle.test.ts
T
ekklesiaandClaude Opus 5.5 98e59d4ba0
CI / verify (pull_request) Successful in 1m5s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / package-release (pull_request) Successful in 35s
CI / image-build (pull_request) Successful in 31s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
CI / package-publication (pull_request) Successful in 44s
chore(release): prepare 0.2.0
Every platform workspace package and every public @ekklesia/* package
declares 0.2.0, and the deploy README's image build example names it.
templates/module keeps its own version.

0.2.0, not 0.1.3: before 1.0 a minor version is the breaking one
(docs/development/releases.md, ADR 0054), and since 0.1.2 main has
broken modules, module authors, and operators:
- #64 (ADR 0048): one generic image; installed modules live outside it;
- #66, #72 (ADR 0049, 0050): modules install as Module Package v1, and
  move to the Module Database ABI;
- #71: the manifest v8 cutover; v7 manifests are refused;
- #79 (ADR 0053): the curated SDK drops defineModuleSchema and the
  legacy ModuleDatabase type;
plus #67 (AGPL), #74, #77 (licensing, native targets), and the public
package train (#80, #81). It is also the first package train to publish:
0.1.2 never will be.

Because 0.2.0 is outside ^0.1.0, the same change moves to ^0.2.0:
- the standalone fixture's @ekklesia ranges and its exact module-pack pin;
- the compatibility ranges of the modules built for this release: the
  template, the standalone fixture, and the native proof module;
- the test fixtures that model a module of the current release, or the
  current platform's versions;
- the module-development guide's examples, and the release guide, which
  now names what a minor bump moves.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 17:26:13 -05:00

1622 lines
66 KiB
TypeScript

import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const poolConstructor = vi.hoisted(() => vi.fn());
const betterAuthMock = vi.hoisted(() => vi.fn());
const drizzleAdapterMock = vi.hoisted(() => vi.fn());
const serveMock = vi.hoisted(() => vi.fn());
const processOnSpy = vi.hoisted(() => vi.fn());
const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn());
const loadDashboardHostAssetsMock = vi.hoisted(() => vi.fn());
vi.mock("pg", () => ({ Pool: poolConstructor }));
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
// Every startup reads the built Dashboard (ADR 0044), and this file never depends on a built shell,
// so the loader answers a small consistent snapshot unless a test makes it fail.
vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/dashboard-host-assets.js")>();
loadDashboardHostAssetsMock.mockImplementation(async () =>
actual.createDashboardHostAssets("<!doctype html>", "export {};\n"),
);
return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock };
});
vi.mock("../src/display-host-assets.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/display-host-assets.js")>();
return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock };
});
// Wraps the real `createApiApp` in a spy rather than replacing it, so every existing test still
// gets the real Hono application; only the one test that deliberately forces an app-construction
// failure overrides this with `mockImplementationOnce`.
vi.mock("../src/app.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/app.js")>();
return { ...actual, createApiApp: vi.fn(actual.createApiApp) };
});
import { parseModuleManifest } from "@ekklesia/contracts";
import {
type DiscoveredModule,
ModuleDiscoveryError,
ModulePermissionCompositionError,
} from "@ekklesia/core/modules";
import { PermissionRegistryError } from "@ekklesia/core/authorization";
import { createApiApp } from "../src/app.js";
import { createApiRuntimeConfig } from "../src/config.js";
import { DashboardHostAssetError } from "../src/dashboard-host-assets.js";
import { DisplayHostAssetError } from "../src/display-host-assets.js";
import {
HEALTH_LIVE_PATH,
HEALTH_READY_PATH,
READINESS_DATABASE_TIMEOUT_MS,
} from "../src/health-routes.js";
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
function validConfig() {
return createApiRuntimeConfig(
{
databaseUrl: "postgresql://ekklesia:[email protected]:5432/ekklesia",
host: "127.0.0.1",
identity: {
baseUrl: "https://identity.example.test",
secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: ["https://identity.example.test"],
},
port: 0,
},
{ environment: "production" },
);
}
/**
* The one query behavior every fake pool in this file needs: startup reads the stored module
* enablement configuration (`ModuleLifecycleStore.readConfiguration`) before it imports any module
* entrypoint, and an installation that has never disabled anything genuinely has zero rows. Returning
* an empty result set is therefore the accurate stand-in for a real, migrated, unconfigured database
* — not a way to skip the read. The tests that care about a *specific* configuration drive the real
* resolver directly instead (`core/tests/module-lifecycle.test.ts`) or supply real rows through the
* real store (`core/tests/module-lifecycle-store.integration.ts`).
*/
function emptyQuery() {
return vi
.fn()
.mockResolvedValue({ command: "SELECT", fields: [], oid: 0, rowCount: 0, rows: [] });
}
const temporaryDirectories: string[] = [];
async function createTemporaryModulesDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-modules-"));
temporaryDirectories.push(directory);
return directory;
}
/** Writes a minimal, structurally valid module fixture (package.json + ekklesia.module.json +
* server entrypoint) directly to disk — real discovery, not a hand-built double. The server
* entrypoint throws immediately if ever imported, so a test that reaches a successful assertion
* without that throw firing is direct evidence permission composition never executed it. */
async function createFixtureModule(
modulesDirectory: string,
id: string,
manifestOverrides: Record<string, unknown> = {},
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
const manifest = {
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
permissions: [],
entrypoints: { server: "./dist/server/index.js" },
...manifestOverrides,
};
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${id}`,
type: "module",
version: "0.1.0",
}),
),
writeFile(path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify(manifest)),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
'throw new Error("permission composition must never execute a module server entrypoint");',
),
]);
}
const eventSymbols: symbol[] = [];
/** Same technique as `core/tests/module-runtime.test.ts`: a dynamically imported module fixture
* cannot close over this test file's local variables, so lifecycle events cross that boundary
* through a `globalThis` symbol instead. Returns the same `events` array the fixture's generated
* source pushes into, so test code can both read it directly and push its own markers (pool
* close, server close) onto the identical array to observe cross-cutting shutdown order. */
function createEventRecorder(): { events: string[]; expression: string } {
const key = `ekklesia.api.lifecycle.test.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
const symbol = Symbol.for(key);
const events: string[] = [];
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = events;
eventSymbols.push(symbol);
return { events, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
}
/** Same globalThis-symbol technique as `createEventRecorder`, but captures an actual object
* reference (such as `context.services.database`) rather than a string label, so a test can prove
* two values pushed from different, independently dynamically-imported invocations (a module's own
* `start` hook and one of its HTTP handlers) are the exact same object. */
function createReferenceRecorder(): { values: unknown[]; expression: string } {
const key = `ekklesia.api.lifecycle.test.ref.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
const symbol = Symbol.for(key);
const values: unknown[] = [];
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = values;
eventSymbols.push(symbol);
return { values, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
}
interface WorkingFixtureModuleOptions {
/** Declares one `GET /ping` route against `<id>.ping.read` when true. */
readonly withRoute?: boolean;
readonly registerThrows?: boolean;
readonly startThrows?: boolean;
readonly stopThrows?: boolean;
/** Test-only: the stop hook records its marker and then never settles, so an API-level deadline
* can be exercised deterministically without any sleep. */
readonly stopNeverSettles?: boolean;
readonly dependencies?: Record<string, unknown>;
/** Declares one protected display artifact without importing it. */
readonly withDisplay?: boolean;
}
/** Writes a real, working module fixture — unlike `createFixtureModule`, this entrypoint does not
* throw on import; it pushes lifecycle markers onto `recorder.events` and can be told to fail at a
* specific hook, so tests can prove exactly what a successful (or a specifically-failing) module
* runtime startup actually executes and in what order, relative to pool/server construction. */
async function createWorkingFixtureModule(
modulesDirectory: string,
id: string,
recorder: { expression: string },
options: WorkingFixtureModuleOptions = {},
): Promise<void> {
const permissionId = `${id}.ping.read`;
const displayPermission = `${id}.display.view`;
const permissions = [
...(options.withRoute
? [{ baseline: "public" as const, description: "Ping.", id: permissionId }]
: []),
...(options.withDisplay
? [
{
baseline: "restricted" as const,
description: "View the display.",
id: displayPermission,
},
]
: []),
];
await createFixtureModule(modulesDirectory, id, {
entrypoints: {
server: "./dist/server/index.js",
...(options.withDisplay
? {
display: {
artifact: "./dist/display/index.js",
permission: displayPermission,
},
}
: {}),
},
permissions,
...(options.dependencies ? { dependencies: options.dependencies } : {}),
});
if (options.withDisplay) {
await mkdir(path.join(modulesDirectory, id, "dist/display"), { recursive: true });
await writeFile(
path.join(modulesDirectory, id, "dist/display/index.js"),
'throw new Error("display artifact must never execute during server startup");\n',
);
}
const routeSource = options.withRoute
? `http.route({
method: "GET",
path: "/ping",
permission: ${JSON.stringify(permissionId)},
maxBodyBytes: 0,
handler: async () => new Response("pong", { status: 200 }),
});`
: "";
const serverSource = `export default {
id: ${JSON.stringify(id)},
version: "0.1.0",
register({ http }) {
${recorder.expression}.push(${JSON.stringify(`${id}:register`)});
${routeSource}
${options.registerThrows ? 'throw new Error("register failed");' : ""}
},
start() {
${recorder.expression}.push(${JSON.stringify(`${id}:start`)});
${options.startThrows ? 'throw new Error("start failed");' : ""}
},
stop() {
${recorder.expression}.push(${JSON.stringify(`${id}:stop`)});
${options.stopThrows ? 'throw new Error("stop failed");' : ""}
${options.stopNeverSettles ? "return new Promise(() => {});" : ""}
},
};`;
await writeFile(path.join(modulesDirectory, id, "dist/server/index.js"), serverSource);
}
/** A minimal, in-memory `DiscoveredModule` double built from a real, validated manifest — used
* only to reach `composePlatformPermissionRegistry`'s own duplicate-detection failure, which is
* unreachable through real filesystem discovery (module ids are unique and every permission id is
* namespaced by its own module's id, so no two genuinely discovered modules can ever collide). */
function fakeDiscoveredModule(
id: string,
permissions: ReadonlyArray<{
id: string;
baseline: "public" | "authenticated" | "restricted";
description: string;
}>,
): DiscoveredModule {
const manifest = parseModuleManifest({
capabilities: [],
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: { server: "./dist/does-not-exist/index.js" },
id,
manifestVersion: 8,
name: `${id} module`,
permissions,
version: "0.1.0",
});
return {
contributions: [],
directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest,
manifestPath: `/modules/${id}/ekklesia.module.json`,
packageName: `@test/${id}`,
};
}
/** A minimal fake `ServerType`: enough of Node's `http.Server` shape for `bindServer`/`shutdown`
* to drive without a real socket. `close` and `once` are spies so tests can assert call counts;
* `triggerError` simulates the server's own `'error'` event (e.g. a bind failure). */
type ClientErrorHandler = (error: NodeJS.ErrnoException, socket: import("node:net").Socket) => void;
function fakeServer() {
let errorHandler: ((error: Error) => void) | undefined;
let clientErrorHandler: ClientErrorHandler | undefined;
const server = {
address: () => ({ address: "127.0.0.1", family: "IPv4", port: 4_000 }),
close: vi.fn((callback?: () => void) => {
callback?.();
return server;
}),
listening: true,
on: vi.fn((event: string, handler: ClientErrorHandler) => {
if (event === "clientError") {
clientErrorHandler = handler;
}
return server;
}),
once: vi.fn((event: string, handler: (error: Error) => void) => {
if (event === "error") {
errorHandler = handler;
}
return server;
}),
};
return {
/** The `clientError` handler `bindServer` registered — the real event path Node uses for a
* header/request timeout, a parse failure, and a header overflow alike. */
clientErrorHandler: () => clientErrorHandler,
server,
triggerError: (error: Error) => errorHandler?.(error),
};
}
/** Resolves the listening callback asynchronously, matching real `@hono/node-server` behavior
* (the callback fires on the `listening` event, after `serve()` itself has already returned) —
* resolving synchronously would reference `bindServer`'s own `server` const before it is
* initialized. */
function mockSuccessfulServe() {
const { clientErrorHandler, server, triggerError } = fakeServer();
serveMock.mockImplementation((_options: unknown, listeningListener?: () => void) => {
queueMicrotask(() => listeningListener?.());
return server;
});
return { clientErrorHandler, server, triggerError };
}
function mockFailingServe(error: Error) {
const { server, triggerError } = fakeServer();
serveMock.mockImplementation(() => {
queueMicrotask(() => triggerError(error));
return server;
});
return server;
}
/** Captures signal handlers registered via `process.on` without ever installing a real one, so a
* test can trigger "SIGINT"/"SIGTERM" by calling the captured function directly. */
function mockProcessOn(): { handlers: Record<string, () => void>; restore: () => void } {
const handlers: Record<string, () => void> = {};
const fake = vi.fn((event: string, handler: () => void) => {
handlers[event] = handler;
return process;
});
const onSpy = vi.spyOn(process, "on").mockImplementation(fake as unknown as typeof process.on);
return { handlers, restore: () => onSpy.mockRestore() };
}
describe("Ekklesia API import safety", () => {
const originalEnv = { ...process.env };
const originalProcessOn = process.on.bind(process);
beforeEach(() => {
poolConstructor.mockClear();
betterAuthMock.mockClear();
drizzleAdapterMock.mockClear();
serveMock.mockClear();
processOnSpy.mockClear();
for (const key of Object.keys(process.env)) {
if (!(key in originalEnv)) {
delete process.env[key];
}
}
process.env = { ...originalEnv };
process.on = originalProcessOn;
});
it("does not construct a database pool, Better Auth, or an HTTP server merely by importing the app factory", async () => {
await import("../src/app.js");
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
});
it("does not construct a database pool, Better Auth, or an HTTP server merely by importing configuration", async () => {
await import("../src/config.js");
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
});
it("does not read environment variables, connect to PostgreSQL, construct Better Auth, start a server, or install signal handlers merely by importing the executable server module", async () => {
// Deliberately leave every runtime-required environment variable unset. If importing this
// module performed any runtime composition, missing configuration would throw here.
delete process.env.DATABASE_URL;
delete process.env.EKKLESIA_IDENTITY_SECRET;
delete process.env.EKKLESIA_IDENTITY_BASE_URL;
delete process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS;
const onSpy = vi.spyOn(process, "on").mockImplementation(processOnSpy);
await expect(import("../src/server.js")).resolves.toBeDefined();
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(drizzleAdapterMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
expect(processOnSpy).not.toHaveBeenCalled();
onSpy.mockRestore();
});
it("exposes startApi and main as explicit functions without invoking them at import time", async () => {
const module = await import("../src/server.js");
expect(typeof module.startApi).toBe("function");
expect(typeof module.main).toBe("function");
expect(serveMock).not.toHaveBeenCalled();
});
});
describe("Ekklesia API shutdown lifecycle", () => {
beforeEach(() => {
poolConstructor.mockReset();
betterAuthMock.mockReset();
drizzleAdapterMock.mockClear();
serveMock.mockReset();
});
it("makes every concurrent shutdown caller await the exact same server-close and pool-close, each exactly once", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
const { server } = mockSuccessfulServe();
const running = await startApi(validConfig());
await Promise.all([running.shutdown(), running.shutdown(), running.shutdown()]);
expect(server.close).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("constructs exactly one physical PostgreSQL pool for both the identity and authorization views, and closes only that one pool on shutdown", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
const running = await startApi(validConfig());
// Identity (via Better Auth's Drizzle adapter) and authorization both read from the same
// running API, yet only one `new Pool(...)` call is ever made — proving the shared-pool
// composition in apps/api/src/database.ts never falls back to a second, independent pool.
expect(poolConstructor).toHaveBeenCalledTimes(1);
await running.shutdown();
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("answers readiness with one bounded ping on the shared pool, and turns it 503 the moment shutdown begins", async () => {
const query = emptyQuery();
poolConstructor.mockImplementation(function fakePool() {
return { end: vi.fn().mockResolvedValue(undefined), on: vi.fn(), query };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
const running = await startApi(validConfig());
query.mockClear();
const ready = await running.app.request(HEALTH_READY_PATH);
expect(ready.status).toBe(200);
expect(query).toHaveBeenCalledTimes(1);
// The statement timeout is server-side and scoped to this one transaction, so PostgreSQL itself
// cancels a stalled ping rather than the route merely abandoning it.
expect(query).toHaveBeenCalledWith(
`BEGIN READ ONLY; SET LOCAL statement_timeout = ${READINESS_DATABASE_TIMEOUT_MS}; SELECT 1; COMMIT`,
);
const shutdown = running.shutdown();
const draining = await running.app.request(HEALTH_READY_PATH);
expect(draining.status).toBe(503);
expect(await draining.json()).toEqual({ code: "NOT_READY" });
// Draining is not dead: liveness still answers while in-flight requests finish.
expect((await running.app.request(HEALTH_LIVE_PATH)).status).toBe(200);
expect(query).toHaveBeenCalledTimes(1);
await shutdown;
});
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
const { server } = mockSuccessfulServe();
const running = await startApi(validConfig());
await running.shutdown();
await running.shutdown();
expect(server.close).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("closes an already-created database pool when identity construction fails during startup", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockImplementation(() => {
throw new Error("postgresql://admin:[email protected]/x");
});
await expect(startApi(validConfig())).rejects.toMatchObject({
code: "IDENTITY_CONSTRUCTION_FAILED",
});
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("refuses a production configuration that enables public signup before any pool, Better Auth instance, module runtime, or server exists", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
// The rejection happens in `createApiRuntimeConfig` itself — the same boundary the executable
// entry point calls before `startApi` — so `startApi` is never even reachable with such a
// configuration, and no runtime resource is ever constructed to leak.
let captured: unknown;
try {
createApiRuntimeConfig(
{
databaseUrl: "postgresql://ekklesia:[email protected]:5432/ekklesia",
host: "127.0.0.1",
identity: {
baseUrl: "https://identity.example.test",
publicSignupEnabled: true,
secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: ["https://identity.example.test"],
},
port: 0,
},
{ environment: "production" },
);
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" });
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
expect(poolEnd).not.toHaveBeenCalled();
});
it("refuses an unsafe production database transport before any pool, Better Auth instance, module runtime, or server exists", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
// Both an explicit production plaintext selection and a URL that would silently downgrade the
// policy are rejected in `createApiRuntimeConfig` — the same boundary the executable entry
// point calls before `startApi` — so no runtime resource is ever constructed to leak.
for (const [label, input] of [
[
"explicit production plaintext",
{
databaseTransport: { mode: "disable" },
databaseUrl: "postgresql://ekklesia:[email protected]:5432/ekklesia",
},
],
[
"connection-string TLS downgrade",
{
databaseUrl:
"postgresql://ekklesia:[email protected]:5432/ekklesia?sslmode=disable",
},
],
] as const) {
let captured: unknown;
try {
createApiRuntimeConfig(
{
host: "127.0.0.1",
identity: {
baseUrl: "https://identity.example.test",
secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: ["https://identity.example.test"],
},
port: 0,
...input,
},
{ environment: "production" },
);
} catch (error) {
captured = error;
}
expect(captured, `expected ${label} to be rejected`).toBeInstanceOf(Error);
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
expect(poolEnd).not.toHaveBeenCalled();
}
});
it("closes an already-created database pool when the server fails to bind its listen address", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockFailingServe(new Error("EADDRINUSE"));
await expect(startApi(validConfig())).rejects.toThrow();
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("wires SIGINT/SIGTERM to the same shutdown lifecycle and only calls the injected exit, never the real process.exit", async () => {
const { handlers, restore } = mockProcessOn();
const shutdown = vi.fn().mockResolvedValue(undefined);
const exit = vi.fn();
installShutdownSignalHandlers({ shutdown }, exit);
expect(handlers.SIGINT).toEqual(expect.any(Function));
expect(handlers.SIGTERM).toEqual(expect.any(Function));
handlers.SIGINT?.();
await Promise.resolve();
await Promise.resolve();
expect(shutdown).toHaveBeenCalledTimes(1);
expect(exit).toHaveBeenCalledWith(0);
// A second signal after the first must not trigger a second shutdown.
handlers.SIGTERM?.();
await Promise.resolve();
expect(shutdown).toHaveBeenCalledTimes(1);
expect(exit).toHaveBeenCalledTimes(1);
restore();
});
it("exits with a failure code, without throwing, when signal-triggered shutdown itself fails", async () => {
const { handlers, restore } = mockProcessOn();
const shutdown = vi.fn().mockRejectedValue(new Error("pool close failed"));
const exit = vi.fn();
installShutdownSignalHandlers({ shutdown }, exit);
handlers.SIGTERM?.();
await Promise.resolve();
await Promise.resolve();
expect(exit).toHaveBeenCalledWith(1);
restore();
});
});
describe("Ekklesia API Node server timeouts", () => {
beforeEach(() => {
poolConstructor.mockReset();
betterAuthMock.mockReset();
drizzleAdapterMock.mockClear();
serveMock.mockReset();
});
it("configures finite, typed header/request/keep-alive timeouts on the underlying Node server", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
const running = await startApi(validConfig());
expect(serveMock).toHaveBeenCalledTimes(1);
const [options] = serveMock.mock.calls[0] as [
{
serverOptions?: {
connectionsCheckingInterval?: number;
headersTimeout?: number;
keepAliveTimeout?: number;
requestTimeout?: number;
};
},
];
expect(options.serverOptions?.headersTimeout).toBeGreaterThan(0);
expect(options.serverOptions?.requestTimeout).toBeGreaterThan(0);
expect(options.serverOptions?.keepAliveTimeout).toBeGreaterThan(0);
// Node only enforces headersTimeout/requestTimeout when this periodic check runs (verified
// directly against the pinned Node runtime — its own default, 30s, would otherwise let a
// violating connection sit for up to an additional 30s past its actual timeout).
expect(options.serverOptions?.connectionsCheckingInterval).toBeGreaterThan(0);
expect(options.serverOptions?.connectionsCheckingInterval).toBeLessThan(30_000);
// Node requires headersTimeout not to exceed requestTimeout.
expect(options.serverOptions?.headersTimeout).toBeLessThanOrEqual(
options.serverOptions?.requestTimeout ?? 0,
);
await running.shutdown();
});
it("allows a test-only override to narrow the timeouts without changing the production defaults", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
const running = await startApi(validConfig(), {
nodeServerTimeoutOverride: { headersTimeoutMs: 50, requestTimeoutMs: 100 },
});
const [options] = serveMock.mock.calls[0] as [
{
serverOptions?: {
headersTimeout?: number;
keepAliveTimeout?: number;
requestTimeout?: number;
};
},
];
expect(options.serverOptions?.headersTimeout).toBe(50);
expect(options.serverOptions?.requestTimeout).toBe(100);
// The un-overridden keep-alive timeout keeps its production default.
expect(options.serverOptions?.keepAliveTimeout).toBeGreaterThan(0);
await running.shutdown();
});
// Whether Node actually *emits* anything for a real header/request timeout — and that exactly one
// 408 is written, the socket closes, and no handler runs — is proven only at the real transport
// boundary, by the raw-socket scenarios in `apps/api/tests/api-http.integration.ts`. Node's
// general `'timeout'` event is NOT that path (it belongs to `server.setTimeout()`, which this
// server never arms); `'clientError'` is. The tests below therefore deliberately do not claim to
// prove the event path at all — they cover only the response-reproduction logic that Node's own
// default would otherwise have performed, including the duplicate-response guard, which is
// impractical to drive from a real socket.
function fakeErrorSocket(overrides: { headersSent?: boolean; writable?: boolean } = {}) {
const socket = {
_httpMessage:
overrides.headersSent === undefined ? undefined : { headersSent: overrides.headersSent },
destroy: vi.fn(),
destroyed: false,
writable: overrides.writable ?? true,
write: vi.fn(),
};
return socket as unknown as import("node:net").Socket & {
destroy: ReturnType<typeof vi.fn>;
write: ReturnType<typeof vi.fn>;
};
}
async function startAndCaptureClientErrorHandler() {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
const { clientErrorHandler } = mockSuccessfulServe();
const running = await startApi(validConfig());
const handler = clientErrorHandler();
if (!handler) {
throw new Error("Expected startApi to register a clientError handler.");
}
return { handler, running };
}
it("reproduces Node's own client-error responses byte-for-byte, since registering a listener suppresses them", async () => {
const { handler, running } = await startAndCaptureClientErrorHandler();
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
for (const [code, expected] of [
["ERR_HTTP_REQUEST_TIMEOUT", "HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n"],
[
"HPE_HEADER_OVERFLOW",
"HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n",
],
["HPE_INVALID_METHOD", "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"],
[undefined, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"],
] as const) {
const socket = fakeErrorSocket();
const error = Object.assign(new Error("parser detail that must never be logged"), { code });
handler(error, socket);
expect(socket.write).toHaveBeenCalledTimes(1);
expect(socket.write).toHaveBeenCalledWith(expected);
expect(socket.destroy).toHaveBeenCalledTimes(1);
}
await running.shutdown();
} finally {
errorSpy.mockRestore();
}
});
it("never writes a second response when a handler already began responding, but still closes the socket", async () => {
const { handler, running } = await startAndCaptureClientErrorHandler();
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
const socket = fakeErrorSocket({ headersSent: true });
handler(Object.assign(new Error("timeout"), { code: "ERR_HTTP_REQUEST_TIMEOUT" }), socket);
expect(socket.write).not.toHaveBeenCalled();
expect(socket.destroy).toHaveBeenCalledTimes(1);
// An unwritable socket is likewise never written to, only destroyed.
const closedSocket = fakeErrorSocket({ writable: false });
handler(
Object.assign(new Error("timeout"), { code: "ERR_HTTP_REQUEST_TIMEOUT" }),
closedSocket,
);
expect(closedSocket.write).not.toHaveBeenCalled();
expect(closedSocket.destroy).toHaveBeenCalledTimes(1);
await running.shutdown();
} finally {
errorSpy.mockRestore();
}
});
it("logs one fixed sanitized line per client error, never the caught error's own message", async () => {
const { handler, running } = await startAndCaptureClientErrorHandler();
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
handler(
Object.assign(new Error("Parse Error: /secret-path?token=abc123"), {
code: "HPE_INVALID_METHOD",
}),
fakeErrorSocket(),
);
expect(errorSpy).toHaveBeenCalledTimes(1);
const emitted = errorSpy.mock.calls.flat().map(String).join("\n");
expect(emitted).not.toContain("secret-path");
expect(emitted).not.toContain("abc123");
expect(emitted).not.toContain("Parse Error");
expect(emitted).not.toContain("HPE_");
expect(emitted).not.toMatch(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/u);
await running.shutdown();
} finally {
errorSpy.mockRestore();
}
});
});
describe("Ekklesia API module permission composition at startup", () => {
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
beforeEach(() => {
poolConstructor.mockReset();
betterAuthMock.mockReset();
drizzleAdapterMock.mockClear();
serveMock.mockReset();
});
it("discovers and composes permissions before constructing the database pool, Better Auth, or the HTTP server", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
// An incompatible platform version fails discovery itself (MODULE_PLATFORM_INCOMPATIBLE),
// before permission composition — and, since composition runs before any pool exists, before
// any pool, Better Auth instance, or HTTP server could ever be constructed.
await createFixtureModule(modulesDirectory, "incompatible", {
compatibility: { moduleSdk: "^0.1.0", platform: ">=99.0.0" },
});
await expect(
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
).rejects.toBeInstanceOf(ModuleDiscoveryError);
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
});
it("fails startup when a module claims the reserved core.* permission namespace, creating no pool, Better Auth instance, or server", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
// Only a module whose own id is literally "core" can produce a "core."-prefixed permission
// that still passes the manifest's own module-namespace check.
await createFixtureModule(modulesDirectory, "core", {
permissions: [
{ baseline: "restricted", description: "Not really Core's.", id: "core.rogue.permission" },
],
});
let captured: unknown;
try {
await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(ModulePermissionCompositionError);
expect(captured).toMatchObject({ code: "MODULE_PERMISSION_RESERVED_NAMESPACE" });
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
});
it("fails startup on a duplicate permission declaration, creating no pool, Better Auth instance, or server", async () => {
const first = fakeDiscoveredModule("calendar", [
{ baseline: "restricted", description: "Read events.", id: "calendar.events.read" },
]);
const second = fakeDiscoveredModule("calendar", [
{ baseline: "public", description: "Also read events.", id: "calendar.events.read" },
]);
let captured: unknown;
try {
await startApi(validConfig(), { discoveredModulesOverride: [first, second] });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(PermissionRegistryError);
expect(captured).toMatchObject({ code: "PERMISSION_REGISTRY_DUPLICATE_DECLARATION" });
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
});
it("keeps a reserved-namespace failure and a generic duplicate-declaration failure distinguishable", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
await createFixtureModule(modulesDirectory, "core", {
permissions: [
{
baseline: "restricted",
description: "Attempting to shadow Core's own permission.",
id: "core.authorization.grants.manage",
},
],
});
let reservedNamespaceError: unknown;
try {
await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
} catch (error) {
reservedNamespaceError = error;
}
const duplicateModule = fakeDiscoveredModule("calendar", [
{ baseline: "restricted", description: "Read events.", id: "calendar.events.read" },
]);
const duplicateModuleAgain = fakeDiscoveredModule("calendar", [
{ baseline: "restricted", description: "Read events, again.", id: "calendar.events.read" },
]);
let duplicateError: unknown;
try {
await startApi(validConfig(), {
discoveredModulesOverride: [duplicateModule, duplicateModuleAgain],
});
} catch (error) {
duplicateError = error;
}
// Even though the first scenario's module attempted to exactly collide with an existing
// Core-owned permission id, it is reported as a reserved-namespace violation, never as a
// generic duplicate — the two failure families never overlap.
expect(reservedNamespaceError).toBeInstanceOf(ModulePermissionCompositionError);
expect(reservedNamespaceError).not.toBeInstanceOf(PermissionRegistryError);
expect(duplicateError).toBeInstanceOf(PermissionRegistryError);
expect(duplicateError).not.toBeInstanceOf(ModulePermissionCompositionError);
});
it("still constructs exactly one pool and succeeds when zero modules are installed", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
expect(poolConstructor).toHaveBeenCalledTimes(1);
await running.shutdown();
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("discovers installed modules from the configured modules directory (ADR 0048)", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
await createFixtureModule(modulesDirectory, "incompatible", {
compatibility: { moduleSdk: "^0.1.0", platform: ">=99.0.0" },
});
// The module that fails discovery lives only in the configured directory, so this rejection
// proves discovery read it rather than the repository's own `modules/`.
await expect(
startApi(validConfig(), { configuredModulesDirectory: modulesDirectory }),
).rejects.toBeInstanceOf(ModuleDiscoveryError);
expect(poolConstructor).not.toHaveBeenCalled();
});
it("starts with zero modules from an empty configured modules directory", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
poolConstructor.mockImplementation(function fakePool() {
return { end: vi.fn().mockResolvedValue(undefined), on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
const running = await startApi(validConfig(), { configuredModulesDirectory: modulesDirectory });
expect(poolConstructor).toHaveBeenCalledTimes(1);
await running.shutdown();
});
it.each([
["a relative path", "relative/private-mount-5b2a", "MODULES_DIRECTORY_INVALID"],
["a missing directory", "/nonexistent/private-mount-5b2a", "MODULES_DIRECTORY_UNREADABLE"],
])("fails closed on %s, before any pool exists, naming no path", async (_label, configured, code) => {
let captured: unknown;
try {
await startApi(validConfig(), { configuredModulesDirectory: configured });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(ModuleDiscoveryError);
const issues = (captured as ModuleDiscoveryError).issues;
expect(issues.map((issue) => issue.code)).toEqual([code]);
expect(JSON.stringify(issues)).not.toContain("private-mount-5b2a");
expect((captured as Error).message).not.toContain("private-mount-5b2a");
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
});
it("lets the test-only override take precedence over a configured directory", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
poolConstructor.mockImplementation(function fakePool() {
return { end: vi.fn().mockResolvedValue(undefined), on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
// The configured value is unusable; the override wins, so it is never even resolved.
const running = await startApi(validConfig(), {
configuredModulesDirectory: "relative/never-resolved",
modulesDirectoryOverride: modulesDirectory,
});
expect(poolConstructor).toHaveBeenCalledTimes(1);
await running.shutdown();
});
it("does not perform module discovery merely by importing the platform-permissions module", async () => {
const module = await import("../src/platform-permissions.js");
expect(typeof module.composeStartupPermissionRegistry).toBe("function");
expect(poolConstructor).not.toHaveBeenCalled();
});
});
describe("Ekklesia API module runtime startup and shutdown", () => {
afterEach(async () => {
const globalValues = globalThis as unknown as Record<PropertyKey, unknown>;
for (const symbol of eventSymbols.splice(0)) {
delete globalValues[symbol];
}
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
beforeEach(() => {
poolConstructor.mockReset();
betterAuthMock.mockReset();
drizzleAdapterMock.mockClear();
serveMock.mockReset();
loadDisplayHostAssetsMock.mockReset();
vi.mocked(createApiApp).mockClear();
});
function mockPoolAndAuth(): { poolEnd: ReturnType<typeof vi.fn> } {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
return { poolEnd };
}
it("fails on missing display-host assets before importing module code and still closes startup resources", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "announcements", recorder, {
withDisplay: true,
});
const { poolEnd } = mockPoolAndAuth();
loadDisplayHostAssetsMock.mockRejectedValueOnce(new DisplayHostAssetError());
const failure = await startApi(validConfig(), {
modulesDirectoryOverride: modulesDirectory,
}).catch((error: unknown) => error);
expect(failure).toMatchObject({
code: "DISPLAY_HOST_ASSET_UNAVAILABLE",
message: "The display host browser assets are unavailable.",
name: "DisplayHostAssetError",
});
expect((failure as Error).stack).toBe(
"DisplayHostAssetError: The display host browser assets are unavailable.",
);
expect(loadDisplayHostAssetsMock).toHaveBeenCalledTimes(1);
expect(recorder.events).toEqual([]);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("fails on missing Dashboard assets before importing module code and still closes startup resources", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder);
const { poolEnd } = mockPoolAndAuth();
loadDashboardHostAssetsMock.mockRejectedValueOnce(new DashboardHostAssetError());
const failure = await startApi(validConfig(), {
modulesDirectoryOverride: modulesDirectory,
}).catch((error: unknown) => error);
expect(failure).toMatchObject({
code: "DASHBOARD_HOST_ASSET_UNAVAILABLE",
message: "The Dashboard host browser assets are unavailable.",
name: "DashboardHostAssetError",
});
expect((failure as Error).stack).toBe(
"DashboardHostAssetError: The Dashboard host browser assets are unavailable.",
);
expect(recorder.events).toEqual([]);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("does not read display-host assets when the eligible set declares no display", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder);
mockPoolAndAuth();
mockSuccessfulServe();
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
expect(loadDisplayHostAssetsMock).not.toHaveBeenCalled();
await running.shutdown();
});
it("starts the module runtime from the exact discovered inventory permission composition used, mounting routes in discovery order", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "alpha", recorder, { withRoute: true });
await createWorkingFixtureModule(modulesDirectory, "beta", recorder, { withRoute: true });
mockPoolAndAuth();
mockSuccessfulServe();
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
expect(recorder.events).toEqual([
"alpha:register",
"beta:register",
"alpha:start",
"beta:start",
]);
// Hono records one internal route entry per handler in a route's middleware chain, not one
// per logical route, so entries for the same method/path are deduplicated (preserving first-
// seen order) before asserting mounting order.
const moduleRoutePaths = [
...new Set(
running.app.routes
.filter((route) => route.path.startsWith("/api/v1/modules/"))
.map((route) => `${route.method} ${route.path}`),
),
];
expect(moduleRoutePaths).toEqual([
"GET /api/v1/modules/alpha/ping",
"GET /api/v1/modules/beta/ping",
]);
await running.shutdown();
});
it("gives a module's HTTP handler the exact same database facade object its own start hook received", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const refs = createReferenceRecorder();
await createFixtureModule(modulesDirectory, "db-identity", {
permissions: [{ baseline: "public", description: "Ping.", id: "db-identity.ping.read" }],
});
await writeFile(
path.join(modulesDirectory, "db-identity", "dist/server/index.js"),
`export default {
id: "db-identity",
version: "0.1.0",
register({ http }) {
http.route({
method: "GET",
path: "/ping",
permission: "db-identity.ping.read",
maxBodyBytes: 0,
handler: async (_request, context) => {
${refs.expression}.push(context.services.database);
return new Response(null, { status: 204 });
},
});
},
start(context) {
${refs.expression}.push(context.services.database);
}
};`,
);
mockPoolAndAuth();
mockSuccessfulServe();
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
const response = await running.app.request("/api/v1/modules/db-identity/ping");
expect(response.status).toBe(204);
expect(refs.values).toHaveLength(2);
expect(refs.values[0]).toBeDefined();
expect(refs.values[0]).toBe(refs.values[1]);
await running.shutdown();
});
it("closes the pool exactly once and binds no server when a module's entrypoint fails to load", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
await createFixtureModule(modulesDirectory, "broken-export");
await writeFile(
path.join(modulesDirectory, "broken-export", "dist/server/index.js"),
"export default { thisIsNotAValidRuntimeDefinition: true };",
);
const { poolEnd } = mockPoolAndAuth();
await expect(
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
).rejects.toMatchObject({
issues: [expect.objectContaining({ code: "MODULE_RUNTIME_EXPORT_INVALID" })],
});
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("closes the pool exactly once and binds no server when a module's register hook fails", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "register-fails", recorder, {
registerThrows: true,
});
const { poolEnd } = mockPoolAndAuth();
await expect(
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
).rejects.toMatchObject({
issues: [expect.objectContaining({ code: "MODULE_REGISTER_FAILED" })],
});
expect(recorder.events).toEqual(["register-fails:register"]);
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("closes the pool exactly once and binds no server when a module's start hook fails, preserving Core's rollback of already-started modules", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "one", recorder);
await createWorkingFixtureModule(modulesDirectory, "two", recorder, {
dependencies: { modules: { required: { one: "^0.1.0" } } },
startThrows: true,
});
const { poolEnd } = mockPoolAndAuth();
await expect(
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
).rejects.toMatchObject({
issues: [expect.objectContaining({ code: "MODULE_START_FAILED" })],
});
// The module whose start hook failed is rolled back first through its own stop hook, then the
// module that had already started, in reverse order.
expect(recorder.events).toEqual([
"one:register",
"two:register",
"one:start",
"two:start",
"two:stop",
"one:stop",
]);
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("stops the module runtime and closes the pool, binding no server, when app construction fails", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "mounted", recorder);
const { poolEnd } = mockPoolAndAuth();
vi.mocked(createApiApp).mockImplementationOnce(() => {
throw new Error("forced app construction failure");
});
await expect(
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
).rejects.toThrow("forced app construction failure");
expect(recorder.events).toEqual(["mounted:register", "mounted:start", "mounted:stop"]);
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("stops the module runtime and closes the pool when the server fails to bind", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "mounted", recorder);
const { poolEnd } = mockPoolAndAuth();
mockFailingServe(new Error("EADDRINUSE"));
await expect(
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
).rejects.toThrow();
expect(recorder.events).toEqual(["mounted:register", "mounted:start", "mounted:stop"]);
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("drains the HTTP server, then stops the module runtime, then closes the pool, in that exact order", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "ordered", recorder);
poolConstructor.mockImplementation(function fakePool() {
return {
end: vi.fn(async () => {
recorder.events.push("pool:end");
}),
on: vi.fn(),
query: emptyQuery(),
};
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
const { server } = mockSuccessfulServe();
const originalClose = server.close;
server.close = vi.fn((callback?: () => void) => {
recorder.events.push("server:close");
return originalClose(callback);
});
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
recorder.events.length = 0;
await running.shutdown();
expect(recorder.events).toEqual(["server:close", "ordered:stop", "pool:end"]);
});
it("gives a module's stop hook the exact same database facade object its start hook received, while the pool is still open", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const refs = createReferenceRecorder();
await createFixtureModule(modulesDirectory, "db-stop-identity");
await writeFile(
path.join(modulesDirectory, "db-stop-identity", "dist/server/index.js"),
`export default {
id: "db-stop-identity",
version: "0.1.0",
start(context) { ${refs.expression}.push(context.services.database); },
stop(context) { ${refs.expression}.push(context.services.database); }
};`,
);
const { poolEnd } = mockPoolAndAuth();
mockSuccessfulServe();
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
await running.shutdown();
expect(refs.values).toHaveLength(2);
expect(refs.values[0]).toBeDefined();
expect(refs.values[0]).toBe(refs.values[1]);
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("still runs pool cleanup and lets the shutdown rejection surface when the module runtime fails to stop", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "stop-fails", recorder, {
stopThrows: true,
});
const { poolEnd } = mockPoolAndAuth();
mockSuccessfulServe();
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
// Shutdown now aggregates which stages failed; the module-runtime error is carried intact.
await expect(running.shutdown()).rejects.toMatchObject({
drainTimedOut: false,
moduleStopError: {
issues: [expect.objectContaining({ code: "MODULE_STOP_FAILED", moduleId: "stop-fails" })],
},
moduleStopFailed: true,
name: "ApiShutdownError",
});
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("keeps a direct discoveredModulesOverride driving both permission composition and module runtime startup", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder, { withRoute: true });
const discovered = await (await import("@ekklesia/core/modules")).discoverModules({
moduleSdkVersion: "0.2.0",
modulesDirectory,
platformVersion: "0.2.0",
});
mockPoolAndAuth();
mockSuccessfulServe();
const running = await startApi(validConfig(), { discoveredModulesOverride: discovered });
expect(recorder.events).toEqual(["calendar:register", "calendar:start"]);
// Hono records one internal route entry per handler in a route's middleware chain, not one
// per logical route, so entries for the same method/path are deduplicated (preserving first-
// seen order) before asserting mounting order.
const moduleRoutePaths = [
...new Set(
running.app.routes
.filter((route) => route.path.startsWith("/api/v1/modules/"))
.map((route) => `${route.method} ${route.path}`),
),
];
expect(moduleRoutePaths).toEqual(["GET /api/v1/modules/calendar/ping"]);
await running.shutdown();
});
it("performs no filesystem I/O, module import, pool construction, Better Auth construction, or server binding merely by importing the module-route adapter", async () => {
const module = await import("../src/module-http-routes.js");
expect(typeof module.mountModuleHttpRoutes).toBe("function");
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
});
/**
* Pass 4 correction: the API composition's own bounded-cleanup guarantees.
*
* Core proves an individual hook deadline in isolation; these prove that `startApi`/`shutdown`
* actually reach pool closure through those deadlines, which is where the platform's promise ("the
* pool always closes") is either kept or broken. Every deadline here is a short, test-only
* override; production keeps the 10-second hook and 30-second drain defaults.
*/
describe("Ekklesia API bounded module cleanup", () => {
it("bounds a never-settling startup rollback, still rolls back earlier modules, closes the pool, and binds no listener", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "one", recorder);
await createWorkingFixtureModule(modulesDirectory, "two", recorder, {
dependencies: { modules: { required: { one: "^0.1.0" } } },
startThrows: true,
stopNeverSettles: true,
});
const { poolEnd } = mockPoolAndAuth();
await expect(
startApi(validConfig(), {
moduleHookTimeoutMsOverride: 25,
modulesDirectoryOverride: modulesDirectory,
}),
).rejects.toMatchObject({
issues: [
expect.objectContaining({ code: "MODULE_START_FAILED", moduleId: "two" }),
expect.objectContaining({ code: "MODULE_ROLLBACK_TIMEOUT", moduleId: "two" }),
],
});
// The failing module's own stop hook was attempted first and timed out; the earlier module
// still received its rollback afterwards.
expect(recorder.events).toEqual([
"one:register",
"two:register",
"one:start",
"two:start",
"two:stop",
"one:stop",
]);
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("bounds a never-settling normal stop, attempts the earlier module, and still closes the pool", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "one", recorder);
await createWorkingFixtureModule(modulesDirectory, "two", recorder, {
dependencies: { modules: { required: { one: "^0.1.0" } } },
stopNeverSettles: true,
});
const { poolEnd } = mockPoolAndAuth();
mockSuccessfulServe();
const running = await startApi(validConfig(), {
moduleHookTimeoutMsOverride: 25,
modulesDirectoryOverride: modulesDirectory,
});
await expect(running.shutdown()).rejects.toMatchObject({
drainTimedOut: false,
moduleStopError: {
issues: [expect.objectContaining({ code: "MODULE_STOP_TIMEOUT", moduleId: "two" })],
},
moduleStopFailed: true,
name: "ApiShutdownError",
});
expect(recorder.events.slice(-2)).toEqual(["two:stop", "one:stop"]);
expect(poolEnd).toHaveBeenCalledTimes(1);
// Repeated and concurrent callers share the one memoized result and restart nothing.
const [first, second] = await Promise.allSettled([running.shutdown(), running.shutdown()]);
expect(first.status).toBe("rejected");
expect(second.status).toBe("rejected");
expect(recorder.events.filter((event) => event === "two:stop")).toHaveLength(1);
expect(recorder.events.filter((event) => event === "one:stop")).toHaveLength(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("reports a drain timeout and a module-stop timeout together, after the pool has closed", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "stalls", recorder, {
stopNeverSettles: true,
});
const { poolEnd } = mockPoolAndAuth();
// A server whose `close` callback never fires stands in for a connection that never drains,
// deterministically and without a real socket. `closeIdleConnections`/`closeAllConnections`
// are the supported public APIs the platform calls at the deadline.
const closeAllConnections = vi.fn();
const closeIdleConnections = vi.fn();
const { server } = fakeServer();
// `close` deliberately never invokes its callback: nothing drains, which is exactly what the
// drain deadline exists for — deterministic, with no real socket and no sleep.
const stalled = Object.assign(server, {
close: vi.fn(() => server),
closeAllConnections,
closeIdleConnections,
});
serveMock.mockImplementation((_options: unknown, listeningListener?: () => void) => {
// Deferred exactly like `mockSuccessfulServe`: `bindServer` resolves with `server`, which
// is still in its temporal dead zone during the synchronous `serve()` call.
queueMicrotask(() => listeningListener?.());
return stalled;
});
const running = await startApi(validConfig(), {
drainTimeoutMsOverride: 25,
moduleHookTimeoutMsOverride: 25,
modulesDirectoryOverride: modulesDirectory,
});
let captured: unknown;
try {
await running.shutdown();
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({
drainTimedOut: true,
moduleStopFailed: true,
name: "ApiShutdownError",
});
const issues = (captured as { moduleStopError: { issues: Array<{ code: string }> } })
.moduleStopError.issues;
expect(issues).toEqual([
expect.objectContaining({ code: "MODULE_STOP_TIMEOUT", moduleId: "stalls" }),
]);
// The platform force-closed through Node's public APIs, then still stopped modules and closed
// the pool — neither deadline skipped a later stage.
expect(closeIdleConnections).toHaveBeenCalledTimes(1);
expect(closeAllConnections).toHaveBeenCalledTimes(1);
expect(recorder.events).toContain("stalls:stop");
expect(poolEnd).toHaveBeenCalledTimes(1);
// Nothing about the request, socket, connection, or module exception leaks.
const publicText = `${String(captured)}\n${(captured as Error).stack ?? ""}\n${JSON.stringify(
captured,
)}`;
for (const secret of ["127.0.0.1", "postgres", "password", "select ", "Error: stop"]) {
expect(publicText.toLowerCase()).not.toContain(secret.toLowerCase());
}
});
});
});