fix: harden identity HTTP transport
CI / verify (pull_request) Successful in 25s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 25s

This commit is contained in:
ekklesia
2026-07-21 23:50:41 -05:00
parent c91f0c8c6d
commit d9b73fd81a
15 changed files with 967 additions and 72 deletions
+190
View File
@@ -11,6 +11,84 @@ vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
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" },
);
}
/** 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;
const server = {
address: () => ({ address: "127.0.0.1", family: "IPv4", port: 4_000 }),
close: vi.fn((callback?: () => void) => {
callback?.();
return server;
}),
listening: true,
once: vi.fn((event: string, handler: (error: Error) => void) => {
if (event === "error") {
errorHandler = handler;
}
return server;
}),
};
return {
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 { server, triggerError } = fakeServer();
serveMock.mockImplementation((_options: unknown, listeningListener?: () => void) => {
queueMicrotask(() => listeningListener?.());
return server;
});
return { 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);
@@ -73,3 +151,115 @@ describe("Ekklesia API import safety", () => {
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("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(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(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();
});
});