diff --git a/middleware/test/channelApi/adminKeysRouter.test.ts b/middleware/test/channelApi/adminKeysRouter.test.ts index 30137157..e520e810 100644 --- a/middleware/test/channelApi/adminKeysRouter.test.ts +++ b/middleware/test/channelApi/adminKeysRouter.test.ts @@ -1,7 +1,5 @@ import { strict as assert } from 'node:assert'; -import { after, before, describe, it } from 'node:test'; -import type { AddressInfo } from 'node:net'; -import type { Server } from 'node:http'; +import { before, describe, it } from 'node:test'; import express from 'express'; @@ -11,6 +9,7 @@ import type { OperatorAuthAccessor } from '../../packages/plugin-api/src/index.j import { createOperatorAuthAccessor } from '../../src/auth/operatorAuthAccessor.js'; import { signSession } from '../../src/auth/sessionJwt.js'; import { EmailWhitelist } from '../../src/auth/whitelist.js'; +import { createInProcessClient, type InProcessClient } from '../support/inProcessHttp.js'; import { createFakeSecrets } from './testSecrets.js'; /** Always-valid stub — used by the CRUD tests below, which exercise the @@ -28,8 +27,8 @@ function alwaysValidOperatorAuth(): OperatorAuthAccessor { * `publicPathsExemption.test.ts` for the `publicPaths.ts` side of the story. */ describe('channelApi/adminKeysRouter — CRUD (auth stubbed valid)', () => { - let server: Server; - let baseUrl: string; + let client: InProcessClient; + const baseUrl = '/admin/keys'; before(() => { const app = express(); @@ -38,17 +37,11 @@ describe('channelApi/adminKeysRouter — CRUD (auth stubbed valid)', () => { '/admin/keys', createAdminKeysRouter(createApiKeyStore(createFakeSecrets()), alwaysValidOperatorAuth()), ); - server = app.listen(0); - const addr = server.address() as AddressInfo; - baseUrl = `http://127.0.0.1:${String(addr.port)}/admin/keys`; - }); - - after(async () => { - await new Promise((r) => server.close(() => r())); + client = createInProcessClient(app); }); it('POST / creates a key, returning the plaintext token once + a hash-free record', async () => { - const res = await fetch(baseUrl, { + const res = await client.fetch(baseUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label: 'ci' }), @@ -61,7 +54,7 @@ describe('channelApi/adminKeysRouter — CRUD (auth stubbed valid)', () => { }); it('POST / rejects an invalid body', async () => { - const res = await fetch(baseUrl, { + const res = await client.fetch(baseUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ rateLimitPerMinute: -1 }), @@ -70,12 +63,12 @@ describe('channelApi/adminKeysRouter — CRUD (auth stubbed valid)', () => { }); it('GET / lists created keys without their hash', async () => { - await fetch(baseUrl, { + await client.fetch(baseUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label: 'listed' }), }); - const res = await fetch(baseUrl); + const res = await client.fetch(baseUrl); assert.equal(res.status, 200); const body = (await res.json()) as { keys: Array> }; assert.ok(body.keys.some((k) => k['label'] === 'listed')); @@ -83,7 +76,7 @@ describe('channelApi/adminKeysRouter — CRUD (auth stubbed valid)', () => { }); it('POST / accepts a scope set, and GET / shows it (issue #439)', async () => { - const created = await fetch(baseUrl, { + const created = await client.fetch(baseUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label: 'scoped', scopes: ['chat:write', 'memory:read'] }), @@ -92,7 +85,7 @@ describe('channelApi/adminKeysRouter — CRUD (auth stubbed valid)', () => { const body = (await created.json()) as { key: { id: string; scopes: string[] } }; assert.deepEqual(body.key.scopes, ['chat:write', 'memory:read']); - const listed = (await (await fetch(baseUrl)).json()) as { + const listed = (await (await client.fetch(baseUrl)).json()) as { keys: Array<{ id: string; scopes: string[] }>; }; assert.deepEqual( @@ -106,7 +99,7 @@ describe('channelApi/adminKeysRouter — CRUD (auth stubbed valid)', () => { // a persisted `[]`, so creation must not quietly turn it into the legacy // `chat:write` default. An operator asking for zero capabilities must get // an error, not a chat-capable key — and not a permanently-403 one either. - const res = await fetch(baseUrl, { + const res = await client.fetch(baseUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label: 'readonly', scopes: [] }), @@ -116,14 +109,14 @@ describe('channelApi/adminKeysRouter — CRUD (auth stubbed valid)', () => { assert.equal(body.error, 'invalid_request'); // ...and nothing was minted under that label. - const listed = (await (await fetch(baseUrl)).json()) as { + const listed = (await (await client.fetch(baseUrl)).json()) as { keys: Array<{ label?: string }>; }; assert.ok(!listed.keys.some((k) => k.label === 'readonly')); }); it('POST / without scopes still mints a working, chat-capable key (backward compatible)', async () => { - const res = await fetch(baseUrl, { + const res = await client.fetch(baseUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label: 'unscoped' }), @@ -134,7 +127,7 @@ describe('channelApi/adminKeysRouter — CRUD (auth stubbed valid)', () => { }); it('POST / 400s on a malformed scope instead of 500-ing out of the store', async () => { - const res = await fetch(baseUrl, { + const res = await client.fetch(baseUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ scopes: ['nope'] }), @@ -143,19 +136,19 @@ describe('channelApi/adminKeysRouter — CRUD (auth stubbed valid)', () => { }); it('POST /:id/revoke revokes an existing key and 404s for an unknown one', async () => { - const created = await fetch(baseUrl, { + const created = await client.fetch(baseUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({}), }); const { key } = (await created.json()) as { key: { id: string } }; - const revoked = await fetch(`${baseUrl}/${key.id}/revoke`, { method: 'POST' }); + const revoked = await client.fetch(`${baseUrl}/${key.id}/revoke`, { method: 'POST' }); assert.equal(revoked.status, 200); const revokedBody = (await revoked.json()) as { key: { revokedAt?: number } }; assert.equal(typeof revokedBody.key.revokedAt, 'number'); - const missing = await fetch(`${baseUrl}/does-not-exist/revoke`, { method: 'POST' }); + const missing = await client.fetch(`${baseUrl}/does-not-exist/revoke`, { method: 'POST' }); assert.equal(missing.status, 404); }); }); @@ -172,8 +165,8 @@ describe('channelApi/adminKeysRouter — operator-session auth (real verificatio const whitelist = new EmailWhitelist('operator@example.com'); const operatorAuth = createOperatorAuthAccessor({ signingKey, whitelist }); - let server: Server; - let baseUrl: string; + let client: InProcessClient; + const baseUrl = '/admin/keys'; before(() => { const app = express(); @@ -182,24 +175,18 @@ describe('channelApi/adminKeysRouter — operator-session auth (real verificatio '/admin/keys', createAdminKeysRouter(createApiKeyStore(createFakeSecrets()), operatorAuth), ); - server = app.listen(0); - const addr = server.address() as AddressInfo; - baseUrl = `http://127.0.0.1:${String(addr.port)}/admin/keys`; - }); - - after(async () => { - await new Promise((r) => server.close(() => r())); + client = createInProcessClient(app); }); it('no Cookie header → 401 auth.missing', async () => { - const res = await fetch(baseUrl); + const res = await client.fetch(baseUrl); assert.equal(res.status, 401); const body = (await res.json()) as { code: string }; assert.equal(body.code, 'auth.missing'); }); it('garbage/invalid cookie value → 401 auth.invalid', async () => { - const res = await fetch(baseUrl, { + const res = await client.fetch(baseUrl, { headers: { cookie: 'omadia_session=not-a-real-jwt' }, }); assert.equal(res.status, 401); @@ -218,7 +205,7 @@ describe('channelApi/adminKeysRouter — operator-session auth (real verificatio }, signingKey, ); - const res = await fetch(baseUrl, { + const res = await client.fetch(baseUrl, { headers: { cookie: `omadia_session=${token}` }, }); assert.equal(res.status, 200); @@ -237,7 +224,7 @@ describe('channelApi/adminKeysRouter — operator-session auth (real verificatio }, signingKey, ); - const res = await fetch(baseUrl, { + const res = await client.fetch(baseUrl, { headers: { cookie: `omadia_session=${token}` }, }); assert.equal(res.status, 401); @@ -248,7 +235,7 @@ describe('channelApi/adminKeysRouter — operator-session auth (real verificatio // must sit on top of the operator-session check, never beside it. A // caller that can mint `scopes: ['*']` without a session would be the // worst possible version of this router. - const anonymous = await fetch(baseUrl, { + const anonymous = await client.fetch(baseUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label: 'anonymous-wildcard', scopes: ['*'] }), @@ -267,7 +254,7 @@ describe('channelApi/adminKeysRouter — operator-session auth (real verificatio signingKey, ); const listed = (await ( - await fetch(baseUrl, { headers: { cookie: `omadia_session=${token}` } }) + await client.fetch(baseUrl, { headers: { cookie: `omadia_session=${token}` } }) ).json()) as { keys: Array<{ label?: string }> }; assert.equal( listed.keys.some((k) => k.label === 'anonymous-wildcard'), @@ -283,8 +270,8 @@ describe('channelApi/adminKeysRouter — operator-session auth (real verificatio * check — every route must refuse to serve. */ describe('channelApi/adminKeysRouter — fails closed without operatorAuth', () => { - let server: Server; - let baseUrl: string; + let client: InProcessClient; + const baseUrl = '/admin/keys'; before(() => { const app = express(); @@ -293,24 +280,18 @@ describe('channelApi/adminKeysRouter — fails closed without operatorAuth', () '/admin/keys', createAdminKeysRouter(createApiKeyStore(createFakeSecrets()), undefined), ); - server = app.listen(0); - const addr = server.address() as AddressInfo; - baseUrl = `http://127.0.0.1:${String(addr.port)}/admin/keys`; - }); - - after(async () => { - await new Promise((r) => server.close(() => r())); + client = createInProcessClient(app); }); it('GET / → 503 operator_auth.unavailable, even with no cookie at all', async () => { - const res = await fetch(baseUrl); + const res = await client.fetch(baseUrl); assert.equal(res.status, 503); const body = (await res.json()) as { code: string }; assert.equal(body.code, 'operator_auth.unavailable'); }); it('POST / → 503 operator_auth.unavailable — never falls through to create a key', async () => { - const res = await fetch(baseUrl, { + const res = await client.fetch(baseUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label: 'should-never-be-created' }), diff --git a/middleware/test/channelApi/chatRouter.test.ts b/middleware/test/channelApi/chatRouter.test.ts index 0001c352..f97d2552 100644 --- a/middleware/test/channelApi/chatRouter.test.ts +++ b/middleware/test/channelApi/chatRouter.test.ts @@ -1,7 +1,5 @@ import { strict as assert } from 'node:assert'; -import { after, before, describe, it } from 'node:test'; -import type { AddressInfo } from 'node:net'; -import type { Server } from 'node:http'; +import { before, describe, it } from 'node:test'; import express from 'express'; import type { CoreApi, IncomingTurn } from '@omadia/channel-sdk'; @@ -13,6 +11,7 @@ import { createApiChatRouter, internalConversationId, } from '../../packages/harness-channel-api/src/chatRouter.js'; +import { createInProcessClient, type InProcessClient } from '../support/inProcessHttp.js'; import { createFakeSecrets } from './testSecrets.js'; // Import from source: `graphScopeFor` was added after the last dist build, // so the built `@omadia/orchestrator` barrel doesn't re-export it yet (same @@ -28,8 +27,8 @@ function parseNdjson(body: string): unknown[] { } describe('channelApi/chatRouter — wiring (auth, rate limit, audit, NDJSON framing)', () => { - let server: Server; - let baseUrl: string; + let client: InProcessClient; + const baseUrl = '/chat'; let apiKeys: ReturnType; let auditLog: ReturnType; let rateLimiter: ReturnType; @@ -59,17 +58,11 @@ describe('channelApi/chatRouter — wiring (auth, rate limit, audit, NDJSON fram }, }), ); - server = app.listen(0); - const addr = server.address() as AddressInfo; - baseUrl = `http://127.0.0.1:${String(addr.port)}/chat`; - }); - - after(async () => { - await new Promise((r) => server.close(() => r())); + client = createInProcessClient(app); }); it('401s when no Authorization header is sent', async () => { - const res = await fetch(baseUrl, { + const res = await client.fetch(baseUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message: 'hi' }), @@ -78,7 +71,7 @@ describe('channelApi/chatRouter — wiring (auth, rate limit, audit, NDJSON fram }); it('401s for an unknown API key', async () => { - const res = await fetch(baseUrl, { + const res = await client.fetch(baseUrl, { method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer omk_not-a-real-key' }, body: JSON.stringify({ message: 'hi' }), @@ -90,7 +83,7 @@ describe('channelApi/chatRouter — wiring (auth, rate limit, audit, NDJSON fram const created = await apiKeys.create({ label: 'streamer' }); const before = (await auditLog.list()).length; - const res = await fetch(baseUrl, { + const res = await client.fetch(baseUrl, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${created.token}` }, body: JSON.stringify({ message: 'ping', conversationId: 'conv-1' }), @@ -132,7 +125,7 @@ describe('channelApi/chatRouter — wiring (auth, rate limit, audit, NDJSON fram it('401s once the key has been revoked — no further calls succeed', async () => { const created = await apiKeys.create({ label: 'to-revoke' }); - const first = await fetch(baseUrl, { + const first = await client.fetch(baseUrl, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${created.token}` }, body: JSON.stringify({ message: 'hi' }), @@ -141,7 +134,7 @@ describe('channelApi/chatRouter — wiring (auth, rate limit, audit, NDJSON fram await apiKeys.revoke(created.record.id); - const second = await fetch(baseUrl, { + const second = await client.fetch(baseUrl, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${created.token}` }, body: JSON.stringify({ message: 'hi again' }), @@ -151,14 +144,14 @@ describe('channelApi/chatRouter — wiring (auth, rate limit, audit, NDJSON fram it('429s once a key exceeds its configured rate limit', async () => { const created = await apiKeys.create({ label: 'limited', rateLimitPerMinute: 1 }); - const first = await fetch(baseUrl, { + const first = await client.fetch(baseUrl, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${created.token}` }, body: JSON.stringify({ message: 'one' }), }); assert.equal(first.status, 200); - const second = await fetch(baseUrl, { + const second = await client.fetch(baseUrl, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${created.token}` }, body: JSON.stringify({ message: 'two' }), @@ -168,7 +161,7 @@ describe('channelApi/chatRouter — wiring (auth, rate limit, audit, NDJSON fram it('400s on an empty message', async () => { const created = await apiKeys.create({ label: 'validator' }); - const res = await fetch(baseUrl, { + const res = await client.fetch(baseUrl, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${created.token}` }, body: JSON.stringify({ message: '' }), @@ -182,10 +175,9 @@ describe('channelApi/chatRouter — wiring (auth, rate limit, audit, NDJSON fram * (throwing, capturing turns) without cross-contaminating the shared * `before()` fixture above. */ function startTestServer(core: Pick): { - baseUrl: string; + client: InProcessClient; apiKeys: ReturnType; auditLog: ReturnType; - close: () => Promise; } { const secrets = createFakeSecrets(); const apiKeys = createApiKeyStore(secrets); @@ -195,14 +187,7 @@ function startTestServer(core: Pick): { const app = express(); app.use(express.json()); app.use(createApiChatRouter({ channelId: '@omadia/channel-api', apiKeys, auditLog, rateLimiter, core })); - const server = app.listen(0); - const addr = server.address() as AddressInfo; - return { - baseUrl: `http://127.0.0.1:${String(addr.port)}/chat`, - apiKeys, - auditLog, - close: () => new Promise((r) => server.close(() => r())), - }; + return { client: createInProcessClient(app), apiKeys, auditLog }; } describe('channelApi/chatRouter — cross-key conversationId isolation (finding #1)', () => { @@ -218,12 +203,12 @@ describe('channelApi/chatRouter — cross-key conversationId isolation (finding const keyA = await harness.apiKeys.create({ label: 'A' }); const keyB = await harness.apiKeys.create({ label: 'B' }); - await fetch(harness.baseUrl, { + await harness.client.fetch('/chat', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${keyA.token}` }, body: JSON.stringify({ message: 'hi from A', conversationId: 'shared-thread' }), }); - await fetch(harness.baseUrl, { + await harness.client.fetch('/chat', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${keyB.token}` }, body: JSON.stringify({ message: 'hi from B', conversationId: 'shared-thread' }), @@ -243,8 +228,6 @@ describe('channelApi/chatRouter — cross-key conversationId isolation (finding capturedTurns[1]?.conversationId, internalConversationId(keyB.record.id, 'shared-thread'), ); - - await harness.close(); }); }); @@ -260,12 +243,12 @@ describe('channelApi/chatRouter — same-key conversationId collision via lossy const key = await harness.apiKeys.create({ label: 'punctuation' }); - await fetch(harness.baseUrl, { + await harness.client.fetch('/chat', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${key.token}` }, body: JSON.stringify({ message: 'hi', conversationId: 'case/a' }), }); - await fetch(harness.baseUrl, { + await harness.client.fetch('/chat', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${key.token}` }, body: JSON.stringify({ message: 'hi', conversationId: 'case?a' }), @@ -282,8 +265,6 @@ describe('channelApi/chatRouter — same-key conversationId collision via lossy // transform that made `"case/a"` and `"case?a"` collide before this fix, // since plain concatenation left that punctuation exposed). assert.notEqual(graphScopeFor(undefined, idA), graphScopeFor(undefined, idB)); - - await harness.close(); }); it('two long caller-supplied conversationIds differing only past the 80-char sanitizeScope truncation cutoff never collide', async () => { @@ -302,12 +283,12 @@ describe('channelApi/chatRouter — same-key conversationId collision via lossy // before this fix. const longBase = 'x'.repeat(150); - await fetch(harness.baseUrl, { + await harness.client.fetch('/chat', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${key.token}` }, body: JSON.stringify({ message: 'hi', conversationId: `${longBase}-tail-one` }), }); - await fetch(harness.baseUrl, { + await harness.client.fetch('/chat', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${key.token}` }, body: JSON.stringify({ message: 'hi', conversationId: `${longBase}-tail-two` }), @@ -318,8 +299,6 @@ describe('channelApi/chatRouter — same-key conversationId collision via lossy const idB = capturedTurns[1]?.conversationId ?? ''; assert.notEqual(idA, idB); assert.notEqual(graphScopeFor(undefined, idA), graphScopeFor(undefined, idB)); - - await harness.close(); }); }); @@ -331,7 +310,7 @@ describe('channelApi/chatRouter — audit-log accuracy for every authenticated o }, }); - const res = await fetch(harness.baseUrl, { + const res = await harness.client.fetch('/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message: 'hi' }), @@ -342,7 +321,6 @@ describe('channelApi/chatRouter — audit-log accuracy for every authenticated o 0, 'a call that never authenticated must not produce an audit entry', ); - await harness.close(); }); it('audits status "rate_limited" for an authenticated call over quota — never "ok"', async () => { @@ -353,12 +331,12 @@ describe('channelApi/chatRouter — audit-log accuracy for every authenticated o }); const created = await harness.apiKeys.create({ label: 'quota', rateLimitPerMinute: 1 }); - await fetch(harness.baseUrl, { + await harness.client.fetch('/chat', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${created.token}` }, body: JSON.stringify({ message: 'one' }), }); - const res = await fetch(harness.baseUrl, { + const res = await harness.client.fetch('/chat', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${created.token}` }, body: JSON.stringify({ message: 'two' }), @@ -370,8 +348,6 @@ describe('channelApi/chatRouter — audit-log accuracy for every authenticated o assert.equal(entries[0]?.status, 'ok'); assert.equal(entries[1]?.status, 'rate_limited'); assert.equal(entries[1]?.keyId, created.record.id); - - await harness.close(); }); it('audits status "invalid_request" for a schema-invalid body — never "ok"', async () => { @@ -382,7 +358,7 @@ describe('channelApi/chatRouter — audit-log accuracy for every authenticated o }); const created = await harness.apiKeys.create({ label: 'validator' }); - const res = await fetch(harness.baseUrl, { + const res = await harness.client.fetch('/chat', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${created.token}` }, body: JSON.stringify({ message: '' }), @@ -392,8 +368,6 @@ describe('channelApi/chatRouter — audit-log accuracy for every authenticated o const entries = await harness.auditLog.list(); assert.equal(entries.length, 1); assert.equal(entries[0]?.status, 'invalid_request'); - - await harness.close(); }); it('audits status "error" — never "ok" — when the orchestrator throws mid-turn', async () => { @@ -405,7 +379,7 @@ describe('channelApi/chatRouter — audit-log accuracy for every authenticated o }); const created = await harness.apiKeys.create({ label: 'crasher' }); - const res = await fetch(harness.baseUrl, { + const res = await harness.client.fetch('/chat', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${created.token}` }, body: JSON.stringify({ message: 'hi' }), @@ -423,8 +397,6 @@ describe('channelApi/chatRouter — audit-log accuracy for every authenticated o 'error', 'a mid-turn throw must be audited as "error", not optimistically as "ok"', ); - - await harness.close(); }); it('audits status "error" — never "ok" — for an in-band {type:"error"} event with no throw', async () => { @@ -441,7 +413,7 @@ describe('channelApi/chatRouter — audit-log accuracy for every authenticated o }); const created = await harness.apiKeys.create({ label: 'in-band-error' }); - const res = await fetch(harness.baseUrl, { + const res = await harness.client.fetch('/chat', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${created.token}` }, body: JSON.stringify({ message: 'hi' }), @@ -460,7 +432,5 @@ describe('channelApi/chatRouter — audit-log accuracy for every authenticated o 'error', 'an in-band error event with no throw must be audited as "error", not "ok"', ); - - await harness.close(); }); }); diff --git a/middleware/test/channelApi/chatRouterPrivacyIntegration.test.ts b/middleware/test/channelApi/chatRouterPrivacyIntegration.test.ts index d6bcdddc..906672be 100644 --- a/middleware/test/channelApi/chatRouterPrivacyIntegration.test.ts +++ b/middleware/test/channelApi/chatRouterPrivacyIntegration.test.ts @@ -13,9 +13,7 @@ */ import { strict as assert } from 'node:assert'; -import { after, before, describe, it } from 'node:test'; -import type { AddressInfo } from 'node:net'; -import type { Server } from 'node:http'; +import { before, describe, it } from 'node:test'; import express from 'express'; import type { IncomingTurn } from '@omadia/channel-sdk'; @@ -32,6 +30,7 @@ import { createApiKeyStore } from '../../packages/harness-api-key-auth/src/apiKe import { createAuditLog } from '../../packages/harness-api-key-auth/src/auditLog.js'; import { createRateLimiter } from '../../packages/harness-api-key-auth/src/rateLimiter.js'; import { createApiChatRouter } from '../../packages/harness-channel-api/src/chatRouter.js'; +import { createInProcessClient, type InProcessClient } from '../support/inProcessHttp.js'; import { createFakeSecrets } from './testSecrets.js'; const providerCapabilities = { @@ -92,8 +91,8 @@ function parseNdjson(body: string): Array> { } describe('channelApi/chatRouter — real orchestrator + real privacy-guard', () => { - let server: Server; - let baseUrl: string; + let client: InProcessClient; + const baseUrl = '/chat'; let apiKeys: ReturnType; const mainRequests: string[] = []; @@ -131,19 +130,13 @@ describe('channelApi/chatRouter — real orchestrator + real privacy-guard', () }, }), ); - server = app.listen(0); - const addr = server.address() as AddressInfo; - baseUrl = `http://127.0.0.1:${String(addr.port)}/chat`; - }); - - after(async () => { - await new Promise((r) => server.close(() => r())); + client = createInProcessClient(app); }); it('masks PII on the wire to the LLM, and the streamed done event carries a privacy receipt', async () => { const created = await apiKeys.create({ label: 'privacy-check' }); - const res = await fetch(baseUrl, { + const res = await client.fetch(baseUrl, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${created.token}` }, body: JSON.stringify({ diff --git a/middleware/test/support/inProcessHttp.test.ts b/middleware/test/support/inProcessHttp.test.ts new file mode 100644 index 00000000..3a50e934 --- /dev/null +++ b/middleware/test/support/inProcessHttp.test.ts @@ -0,0 +1,111 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; + +import express from 'express'; + +import { createInProcessClient } from './inProcessHttp.js'; + +/** + * Self-coverage for the in-process HTTP driver that the channelApi router + * suites run on (issue #564). These are deliberately behavioural, not + * mock-counting: every assertion below fails if the transport silently drops + * status, headers, the request body, or a streamed chunk — the exact ways an + * in-memory shim can diverge from a real loopback socket. + */ +describe('support/inProcessHttp', () => { + it('never binds a port — the server is created but never listen()ed', () => { + const app = express(); + const { server } = createInProcessClient(app); + // A listen()ed server reports an AddressInfo here; an un-listened one is + // null. This is the whole point of the driver: no ephemeral-port bind, no + // TCP handshake, nothing to contend under a loaded runner. + assert.equal(server.address(), null); + assert.equal(server.listening, false); + }); + + it('passes the request method, path, and JSON body through to the handler', async () => { + const app = express(); + app.use(express.json()); + app.post('/echo/:id', (req, res) => { + res.status(202).json({ id: req.params.id, method: req.method, body: req.body }); + }); + const { fetch } = createInProcessClient(app); + + const res = await fetch('/echo/abc', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ hello: 'world' }), + }); + assert.equal(res.status, 202); + assert.deepEqual(await res.json(), { + id: 'abc', + method: 'POST', + body: { hello: 'world' }, + }); + }); + + it('forwards request headers and surfaces response headers', async () => { + const app = express(); + app.get('/h', (req, res) => { + res.setHeader('x-custom', 'from-handler'); + res.json({ seen: req.get('x-caller') }); + }); + const { fetch } = createInProcessClient(app); + + const res = await fetch('/h', { headers: { 'x-caller': 'test' } }); + assert.equal(res.headers.get('x-custom'), 'from-handler'); + assert.equal((await res.json() as { seen: string }).seen, 'test'); + }); + + it('propagates non-2xx status codes verbatim (404 for an unmounted path)', async () => { + const app = express(); + app.get('/only', (_req, res) => { + res.json({ ok: true }); + }); + const { fetch } = createInProcessClient(app); + + const res = await fetch('/nope'); + assert.equal(res.status, 404); + }); + + it('buffers a chunked/streamed response — every written chunk arrives, in order', async () => { + const app = express(); + app.get('/stream', (_req, res) => { + res.status(200); + res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8'); + res.flushHeaders(); + let n = 0; + const timer = setInterval(() => { + res.write(`${JSON.stringify({ n: n++ })}\n`); + if (n === 3) { + clearInterval(timer); + res.end(); + } + }, 3); + }); + const { fetch } = createInProcessClient(app); + + const res = await fetch('/stream'); + assert.equal(res.status, 200); + assert.match(res.headers.get('content-type') ?? '', /x-ndjson/); + const lines = (await res.text()) + .trim() + .split('\n') + .map((l) => JSON.parse(l) as { n: number }); + assert.deepEqual(lines, [{ n: 0 }, { n: 1 }, { n: 2 }]); + }); + + it('keeps concurrent in-flight requests isolated (no shared-connection bleed)', async () => { + const app = express(); + app.get('/id/:n', (req, res) => { + // Resolve out of call order to shake out any response cross-wiring. + setTimeout(() => res.json({ n: Number(req.params.n) }), (5 - Number(req.params.n)) * 3); + }); + const { fetch } = createInProcessClient(app); + + const results = await Promise.all( + [1, 2, 3, 4].map(async (n) => (await (await fetch(`/id/${n}`)).json() as { n: number }).n), + ); + assert.deepEqual(results.sort(), [1, 2, 3, 4]); + }); +}); diff --git a/middleware/test/support/inProcessHttp.ts b/middleware/test/support/inProcessHttp.ts new file mode 100644 index 00000000..7e98e66f --- /dev/null +++ b/middleware/test/support/inProcessHttp.ts @@ -0,0 +1,150 @@ +import { createServer, request as httpRequest, type RequestListener, type Server } from 'node:http'; +import type { Socket } from 'node:net'; +import { Duplex } from 'node:stream'; + +/** + * A `fetch`-shaped response, carrying only the surface the middleware router + * suites actually assert against: status, headers, and a buffered body. + */ +export interface InProcessResponse { + readonly status: number; + readonly headers: Headers; + text(): Promise; + json(): Promise; +} + +/** Init options mirroring the `fetch` calls the suites already use. */ +export interface InProcessRequestInit { + method?: string; + headers?: Record; + body?: string; +} + +/** + * Two cross-wired {@link Duplex} streams that behave like the endpoints of a + * loopback socket: bytes written to one surface as reads on the other. No file + * descriptor, no port, no kernel socket — so nothing here contends for the + * ephemeral-port range or pays a TCP handshake under a loaded CI runner, which + * is the exact cost issue #564 identifies. + * + * The extra no-op methods are the slice of the `net.Socket` surface that + * Node's own HTTP server- and client-side state machines poke at + * (`setTimeout`, `setNoDelay`, …); a bare Duplex is missing them and the + * parsers throw. + */ +function socketPair(): [Duplex, Duplex] { + // `a` and `b` reference each other, but only from `write`/`final`, which run + // strictly after both bindings are initialized — so `const` forward refs are + // safe here (no TDZ hazard at call time). + const a: Duplex = new Duplex({ + read() {}, + write(chunk, _enc, cb) { + b.push(chunk); + cb(); + }, + final(cb) { + b.push(null); + cb(); + }, + }); + const b: Duplex = new Duplex({ + read() {}, + write(chunk, _enc, cb) { + a.push(chunk); + cb(); + }, + final(cb) { + a.push(null); + cb(); + }, + }); + const stub = { + setTimeout() { + return this; + }, + setNoDelay() { + return this; + }, + setKeepAlive() { + return this; + }, + ref() { + return this; + }, + unref() { + return this; + }, + address() { + return { address: '127.0.0.1', family: 'IPv4', port: 0 }; + }, + remoteAddress: '127.0.0.1', + remoteFamily: 'IPv4', + remotePort: 0, + localAddress: '127.0.0.1', + localPort: 0, + }; + Object.assign(a, stub); + Object.assign(b, stub); + return [a, b]; +} + +function flattenHeaders(raw: NodeJS.Dict): Headers { + const headers = new Headers(); + for (const [name, value] of Object.entries(raw)) { + if (value === undefined) continue; + for (const v of Array.isArray(value) ? value : [value]) headers.append(name, v); + } + return headers; +} + +/** A `fetch`-shaped caller bound to an in-process Express app. */ +export interface InProcessClient { + fetch(path: string, init?: InProcessRequestInit): Promise; + /** The underlying (never-listened) server — exposed for assertions/teardown. */ + server: Server; +} + +/** + * Drives an Express app (or any {@link RequestListener}) entirely in-process: + * a real `http.Server` that is never `listen()`ed, fed a synthetic connection + * and driven by Node's own HTTP client so the response is parsed by Node, not + * by hand. Returns a `fetch`-shaped caller. + */ +export function createInProcessClient(handler: RequestListener): InProcessClient { + const server = createServer(handler); + function inProcessFetch(path: string, init: InProcessRequestInit = {}): Promise { + const [clientSide, serverSide] = socketPair(); + server.emit('connection', serverSide); + return new Promise((resolve, reject) => { + const req = httpRequest( + { + // The synthetic Duplex stands in for the real net.Socket the client + // would otherwise open — a deliberate transport swap, hence the cast. + createConnection: () => clientSide as unknown as Socket, + method: init.method ?? 'GET', + path, + host: 'localhost', + headers: init.headers, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => { + const buf = Buffer.concat(chunks); + resolve({ + status: res.statusCode ?? 0, + headers: flattenHeaders(res.headers), + text: async () => buf.toString('utf8'), + json: async () => JSON.parse(buf.toString('utf8')) as unknown, + }); + }); + res.on('error', reject); + }, + ); + req.on('error', reject); + if (init.body !== undefined) req.write(init.body); + req.end(); + }); + } + return { fetch: inProcessFetch, server }; +}