import { describe, expect, it, vi } from 'vitest'; import { ChatGateway } from './chat.gateway.js'; const CONVERSATION_ID = 'conversation-1'; const CANARY = 'sk_canary12345678'; /** * Task 5 fence (F, GREEN control): the redaction boundary is pre-Task-5 egress/persistence * functionality, orthogonal to runtime routing — it must keep holding unchanged across Task 5. * After Step Three, `relayEvent` consumes normalized {@link LegacyRuntimeEvent}s and never reads * the runtime slot, a pi session, or records usage: the runtime owns all session/token * bookkeeping and hands finished usage back on the `settled` event. So this control fronts slot0 * with a bare, getSession-less stub — proving the redaction path is independent of the runtime — * and drives the gateway with the normalized event vocabulary (`text_delta` / `settled`). */ function clientConversationKey(clientId: string, conversationId: string): string { return `${clientId}${conversationId}`; } type GatewayInternals = { clientSessions: Map; relayEvent(client: unknown, conversationId: string, event: unknown): void; }; function buildGateway() { const brain = { conversations: { addMessage: vi.fn().mockResolvedValue(undefined), }, }; // slot0 is the ChatRuntimeRouter in production; relayEvent never touches it, so a bare stub // with no getSession proves the redaction boundary is orthogonal to runtime routing. const runtime = {}; const gateway = new ChatGateway( runtime 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, 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: 'text_delta', text: 'sk_canary' }); expect(JSON.stringify(client.emit.mock.calls)).not.toContain('sk_canary'); gateway.relayEvent(client, CONVERSATION_ID, { type: 'text_delta', text: '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: 'text_delta', text: 'token ' }); gateway.relayEvent(client, CONVERSATION_ID, { type: 'text_delta', text: '=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: 'text_delta', text: '-----BEGIN PRIVATE KEY-----\ncanary', }); gateway.relayEvent(client, CONVERSATION_ID, { type: 'text_delta', text: '\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: 'text_delta', text: '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, 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: 'text_delta', text: 'first response ', }); gateway.relayEvent(client, secondConversation, { type: 'text_delta', text: '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, assistantText: CANARY, toolCalls: [], pendingToolCalls: new Map(), scope: { userId: 'user-1', tenantId: 'tenant-1' }, }); gateway.relayEvent(client, CONVERSATION_ID, { type: 'settled' }); 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); }); });