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()); 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 })); // 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(); 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 { installShutdownSignalHandlers, startApi } from "../src/server.js"; function validConfig() { return createApiRuntimeConfig( { databaseUrl: "postgresql://ekklesia:ekklesia@127.0.0.1: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" }, ); } const temporaryDirectories: string[] = []; async function createTemporaryModulesDirectory(): Promise { 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 = {}, ): Promise { const moduleDirectory = path.join(modulesDirectory, id); const manifest = { manifestVersion: 3, id, name: `${id} module`, version: "0.1.0", compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.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)[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)[symbol] = values; eventSymbols.push(symbol); return { values, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` }; } interface WorkingFixtureModuleOptions { /** Declares one `GET /ping` route against `.ping.read` when true. */ readonly withRoute?: boolean; readonly registerThrows?: boolean; readonly startThrows?: boolean; readonly stopThrows?: boolean; readonly dependencies?: Record; } /** 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 { const permissionId = `${id}.ping.read`; await createFixtureModule(modulesDirectory, id, { permissions: options.withRoute ? [{ baseline: "public", description: "Ping.", id: permissionId }] : [], ...(options.dependencies ? { dependencies: options.dependencies } : {}), }); 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");' : ""} }, };`; 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.1.0", platform: "^0.1.0" }, entrypoints: { server: "./dist/does-not-exist/index.js" }, id, manifestVersion: 3, name: `${id} module`, permissions, version: "0.1.0", }); return { 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 void>; restore: () => void } { const handlers: Record 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() }; }); 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() }; }); 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("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() }; }); 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() }; }); betterAuthMock.mockImplementation(() => { throw new Error("postgresql://admin:secret@db.internal.example/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() }; }); 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:ekklesia@127.0.0.1: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() }; }); 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:ekklesia@db.example.test:5432/ekklesia", }, ], [ "connection-string TLS downgrade", { databaseUrl: "postgresql://ekklesia:ekklesia@db.example.test: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() }; }); 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() }; }); 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() }; }); 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; write: ReturnType; }; } async function startAndCaptureClientErrorHandler() { const poolEnd = vi.fn().mockResolvedValue(undefined); poolConstructor.mockImplementation(function fakePool() { return { end: poolEnd, on: vi.fn() }; }); 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() }; }); 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("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; 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(); vi.mocked(createApiApp).mockClear(); }); function mockPoolAndAuth(): { poolEnd: ReturnType } { const poolEnd = vi.fn().mockResolvedValue(undefined); poolConstructor.mockImplementation(function fakePool() { return { end: poolEnd, on: vi.fn() }; }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); return { poolEnd }; } 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(), }; }); 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.1.0", modulesDirectory, platformVersion: "0.1.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(); }); });