Add a single platform-owned request-body policy (hono/body-limit) with small, named limits for Better Auth and Core's grant-mutation endpoint, and a new required maxBodyBytes field on the executable module route contract so every module route makes a conscious no-body-or-bounded-body choice, capped at a platform ceiling. Every oversized request gets one sanitized 413 contract and never reaches business logic, authorization, or a handler. Also configure finite Node header/request/keep-alive timeouts, including the non-obvious connectionsCheckingInterval needed for them to take effect promptly, so a slow-header or slow-body connection cannot hold a socket indefinitely. Co-Authored-By: Claude Sonnet 5 <[email protected]>
1036 lines
41 KiB
TypeScript
1036 lines
41 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());
|
|
|
|
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<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 { 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" },
|
|
);
|
|
}
|
|
|
|
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: 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<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;
|
|
readonly dependencies?: Record<string, unknown>;
|
|
}
|
|
|
|
/** 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`;
|
|
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). */
|
|
function fakeServer() {
|
|
let errorHandler: ((error: Error) => void) | undefined;
|
|
let timeoutHandler: (() => void) | 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: () => void) => {
|
|
if (event === "timeout") {
|
|
timeoutHandler = handler;
|
|
}
|
|
return server;
|
|
}),
|
|
once: vi.fn((event: string, handler: (error: Error) => void) => {
|
|
if (event === "error") {
|
|
errorHandler = handler;
|
|
}
|
|
return server;
|
|
}),
|
|
};
|
|
return {
|
|
server,
|
|
triggerError: (error: Error) => errorHandler?.(error),
|
|
triggerTimeout: () => timeoutHandler?.(),
|
|
};
|
|
}
|
|
|
|
/** 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 { server, triggerError, triggerTimeout } = fakeServer();
|
|
serveMock.mockImplementation((_options: unknown, listeningListener?: () => void) => {
|
|
queueMicrotask(() => listeningListener?.());
|
|
return server;
|
|
});
|
|
return { server, triggerError, triggerTimeout };
|
|
}
|
|
|
|
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() };
|
|
});
|
|
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:[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("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();
|
|
});
|
|
|
|
it("logs a sanitized operational event, and nothing else, when the underlying server reports a timeout", 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 { triggerTimeout } = mockSuccessfulServe();
|
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
|
|
try {
|
|
const running = await startApi(validConfig());
|
|
triggerTimeout();
|
|
|
|
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
const [message] = errorSpy.mock.calls[0] as [string];
|
|
expect(message).toContain("timeout");
|
|
expect(message).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<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();
|
|
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() };
|
|
});
|
|
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" })],
|
|
});
|
|
|
|
expect(recorder.events).toEqual([
|
|
"one:register",
|
|
"two:register",
|
|
"one:start",
|
|
"two:start",
|
|
"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 });
|
|
|
|
await expect(running.shutdown()).rejects.toMatchObject({
|
|
issues: [expect.objectContaining({ code: "MODULE_STOP_FAILED", moduleId: "stop-fails" })],
|
|
});
|
|
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();
|
|
});
|
|
});
|