Files
stack/apps/gateway/src/chat/__tests__/chat-security.test.ts
T
2026-08-12 14:19:58 -05:00

1204 lines
49 KiB
TypeScript

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<constraintKey>` (parent + nested children).
const collect = (errors: ValidationError[], prefix = ''): Map<string, Set<string>> => {
const map = new Map<string, Set<string>>();
const add = (path: string, keys: Iterable<string>): void => {
const set = map.get(path) ?? new Set<string>();
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<ValidationError[]> => {
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<boolean> => {
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<string[]> => {
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<string, unknown>,
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<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }> => {
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<string, unknown>,
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<string, unknown>,
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<void> => {
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);
});
});