import 'reflect-metadata'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { ValidationPipe, type ArgumentMetadata } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { validateSync, type ValidationError } from 'class-validator'; import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types'; import { AgentService } from '../../agent/agent.service.js'; import { AuthGuard } from '../../auth/auth.guard.js'; import { HarnessRegistry } from '../../harness/harness.registry.js'; import { HARNESS_REGISTRY } from '../../harness/harness.tokens.js'; import type { AuthenticatedUserLike } from '../../auth/session-scope.js'; import { SendMessageDto } from '../../conversations/conversations.dto.js'; import { ChatController } from '../chat.controller.js'; import { ChatGateway } from '../chat.gateway.js'; import { ChatRuntimeRouter } from '../chat-runtime-router.js'; import { EmbeddedChatRuntime } from '../embedded-chat.runtime.js'; import { HarnessChatRuntime } from '../harness-chat.runtime.js'; import type { ChatRuntime } from '../chat-runtime.js'; import { ChatRequestDto, HarnessTurnSendDto } from '../chat.dto.js'; import { validateSocketSession } from '../chat.gateway-auth.js'; describe('Chat controller source hardening', () => { it('applies AuthGuard and reads the current user', () => { const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8'); expect(source).toContain('@UseGuards(AuthGuard)'); expect(source).toContain('@CurrentUser() user: AuthenticatedUserLike'); expect(source).toContain('const scope = scopeFromUser(user);'); }); }); describe('Chat runtime routing hardening (Task Five)', () => { it('routes /api/chat through the exclusive ChatRuntimeRouter, never the embedded AgentService', () => { const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8'); // pi-rpc /api/chat must resolve execution through the one runtime router and never // reach into embedded agent execution. Legacy embedded behaviour lives behind // EmbeddedChatRuntime, reachable only via the router in legacy mode. expect(source).toContain('ChatRuntimeRouter'); expect(source).not.toContain('@Inject(AgentService)'); expect(source).not.toContain("from '../agent/agent.service.js'"); }); it('gateway no longer injects the embedded AgentService or RoutingEngineService', () => { const source = readFileSync(resolve('src/chat/chat.gateway.ts'), 'utf8'); expect(source).toContain('ChatRuntimeRouter'); expect(source).not.toContain('@Inject(AgentService)'); expect(source).not.toContain('@Inject(RoutingEngineService)'); }); }); describe('Harness turn:send DTO validation (Task Five, group 2 — frozen wire contract, production pipe)', () => { // Correction #2 (Scrappy fe3e02): drive PLAIN wire payloads through the EXACT production // validation the gateway applies to inbound bodies — the global ValidationPipe in // apps/gateway/src/main.ts: { whitelist, forbidNonWhitelisted, transform }. This exercises // the real plainToInstance transform, nested @Type/@ValidateNested recursion, and whitelist // stripping — the path a turn:send actually travels — rather than a hand-built class instance // fed to validateSync (which never runs @Type and is masked green by class-validator's // empty-metadata unknownValue behaviour). Anti-masking: every VALUE-rule red asserts the field // carries a REAL value constraint (not `whitelistValidation`/`unknownValue`), which the // decorator-less stub can NEVER produce; every authority-field red asserts the forbidden field // is rejected while a valid field is NOT — false against the stub, which over-rejects everything. const PRODUCTION_PIPE = () => new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }); // Same production configuration, but hand back the raw ValidationError[] instead of throwing a // BadRequestException, so the test can inspect per-field constraint keys and nested children. const failPipe = new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true, exceptionFactory: (errs: ValidationError[]) => errs as unknown as Error, }); const asBody = (metatype: ArgumentMetadata['metatype']): ArgumentMetadata => ({ type: 'body', metatype, data: '', }); const UUID_V4 = '11111111-1111-4111-8111-111111111111'; const validSelection = () => ({ harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' }); const validPayload = () => ({ conversationId: UUID_V4, content: 'hello there', selection: validSelection(), idempotencyKey: UUID_V4, }); // Constraint keys that mean "the field was rejected for existing", NOT "its VALUE failed a real // rule". The decorator-less RED stub can only ever emit these (or nothing), so requiring a REAL // value constraint on a field is unmaskable until Step Three attaches the decorators. const NON_VALUE = new Set(['whitelistValidation', 'unknownValue']); // Flatten the error tree to `dotted.path -> Set` (parent + nested children). const collect = (errors: ValidationError[], prefix = ''): Map> => { const map = new Map>(); const add = (path: string, keys: Iterable): void => { const set = map.get(path) ?? new Set(); for (const key of keys) set.add(key); map.set(path, set); }; for (const error of errors) { const path = prefix ? `${prefix}.${error.property}` : error.property; if (error.constraints) add(path, Object.keys(error.constraints)); if (error.children?.length) for (const [p, s] of collect(error.children, path)) add(p, s); } return map; }; // Run the production pipe over a plain payload; return the ValidationError[] it raised (empty // when the payload is accepted). const errorsFor = async ( payload: unknown, metatype: ArgumentMetadata['metatype'] = HarnessTurnSendDto, ): Promise => { try { await failPipe.transform(payload, asBody(metatype)); return []; } catch (thrown) { return thrown as ValidationError[]; } }; // True when `path` is rejected by a REAL value rule (IsUUID, IsNotEmpty, MaxLength, …), i.e. a // constraint that is not a mere existence/whitelist rejection. const hasValueConstraint = async ( payload: unknown, path: string, metatype: ArgumentMetadata['metatype'] = HarnessTurnSendDto, ): Promise => { const keys = collect(await errorsFor(payload, metatype)).get(path); return keys ? [...keys].some((key) => !NON_VALUE.has(key)) : false; }; // Paths rejected purely for existing outside the whitelist (authority / unknown-field defence). const forbiddenFields = async (payload: unknown): Promise => { const out: string[] = []; for (const [path, keys] of collect(await errorsFor(payload))) { if (keys.has('whitelistValidation')) out.push(path); } return out; }; // --- GREEN controls: prove the production pipe machinery genuinely accepts a well-formed, // already-decorated DTO AND enforces its constraints — so the group's reds below are the // STUB's missing decorators, not a broken harness. Both pass today. --- it('GREEN control: the production pipe accepts a well-formed, decorated ChatRequestDto', async () => { await expect( PRODUCTION_PIPE().transform({ content: 'hello there' }, asBody(ChatRequestDto)), ).resolves.toBeTruthy(); }); it('GREEN control: the same production pipe rejects over-long ChatRequestDto content (engine truly enforces)', async () => { expect( await hasValueConstraint({ content: 'x'.repeat(10_001) }, 'content', ChatRequestDto), ).toBe(true); }); // --- RED value-rule fence: each asserts the turn:send field is rejected by a REAL value rule. // All fail against the decorator-less stub; they go green when Step Three adds the decorators. // No validation implementation is permitted during RED collection. --- it('requires a conversation id (rejects a missing conversationId)', async () => { expect( await hasValueConstraint({ ...validPayload(), conversationId: undefined }, 'conversationId'), ).toBe(true); }); it('requires a UUID conversation id (rejects a non-UUID conversationId)', async () => { expect( await hasValueConstraint( { ...validPayload(), conversationId: 'not-a-uuid' }, 'conversationId', ), ).toBe(true); }); it('rejects empty / whitespace-only content (content is trimmed 1..10000)', async () => { expect(await hasValueConstraint({ ...validPayload(), content: ' ' }, 'content')).toBe(true); }); it('rejects content above 10000 characters', async () => { expect( await hasValueConstraint({ ...validPayload(), content: 'x'.repeat(10_001) }, 'content'), ).toBe(true); }); it('requires the nested selection triple (rejects a missing selection)', async () => { expect(await hasValueConstraint({ ...validPayload(), selection: undefined }, 'selection')).toBe( true, ); }); it('rejects malformed selection nesting (a non-object selection)', async () => { expect( await hasValueConstraint( { ...validPayload(), selection: 'pi/anthropic/claude' }, 'selection', ), ).toBe(true); }); it('rejects a selection with a blank harnessId (each id must be non-empty)', async () => { const payload = { ...validPayload(), selection: { harnessId: '', providerId: 'anthropic', modelId: 'claude' }, }; expect(await hasValueConstraint(payload, 'selection.harnessId')).toBe(true); }); it('rejects a selection missing the modelId', async () => { const payload = { ...validPayload(), selection: { harnessId: 'pi', providerId: 'anthropic' } }; expect(await hasValueConstraint(payload, 'selection.modelId')).toBe(true); }); it('requires a UUID-v4 idempotency key (rejects a missing key)', async () => { expect( await hasValueConstraint({ ...validPayload(), idempotencyKey: undefined }, 'idempotencyKey'), ).toBe(true); }); it('rejects a non-UUID-v4 idempotency key', async () => { expect( await hasValueConstraint( { ...validPayload(), idempotencyKey: 'not-a-key' }, 'idempotencyKey', ), ).toBe(true); }); // --- RED authority/unknown-field fence: the forbidden field is rejected while a valid field is // NOT spuriously rejected. False against the stub (which over-rejects every field, including // `content`); true only once Step Three whitelists the legitimate fields. --- it('rejects a top-level authority field (provider) without flagging valid fields', async () => { const forbidden = await forbiddenFields({ ...validPayload(), provider: 'openai' }); expect(forbidden).toContain('provider'); expect(forbidden).not.toContain('content'); }); it('rejects a top-level modelId authority field without flagging valid fields', async () => { const forbidden = await forbiddenFields({ ...validPayload(), modelId: 'gpt-5' }); expect(forbidden).toContain('modelId'); expect(forbidden).not.toContain('content'); }); it('rejects an attachments field (not part of the frozen turn:send contract)', async () => { const forbidden = await forbiddenFields({ ...validPayload(), attachments: [{ id: 'a1' }] }); expect(forbidden).toContain('attachments'); expect(forbidden).not.toContain('content'); }); }); describe('Chat runtime routing — behavioural /api/chat fence (Task Five, group 3)', () => { // The router's own runtime tokens. The controller must reach chat execution ONLY through the // router; the embedded AgentService below is the forbidden path proven untouched. const embedded: ChatRuntime = { kind: 'embedded' }; const harness: ChatRuntime = { kind: 'harness' }; // Structurally-complete, non-sentinel conversation service; its methods are never invoked here. const boundConversationService = { attach: () => Promise.reject(new Error('unused')), detach: () => Promise.reject(new Error('unused')), send: () => Promise.reject(new Error('unused')), subscribeFrom: async function* () { throw new Error('unused'); }, } as unknown as HarnessConversationService; it('handles an /api/chat turn without invoking the embedded AgentService (behavioural, zero calls)', async () => { const calls = { getSession: 0, createSession: 0, onEvent: 0, prompt: 0 }; // A spy standing in for the forbidden embedded runtime. `createSession` rejects so today's // controller bails immediately (never reaching the 120s agent-response wait) while still // recording that it reached into the embedded path — the RED anchor. Under Step Three the // router owns execution and this spy is never touched, so every counter stays 0 (GREEN). // Any masking mutation that re-enters embedded execution flips a counter and re-reds the test. const agentSpy = { getSession: () => { calls.getSession += 1; return undefined; }, createSession: () => { calls.createSession += 1; return Promise.reject(new Error('spy: embedded AgentService must not be used')); }, onEvent: () => { calls.onEvent += 1; return () => {}; }, prompt: () => { calls.prompt += 1; return Promise.resolve(); }, }; const moduleRef = await Test.createTestingModule({ controllers: [ChatController], providers: [ { provide: AgentService, useValue: agentSpy }, { // Provided so the Step-Three controller (which injects the router) still resolves here; // the real, empty registry seeded with a pi adapter keeps the router pi-rpc-ready. provide: HARNESS_REGISTRY, useFactory: () => { const registry = new HarnessRegistry(); registry.register({ id: 'pi', describe: () => Promise.reject(new Error('unused')), catalog: () => Promise.reject(new Error('unused')), create: () => Promise.reject(new Error('unused')), resume: () => Promise.reject(new Error('unused')), } as HarnessAdapter); return registry; }, }, { provide: ChatRuntimeRouter, useFactory: (registry: HarnessRegistry) => new ChatRuntimeRouter(registry, boundConversationService, embedded, harness, 'pi-rpc'), inject: [HARNESS_REGISTRY], }, ], }) // ChatController's @UseGuards(AuthGuard) is resolved during instance loading; AuthGuard // injects AUTH, an HTTP-only concern never exercised by a direct handler call. Stub it so // the graph resolves and the test reds on BEHAVIOUR, not on a DI collection error. .overrideGuard(AuthGuard) .useValue({ canActivate: () => true }) .compile(); try { const controller = moduleRef.get(ChatController, { strict: false }); const user = { id: 'user-1' } as AuthenticatedUserLike; try { await controller.chat({ content: 'route me' } as ChatRequestDto, user); } catch { // Today: SERVICE_UNAVAILABLE from the rejecting spy. Under Step Three: the router path may // reject on the deliberately-unbound fake conversation service. Either way the // embedded-call counters below are the contract, not the handler's return value. } expect(calls).toEqual({ getSession: 0, createSession: 0, onEvent: 0, prompt: 0 }); } finally { await moduleRef.close(); } }); it('wires the exclusive ChatRuntimeRouter into the chat module graph (defense-in-depth source check)', () => { const source = readFileSync(resolve('src/chat/chat.module.ts'), 'utf8'); // The controller can only inject the router if the module actually provides it. RED today: // ChatModule provides only ChatGateway. GREEN once Step Three registers ChatRuntimeRouter. expect(source).toContain('ChatRuntimeRouter'); }); }); describe('WebSocket session authentication', () => { it('returns null when the handshake does not resolve to a session', async () => { const result = await validateSocketSession( {}, { api: { getSession: vi.fn().mockResolvedValue(null), }, }, ); expect(result).toBeNull(); }); it('returns the resolved session when Better Auth accepts the headers', async () => { const session = { user: { id: 'user-1' }, session: { id: 'session-1' } }; const result = await validateSocketSession( { cookie: 'session=abc' }, { api: { getSession: vi.fn().mockResolvedValue(session), }, }, ); expect(result).toEqual(session); }); }); describe('Non-Discord ("Telegram-equivalent") socket ingress rejection (Task Five, both runtime modes)', () => { // Scrappy C adjudication regression: a non-Discord service socket — modelled as a "Telegram" // client presenting a handshake token the gateway does NOT honour and carrying no Better-Auth // session — must be DISCONNECTED at handleConnection, never gain the `discordService` trust flag // or any user scope, receive no manifest and no ack, and reach NEITHER the embedded runtime NOR // the harness. This must hold in BOTH legacy and pi-rpc modes: introducing the exclusive // ChatRuntimeRouter / pi-rpc path must not open a second, non-Discord service ingress. This is a // GREEN control (it holds on this branch and must keep holding through Step Three); no Telegram // production route or plugin exists or is added — the assertion is that no such surface is // reachable. The runtime slot is fronted with a REAL, ready ChatRuntimeRouter over // EmbeddedChatRuntime + HarnessChatRuntime so that any accidental dispatch would flip a spy // rather than silently pass; the router is never resolved because the socket is rejected first. let priorMode: string | undefined; beforeEach(() => { priorMode = process.env['CHAT_HARNESS_RUNTIME']; }); afterEach(() => { if (priorMode === undefined) delete process.env['CHAT_HARNESS_RUNTIME']; else process.env['CHAT_HARNESS_RUNTIME'] = priorMode; }); // A pi-ready registry so a pi-rpc router resolves the harness cleanly at onModuleInit — modelling // the hostile condition where the harness is live yet the non-Discord socket is still rejected. const readyPiRegistry = (): HarnessRegistry => { const registry = new HarnessRegistry(); registry.register({ id: 'pi', describe: () => Promise.reject(new Error('unused')), catalog: () => Promise.reject(new Error('unused')), create: () => Promise.reject(new Error('unused')), resume: () => Promise.reject(new Error('unused')), } as HarnessAdapter); return registry; }; // Non-sentinel conversation service so pi-rpc onModuleInit resolves the harness (does not throw // conversation_service_unavailable); its methods must never be invoked on the rejection path. const availableConversationService = { attach: () => Promise.reject(new Error('unused')), detach: () => Promise.reject(new Error('unused')), send: () => Promise.reject(new Error('unused')), subscribeFrom: async function* () { throw new Error('unused'); }, } as unknown as HarnessConversationService; const readyRouter = ( mode: 'legacy' | 'pi-rpc', agentService: unknown, harnessConversations: unknown, ): ChatRuntimeRouter => { const embedded = new EmbeddedChatRuntime(agentService as never); const harness = new HarnessChatRuntime(harnessConversations as never); const router = new ChatRuntimeRouter( readyPiRegistry(), availableConversationService, embedded, harness, mode, ); router.onModuleInit(); return router; }; it.each(['legacy', 'pi-rpc'] as const)( 'disconnects a Telegram-shaped unauthenticated socket and dispatches to no runtime (%s mode)', async (mode) => { process.env['CHAT_HARNESS_RUNTIME'] = mode; const agentService = { getSession: vi.fn().mockReturnValue(undefined), createSession: vi.fn(), recordMessage: vi.fn(), onEvent: vi.fn().mockReturnValue((): void => undefined), addChannel: vi.fn(), prompt: vi.fn().mockResolvedValue(undefined), }; const harnessConversations = { append: vi.fn() }; // Auth stub that resolves NO session for the Telegram socket's headers — the sole gate a // non-Discord client must pass, and does not. const auth = { api: { getSession: vi.fn().mockResolvedValue(null) } }; const gateway = new ChatGateway( readyRouter(mode, agentService, harnessConversations) as never, auth as never, { conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never, {} as never, {} as never, {} as never, ); const client = { id: `telegram-raw-${mode}`, // A non-Discord service handshake: the gateway only honours `discordServiceToken`, so this // token is ignored, and there is no session cookie for validateSocketSession to resolve. handshake: { auth: { telegramServiceToken: 'ignored-non-discord-token' }, headers: {} }, data: {} as Record, emit: vi.fn(), disconnect: vi.fn(), }; await gateway.handleConnection(client as never); // Rejected at the door: disconnected, no trust flag, no user scope, no manifest, and — the // Task 5 send-capability rule — no send-protocol advertisement to an unauthenticated socket. expect(client.disconnect).toHaveBeenCalled(); expect(client.data.discordService).not.toBe(true); expect(client.data.user).toBeUndefined(); expect(client.emit).not.toHaveBeenCalledWith('commands:manifest', expect.anything()); expect(client.emit).not.toHaveBeenCalledWith('chat:send-capability', expect.anything()); // Even if the ignored socket then attempts a message, it carries no scope, so the send path // never begins and no runtime is dispatched. await gateway.handleMessage( client as never, { conversationId: 'Nova:telegram:chat-1', content: 'via telegram', } as never, ); expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); expect(agentService.createSession).not.toHaveBeenCalled(); expect(agentService.prompt).not.toHaveBeenCalled(); expect(harnessConversations.append).not.toHaveBeenCalled(); }, ); // A minimal authenticated-browser connection harness for the send-capability advertisement. const connectAuthedBrowser = async ( mode: 'legacy' | 'pi-rpc', clientId: string, ): Promise<{ emit: ReturnType; disconnect: ReturnType }> => { process.env['CHAT_HARNESS_RUNTIME'] = mode; const agentService = { getSession: vi.fn().mockReturnValue(undefined), createSession: vi.fn(), recordMessage: vi.fn(), onEvent: vi.fn().mockReturnValue((): void => undefined), addChannel: vi.fn(), prompt: vi.fn().mockResolvedValue(undefined), }; const harnessConversations = { append: vi.fn() }; const auth = { api: { getSession: vi .fn() .mockResolvedValue({ user: { id: 'user-a' }, session: { id: 'session-a' } }), }, }; const gateway = new ChatGateway( readyRouter(mode, agentService, harnessConversations) as never, auth as never, { conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never, { getManifest: vi.fn().mockReturnValue({ commands: [] }) } as never, {} as never, {} as never, ); const client = { id: clientId, handshake: { auth: {}, headers: { cookie: 'session=abc' } }, data: {} as Record, emit: vi.fn(), disconnect: vi.fn(), }; await gateway.handleConnection(client as never); return client as never; }; it('advertises legacy-message exactly once to an authenticated browser in legacy mode (Task 5 MAJOR-1)', async () => { const client = await connectAuthedBrowser('legacy', 'browser-cap-legacy'); // Legacy mode: the connected Gateway handles the `message` event, so it advertises // `legacy-message` — targeted, connection-bound, exactly once. expect(client.emit).toHaveBeenCalledWith('chat:send-capability', { protocol: 'legacy-message', connectionId: 'browser-cap-legacy', }); const capabilityCalls = client.emit.mock.calls.filter( (call: unknown[]) => call[0] === 'chat:send-capability', ); expect(capabilityCalls).toHaveLength(1); expect(client.disconnect).not.toHaveBeenCalled(); }); it('advertises unavailable (never turn-send) to an authenticated browser in pi-rpc mode (Task 5 MAJOR-1)', async () => { const client = await connectAuthedBrowser('pi-rpc', 'browser-cap-pirpc'); // pi-rpc mode: the legacy `message` handler fails closed and the authenticated `turn:send` // handler lands in Task 15, so Task 5 advertises `unavailable` — never `turn-send`. expect(client.emit).toHaveBeenCalledWith('chat:send-capability', { protocol: 'unavailable', connectionId: 'browser-cap-pirpc', }); const capabilityCalls = client.emit.mock.calls.filter( (call: unknown[]) => call[0] === 'chat:send-capability', ); expect(capabilityCalls).toHaveLength(1); expect(capabilityCalls[0]?.[1]).not.toMatchObject({ protocol: 'turn-send' }); }); it.each(['legacy', 'pi-rpc'] as const)( 'never advertises send-capability to a Discord service socket (%s mode, Task 5 MAJOR-1)', async (mode) => { process.env['CHAT_HARNESS_RUNTIME'] = mode; process.env['DISCORD_SERVICE_TOKEN'] = 'super-secret-discord-token'; const agentService = { getSession: vi.fn().mockReturnValue(undefined), createSession: vi.fn(), recordMessage: vi.fn(), onEvent: vi.fn().mockReturnValue((): void => undefined), addChannel: vi.fn(), prompt: vi.fn().mockResolvedValue(undefined), }; const gateway = new ChatGateway( readyRouter(mode, agentService, { append: vi.fn() }) as never, { api: { getSession: vi.fn() } } as never, { conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never, {} as never, {} as never, {} as never, ); const client = { id: `discord-service-${mode}`, handshake: { auth: { discordServiceToken: 'super-secret-discord-token' }, headers: {} }, data: {} as Record, emit: vi.fn(), disconnect: vi.fn(), }; await gateway.handleConnection(client as never); // The trusted Discord service socket is not a browser; it never receives a browser // send-protocol advertisement. expect(client.data.discordService).toBe(true); expect(client.emit).not.toHaveBeenCalledWith('chat:send-capability', expect.anything()); delete process.env['DISCORD_SERVICE_TOKEN']; }, ); // Record-and-forward instrumentation at the REAL returned-lease boundary: wrap the lease's own // dispatch/dispose so the test observes the Gateway's invocation counts through the real // ChatRuntimeRouter -> EmbeddedChatRuntime path — no canned lease, synthesized method, or shim. const instrumentLease = ( router: ChatRuntimeRouter, order: string[], counters: { dispatch: number; dispose: number }, ): void => { const realPrepare = router.prepareLegacySocketTurn.bind(router) as ( ...args: unknown[] ) => Promise<{ ok: boolean; value?: { dispatch: () => unknown; dispose: () => unknown } }>; vi.spyOn(router, 'prepareLegacySocketTurn').mockImplementation((async (...args: unknown[]) => { const result = await realPrepare(...args); if (result.ok && result.value) { const lease = result.value; const realDispatch = lease.dispatch.bind(lease); const realDispose = lease.dispose.bind(lease); lease.dispatch = (): unknown => { counters.dispatch += 1; order.push('dispatch'); return realDispatch(); }; lease.dispose = (): unknown => { counters.dispose += 1; return realDispose(); }; } return result; }) as never); }; it('orders a legacy browser turn persist -> ack -> lease.dispatch -> prompt, each exactly once (Task 5 G2)', async () => { process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; const order: string[] = []; const counters = { dispatch: 0, dispose: 0 }; const session = { provider: 'configured-provider', modelId: 'configured-model', piSession: { thinkingLevel: 'medium', getAvailableThinkingLevels: (): string[] => ['medium'], }, }; const agentService = { getSession: vi.fn().mockReturnValue(undefined), createSession: vi.fn().mockResolvedValue(session), recordMessage: vi.fn(), onEvent: vi.fn().mockReturnValue((): void => undefined), addChannel: vi.fn(), removeChannel: vi.fn(), prompt: vi.fn().mockImplementation(async (): Promise => { order.push('prompt'); }), }; const harnessConversations = { append: vi.fn() }; const brain = { conversations: { findById: vi.fn().mockResolvedValue({ id: 'conversation-order-1' }), findMessages: vi.fn().mockResolvedValue([]), create: vi.fn().mockResolvedValue(undefined), update: vi.fn().mockResolvedValue(undefined), addMessage: vi.fn().mockImplementation(async (): Promise<{ id: string }> => { order.push('persist'); return { id: 'message-order-1' }; }), }, }; const router = readyRouter('legacy', agentService, harnessConversations); instrumentLease(router, order, counters); const gateway = new ChatGateway( router as never, { api: { getSession: vi.fn() } } as never, brain as never, {} as never, {} as never, {} as never, ); const client = { id: 'browser-order-1', data: { user: { id: 'user-a' } }, emit: vi.fn().mockImplementation((event: string): void => { if (event === 'message:ack') order.push('ack'); }), }; await gateway.handleMessage( client as never, { conversationId: 'conversation-order-1', content: 'ordered hello', } as never, ); // The Gateway persists the user turn, THEN acks, THEN invokes the one-shot lease dispatch which // finally prompts. Observed at the real lease boundary: dispatch is invoked exactly once and the // runtime prompts exactly once. expect(order).toEqual(['persist', 'ack', 'dispatch', 'prompt']); expect(counters.dispatch).toBe(1); expect(agentService.prompt).toHaveBeenCalledTimes(1); expect(harnessConversations.append).not.toHaveBeenCalled(); }); it('drops a legacy browser turn on persistence failure: zero info/ack/dispatch/prompt, one clean disposal (Task 5 G2)', async () => { process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; const order: string[] = []; const counters = { dispatch: 0, dispose: 0 }; const unsub = vi.fn(); const session = { provider: 'configured-provider', modelId: 'configured-model', piSession: { thinkingLevel: 'medium', getAvailableThinkingLevels: (): string[] => ['medium'], }, }; const agentService = { getSession: vi.fn().mockReturnValue(undefined), createSession: vi.fn().mockResolvedValue(session), recordMessage: vi.fn(), onEvent: vi.fn().mockReturnValue(unsub), addChannel: vi.fn(), removeChannel: vi.fn(), prompt: vi.fn().mockResolvedValue(undefined), }; const harnessConversations = { append: vi.fn() }; const brain = { conversations: { findById: vi.fn().mockResolvedValue({ id: 'conversation-fail-1' }), findMessages: vi.fn().mockResolvedValue([]), create: vi.fn().mockResolvedValue(undefined), update: vi.fn().mockResolvedValue(undefined), addMessage: vi.fn().mockRejectedValue(new Error('persistence unavailable')), }, }; const router = readyRouter('legacy', agentService, harnessConversations); instrumentLease(router, order, counters); const gateway = new ChatGateway( router as never, { api: { getSession: vi.fn() } } as never, brain as never, {} as never, {} as never, {} as never, ); const client = { id: 'browser-fail-1', data: { user: { id: 'user-a' } }, emit: vi.fn(), }; await gateway.handleMessage( client as never, { conversationId: 'conversation-fail-1', content: 'will fail persistence', } as never, ); // A failed user-message persistence aborts the turn with no accept-then-lose: no session:info, // no ack, the lease never dispatches or prompts, and the just-prepared lease is disposed exactly // once (listener/channel cleanup) without throwing. The fixed safe error is surfaced. expect(client.emit).not.toHaveBeenCalledWith('session:info', expect.anything()); expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); expect(counters.dispatch).toBe(0); expect(agentService.prompt).not.toHaveBeenCalled(); expect(counters.dispose).toBe(1); expect(unsub).toHaveBeenCalledTimes(1); expect(client.emit).toHaveBeenCalledWith( 'error', expect.objectContaining({ code: 'persist_failed' }), ); expect(harnessConversations.append).not.toHaveBeenCalled(); }); it('drops a legacy browser turn when persistence RESOLVES nullish (not only on rejection): zero info/ack/dispatch/prompt, one clean disposal (Task 5 finding 2)', async () => { // Companion to the rejected-promise case above. A brain adapter that resolves `undefined`/`null` // instead of throwing must be treated as a persistence FAILURE, never as a saved message — the // pre-fix code accepted a nullish resolve and dispatched a turn whose user message was never // durably stored. RED before finding-2: the turn acks + dispatches + prompts on a phantom persist. process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; const order: string[] = []; const counters = { dispatch: 0, dispose: 0 }; const unsub = vi.fn(); const session = { provider: 'configured-provider', modelId: 'configured-model', piSession: { thinkingLevel: 'medium', getAvailableThinkingLevels: (): string[] => ['medium'], }, }; const agentService = { getSession: vi.fn().mockReturnValue(undefined), createSession: vi.fn().mockResolvedValue(session), recordMessage: vi.fn(), onEvent: vi.fn().mockReturnValue(unsub), addChannel: vi.fn(), removeChannel: vi.fn(), prompt: vi.fn().mockResolvedValue(undefined), }; const harnessConversations = { append: vi.fn() }; const brain = { conversations: { findById: vi.fn().mockResolvedValue({ id: 'conversation-nullish-1' }), findMessages: vi.fn().mockResolvedValue([]), create: vi.fn().mockResolvedValue(undefined), update: vi.fn().mockResolvedValue(undefined), // Resolves nullish rather than throwing: the fix must still fail the turn closed. addMessage: vi.fn().mockResolvedValue(undefined), }, }; const router = readyRouter('legacy', agentService, harnessConversations); instrumentLease(router, order, counters); const gateway = new ChatGateway( router as never, { api: { getSession: vi.fn() } } as never, brain as never, {} as never, {} as never, {} as never, ); const client = { id: 'browser-nullish-1', data: { user: { id: 'user-a' } }, emit: vi.fn(), }; await gateway.handleMessage( client as never, { conversationId: 'conversation-nullish-1', content: 'persist resolves undefined', } as never, ); expect(client.emit).not.toHaveBeenCalledWith('session:info', expect.anything()); expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); expect(counters.dispatch).toBe(0); expect(agentService.prompt).not.toHaveBeenCalled(); expect(counters.dispose).toBe(1); expect(unsub).toHaveBeenCalledTimes(1); expect(client.emit).toHaveBeenCalledWith( 'error', expect.objectContaining({ code: 'persist_failed' }), ); expect(harnessConversations.append).not.toHaveBeenCalled(); }); it("fails a legacy browser send closed when the supplied conversationId is not the sender's: conversation_unavailable, zero persist/mint/dispatch/prompt (Task 5 finding 1)", async () => { // A browser socket that supplies a conversationId it does not own must be refused at admission, // BEFORE any runtime effect. Pre-fix, an unresolved/foreign id fell through to session mint + // dispatch, letting a caller attach to (or resurrect) a conversation outside their scope. process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; const order: string[] = []; const counters = { dispatch: 0, dispose: 0 }; const agentService = { getSession: vi.fn().mockReturnValue(undefined), createSession: vi.fn().mockResolvedValue(undefined), recordMessage: vi.fn(), onEvent: vi.fn().mockReturnValue((): void => undefined), addChannel: vi.fn(), removeChannel: vi.fn(), prompt: vi.fn().mockResolvedValue(undefined), }; const harnessConversations = { append: vi.fn() }; const addMessage = vi.fn().mockResolvedValue({ id: 'must-not-persist' }); const brain = { conversations: { // Scoped lookup: the sender does not own this id, so admission resolves undefined. findById: vi.fn().mockResolvedValue(undefined), findMessages: vi.fn().mockResolvedValue([]), create: vi.fn().mockResolvedValue(undefined), update: vi.fn().mockResolvedValue(undefined), addMessage, }, }; const router = readyRouter('legacy', agentService, harnessConversations); instrumentLease(router, order, counters); const gateway = new ChatGateway( router as never, { api: { getSession: vi.fn() } } as never, brain as never, {} as never, {} as never, {} as never, ); const client = { id: 'browser-foreign-1', data: { user: { id: 'user-a' } }, emit: vi.fn(), }; await gateway.handleMessage( client as never, { conversationId: 'conversation-foreign-1', content: 'let me into a conversation I do not own', } as never, ); // Admission consults the scoped durable record with the SENDER's id, then fails closed: no // persist, no session mint, no lease dispatch/dispose, no prompt, no ack — only the typed refusal. expect(brain.conversations.findById).toHaveBeenCalledWith('conversation-foreign-1', 'user-a'); expect(addMessage).not.toHaveBeenCalled(); expect(brain.conversations.create).not.toHaveBeenCalled(); expect(agentService.createSession).not.toHaveBeenCalled(); expect(agentService.onEvent).not.toHaveBeenCalled(); expect(agentService.addChannel).not.toHaveBeenCalled(); expect(agentService.prompt).not.toHaveBeenCalled(); expect(counters.dispatch).toBe(0); expect(counters.dispose).toBe(0); expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); expect(client.emit).toHaveBeenCalledWith( 'error', expect.objectContaining({ code: 'conversation_unavailable' }), ); expect(harnessConversations.append).not.toHaveBeenCalled(); }); it('admits a legacy browser send with NO conversationId by minting a scoped durable record first, then dispatches once (Task 5 finding 1)', async () => { // The distinct server-minted-new path: a client that omits conversationId is a brand-new // conversation. Admission must CREATE the durable record (scoped to the sender) before the turn // persists/dispatches, and must not consult the ownership lookup (there is nothing to authorize // yet). This guards the fix from over-reaching and breaking new-conversation creation. process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; const order: string[] = []; const counters = { dispatch: 0, dispose: 0 }; const session = { provider: 'configured-provider', modelId: 'configured-model', piSession: { thinkingLevel: 'medium', getAvailableThinkingLevels: (): string[] => ['medium'], }, }; const agentService = { getSession: vi.fn().mockReturnValue(undefined), createSession: vi.fn().mockResolvedValue(session), recordMessage: vi.fn(), onEvent: vi.fn().mockReturnValue((): void => undefined), addChannel: vi.fn(), removeChannel: vi.fn(), prompt: vi.fn().mockResolvedValue(undefined), }; const harnessConversations = { append: vi.fn() }; // The durable store honours the mint: it returns the exact record asked for (same id, same // scoped owner). Admission requires that non-null, correctly-identified record before any effect. const create = vi.fn((data: { id: string; userId: string }) => Promise.resolve({ id: data.id, userId: data.userId }), ); const brain = { conversations: { findById: vi.fn().mockResolvedValue(undefined), findMessages: vi.fn().mockResolvedValue([]), create, update: vi.fn().mockResolvedValue(undefined), addMessage: vi.fn().mockResolvedValue({ id: 'persisted-new' }), }, }; const router = readyRouter('legacy', agentService, harnessConversations); instrumentLease(router, order, counters); const gateway = new ChatGateway( router as never, { api: { getSession: vi.fn() } } as never, brain as never, {} as never, {} as never, {} as never, ); const client = { id: 'browser-new-1', data: { user: { id: 'user-a' } }, emit: vi.fn(), }; await gateway.handleMessage( client as never, { content: 'first message with no conversationId', } as never, ); // The durable record is minted for the sender (create with the same server id the turn persists // under), and the admitted new-conversation turn dispatches exactly once. expect(create).toHaveBeenCalledWith( expect.objectContaining({ id: expect.any(String), userId: 'user-a' }), ); expect(counters.dispatch).toBe(1); expect(agentService.prompt).toHaveBeenCalledTimes(1); expect(harnessConversations.append).not.toHaveBeenCalled(); }); it('fails a legacy browser send with NO conversationId closed when the durable mint resolves nullish, with zero dispatch/persist/prompt/ack (Task 5 finding 1)', async () => { // Minting is not fire-and-forget: if create resolves nullish (the durable write silently // produced no record), admission must fail closed BEFORE any runtime effect rather than dispatch // against a conversation that was never persisted. process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; const order: string[] = []; const counters = { dispatch: 0, dispose: 0 }; const session = { provider: 'configured-provider', modelId: 'configured-model', piSession: { thinkingLevel: 'medium', getAvailableThinkingLevels: (): string[] => ['medium'], }, }; const agentService = { getSession: vi.fn().mockReturnValue(undefined), createSession: vi.fn().mockResolvedValue(session), recordMessage: vi.fn(), onEvent: vi.fn().mockReturnValue((): void => undefined), addChannel: vi.fn(), removeChannel: vi.fn(), prompt: vi.fn().mockResolvedValue(undefined), }; const harnessConversations = { append: vi.fn() }; const create = vi.fn().mockResolvedValue(undefined); const addMessage = vi.fn().mockResolvedValue({ id: 'persisted-new' }); const brain = { conversations: { findById: vi.fn().mockResolvedValue(undefined), findMessages: vi.fn().mockResolvedValue([]), create, update: vi.fn().mockResolvedValue(undefined), addMessage, }, }; const router = readyRouter('legacy', agentService, harnessConversations); instrumentLease(router, order, counters); const gateway = new ChatGateway( router as never, { api: { getSession: vi.fn() } } as never, brain as never, {} as never, {} as never, {} as never, ); const client = { id: 'browser-new-nullish-1', data: { user: { id: 'user-a' } }, emit: vi.fn(), }; await gateway.handleMessage( client as never, { content: 'first message, mint resolves nullish', } as never, ); // The mint was attempted for the sender, then admission failed closed: no persist, no session // mint, no lease dispatch/dispose, no prompt, no ack — only the typed refusal. expect(create).toHaveBeenCalledWith( expect.objectContaining({ id: expect.any(String), userId: 'user-a' }), ); expect(addMessage).not.toHaveBeenCalled(); expect(agentService.createSession).not.toHaveBeenCalled(); expect(agentService.prompt).not.toHaveBeenCalled(); expect(counters.dispatch).toBe(0); expect(counters.dispose).toBe(0); expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); expect(client.emit).toHaveBeenCalledWith( 'error', expect.objectContaining({ code: 'conversation_unavailable' }), ); expect(harnessConversations.append).not.toHaveBeenCalled(); }); it('fails a legacy browser send with NO conversationId closed when the durable mint throws, with zero dispatch/persist/prompt/ack (Task 5 finding 1)', async () => { // A create that rejects (durable store error) is a persistence failure, not a reason to proceed: // the exception is caught at the admission seam and collapses to the same fail-closed refusal. process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; const order: string[] = []; const counters = { dispatch: 0, dispose: 0 }; const session = { provider: 'configured-provider', modelId: 'configured-model', piSession: { thinkingLevel: 'medium', getAvailableThinkingLevels: (): string[] => ['medium'], }, }; const agentService = { getSession: vi.fn().mockReturnValue(undefined), createSession: vi.fn().mockResolvedValue(session), recordMessage: vi.fn(), onEvent: vi.fn().mockReturnValue((): void => undefined), addChannel: vi.fn(), removeChannel: vi.fn(), prompt: vi.fn().mockResolvedValue(undefined), }; const harnessConversations = { append: vi.fn() }; const create = vi.fn().mockRejectedValue(new Error('durable store unavailable')); const addMessage = vi.fn().mockResolvedValue({ id: 'persisted-new' }); const brain = { conversations: { findById: vi.fn().mockResolvedValue(undefined), findMessages: vi.fn().mockResolvedValue([]), create, update: vi.fn().mockResolvedValue(undefined), addMessage, }, }; const router = readyRouter('legacy', agentService, harnessConversations); instrumentLease(router, order, counters); const gateway = new ChatGateway( router as never, { api: { getSession: vi.fn() } } as never, brain as never, {} as never, {} as never, {} as never, ); const client = { id: 'browser-new-throw-1', data: { user: { id: 'user-a' } }, emit: vi.fn(), }; await gateway.handleMessage( client as never, { content: 'first message, mint throws', } as never, ); // The mint was attempted for the sender, then admission failed closed on the thrown error: no // persist, no session mint, no lease dispatch/dispose, no prompt, no ack — only the typed refusal. expect(create).toHaveBeenCalledWith( expect.objectContaining({ id: expect.any(String), userId: 'user-a' }), ); expect(addMessage).not.toHaveBeenCalled(); expect(agentService.createSession).not.toHaveBeenCalled(); expect(agentService.prompt).not.toHaveBeenCalled(); expect(counters.dispatch).toBe(0); expect(counters.dispose).toBe(0); expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); expect(client.emit).toHaveBeenCalledWith( 'error', expect.objectContaining({ code: 'conversation_unavailable' }), ); expect(harnessConversations.append).not.toHaveBeenCalled(); }); }); describe('Chat DTO validation', () => { it('rejects unsupported message roles', () => { const dto = Object.assign(new SendMessageDto(), { content: 'hello', role: 'moderator', }); const errors = validateSync(dto); expect(errors.length).toBeGreaterThan(0); }); it('rejects oversized conversation message content above 10000 characters', () => { const dto = Object.assign(new SendMessageDto(), { content: 'x'.repeat(10_001), role: 'user', }); const errors = validateSync(dto); expect(errors.length).toBeGreaterThan(0); }); it('rejects oversized chat content above 10000 characters', () => { const dto = Object.assign(new ChatRequestDto(), { content: 'x'.repeat(10_001), }); const errors = validateSync(dto); expect(errors.length).toBeGreaterThan(0); }); });