fix: harden identity HTTP transport
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user