import { describe, expect, it, vi } from 'vitest'; import { ChatGateway } from './chat.gateway.js'; const CONVERSATION_ID = 'conversation-1'; const CANARY = 'sk_canary12345678'; function clientConversationKey(clientId: string, conversationId: string): string { return `${clientId}\u0000${conversationId}`; } 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 a secret split across assistant deltas before egress and persistence', (): void => { const { gateway } = buildGateway(); const client = { connected: true, id: 'client-1', data: { user: { id: 'user-1' } }, emit: vi.fn(), }; const session = { clientId: client.id, conversationId: CONVERSATION_ID, cleanup: vi.fn(), assistantText: '', toolCalls: [], pendingToolCalls: new Map(), scope: { userId: 'user-1', tenantId: 'tenant-1' }, }; gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), session); gateway.relayEvent(client, CONVERSATION_ID, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'sk_canary' }, }); expect(JSON.stringify(client.emit.mock.calls)).not.toContain('sk_canary'); gateway.relayEvent(client, CONVERSATION_ID, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: '12345678 ' }, }); expect(client.emit).toHaveBeenCalledWith('agent:text', { conversationId: CONVERSATION_ID, text: '[REDACTED_SECRET] ', }); expect(session.assistantText).toBe(`${CANARY} `); expect(JSON.stringify(client.emit.mock.calls)).not.toContain(CANARY); }); it('retains a split secret label until its value can be redacted', (): void => { const { gateway } = buildGateway(); const client = { connected: true, id: 'client-1', data: { user: { id: 'user-1' } }, emit: vi.fn(), }; gateway.relayEvent(client, CONVERSATION_ID, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'token ' }, }); gateway.relayEvent(client, CONVERSATION_ID, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: '=canaryvalue123 ' }, }); expect(client.emit).toHaveBeenCalledWith('agent:text', { conversationId: CONVERSATION_ID, text: '[REDACTED_SECRET] ', }); expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canaryvalue123'); }); it('holds a streamed private key until it can be redacted', (): void => { const { gateway } = buildGateway(); const client = { connected: true, id: 'client-1', data: { user: { id: 'user-1' } }, emit: vi.fn(), }; gateway.relayEvent(client, CONVERSATION_ID, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: '-----BEGIN PRIVATE KEY-----\ncanary' }, }); gateway.relayEvent(client, CONVERSATION_ID, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: '\n-----END PRIVATE KEY-----' }, }); expect(client.emit).toHaveBeenCalledWith('agent:text', { conversationId: CONVERSATION_ID, text: '[REDACTED_SECRET]', }); expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canary'); }); it('drops an oversized unterminated stream fragment rather than retaining it', (): void => { const { gateway } = buildGateway(); const client = { connected: true, id: 'client-1', data: { user: { id: 'user-1' } }, emit: vi.fn(), }; gateway.relayEvent(client, CONVERSATION_ID, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'x'.repeat(8_193) }, }); expect(client.emit).toHaveBeenCalledWith('agent:text', { conversationId: CONVERSATION_ID, text: '[REDACTED_STREAM_OVERFLOW]', }); }); it('isolates concurrent conversation streams sharing one Discord socket', (): void => { const { gateway } = buildGateway(); const client = { connected: true, id: 'discord-client', data: { user: { id: 'user-1' } }, emit: vi.fn(), }; const firstConversation = 'Nova:discord:thread-1'; const secondConversation = 'Nova:discord:thread-2'; const createSession = (conversationId: string) => ({ clientId: client.id, conversationId, cleanup: vi.fn(), assistantText: '', toolCalls: [], pendingToolCalls: new Map(), scope: { userId: 'user-1', tenantId: 'tenant-1' }, }); const firstSession = createSession(firstConversation); const secondSession = createSession(secondConversation); gateway.clientSessions.set(clientConversationKey(client.id, firstConversation), firstSession); gateway.clientSessions.set(clientConversationKey(client.id, secondConversation), secondSession); gateway.relayEvent(client, firstConversation, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'first response ' }, }); gateway.relayEvent(client, secondConversation, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'second response ' }, }); expect(firstSession.assistantText).toBe('first response '); expect(secondSession.assistantText).toBe('second response '); expect(client.emit).toHaveBeenCalledWith('agent:text', { conversationId: firstConversation, text: 'first response ', }); expect(client.emit).toHaveBeenCalledWith('agent:text', { conversationId: secondConversation, text: 'second response ', }); }); 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(clientConversationKey(client.id, CONVERSATION_ID), { clientId: 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); }); });