From 0c9d8a5d585060e3760684ccb458af46846b147f Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Fri, 14 Aug 2026 15:23:53 +0200 Subject: [PATCH] fix(#707): route the remaining test servers through a loopback listen helper #703 fixed the mechanism but could only convert the call sites that already waited for their `listening` callback. The remaining 57 read `server.address().port` synchronously on the next line, which stops working the moment a host is passed: `listen` then goes through the `dns.lookup` path even for an IP literal and no longer binds synchronously. They now go through `test/_helpers/listenLoopback.ts`, which binds 127.0.0.1 and resolves on `listening`. Enclosing hooks and three helper functions became async to match. canvas-core's WebSocket stub server had the same shape and now binds the loopback too. Also removed 23 now-dead `await once('listening')` waits that followed a converted site: the helper already resolves after `listening`, so the second wait could never fire and hung 12 files to the 120s test timeout. Those files had been awaiting correctly all along -- they only ever needed the host. Corrects #703's explanation. That entry claimed the wildcard socket is IPV6_V6ONLY. Measured on macOS it is not: `[::]` is dual-stack and http://127.0.0.1: normally reaches it, which is why the bug presented as intermittent rather than as a hard failure. The real mechanism is that the wildcard bind's port is chosen only against other wildcard binds, while a process binding 127.0.0.1: specifically may already hold it -- and on BSD/macOS the more specific bind coexists with the wildcard and wins for connections to 127.0.0.1. Local dev servers bind 127.0.0.1 by default, which is why the observed shadowers were an MCP server and a Flask app. Closes #707 --- docs/CHANGELOG.md | 28 +++++++++++++ .../packages/canvas-core/tools/stubServer.ts | 6 ++- middleware/test/_helpers/listenLoopback.ts | 41 +++++++++++++++++++ .../test/auth/adminAuthProvidersRoute.test.ts | 5 ++- middleware/test/auth/adminUsersRoute.test.ts | 3 +- middleware/test/auth/requireApiKey.test.ts | 31 +++++++------- middleware/test/auth/setupRoute.test.ts | 4 +- .../test/builder/builderPreviewRoutes.test.ts | 7 ++-- middleware/test/channelRouteRebind.test.ts | 4 +- .../test/chatRouterAgentRouting.test.ts | 9 ++-- .../test/chatSessionsRouterGraceful.test.ts | 5 ++- middleware/test/cliBackendDetector.test.ts | 7 ++-- .../test/conductorWebhookInbound.test.ts | 4 +- middleware/test/datasetsRoute.test.ts | 4 +- middleware/test/devGraphPlansCache.test.ts | 3 +- middleware/test/devGraphRouter.test.ts | 5 ++- .../test/devplatform/devPlatform.e2e.test.ts | 4 +- .../test/devplatform/devPlatformGates.test.ts | 4 +- .../devplatform/devPlatformGithubApp.test.ts | 7 ++-- .../devPlatformPipeline.wire.pg.test.ts | 4 +- .../devplatform/devPlatformRoutes.harness.ts | 14 ++----- .../devplatform/devPlatformRoutes.test.ts | 3 +- .../test/devplatform/devRunnerApi.harness.ts | 10 ++--- .../test/devplatform/devRunnerApi.test.ts | 3 +- .../test/devplatform/devWebhooks.test.ts | 10 ++--- .../devWebhooksConcurrency.pg.test.ts | 4 +- .../devplatform/goldenFixture.e2e.test.ts | 4 +- .../devplatform/scopedScmToken.pg.test.ts | 4 +- middleware/test/diagramsRouter.test.ts | 5 ++- middleware/test/documentsRouter.test.ts | 3 +- middleware/test/memoryPurgeRoute.test.ts | 4 +- middleware/test/operatorAgentsRouter.test.ts | 7 ++-- middleware/test/profilesImportRoute.test.ts | 5 ++- middleware/test/profilesRouter.test.ts | 7 ++-- .../publicMcpKeyBindingsAdmin.test.ts | 21 +++++----- middleware/test/registryConfig.test.ts | 5 ++- middleware/test/registryInstallMerge.test.ts | 29 ++++++------- middleware/test/registrySetupProfile.test.ts | 5 ++- .../test/storeProviderCollision.test.ts | 4 +- .../test/storeReadinessProjection.test.ts | 10 ++--- middleware/test/uiPrefsRoute.test.ts | 4 +- 41 files changed, 212 insertions(+), 134 deletions(-) create mode 100644 middleware/test/_helpers/listenLoopback.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 50e368232..b1ca3f260 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -18,6 +18,34 @@ entry. See `CONTRIBUTING.md` § Releases & changelog. ## [Unreleased] +### Fixed — the rest of the test servers now bind the port they dial (#707) + +- **The remaining 57 `listen(0)` sites are converted.** #703 fixed the + mechanism but could only convert the call sites that already waited for + their `listening` callback. The rest read `server.address().port` + synchronously on the next line, which stops working the moment a host is + passed — `listen` then goes through the `dns.lookup` path even for an IP + literal and no longer binds synchronously. They now go through a + `listenLoopback()` helper that binds 127.0.0.1 and resolves on `listening`, + so no test server is left holding a port it never dials. +- **A correction to #703's explanation.** That entry said the wildcard socket + is `IPV6_V6ONLY`. Measured on macOS, it is not: `[::]` is dual-stack and + `http://127.0.0.1:` normally reaches it, which is exactly why the bug + presented as intermittent rather than as a hard failure. The real mechanism + is that the wildcard bind's port is chosen only against other wildcard + binds, while a process that binds `127.0.0.1:` **specifically** may + already hold it — and on BSD/macOS the more specific bind coexists with the + wildcard and wins for connections to 127.0.0.1. Local dev servers bind + 127.0.0.1 by default, which is why the observed shadowers were an MCP server + and a Flask app. The fix and its rationale are unchanged; only the + description of *why* the port was unprotected was wrong. +- `canvas-core`'s WebSocket stub server had the same shape (`port: 0`, no + host, callers dialling `ws://127.0.0.1:`) and now binds the loopback + too. +- Removed 23 now-dead `await once('listening')` waits that followed a + converted site. The helper already resolves after `listening`, so a second + wait could never fire — it hung 12 files to the 120s test timeout. + ### Fixed — test servers bound a port they never dialled (CI flake) - **Intermittent 401/404 in the middleware test suite.** A test would fail with diff --git a/middleware/packages/canvas-core/tools/stubServer.ts b/middleware/packages/canvas-core/tools/stubServer.ts index 2b04b9c3d..b71a7e97e 100644 --- a/middleware/packages/canvas-core/tools/stubServer.ts +++ b/middleware/packages/canvas-core/tools/stubServer.ts @@ -24,7 +24,11 @@ function stamp(message: Record, turnId: string, canvasSessionId } export function startStubServer(port = 0): Promise<{ port: number; close: () => Promise }> { - const wss = new WebSocketServer({ port, path: '/omadia-ui/canvas' }); + // `host` is explicit for the same reason the HTTP test helper binds it: with + // `port = 0` and no host the socket lands on the wildcard, whose chosen port + // is not reserved against a process holding that port on 127.0.0.1 — the + // address every caller below actually dials. + const wss = new WebSocketServer({ port, host: '127.0.0.1', path: '/omadia-ui/canvas' }); wss.on('connection', (ws: WebSocket) => { const handshakeId = `hs-${Math.random().toString(36).slice(2)}`; diff --git a/middleware/test/_helpers/listenLoopback.ts b/middleware/test/_helpers/listenLoopback.ts new file mode 100644 index 000000000..84910376b --- /dev/null +++ b/middleware/test/_helpers/listenLoopback.ts @@ -0,0 +1,41 @@ +import type { Server } from 'node:http'; + +/** + * Start a test server on a free port of the IPv4 loopback and resolve once it + * is actually listening. + * + * WHY NOT A BARE `listen(0)` + * -------------------------- + * `listen(0)` with no host binds the wildcard `[::]`. That socket is + * dual-stack, so `http://127.0.0.1:` normally reaches it — which is why + * the bug this replaces looked intermittent rather than simply broken. + * + * The port, though, is only chosen against other *wildcard* binds. A process + * that binds `127.0.0.1:` specifically may already hold that exact port, + * and on BSD/macOS the more specific bind coexists with the wildcard and + * **wins** for connections addressed to 127.0.0.1. Local dev servers bind + * 127.0.0.1 by default, so this is common: a request meant for the harness is + * answered by whatever else is listening. Observed in practice — an MCP server + * replying `401 … provide valid authorization token`, a Flask app replying + * `404 `, and a non-HTTP peer that surfaced as + * `HTTPParserError: Response does not match the HTTP/1.1 protocol`. + * + * Binding 127.0.0.1 explicitly makes the reserved port and the dialled port + * the same port, so a collision is an honest `EADDRINUSE` instead of a test + * silently talking to a stranger. + * + * WHY THIS IS ASYNC + * ----------------- + * Passing a host sends the call through the `dns.lookup` path even for an IP + * literal, so the bind no longer completes synchronously and + * `server.address()` is `null` on the next line. Awaiting `listening` is the + * whole reason this helper exists rather than one extra argument at each site. + */ +export function listenLoopback(target: { + listen(port: number, host: string, cb: () => void): Server; +}): Promise { + return new Promise((resolve, reject) => { + const server = target.listen(0, '127.0.0.1', () => { resolve(server); }); + server.once('error', reject); + }); +} diff --git a/middleware/test/auth/adminAuthProvidersRoute.test.ts b/middleware/test/auth/adminAuthProvidersRoute.test.ts index 812836307..0885f2937 100644 --- a/middleware/test/auth/adminAuthProvidersRoute.test.ts +++ b/middleware/test/auth/adminAuthProvidersRoute.test.ts @@ -15,6 +15,7 @@ import { } from '../../src/auth/providerRegistry.js'; import type { AuthProvider } from '../../src/auth/providers/AuthProvider.js'; import { createAdminAuthRouter } from '../../src/routes/adminAuth.js'; +import { listenLoopback } from '../_helpers/listenLoopback.js'; /** * Provider-toggle integration test. Stubs the platform-settings KV + @@ -98,7 +99,7 @@ describe('/api/v1/admin/auth/providers router', () => { let audit: InMemoryAuditLog; let session: ForgedSession; - before(() => { + before(async () => { catalog = new ProviderCatalog(); catalog.add(fakeLocal); catalog.add(fakeEntra); @@ -130,7 +131,7 @@ describe('/api/v1/admin/auth/providers router', () => { audit: audit as unknown as AdminAuditLog, }), ); - server = app.listen(0); + server = await listenLoopback(app); const addr = server.address() as AddressInfo; baseUrl = `http://127.0.0.1:${String(addr.port)}`; }); diff --git a/middleware/test/auth/adminUsersRoute.test.ts b/middleware/test/auth/adminUsersRoute.test.ts index 3c8548802..50544b548 100644 --- a/middleware/test/auth/adminUsersRoute.test.ts +++ b/middleware/test/auth/adminUsersRoute.test.ts @@ -14,6 +14,7 @@ import type { UserStore, } from '../../src/auth/userStore.js'; import { createAdminUsersRouter } from '../../src/routes/adminUsers.js'; +import { listenLoopback } from '../_helpers/listenLoopback.js'; /** * Postgres-free integration test for the admin-users router. Stubs out @@ -167,7 +168,7 @@ describe('/api/v1/admin/users router', () => { audit: audit as unknown as AdminAuditLog, }), ); - server = app.listen(0); + server = await listenLoopback(app); const addr = server.address() as AddressInfo; baseUrl = `http://127.0.0.1:${String(addr.port)}`; }); diff --git a/middleware/test/auth/requireApiKey.test.ts b/middleware/test/auth/requireApiKey.test.ts index 99b02ed0d..801dadb31 100644 --- a/middleware/test/auth/requireApiKey.test.ts +++ b/middleware/test/auth/requireApiKey.test.ts @@ -10,6 +10,7 @@ import { createAuditLog } from '../../packages/harness-api-key-auth/src/auditLog import { createRateLimiter } from '../../packages/harness-api-key-auth/src/rateLimiter.js'; import { requireApiKey } from '../../packages/harness-api-key-auth/src/requireApiKey.js'; import { createFakeSecrets } from '../channelApi/testSecrets.js'; +import { listenLoopback } from '../_helpers/listenLoopback.js'; /** * Issue #439 — the reusable half of the story: any route, kernel or plugin, @@ -17,16 +18,16 @@ import { createFakeSecrets } from '../channelApi/testSecrets.js'; * key instead of the `omadia_session` cookie. Mirrors the router-level * fixture style of `test/channelApi/chatRouter.test.ts`. */ -function startGuardedServer(opts: { +async function startGuardedServer(opts: { scope?: string; withRateLimiter?: boolean; -}): { +}): Promise<{ baseUrl: string; apiKeys: ReturnType; auditLog: ReturnType; secrets: ReturnType; close: () => Promise; -} { +}> { const secrets = createFakeSecrets(); const apiKeys = createApiKeyStore(secrets); const auditLog = createAuditLog(secrets); @@ -47,7 +48,7 @@ function startGuardedServer(opts: { res.json({ keyId: req.apiKey?.keyId, scopes: req.apiKey?.scopes }); }, ); - const server: Server = app.listen(0); + const server: Server = await listenLoopback(app); const addr = server.address() as AddressInfo; return { baseUrl: `http://127.0.0.1:${String(addr.port)}/guarded`, @@ -59,10 +60,10 @@ function startGuardedServer(opts: { } describe('auth/requireApiKey — authentication', () => { - let harness: ReturnType; + let harness: Awaited>; - before(() => { - harness = startGuardedServer({}); + before(async () => { + harness = await startGuardedServer({}); }); after(async () => { await harness.close(); @@ -123,7 +124,7 @@ describe('auth/requireApiKey — authentication', () => { }); it('does not audit an unauthenticated call — there is no caller identity to attribute', async () => { - const local = startGuardedServer({}); + const local = await startGuardedServer({}); await fetch(local.baseUrl); await fetch(local.baseUrl, { headers: { authorization: 'Bearer omk_nope' } }); assert.equal((await local.auditLog.list()).length, 0); @@ -133,7 +134,7 @@ describe('auth/requireApiKey — authentication', () => { describe('auth/requireApiKey — scopes', () => { it('403s a key that lacks the required scope, and audits it as forbidden', async () => { - const local = startGuardedServer({ scope: 'memory:read' }); + const local = await startGuardedServer({ scope: 'memory:read' }); const created = await local.apiKeys.create({ label: 'chat-only' }); const res = await fetch(local.baseUrl, { @@ -153,7 +154,7 @@ describe('auth/requireApiKey — scopes', () => { }); it('lets a key with the exact scope through', async () => { - const local = startGuardedServer({ scope: 'memory:read' }); + const local = await startGuardedServer({ scope: 'memory:read' }); const created = await local.apiKeys.create({ scopes: ['memory:read'] }); const res = await fetch(local.baseUrl, { headers: { authorization: `Bearer ${created.token}` }, @@ -163,7 +164,7 @@ describe('auth/requireApiKey — scopes', () => { }); it('lets a wildcard key through any scope gate', async () => { - const local = startGuardedServer({ scope: 'memory:read' }); + const local = await startGuardedServer({ scope: 'memory:read' }); const created = await local.apiKeys.create({ scopes: ['*'] }); const res = await fetch(local.baseUrl, { headers: { authorization: `Bearer ${created.token}` }, @@ -173,7 +174,7 @@ describe('auth/requireApiKey — scopes', () => { }); it('authenticates without any scope gate when `scope` is omitted', async () => { - const local = startGuardedServer({}); + const local = await startGuardedServer({}); const created = await local.apiKeys.create({ scopes: ['memory:read'] }); const res = await fetch(local.baseUrl, { headers: { authorization: `Bearer ${created.token}` }, @@ -188,7 +189,7 @@ describe('auth/requireApiKey — scopes', () => { // used to hydrate to `['chat:write']`, so a key deliberately restricted // away from chat authenticated against a `chat:write` route. for (const corrupt of ['memory:read', ['Chat:Write'], ['chat:write', 'nonsense'], []]) { - const local = startGuardedServer({ scope: 'chat:write' }); + const local = await startGuardedServer({ scope: 'chat:write' }); const created = await local.apiKeys.create({ label: 'restricted', scopes: ['memory:read'] }); const raw = await local.secrets.get(`key:${created.record.id}`); assert.ok(raw); @@ -213,7 +214,7 @@ describe('auth/requireApiKey — scopes', () => { describe('auth/requireApiKey — rate limiting', () => { it('429s past the per-key budget and audits it, without invoking the handler', async () => { - const local = startGuardedServer({ withRateLimiter: true }); + const local = await startGuardedServer({ withRateLimiter: true }); const created = await local.apiKeys.create({ rateLimitPerMinute: 1 }); const first = await fetch(local.baseUrl, { @@ -241,7 +242,7 @@ describe('auth/requireApiKey — rate limiting', () => { }); it('burns quota before the scope check, so scope probing is not free', async () => { - const local = startGuardedServer({ withRateLimiter: true, scope: 'memory:read' }); + const local = await startGuardedServer({ withRateLimiter: true, scope: 'memory:read' }); const created = await local.apiKeys.create({ rateLimitPerMinute: 1, scopes: ['chat:write'] }); const first = await fetch(local.baseUrl, { diff --git a/middleware/test/auth/setupRoute.test.ts b/middleware/test/auth/setupRoute.test.ts index 77facf25b..d93fdfda1 100644 --- a/middleware/test/auth/setupRoute.test.ts +++ b/middleware/test/auth/setupRoute.test.ts @@ -19,6 +19,7 @@ import { providerVerifiedAtVaultKey, } from '../../src/platform/providerCredentialVerifier.js'; import type { SecretVault } from '../../src/secrets/vault.js'; +import { listenLoopback } from '../_helpers/listenLoopback.js'; /** * OB-61 — /api/v1/auth/setup integration test. Drives the route via @@ -203,8 +204,7 @@ async function startHarness(opts: { return originalFetch(input as RequestInfo, init); }) as typeof fetch; - const server = app.listen(0); - await new Promise((resolve) => server.once('listening', () => resolve())); + const server = await listenLoopback(app); const port = (server.address() as AddressInfo).port; return { diff --git a/middleware/test/builder/builderPreviewRoutes.test.ts b/middleware/test/builder/builderPreviewRoutes.test.ts index 3bfa4934c..d692f5ea5 100644 --- a/middleware/test/builder/builderPreviewRoutes.test.ts +++ b/middleware/test/builder/builderPreviewRoutes.test.ts @@ -24,6 +24,7 @@ import type { } from '../../src/plugins/builder/previewRuntime.js'; import { createBuilderRouter } from '../../src/routes/builder.js'; import type { BuildPipeline } from '../../src/plugins/builder/buildPipeline.js'; +import { listenLoopback } from '../_helpers/listenLoopback.js'; // Augment express Request with the session shape the production code expects. declare module 'express-serve-static-core' { @@ -283,8 +284,7 @@ async function startHarness(opts: { }), ); - const server = app.listen(0); - await new Promise((resolve) => server.once('listening', resolve)); + const server = await listenLoopback(app); const port = (server.address() as AddressInfo).port; const baseUrl = `http://127.0.0.1:${String(port)}`; @@ -800,8 +800,7 @@ describe('builder preview routes', () => { next(); }); app.use('/api/v1/builder', createBuilderRouter({ store, quota })); - const server = app.listen(0); - await new Promise((resolve) => server.once('listening', resolve)); + const server = await listenLoopback(app); const port = (server.address() as AddressInfo).port; const res = await fetch(`http://127.0.0.1:${String(port)}/api/v1/builder/models`); diff --git a/middleware/test/channelRouteRebind.test.ts b/middleware/test/channelRouteRebind.test.ts index 8e50a1719..4f5b77bb2 100644 --- a/middleware/test/channelRouteRebind.test.ts +++ b/middleware/test/channelRouteRebind.test.ts @@ -5,6 +5,7 @@ import { after, before, describe, it } from 'node:test'; import express, { type Express } from 'express'; import { ExpressRouteRegistry } from '../src/channels/routeRegistry.js'; +import { listenLoopback } from './_helpers/listenLoopback.js'; /** Regression coverage for #395: a plugin hot-reinstall must rebind the * inbound handler in place, not serve the stale first-mounted route. */ @@ -21,8 +22,7 @@ describe('ExpressRouteRegistry · hot-reinstall handler rebind (#395)', () => { app = express(); app.use(express.json()); registry = new ExpressRouteRegistry(app); - server = app.listen(0); - await new Promise((resolve) => server.once('listening', resolve)); + server = await listenLoopback(app); const { port } = server.address() as AddressInfo; baseUrl = `http://127.0.0.1:${port}`; }); diff --git a/middleware/test/chatRouterAgentRouting.test.ts b/middleware/test/chatRouterAgentRouting.test.ts index e143de986..3500dd05f 100644 --- a/middleware/test/chatRouterAgentRouting.test.ts +++ b/middleware/test/chatRouterAgentRouting.test.ts @@ -29,6 +29,7 @@ import type { SessionConfigSnapshot, } from '@omadia/orchestrator'; import { createChatRouter } from '../src/routes/chat.js'; +import { listenLoopback } from './_helpers/listenLoopback.js'; function fakeChatAgent(label: string): ChatAgent { return { @@ -83,7 +84,7 @@ describe('createChatRouter (Phase A)', () => { let availableAgents: Map; let fallbackSlug: string | undefined; - function mountApp(): void { + async function mountApp(): Promise { const app = express(); app.use(express.json()); app.use( @@ -101,18 +102,18 @@ describe('createChatRouter (Phase A)', () => { }), }), ); - server = app.listen(0); + server = await listenLoopback(app); const addr = server.address() as AddressInfo; baseUrl = `http://127.0.0.1:${String(addr.port)}/api/chat`; } - before(() => { + before(async () => { store = new FakeStore(); availableAgents = new Map(); availableAgents.set(SLUG_PUBLIC, fakeChatAgent(SLUG_PUBLIC)); availableAgents.set(SLUG_GENERAL, fakeChatAgent(SLUG_GENERAL)); fallbackSlug = SLUG_PUBLIC; - mountApp(); + await mountApp(); }); after(async () => { diff --git a/middleware/test/chatSessionsRouterGraceful.test.ts b/middleware/test/chatSessionsRouterGraceful.test.ts index 6f5fba162..ac5ddde64 100644 --- a/middleware/test/chatSessionsRouterGraceful.test.ts +++ b/middleware/test/chatSessionsRouterGraceful.test.ts @@ -20,6 +20,7 @@ import express from 'express'; import type { ChatSession, ChatSessionStore } from '@omadia/orchestrator'; import { createChatSessionsRouter } from '../src/routes/chatSessions.js'; +import { listenLoopback } from './_helpers/listenLoopback.js'; class FakeStore implements Pick { sessions: ChatSession[] = []; @@ -35,14 +36,14 @@ describe('createChatSessionsRouter — graceful (getStore)', () => { // assigning it simulates the orchestrator publishing it after the wizard. let liveStore: ChatSessionStore | undefined; - before(() => { + before(async () => { const app = express(); app.use(express.json()); app.use( '/api/chat', createChatSessionsRouter({ getStore: () => liveStore }), ); - server = app.listen(0); + server = await listenLoopback(app); const addr = server.address() as AddressInfo; baseUrl = `http://127.0.0.1:${String(addr.port)}/api/chat`; }); diff --git a/middleware/test/cliBackendDetector.test.ts b/middleware/test/cliBackendDetector.test.ts index 5a78c04e8..bef6c9d0d 100644 --- a/middleware/test/cliBackendDetector.test.ts +++ b/middleware/test/cliBackendDetector.test.ts @@ -13,6 +13,7 @@ import { } from '../src/platform/cliBackendDetector.js'; import { createAdminCliBackendsRouter } from '../src/routes/adminCliBackends.js'; import { claudeCliAdapter } from '../src/platform/claudeCliAdapter.js'; +import { listenLoopback } from './_helpers/listenLoopback.js'; describe('cliBackendDetector', () => { afterEach(() => { @@ -131,7 +132,7 @@ describe('adminCliBackends route', () => { it('GET / returns the detection snapshot as JSON', async () => { const app = express(); app.use('/api/v1/admin/cli-backends', createAdminCliBackendsRouter()); - server = app.listen(0); + server = await listenLoopback(app); const port = (server.address() as AddressInfo).port; const res = await fetch(`http://127.0.0.1:${port}/api/v1/admin/cli-backends`); @@ -148,7 +149,7 @@ describe('adminCliBackends route', () => { const app = express(); app.use(express.json()); app.use('/api/v1/admin/cli-backends', createAdminCliBackendsRouter()); - server = app.listen(0); + server = await listenLoopback(app); const port = (server.address() as AddressInfo).port; const res = await fetch(`http://127.0.0.1:${port}/api/v1/admin/cli-backends/claude/login/code`, { @@ -163,7 +164,7 @@ describe('adminCliBackends route', () => { const app = express(); app.use(express.json()); app.use('/api/v1/admin/cli-backends', createAdminCliBackendsRouter()); - server = app.listen(0); + server = await listenLoopback(app); const port = (server.address() as AddressInfo).port; const res = await fetch(`http://127.0.0.1:${port}/api/v1/admin/cli-backends/claude/login/cancel`, { diff --git a/middleware/test/conductorWebhookInbound.test.ts b/middleware/test/conductorWebhookInbound.test.ts index ec1f1ba80..fa5e76195 100644 --- a/middleware/test/conductorWebhookInbound.test.ts +++ b/middleware/test/conductorWebhookInbound.test.ts @@ -11,6 +11,7 @@ import { type ConductorWebhookEmitResult, } from '../src/routes/conductorWebhooksInbound.js'; import type { WebhookClaimResult, WebhookInboundOutcome } from '../src/conductor/webhookEndpointStore.js'; +import { listenLoopback } from './_helpers/listenLoopback.js'; // Issue #437 — inbound Conductor webhook route: signature-first verification // (unknown endpoint and wrong secret must answer byte-for-byte the same 401), @@ -74,8 +75,7 @@ async function harness( const app = express(); app.use(createConductorWebhooksInboundRouter(() => deps)); - const server = app.listen(0); - await new Promise((r) => server.once('listening', r)); + const server = await listenLoopback(app); const port = (server.address() as AddressInfo).port; return { base: `http://127.0.0.1:${port}`, outcomes, emitCalls, close: () => new Promise((r) => server.close(() => r())) }; } diff --git a/middleware/test/datasetsRoute.test.ts b/middleware/test/datasetsRoute.test.ts index 98710a656..d1b95a9c5 100644 --- a/middleware/test/datasetsRoute.test.ts +++ b/middleware/test/datasetsRoute.test.ts @@ -9,6 +9,7 @@ import type { NextFunction, Request, Response } from 'express'; import { InMemoryKnowledgeGraph } from '@omadia/knowledge-graph-inmemory'; import { createDatasetsRouter } from '../src/routes/datasets.js'; +import { listenLoopback } from './_helpers/listenLoopback.js'; /** * HTTP integration test for the #430 datasets REST surface, mirroring the @@ -45,8 +46,7 @@ async function makeHarness( const app = express(); app.use(express.json()); app.use(MOUNT, withSession(userId), createDatasetsRouter({ graph })); - const server: Server = app.listen(0); - await new Promise((resolve) => server.once('listening', resolve)); + const server: Server = await listenLoopback(app); const { port } = server.address() as AddressInfo; return { baseUrl: `http://127.0.0.1:${String(port)}${MOUNT}`, diff --git a/middleware/test/devGraphPlansCache.test.ts b/middleware/test/devGraphPlansCache.test.ts index 60d49dca1..2ec9d9d56 100644 --- a/middleware/test/devGraphPlansCache.test.ts +++ b/middleware/test/devGraphPlansCache.test.ts @@ -9,6 +9,7 @@ import { type PlanWithSteps, } from '../src/routes/devGraph.js'; import { PlanScopeCache } from '../src/routes/planScopeCache.js'; +import { listenLoopback } from './_helpers/listenLoopback.js'; // #133 — the dev `/plans` overlay endpoint: batched step fetch + a short-TTL // per-scope cache. We count listPlansForScope calls to prove a second request @@ -60,7 +61,7 @@ describe('/api/dev/graph/plans — batched + cached', () => { }); const app = express(); app.use('/api/dev/graph', createDevGraphRouter({ graph, planCache })); - server = app.listen(0); + server = await listenLoopback(app); const addr = server.address() as AddressInfo; baseUrl = `http://127.0.0.1:${String(addr.port)}/api/dev/graph`; }); diff --git a/middleware/test/devGraphRouter.test.ts b/middleware/test/devGraphRouter.test.ts index a3b90033a..265e748c1 100644 --- a/middleware/test/devGraphRouter.test.ts +++ b/middleware/test/devGraphRouter.test.ts @@ -4,6 +4,7 @@ import type { AddressInfo } from 'node:net'; import express from 'express'; import { InMemoryKnowledgeGraph } from '@omadia/knowledge-graph-inmemory'; import { createDevGraphRouter } from '../src/routes/devGraph.js'; +import { listenLoopback } from './_helpers/listenLoopback.js'; describe('/api/dev/graph router', () => { let server: import('node:http').Server; @@ -13,7 +14,7 @@ describe('/api/dev/graph router', () => { before(async () => { const app = express(); app.use('/api/dev/graph', createDevGraphRouter({ graph })); - server = app.listen(0); + server = await listenLoopback(app); const addr = server.address() as AddressInfo; baseUrl = `http://127.0.0.1:${String(addr.port)}/api/dev/graph`; @@ -121,7 +122,7 @@ describe('/api/dev/graph router · memories endpoint', () => { before(async () => { const app = express(); app.use('/api/dev/graph', createDevGraphRouter({ graph })); - server = app.listen(0); + server = await listenLoopback(app); const addr = server.address() as AddressInfo; baseUrl = `http://127.0.0.1:${String(addr.port)}/api/dev/graph`; diff --git a/middleware/test/devplatform/devPlatform.e2e.test.ts b/middleware/test/devplatform/devPlatform.e2e.test.ts index afc165005..beafe87b4 100644 --- a/middleware/test/devplatform/devPlatform.e2e.test.ts +++ b/middleware/test/devplatform/devPlatform.e2e.test.ts @@ -35,6 +35,7 @@ import type { RunnerBackend, RunnerHandle, } from '../../src/devplatform/types.js'; +import { listenLoopback } from '../_helpers/listenLoopback.js'; /** * Epic #470 W0 — the wire unit's end-to-end proof (spec §11/§12). Two parts: @@ -342,8 +343,7 @@ describe('devplatform e2e (pg)', { skip: !pgAvailable }, () => { app.use('/api', requireAuth, (_req, _res, next) => { next(); }); - server = app.listen(0); - await new Promise((r) => server.once('listening', r)); + server = await listenLoopback(app); baseUrl = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}`; wired = assembleDevPlatform({ diff --git a/middleware/test/devplatform/devPlatformGates.test.ts b/middleware/test/devplatform/devPlatformGates.test.ts index 7d8fe043b..c3c744a61 100644 --- a/middleware/test/devplatform/devPlatformGates.test.ts +++ b/middleware/test/devplatform/devPlatformGates.test.ts @@ -6,6 +6,7 @@ import express, { type RequestHandler } from 'express'; import { createDevPlatformGatesRouter, type DevPlatformGatesDeps } from '../../src/devplatform/routes/devPlatformGates.js'; import type { DevJobGate, GateAnswer } from '../../src/devplatform/pipeline/gateStore.js'; +import { listenLoopback } from '../_helpers/listenLoopback.js'; function gate(over: Partial = {}): DevJobGate { return { @@ -71,8 +72,7 @@ async function harness(seed: DevJobGate[], roles: Record = {}, const app = express(); app.use(inject); app.use('/api/v1/admin/dev-platform', createDevPlatformGatesRouter(deps)); - const server = app.listen(0); - await new Promise((r) => server.once('listening', r)); + const server = await listenLoopback(app); const base = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}/api/v1/admin/dev-platform`; return { base, diff --git a/middleware/test/devplatform/devPlatformGithubApp.test.ts b/middleware/test/devplatform/devPlatformGithubApp.test.ts index 4b8158994..d4a63648e 100644 --- a/middleware/test/devplatform/devPlatformGithubApp.test.ts +++ b/middleware/test/devplatform/devPlatformGithubApp.test.ts @@ -17,6 +17,7 @@ import type { DevGithubAppInstallation, DevGithubAppSecrets, } from '../../src/devplatform/githubApp/appStore.js'; +import { listenLoopback } from '../_helpers/listenLoopback.js'; // A real RSA key so mintAppJwt can actually sign. Generated at runtime — never a // literal in this file (the fixture below stands in for a real one everywhere else). @@ -141,8 +142,7 @@ async function harness(opts: HarnessOpts = {}) { app.use(inject); app.use('/api/v1/admin/dev-platform', routers.admin); app.use('/bot-api/v1/dev-platform', routers.public); - const server = app.listen(0); - await new Promise((r) => server.once('listening', r)); + const server = await listenLoopback(app); const port = String((server.address() as AddressInfo).port); return { state, @@ -181,8 +181,7 @@ async function mountLikeIndex(deps: DevPlatformGithubAppDeps): Promise<{ app.use('/api', requireAuth, (_req, _res, next) => next()); app.use('/api/v1/admin/dev-platform', requireAuth, routers.admin); app.use('/api/v1/dev-platform', routers.public); - const server = app.listen(0); - await new Promise((r) => server.once('listening', r)); + const server = await listenLoopback(app); const port = String((server.address() as AddressInfo).port); return { admin: `http://127.0.0.1:${port}/api/v1/admin/dev-platform`, diff --git a/middleware/test/devplatform/devPlatformPipeline.wire.pg.test.ts b/middleware/test/devplatform/devPlatformPipeline.wire.pg.test.ts index 86a7c1735..81337d5c4 100644 --- a/middleware/test/devplatform/devPlatformPipeline.wire.pg.test.ts +++ b/middleware/test/devplatform/devPlatformPipeline.wire.pg.test.ts @@ -19,6 +19,7 @@ import { InMemorySecretVault } from '../../src/secrets/vault.js'; import { mintRunnerToken } from '../../src/devplatform/jobToken.js'; import type { TokenFetch } from '../../src/devplatform/githubApp/installationTokens.js'; import type { PhaseDirective } from '../../src/devplatform/pipeline/phaseEngine.js'; +import { listenLoopback } from '../_helpers/listenLoopback.js'; const { url: PG_URL, reachable: pgAvailable } = await probePgTest({ label: 'pipeline.wire', @@ -156,8 +157,7 @@ describe('dev-platform wiring — a real gated job, end to end through the assem next(); }; mountDevPlatform(app_, requireAuth, wired); - server = app_.listen(0); - await new Promise((r) => server.once('listening', r)); + server = await listenLoopback(app_); baseUrl = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}`; }); diff --git a/middleware/test/devplatform/devPlatformRoutes.harness.ts b/middleware/test/devplatform/devPlatformRoutes.harness.ts index 3160333bc..b97a8370b 100644 --- a/middleware/test/devplatform/devPlatformRoutes.harness.ts +++ b/middleware/test/devplatform/devPlatformRoutes.harness.ts @@ -23,6 +23,7 @@ import { type NewDevJob, type NewDevRepo, } from '../../src/devplatform/types.js'; +import { listenLoopback } from '../_helpers/listenLoopback.js'; /** * Epic #470 W0 — admin REST + SSE router (`/api/v1/admin/dev-platform`). @@ -253,16 +254,9 @@ export async function makeHarness(overrides: Partial = {} }); app.use('/api/v1/admin/dev-platform', createDevPlatformRouter(deps)); - // Bind the IPv4 loopback explicitly. `listen(0)` binds the IPv6 wildcard - // `[::]`, which on macOS/BSD is IPV6_V6ONLY — so the kernel reserves the port - // in the IPv6 space only, while `baseUrl` below dials 127.0.0.1 (IPv4). The - // two ephemeral port spaces are independent, so the dialled port is never - // reserved and an unrelated process holding it silently receives these - // requests: the caller then sees that foreign server's answer (a 401, a 404, - // or bytes undici cannot parse) instead of ours. - const server: Server = await new Promise((resolve) => { - const s = app.listen(0, '127.0.0.1', () => resolve(s)); - }); + // Binds 127.0.0.1 — the address `baseUrl` below dials. See listenLoopback + // for why a wildcard bind lets a stranger answer these requests. + const server: Server = await listenLoopback(app); const port = (server.address() as AddressInfo).port; return { server, baseUrl: `http://127.0.0.1:${String(port)}/api/v1/admin/dev-platform`, diff --git a/middleware/test/devplatform/devPlatformRoutes.test.ts b/middleware/test/devplatform/devPlatformRoutes.test.ts index 2b36f62dc..6ff9badb9 100644 --- a/middleware/test/devplatform/devPlatformRoutes.test.ts +++ b/middleware/test/devplatform/devPlatformRoutes.test.ts @@ -29,6 +29,7 @@ import { postJson, throwsCode, } from './devPlatformRoutes.harness.js'; +import { listenLoopback } from '../_helpers/listenLoopback.js'; describe('devPlatform — session gate', () => { let h: Harness; @@ -487,7 +488,7 @@ describe('devPlatform — cross-operator authorization (review finding)', () => /** * Regression guard for an intermittent 401/404 in this file. * - * `app.listen(0)` binds the IPv6 wildcard `[::]`. On macOS/BSD that socket is + * `await listenLoopback(app)` binds the IPv6 wildcard `[::]`. On macOS/BSD that socket is * IPV6_V6ONLY, so the kernel reserves the port in the IPv6 ephemeral space * only — while the harness hands out `http://127.0.0.1:`, an IPv4 URL. * The two spaces are independent, so the dialled port was never reserved and diff --git a/middleware/test/devplatform/devRunnerApi.harness.ts b/middleware/test/devplatform/devRunnerApi.harness.ts index 8d1acce3e..351ba15c0 100644 --- a/middleware/test/devplatform/devRunnerApi.harness.ts +++ b/middleware/test/devplatform/devRunnerApi.harness.ts @@ -19,6 +19,7 @@ import { type DevJobStatus, type DevRepo, } from '../../src/devplatform/types.js'; +import { listenLoopback } from '../_helpers/listenLoopback.js'; /** * Shared test harness for the `/api/v1/dev-runner` router: in-memory fakes for @@ -197,12 +198,9 @@ export async function makeHarness(overrides: Partial = {}): }), ); - // Bind the IPv4 loopback explicitly — `listen(0)` reserves the port in the - // IPv6 space only, while `baseUrl` dials 127.0.0.1. See the sibling routes - // harness in this directory for the full explanation. - const server: Server = await new Promise((resolve) => { - const s = app.listen(0, '127.0.0.1', () => resolve(s)); - }); + // Binds 127.0.0.1 — the address `baseUrl` below dials. See listenLoopback + // for why a wildcard bind lets a stranger answer these requests. + const server: Server = await listenLoopback(app); const port = (server.address() as AddressInfo).port; return { server, diff --git a/middleware/test/devplatform/devRunnerApi.test.ts b/middleware/test/devplatform/devRunnerApi.test.ts index 219bae096..3ae2a232c 100644 --- a/middleware/test/devplatform/devRunnerApi.test.ts +++ b/middleware/test/devplatform/devRunnerApi.test.ts @@ -13,6 +13,7 @@ import { makeJob, type Harness, } from './devRunnerApi.harness.js'; +import { listenLoopback } from '../_helpers/listenLoopback.js'; /** * Epic #470 W0 — phone-home router contract: the job-token auth gate (no @@ -340,7 +341,7 @@ describe('devRunnerApi — POST /result', () => { }); /** - * Same guard as the sibling routes test: `app.listen(0)` binds the IPv6 + * Same guard as the sibling routes test: `await listenLoopback(app)` binds the IPv6 * wildcard, whose port space is independent of the IPv4 loopback this harness * advertises — so the dialled port was never reserved and a foreign process * could answer these requests. See that file for the full explanation. diff --git a/middleware/test/devplatform/devWebhooks.test.ts b/middleware/test/devplatform/devWebhooks.test.ts index 34955047d..e682c463b 100644 --- a/middleware/test/devplatform/devWebhooks.test.ts +++ b/middleware/test/devplatform/devWebhooks.test.ts @@ -14,6 +14,7 @@ import { type TriggerJobStore, } from '../../src/devplatform/triggers/triggerJobService.js'; import type { DevJob, DevRepo } from '../../src/devplatform/types.js'; +import { listenLoopback } from '../_helpers/listenLoopback.js'; // --------------------------------------------------------------------------- // Fixtures @@ -144,8 +145,7 @@ async function routeHarness(over: Partial = {}) { }; const app = express(); app.use(createDevWebhooksRouter(deps)); - const server = app.listen(0); - await new Promise((r) => server.once('listening', r)); + const server = await listenLoopback(app); const port = (server.address() as AddressInfo).port; return { base: `http://127.0.0.1:${port}`, @@ -439,8 +439,7 @@ describe('devWebhooks route', () => { const app = express(); app.use(express.json()); // WRONG ORDER — consumes the raw bytes first. app.use(createDevWebhooksRouter(deps)); - const server = app.listen(0); - await new Promise((r) => server.once('listening', r)); + const server = await listenLoopback(app); const port = (server.address() as AddressInfo).port; try { const r = await post(`http://127.0.0.1:${port}`, issuesBody()); @@ -476,8 +475,7 @@ describe('devWebhooks route', () => { app.post('/echo', (req, res) => { res.json({ got: (req.body as { n?: number }).n ?? null }); }); - const server = app.listen(0); - await new Promise((r) => server.once('listening', r)); + const server = await listenLoopback(app); const port = (server.address() as AddressInfo).port; const base = `http://127.0.0.1:${port}`; try { diff --git a/middleware/test/devplatform/devWebhooksConcurrency.pg.test.ts b/middleware/test/devplatform/devWebhooksConcurrency.pg.test.ts index 780f0d4b9..4f82ed63a 100644 --- a/middleware/test/devplatform/devWebhooksConcurrency.pg.test.ts +++ b/middleware/test/devplatform/devWebhooksConcurrency.pg.test.ts @@ -22,6 +22,7 @@ import { import { WebhookDeliveryStore } from '../../src/devplatform/triggers/webhookDeliveryStore.js'; import { createDevWebhooksRouter, type DevWebhooksRouterDeps } from '../../src/devplatform/routes/devWebhooks.js'; import type { DevRepo } from '../../src/devplatform/types.js'; +import { listenLoopback } from '../_helpers/listenLoopback.js'; /** * Epic #470 W4 — CONCURRENCY regression for the three defects a cross-family @@ -100,8 +101,7 @@ describe('devplatform/webhook concurrency (pg)', { skip: !pgAvailable }, () => { }; const app = express(); app.use(createDevWebhooksRouter(deps)); - const server = app.listen(0); - await new Promise((r) => server.once('listening', r)); + const server = await listenLoopback(app); const port = (server.address() as AddressInfo).port; return { base: `http://127.0.0.1:${port}`, diff --git a/middleware/test/devplatform/goldenFixture.e2e.test.ts b/middleware/test/devplatform/goldenFixture.e2e.test.ts index 45b69c7fd..2ac468414 100644 --- a/middleware/test/devplatform/goldenFixture.e2e.test.ts +++ b/middleware/test/devplatform/goldenFixture.e2e.test.ts @@ -33,6 +33,7 @@ import type { ForgeIssue, } from '../../src/devplatform/forgeClient.js'; import type { DevJobProvisionInput, RunnerBackend, RunnerHandle } from '../../src/devplatform/types.js'; +import { listenLoopback } from '../_helpers/listenLoopback.js'; /** * Epic #470 W1 — the golden fixture. The graph sink of the wave: one job, driven @@ -357,8 +358,7 @@ describe('dev-platform golden fixture (pg + git)', { skip: !pgAvailable || !gitA next(); }; app.use('/api', requireAuth, (_req, _res, next) => next()); - server = app.listen(0); - await new Promise((r) => server.once('listening', r)); + server = await listenLoopback(app); baseUrl = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}`; wired = assembleDevPlatform({ diff --git a/middleware/test/devplatform/scopedScmToken.pg.test.ts b/middleware/test/devplatform/scopedScmToken.pg.test.ts index dc5d24283..ba2e4092b 100644 --- a/middleware/test/devplatform/scopedScmToken.pg.test.ts +++ b/middleware/test/devplatform/scopedScmToken.pg.test.ts @@ -18,6 +18,7 @@ import { InMemorySecretVault } from '../../src/secrets/vault.js'; import { mintRunnerToken } from '../../src/devplatform/jobToken.js'; import type { TokenFetch } from '../../src/devplatform/githubApp/installationTokens.js'; import type { DevJobProvisionInput, RunnerBackend, RunnerHandle } from '../../src/devplatform/types.js'; +import { listenLoopback } from '../_helpers/listenLoopback.js'; const { url: PG_URL, reachable: pgAvailable } = await probePgTest({ label: 'scopedScmToken', @@ -118,8 +119,7 @@ describe('dev-platform wiring — the runner gets a SCOPED, revocable App token app_.use(express.json()); const requireAuth: RequestHandler = (_req, _res, next) => next(); mountDevPlatform(app_, requireAuth, wired); - server = app_.listen(0); - await new Promise((r) => server.once('listening', r)); + server = await listenLoopback(app_); baseUrl = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}`; }); diff --git a/middleware/test/diagramsRouter.test.ts b/middleware/test/diagramsRouter.test.ts index a159fad76..e50890af5 100644 --- a/middleware/test/diagramsRouter.test.ts +++ b/middleware/test/diagramsRouter.test.ts @@ -8,6 +8,7 @@ import { signUrl, type TigrisStore, } from '@omadia/diagrams'; +import { listenLoopback } from './_helpers/listenLoopback.js'; const SECRET = 'z'.repeat(32); @@ -53,11 +54,11 @@ describe('/diagrams router', () => { const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const fakePng = Buffer.concat([PNG_MAGIC, Buffer.from('fake-body')]); - before(() => { + before(async () => { store.seed('byte5/abc.png', fakePng); const app = express(); app.use('/diagrams', createDiagramsRouter({ store, secret: SECRET })); - server = app.listen(0); + server = await listenLoopback(app); const addr = server.address() as AddressInfo; baseUrl = `http://127.0.0.1:${String(addr.port)}`; }); diff --git a/middleware/test/documentsRouter.test.ts b/middleware/test/documentsRouter.test.ts index ebc68f884..b1dbdd02e 100644 --- a/middleware/test/documentsRouter.test.ts +++ b/middleware/test/documentsRouter.test.ts @@ -10,6 +10,7 @@ import { signDocumentUrl, MEDIA_TYPE, } from '@omadia/plugin-office'; +import { listenLoopback } from './_helpers/listenLoopback.js'; const SECRET = 'z'.repeat(32); @@ -66,7 +67,7 @@ describe('/documents router (office delivery path)', () => { const app = express(); app.use('/documents', createDocumentsRouter({ store, secret: SECRET })); - server = app.listen(0); + server = await listenLoopback(app); const addr = server.address() as AddressInfo; baseUrl = `http://127.0.0.1:${String(addr.port)}`; }); diff --git a/middleware/test/memoryPurgeRoute.test.ts b/middleware/test/memoryPurgeRoute.test.ts index 012ffe9ab..b2b3e0c9f 100644 --- a/middleware/test/memoryPurgeRoute.test.ts +++ b/middleware/test/memoryPurgeRoute.test.ts @@ -16,6 +16,7 @@ import type { } from '@omadia/plugin-api'; import { createMemoryPurgeRouter } from '../src/routes/memoryPurge.js'; +import { listenLoopback } from './_helpers/listenLoopback.js'; /** * The router calls `knowledgeGraph.countMemorableKnowledge(filter)` and @@ -166,8 +167,7 @@ async function makeHarness(graphPool?: Pool): Promise { ...(graphPool ? { graphPool } : {}), }), ); - const server: Server = app.listen(0); - await new Promise((resolve) => server.once('listening', resolve)); + const server: Server = await listenLoopback(app); const { port } = server.address() as AddressInfo; const baseUrl = `http://127.0.0.1:${String(port)}${MOUNT}`; diff --git a/middleware/test/operatorAgentsRouter.test.ts b/middleware/test/operatorAgentsRouter.test.ts index 1c2753c89..f2630b6d1 100644 --- a/middleware/test/operatorAgentsRouter.test.ts +++ b/middleware/test/operatorAgentsRouter.test.ts @@ -28,6 +28,7 @@ import { type OrchestratorRegistry, } from '@omadia/orchestrator'; import { createOperatorAgentsRouter } from '../src/routes/operatorAgents.js'; +import { listenLoopback } from './_helpers/listenLoopback.js'; interface AgentMem { id: string; @@ -211,7 +212,7 @@ describe('createOperatorAgentsRouter', () => { let registry: FakeRegistry; let sessionStore: { list: () => Promise }; - before(() => { + before(async () => { store = new FakeConfigStore(); registry = new FakeRegistry(); sessionStore = { list: () => Promise.resolve([]) }; @@ -225,7 +226,7 @@ describe('createOperatorAgentsRouter', () => { getChatSessionStore: () => sessionStore as unknown as ChatSessionStore, }), ); - server = app.listen(0); + server = await listenLoopback(app); const addr = server.address() as AddressInfo; baseUrl = `http://127.0.0.1:${String(addr.port)}/api/v1/operator/agents`; }); @@ -441,7 +442,7 @@ describe('createOperatorAgentsRouter', () => { getChatSessionStore: () => undefined, }), ); - const s = app.listen(0); + const s = await listenLoopback(app); try { const addr = s.address() as AddressInfo; const res = await fetch( diff --git a/middleware/test/profilesImportRoute.test.ts b/middleware/test/profilesImportRoute.test.ts index b00b9c938..ec0c982c5 100644 --- a/middleware/test/profilesImportRoute.test.ts +++ b/middleware/test/profilesImportRoute.test.ts @@ -20,6 +20,7 @@ import { } from '../src/plugins/uploadedPackageStore.js'; import type { LiveProfileStorageService } from '../src/profileStorage/liveProfileStorageService.js'; import { createProfilesRouter } from '../src/routes/profiles.js'; +import { listenLoopback } from './_helpers/listenLoopback.js'; /** * Phase 2.4 — POST /api/v1/profiles/import-bundle (OB-66) end-to-end tests. @@ -256,7 +257,7 @@ describe('POST /api/v1/profiles/import-bundle', () => { }), }), ); - server = app.listen(0); + server = await listenLoopback(app); const addr = server.address() as AddressInfo; baseUrl = `http://127.0.0.1:${String(addr.port)}`; }); @@ -409,7 +410,7 @@ describe('POST /api/v1/profiles/import-bundle', () => { uploadedPackageStore: emptyStore, }), ); - const altServer = altApp.listen(0); + const altServer = await listenLoopback(altApp); const altPort = (altServer.address() as AddressInfo).port; try { const res = await uploadBundle( diff --git a/middleware/test/profilesRouter.test.ts b/middleware/test/profilesRouter.test.ts index 554f6738b..22c5b99a5 100644 --- a/middleware/test/profilesRouter.test.ts +++ b/middleware/test/profilesRouter.test.ts @@ -13,6 +13,7 @@ import { InMemoryInstalledRegistry } from '../src/plugins/installedRegistry.js'; import type { PluginCatalog } from '../src/plugins/manifestLoader.js'; import { loadProfile } from '../src/plugins/profileLoader.js'; import { createProfilesRouter } from '../src/routes/profiles.js'; +import { listenLoopback } from './_helpers/listenLoopback.js'; type Partial_Plugin = Pick< Plugin, @@ -90,7 +91,7 @@ describe('/api/v1/profiles router', () => { let tmpDir: string; let registry: InMemoryInstalledRegistry; - before(() => { + before(async () => { tmpDir = mkdtempSync(join(tmpdir(), 'profiles-router-test-')); writeProfile( @@ -192,7 +193,7 @@ describe('/api/v1/profiles router', () => { '/api/v1/profiles', createProfilesRouter({ catalog, registry, profilesDir: tmpDir }), ); - server = app.listen(0); + server = await listenLoopback(app); const addr = server.address() as AddressInfo; baseUrl = `http://127.0.0.1:${String(addr.port)}`; }); @@ -455,7 +456,7 @@ describe('/api/v1/profiles router', () => { profilesDir: roundTripDir, }), ); - const secondServer = secondApp.listen(0); + const secondServer = await listenLoopback(secondApp); try { const secondAddr = secondServer.address() as AddressInfo; const secondBase = `http://127.0.0.1:${String(secondAddr.port)}`; diff --git a/middleware/test/publicMcp/publicMcpKeyBindingsAdmin.test.ts b/middleware/test/publicMcp/publicMcpKeyBindingsAdmin.test.ts index d8bc57d10..9a3ae7c56 100644 --- a/middleware/test/publicMcp/publicMcpKeyBindingsAdmin.test.ts +++ b/middleware/test/publicMcp/publicMcpKeyBindingsAdmin.test.ts @@ -18,6 +18,7 @@ import { type BindingExistenceCheck, } from '../../src/routes/publicMcpBindingsRouter.js'; import { createInMemoryPublicMcpKeyBindingStore } from '../../src/mcp/publicMcpKeyBindings.js'; +import { listenLoopback } from '../_helpers/listenLoopback.js'; /** * W5-1 — the admin surface for `public_mcp_key_bindings`. @@ -55,11 +56,11 @@ function neverValidOperatorAuth(): { hasValidSession(): Promise } { /** Mounts the router bare — no `requireAuth`, no parent gate. Anything that * reaches a handler did so through the router's OWN `router.use`. */ -function mountRouter(opts: { +async function mountRouter(opts: { store?: PublicMcpKeyBindingAdminStore | undefined; operatorAuth?: { hasValidSession(cookie: string | undefined): Promise }; existence?: BindingExistenceCheck; -}): { server: Server; baseUrl: string } { +}): Promise<{ server: Server; baseUrl: string }> { const app = express(); app.use(express.json()); app.use( @@ -70,7 +71,7 @@ function mountRouter(opts: { ...(opts.existence ? { existence: opts.existence } : {}), }), ); - const server = app.listen(0); + const server = await listenLoopback(app); const addr = server.address() as AddressInfo; return { server, baseUrl: `http://127.0.0.1:${String(addr.port)}/public-mcp-bindings` }; } @@ -89,7 +90,7 @@ async function withRouter( opts: Parameters[0], fn: (baseUrl: string) => Promise, ): Promise { - const { server, baseUrl } = mountRouter(opts); + const { server, baseUrl } = await mountRouter(opts); try { await fn(baseUrl); } finally { @@ -113,8 +114,8 @@ describe('publicMcpBindingsRouter — fails closed without operatorAuth', () => let server: Server; let baseUrl: string; - before(() => { - ({ server, baseUrl } = mountRouter({ + before(async () => { + ({ server, baseUrl } = await mountRouter({ store: createInMemoryPublicMcpKeyBindingAdminStore(), })); }); @@ -154,9 +155,9 @@ describe('publicMcpBindingsRouter — operator-session gate', () => { let baseUrl: string; let store: PublicMcpKeyBindingAdminStore; - before(() => { + before(async () => { store = createInMemoryPublicMcpKeyBindingAdminStore(); - ({ server, baseUrl } = mountRouter({ store, operatorAuth: neverValidOperatorAuth() })); + ({ server, baseUrl } = await mountRouter({ store, operatorAuth: neverValidOperatorAuth() })); }); after(async () => { await new Promise((r) => server.close(() => r())); @@ -208,9 +209,9 @@ describe('publicMcpBindingsRouter — CRUD (auth stubbed valid)', () => { let baseUrl: string; let store: PublicMcpKeyBindingAdminStore; - before(() => { + before(async () => { store = createInMemoryPublicMcpKeyBindingAdminStore(); - ({ server, baseUrl } = mountRouter({ store, operatorAuth: alwaysValidOperatorAuth() })); + ({ server, baseUrl } = await mountRouter({ store, operatorAuth: alwaysValidOperatorAuth() })); }); after(async () => { await new Promise((r) => server.close(() => r())); diff --git a/middleware/test/registryConfig.test.ts b/middleware/test/registryConfig.test.ts index 597e90001..106d07927 100644 --- a/middleware/test/registryConfig.test.ts +++ b/middleware/test/registryConfig.test.ts @@ -17,6 +17,7 @@ import { import { InMemorySecretVault } from '../src/secrets/vault.js'; import { RegistryClient } from '../src/plugins/registryClient.js'; import { createAdminRegistriesRouter } from '../src/routes/adminRegistries.js'; +import { listenLoopback } from './_helpers/listenLoopback.js'; function freshStore(): { store: VaultBackedRegistryConfigStore; @@ -153,7 +154,7 @@ describe('createAdminRegistriesRouter', () => { let store: RegistryConfigStore; let client: RegistryClient; - before(() => { + before(async () => { const settings = new InMemoryRegistrySettings(); const vault = new InMemorySecretVault(); store = new VaultBackedRegistryConfigStore({ settings, vault }); @@ -162,7 +163,7 @@ describe('createAdminRegistriesRouter', () => { const app = express(); app.use(express.json()); app.use('/api/v1/admin/registries', createAdminRegistriesRouter({ store, client })); - server = app.listen(0); + server = await listenLoopback(app); baseUrl = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}/api/v1/admin/registries`; }); diff --git a/middleware/test/registryInstallMerge.test.ts b/middleware/test/registryInstallMerge.test.ts index e6afc9964..14118d223 100644 --- a/middleware/test/registryInstallMerge.test.ts +++ b/middleware/test/registryInstallMerge.test.ts @@ -16,6 +16,7 @@ import type { IngestInput, IngestResult, } from '../src/plugins/packageUploadService.js'; +import { listenLoopback } from './_helpers/listenLoopback.js'; const HUB = 'https://hub.test'; const ZIP = Buffer.from('PK\x03\x04 office plugin zip'); @@ -125,7 +126,7 @@ describe('store router · remote registry merge (C3)', () => { let server: Server; let base: string; - before(() => { + before(async () => { const client = new RegistryClient({ registries: [{ name: 'omadia-public', url: HUB }], log: () => {}, @@ -171,7 +172,7 @@ describe('store router · remote registry merge (C3)', () => { const app = express(); app.use('/store', createStoreRouter({ catalog, registry: fakeRegistry, client })); - server = app.listen(0); + server = await listenLoopback(app); base = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}/store`; }); @@ -222,7 +223,7 @@ describe('store router · degrades when a registry is down', () => { const catalog = fakeCatalog([plugin('@x/localonly')]); const app = express(); app.use('/store', createStoreRouter({ catalog, registry: fakeRegistry, client })); - const server = app.listen(0); + const server = await listenLoopback(app); const base = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}/store`; try { const res = await fetch(base); @@ -246,7 +247,7 @@ describe('store router · detail resolves a remote-only plugin (C3)', () => { }); const app = express(); app.use('/store', createStoreRouter({ catalog: fakeCatalog([]), registry: fakeRegistry, client })); - const server = app.listen(0); + const server = await listenLoopback(app); const base = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}/store`; try { const res = await fetch(`${base}/${encodeURIComponent('@omadia/plugin-office')}`); @@ -318,7 +319,7 @@ describe('store router · setup_guide flows from the registry manifest_summary', }); const app = express(); app.use('/store', createStoreRouter({ catalog: fakeCatalog([]), registry: fakeRegistry, client })); - const server = app.listen(0); + const server = await listenLoopback(app); const base = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}/store`; try { const res = await fetch(`${base}/${encodeURIComponent('@omadia/channel-discord')}`); @@ -341,7 +342,7 @@ describe('store router · setup_guide flows from the registry manifest_summary', }); const app = express(); app.use('/store', createStoreRouter({ catalog: fakeCatalog([]), registry: fakeRegistry, client })); - const server = app.listen(0); + const server = await listenLoopback(app); const base = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}/store`; try { const res = await fetch(`${base}/${encodeURIComponent('@omadia/plugin-office')}`); @@ -409,7 +410,7 @@ function indexWith(id: string, latest: string): string { const TEAMS = '@omadia/channel-teams'; -function storeServer(catalogPlugins: Plugin[], installed: Record, hubLatest: string) { +async function storeServer(catalogPlugins: Plugin[], installed: Record, hubLatest: string) { const client = new RegistryClient({ registries: [{ name: 'omadia-public', url: HUB }], log: () => {}, @@ -426,14 +427,14 @@ function storeServer(catalogPlugins: Plugin[], installed: Record client, }), ); - const server = app.listen(0); + const server = await listenLoopback(app); const base = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}/store`; return { server, base }; } describe('store router · update detection (C6)', () => { it('flags update-available + available_version when the hub is newer', async () => { - const { server, base } = storeServer( + const { server, base } = await storeServer( [plugin(TEAMS, { version: '0.10.1', kind: 'channel' })], { [TEAMS]: '0.10.1' }, '0.11.0', @@ -450,7 +451,7 @@ describe('store router · update detection (C6)', () => { }); it('stays installed when the hub is NOT newer (numeric semver: 0.10.1 > 0.2.0)', async () => { - const { server, base } = storeServer( + const { server, base } = await storeServer( [plugin(TEAMS, { version: '0.10.1', kind: 'channel' })], { [TEAMS]: '0.10.1' }, '0.2.0', @@ -467,7 +468,7 @@ describe('store router · update detection (C6)', () => { }); it('detail endpoint flags update-available for an installed plugin', async () => { - const { server, base } = storeServer( + const { server, base } = await storeServer( [plugin(TEAMS, { version: '0.10.1', kind: 'channel' })], { [TEAMS]: '0.10.1' }, '0.11.0', @@ -510,7 +511,7 @@ describe('registry install router (C2)', () => { }, } as unknown as PackageUploadService; - before(() => { + before(async () => { const app = express(); app.use(express.json()); app.use( @@ -522,7 +523,7 @@ describe('registry install router (C2)', () => { registry: fakeRegistry, }), ); - server = app.listen(0); + server = await listenLoopback(app); base = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}/install/registry`; }); @@ -595,7 +596,7 @@ describe('registry install router · no registries configured', () => { registry: fakeRegistry, }), ); - const server = app.listen(0); + const server = await listenLoopback(app); const base = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}/install/registry`; try { const res = await fetch(`${base}/${encodeURIComponent('@omadia/plugin-office')}`, { method: 'POST' }); diff --git a/middleware/test/registrySetupProfile.test.ts b/middleware/test/registrySetupProfile.test.ts index 1e66990d0..c6b077436 100644 --- a/middleware/test/registrySetupProfile.test.ts +++ b/middleware/test/registrySetupProfile.test.ts @@ -17,6 +17,7 @@ import { createStoreRouter } from '../src/routes/store.js'; import type { Plugin } from '../src/api/admin-v1.js'; import type { PluginCatalog } from '../src/plugins/manifestLoader.js'; import type { InstalledRegistry } from '../src/plugins/installedRegistry.js'; +import { listenLoopback } from './_helpers/listenLoopback.js'; const HUB = 'https://hub.test'; @@ -90,7 +91,7 @@ describe('store router · remote setup_profile projection (OM-15 #602)', () => { let server: Server; let base: string; - before(() => { + before(async () => { const client = new RegistryClient({ registries: [{ name: 'omadia-public', url: HUB }], log: () => {}, @@ -113,7 +114,7 @@ describe('store router · remote setup_profile projection (OM-15 #602)', () => { '/store', createStoreRouter({ catalog: emptyCatalog, registry: fakeRegistry, client }), ); - server = app.listen(0); + server = await listenLoopback(app); base = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}/store`; }); diff --git a/middleware/test/storeProviderCollision.test.ts b/middleware/test/storeProviderCollision.test.ts index 2f29fe77b..1d57ffbbc 100644 --- a/middleware/test/storeProviderCollision.test.ts +++ b/middleware/test/storeProviderCollision.test.ts @@ -25,6 +25,7 @@ import { createStoreRouter } from '../src/routes/store.js'; import type { Plugin } from '../src/api/admin-v1.js'; import type { PluginCatalog } from '../src/plugins/manifestLoader.js'; import { InMemoryInstalledRegistry } from '../src/plugins/installedRegistry.js'; +import { listenLoopback } from './_helpers/listenLoopback.js'; function plugin(id: string, over: Partial = {}): Plugin { return { @@ -111,8 +112,7 @@ describe('store router · already-provided capability (OM-06 / #671)', () => { vault: { listKeys: async (): Promise => [] }, }), ); - server = app.listen(0); - await new Promise((r) => server.once('listening', r)); + server = await listenLoopback(app); base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; }); diff --git a/middleware/test/storeReadinessProjection.test.ts b/middleware/test/storeReadinessProjection.test.ts index 9481ac677..9a3d7aa42 100644 --- a/middleware/test/storeReadinessProjection.test.ts +++ b/middleware/test/storeReadinessProjection.test.ts @@ -19,6 +19,7 @@ import { createStoreRouter } from '../src/routes/store.js'; import type { Plugin, PluginSetupField } from '../src/api/admin-v1.js'; import type { PluginCatalog } from '../src/plugins/manifestLoader.js'; import { InMemoryInstalledRegistry } from '../src/plugins/installedRegistry.js'; +import { listenLoopback } from './_helpers/listenLoopback.js'; function plugin(id: string, over: Partial = {}): Plugin { return { @@ -136,8 +137,7 @@ describe('store router · readiness projection (OM-16)', () => { '/store/plugins', createStoreRouter({ catalog, registry, vault }), ); - server = app.listen(0); - await new Promise((r) => server.once('listening', r)); + server = await listenLoopback(app); base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; }); @@ -222,8 +222,7 @@ describe('store router · readiness projection (OM-16)', () => { }, }), ); - const srv = app.listen(0); - await new Promise((r) => srv.once('listening', r)); + const srv = await listenLoopback(app); try { const port = (srv.address() as AddressInfo).port; const res = await fetch(`http://127.0.0.1:${port}/store/plugins`); @@ -249,8 +248,7 @@ describe('store router · readiness projection (OM-16)', () => { }); const app = express(); app.use('/store/plugins', createStoreRouter({ catalog, registry })); - const srv = app.listen(0); - await new Promise((r) => srv.once('listening', r)); + const srv = await listenLoopback(app); try { const port = (srv.address() as AddressInfo).port; const res = await fetch(`http://127.0.0.1:${port}/store/plugins`); diff --git a/middleware/test/uiPrefsRoute.test.ts b/middleware/test/uiPrefsRoute.test.ts index 47ae550e2..b0f48977b 100644 --- a/middleware/test/uiPrefsRoute.test.ts +++ b/middleware/test/uiPrefsRoute.test.ts @@ -9,6 +9,7 @@ import type { Request, Response, NextFunction } from 'express'; import { InMemoryMemoryStore } from '@omadia/memory'; import { createUiPrefsRouter } from '../src/routes/uiPrefs.js'; +import { listenLoopback } from './_helpers/listenLoopback.js'; /** * HTTP integration test for the per-user UI-prefs router (issue #287), @@ -51,8 +52,7 @@ async function makeHarness( } app.use(MOUNT, createUiPrefsRouter({ store, log: () => {} })); - const server: Server = app.listen(0); - await new Promise((resolve) => server.once('listening', resolve)); + const server: Server = await listenLoopback(app); const { port } = server.address() as AddressInfo; return {