diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2e0afb070..a48e4d955 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -18,6 +18,35 @@ 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. + + ### Added — the public MCP endpoint serves MRTR to 2026-07-28 clients (#700) - **Two SDK generations behind one path, routed by protocol era.** A request 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 {