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 { 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 }), ); }); });