171 lines
5.1 KiB
TypeScript
171 lines
5.1 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import { ChatGateway } from './chat.gateway.js';
|
|
|
|
const CONVERSATION_ID = 'conversation-1';
|
|
const CANARY = 'sk_canary12345678';
|
|
|
|
type GatewayInternals = {
|
|
clientSessions: Map<string, unknown>;
|
|
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 = {
|
|
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: '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('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);
|
|
});
|
|
});
|