ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
1204 lines
49 KiB
TypeScript
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);
|
|
});
|
|
});
|