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