diff --git a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts new file mode 100644 index 0000000..0ac831a --- /dev/null +++ b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ChatGateway } from './chat.gateway.js'; + +const CONVERSATION_ID = 'conversation-1'; +const CANARY = 'token=sk_canary12345678'; + +type GatewayInternals = { + clientSessions: Map; + relayEvent(client: unknown, conversationId: string, event: unknown): void; +}; + +function buildGateway() { + const brain = { + conversations: { + addMessage: vi.fn().mockResolvedValue(undefined), + }, + }; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + }; + const gateway = new ChatGateway( + agentService as never, + {} as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + + return { gateway: gateway as unknown as GatewayInternals, brain }; +} + +describe('ChatGateway redaction boundary', (): void => { + it('redacts an assistant delta before egress and before its persistence buffer', (): void => { + const { gateway } = buildGateway(); + const client = { + connected: true, + id: 'client-1', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + const session = { + conversationId: CONVERSATION_ID, + cleanup: vi.fn(), + assistantText: '', + toolCalls: [], + pendingToolCalls: new Map(), + scope: { userId: 'user-1', tenantId: 'tenant-1' }, + }; + gateway.clientSessions.set(client.id, session); + + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: CANARY }, + }); + + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: CONVERSATION_ID, + text: '[REDACTED_SECRET]', + }); + expect(session.assistantText).toBe('[REDACTED_SECRET]'); + expect(JSON.stringify(client.emit.mock.calls)).not.toContain(CANARY); + }); + + it('persists only redacted assistant content with classifications', (): void => { + const { gateway, brain } = buildGateway(); + const client = { + connected: true, + id: 'client-1', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + gateway.clientSessions.set(client.id, { + conversationId: CONVERSATION_ID, + cleanup: vi.fn(), + assistantText: CANARY, + toolCalls: [], + pendingToolCalls: new Map(), + scope: { userId: 'user-1', tenantId: 'tenant-1' }, + }); + + gateway.relayEvent(client, CONVERSATION_ID, { type: 'agent_end' }); + + expect(brain.conversations.addMessage).toHaveBeenCalledWith( + expect.objectContaining({ + content: '[REDACTED_SECRET]', + metadata: expect.objectContaining({ classifications: ['secret'] }), + }), + 'user-1', + ); + expect(JSON.stringify(brain.conversations.addMessage.mock.calls)).not.toContain(CANARY); + }); +});