263 lines
9.4 KiB
TypeScript
263 lines
9.4 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} }));
|
|
vi.mock('../../commands/command-executor.service.js', () => ({
|
|
CommandExecutorService: class CommandExecutorService {},
|
|
}));
|
|
vi.mock('../routing/routing-engine.service.js', () => ({
|
|
RoutingEngineService: class RoutingEngineService {},
|
|
}));
|
|
|
|
import { SessionsController } from '../sessions.controller.js';
|
|
import { ChatController } from '../../chat/chat.controller.js';
|
|
import { ChatGateway } from '../../chat/chat.gateway.js';
|
|
import type { AgentSession } from '../agent.service.js';
|
|
import type { SessionInfoDto } from '../session.dto.js';
|
|
|
|
const USER_A = { id: 'user-a', tenantId: 'tenant-a' };
|
|
const USER_B = { id: 'user-b', tenantId: 'tenant-b' };
|
|
const CONVERSATION_ID = '11111111-1111-4111-8111-111111111111';
|
|
|
|
function makeSessionInfo(overrides?: Partial<SessionInfoDto>): SessionInfoDto {
|
|
return {
|
|
id: CONVERSATION_ID,
|
|
provider: 'test-provider',
|
|
modelId: 'test-model',
|
|
createdAt: new Date('2026-07-12T00:00:00Z').toISOString(),
|
|
promptCount: 0,
|
|
channels: [],
|
|
durationMs: 0,
|
|
metrics: {
|
|
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
modelSwitches: 0,
|
|
messageCount: 0,
|
|
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeAgentSession(owner = USER_A): AgentSession {
|
|
return {
|
|
id: CONVERSATION_ID,
|
|
provider: 'test-provider',
|
|
modelId: 'test-model',
|
|
piSession: {
|
|
thinkingLevel: 'off',
|
|
getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
|
|
setThinkingLevel: vi.fn(),
|
|
abort: vi.fn().mockResolvedValue(undefined),
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
getSessionStats: vi.fn(),
|
|
getContextUsage: vi.fn(),
|
|
} as unknown as AgentSession['piSession'],
|
|
listeners: new Set(),
|
|
unsubscribe: vi.fn(),
|
|
createdAt: Date.now(),
|
|
promptCount: 0,
|
|
channels: new Set(),
|
|
skillPromptAdditions: [],
|
|
sandboxDir: '/tmp',
|
|
allowedTools: null,
|
|
userId: owner.id,
|
|
tenantId: owner.tenantId,
|
|
metrics: {
|
|
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
modelSwitches: 0,
|
|
messageCount: 0,
|
|
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeScopedAgentService() {
|
|
const foreign = makeAgentSession(USER_A);
|
|
return {
|
|
listSessions: vi.fn((scope?: { userId: string; tenantId?: string }) =>
|
|
scope?.userId === USER_B.id ? [] : [makeSessionInfo({ id: foreign.id })],
|
|
),
|
|
getSessionInfo: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
|
|
scope?.userId === USER_B.id ? undefined : makeSessionInfo({ id: foreign.id }),
|
|
),
|
|
destroySession: vi.fn(),
|
|
getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
|
|
scope?.userId === USER_B.id ? undefined : foreign,
|
|
),
|
|
createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')),
|
|
onEvent: vi.fn(() => vi.fn()),
|
|
addChannel: vi.fn(),
|
|
removeChannel: vi.fn(),
|
|
recordMessage: vi.fn(),
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
}
|
|
|
|
describe('TESS-M1-SEC-002 AgentService ownership boundary', () => {
|
|
it('requires explicit owner+tenant scope on protected session operations', () => {
|
|
const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');
|
|
|
|
expect(source).toContain('getSession(sessionId: string, scope: ActorTenantScope)');
|
|
expect(source).toContain('listSessions(scope: ActorTenantScope)');
|
|
expect(source).toContain('getSessionInfo(sessionId: string, scope: ActorTenantScope)');
|
|
expect(source).toContain(
|
|
'addChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
|
|
);
|
|
expect(source).toContain(
|
|
'removeChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
|
|
);
|
|
expect(source).toContain(
|
|
'async prompt(sessionId: string, message: string, scope: ActorTenantScope)',
|
|
);
|
|
expect(source).toContain('scope: ActorTenantScope,');
|
|
expect(source).toContain('async destroySession(sessionId: string, scope: ActorTenantScope)');
|
|
expect(source).not.toContain('scope?: ActorTenantScope');
|
|
});
|
|
});
|
|
|
|
describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => {
|
|
it('lists only sessions owned by the authenticated owner+tenant scope', () => {
|
|
const agentService = makeScopedAgentService();
|
|
const controller = new SessionsController(agentService as never);
|
|
|
|
expect(controller.list(USER_B)).toEqual({ sessions: [], total: 0 });
|
|
expect(agentService.listSessions).toHaveBeenCalledWith({
|
|
userId: USER_B.id,
|
|
tenantId: USER_B.tenantId,
|
|
});
|
|
});
|
|
|
|
it('does not reveal another owner/tenant session by guessed id', () => {
|
|
const agentService = makeScopedAgentService();
|
|
const controller = new SessionsController(agentService as never);
|
|
|
|
expect(() => controller.findOne(CONVERSATION_ID, USER_B)).toThrow(NotFoundException);
|
|
expect(agentService.getSessionInfo).toHaveBeenCalledWith(CONVERSATION_ID, {
|
|
userId: USER_B.id,
|
|
tenantId: USER_B.tenantId,
|
|
});
|
|
});
|
|
|
|
it('does not terminate another owner/tenant session by guessed id', async () => {
|
|
const agentService = makeScopedAgentService();
|
|
const controller = new SessionsController(agentService as never);
|
|
|
|
await expect(controller.destroy(CONVERSATION_ID, USER_B)).rejects.toBeInstanceOf(
|
|
NotFoundException,
|
|
);
|
|
expect(agentService.destroySession).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => {
|
|
it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => {
|
|
const agentService = makeScopedAgentService();
|
|
const controller = new ChatController(agentService as never);
|
|
|
|
await expect(
|
|
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
|
|
).rejects.toMatchObject({ status: 404 });
|
|
|
|
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
|
userId: USER_B.id,
|
|
tenantId: USER_B.tenantId,
|
|
});
|
|
expect(agentService.prompt).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => {
|
|
function makeGateway(agentService = makeScopedAgentService()) {
|
|
const brain = {
|
|
conversations: {
|
|
findById: vi.fn().mockResolvedValue(undefined),
|
|
create: vi.fn().mockResolvedValue(undefined),
|
|
update: vi.fn().mockResolvedValue(undefined),
|
|
findMessages: vi.fn().mockResolvedValue([]),
|
|
addMessage: vi.fn().mockResolvedValue(undefined),
|
|
},
|
|
};
|
|
const commandRegistry = { getManifest: vi.fn().mockReturnValue([]) };
|
|
const commandExecutor = { execute: vi.fn() };
|
|
const routingEngine = {
|
|
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
|
|
};
|
|
const gateway = new ChatGateway(
|
|
agentService as never,
|
|
{} as never,
|
|
brain as never,
|
|
commandRegistry as never,
|
|
commandExecutor as never,
|
|
routingEngine as never,
|
|
);
|
|
return { gateway, agentService };
|
|
}
|
|
|
|
function makeSocket() {
|
|
return {
|
|
id: 'socket-b',
|
|
connected: true,
|
|
data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } },
|
|
emit: vi.fn(),
|
|
disconnect: vi.fn(),
|
|
};
|
|
}
|
|
|
|
it('does not attach or send to another owner/tenant session by guessed conversationId', async () => {
|
|
const { gateway, agentService } = makeGateway();
|
|
const socket = makeSocket();
|
|
|
|
await gateway.handleMessage(socket as never, {
|
|
conversationId: CONVERSATION_ID,
|
|
content: 'attach to foreign session',
|
|
});
|
|
|
|
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
|
userId: USER_B.id,
|
|
tenantId: USER_B.tenantId,
|
|
});
|
|
expect(agentService.onEvent).not.toHaveBeenCalled();
|
|
expect(agentService.addChannel).not.toHaveBeenCalled();
|
|
expect(agentService.prompt).not.toHaveBeenCalled();
|
|
expect(socket.emit).toHaveBeenCalledWith(
|
|
'error',
|
|
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
|
);
|
|
});
|
|
|
|
it('does not mutate thinking level on another owner/tenant session', () => {
|
|
const { gateway, agentService } = makeGateway();
|
|
const socket = makeSocket();
|
|
|
|
gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' });
|
|
|
|
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
|
userId: USER_B.id,
|
|
tenantId: USER_B.tenantId,
|
|
});
|
|
expect(socket.emit).toHaveBeenCalledWith(
|
|
'error',
|
|
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
|
);
|
|
});
|
|
|
|
it('does not terminate another owner/tenant session over WebSocket abort', async () => {
|
|
const { gateway, agentService } = makeGateway();
|
|
const socket = makeSocket();
|
|
|
|
await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID });
|
|
|
|
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
|
userId: USER_B.id,
|
|
tenantId: USER_B.tenantId,
|
|
});
|
|
expect(socket.emit).toHaveBeenCalledWith(
|
|
'error',
|
|
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
|
);
|
|
});
|
|
});
|