import { ForbiddenException } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; import { AgentService, type AgentSession } from '../agent.service.js'; import type { ActorTenantScope } from '../../auth/session-scope.js'; const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222'; const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-user', tenantId: 'owner-tenant' }; const FOREIGN_SCOPE: ActorTenantScope = { userId: 'foreign-user', tenantId: 'foreign-tenant' }; type AgentServiceInternals = { sessions: Map; creating: Map>; }; function makeService(operatorMemory: unknown = null): AgentService { return new AgentService( { getDefaultModel: vi.fn(() => null), getRegistry: vi.fn(() => ({})), findModel: vi.fn(), listAvailableModels: vi.fn(() => []), } as never, {} as never, {} as never, { available: false } as never, {} as never, { getToolDefinitions: vi.fn(() => []) } as never, { loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never, null, null, { collect: vi.fn().mockResolvedValue(undefined) } as never, operatorMemory as never, ); } function internals(service: AgentService): AgentServiceInternals { return service as unknown as AgentServiceInternals; } function makeSession(scope: ActorTenantScope = OWNER_SCOPE): 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/tess-session-ownership-test', allowedTools: null, userId: scope.userId, tenantId: scope.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(), }, }; } describe('AgentService owner/tenant scope enforcement', () => { it('allows owner-scoped operations and rejects foreign scopes for seeded sessions', async () => { const service = makeService(); const session = makeSession(); internals(service).sessions.set(CONVERSATION_ID, session); expect(service.getSession(CONVERSATION_ID, OWNER_SCOPE)).toBe(session); expect(service.getSession(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined(); expect(service.getSessionInfo(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined(); expect(service.listSessions(OWNER_SCOPE)).toHaveLength(1); expect(service.listSessions(FOREIGN_SCOPE)).toEqual([]); service.addChannel(CONVERSATION_ID, 'websocket:owner', OWNER_SCOPE); expect(session.channels.has('websocket:owner')).toBe(true); expect(() => service.addChannel(CONVERSATION_ID, 'websocket:foreign', FOREIGN_SCOPE)).toThrow( ForbiddenException, ); expect(() => service.removeChannel(CONVERSATION_ID, 'websocket:owner', FOREIGN_SCOPE)).toThrow( ForbiddenException, ); expect(() => service.updateSessionModel(CONVERSATION_ID, 'foreign-model', FOREIGN_SCOPE), ).toThrow(ForbiddenException); service.updateSessionModel(CONVERSATION_ID, 'owner-model', OWNER_SCOPE); expect(session.modelId).toBe('owner-model'); expect(() => service.applyAgentConfig(CONVERSATION_ID, 'agent-foreign', 'Foreign Agent', FOREIGN_SCOPE), ).toThrow(ForbiddenException); service.applyAgentConfig(CONVERSATION_ID, 'agent-owner', 'Owner Agent', OWNER_SCOPE); expect(session.agentConfigId).toBe('agent-owner'); expect(() => service.onEvent(CONVERSATION_ID, vi.fn(), FOREIGN_SCOPE)).toThrow( ForbiddenException, ); const cleanup = service.onEvent(CONVERSATION_ID, vi.fn(), OWNER_SCOPE); cleanup(); await expect( service.prompt(CONVERSATION_ID, 'foreign prompt', FOREIGN_SCOPE), ).rejects.toBeInstanceOf(ForbiddenException); await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE); expect(session.piSession.prompt).toHaveBeenCalledWith('owner prompt'); await expect(service.destroySession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf( ForbiddenException, ); expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(true); await service.destroySession(CONVERSATION_ID, OWNER_SCOPE); expect(session.piSession.dispose).toHaveBeenCalled(); expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(false); }); it('derives the operator-memory scope on the createSession production path', async () => { const plugin = { capture: vi.fn(), search: vi.fn() }; const service = makeService(plugin); const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox').mockReturnValue([]); // Session construction reaches the real scope derivation before the intentionally incomplete // Pi test double rejects later in createAgentSession. await service.createSession(CONVERSATION_ID, OWNER_SCOPE).catch(() => undefined); expect(buildTools).toHaveBeenCalledWith(expect.any(String), OWNER_SCOPE.userId, { tenantId: OWNER_SCOPE.tenantId, ownerId: OWNER_SCOPE.userId, sessionId: CONVERSATION_ID, }); }); it('denies a foreign actor before it can obtain another session operator-memory scope', async () => { const plugin = { capture: vi.fn(), search: vi.fn() }; const service = makeService(plugin); internals(service).sessions.set(CONVERSATION_ID, makeSession()); const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox'); await expect(service.createSession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf( ForbiddenException, ); expect(buildTools).not.toHaveBeenCalled(); expect(plugin.capture).not.toHaveBeenCalled(); expect(plugin.search).not.toHaveBeenCalled(); }); it('checks owner/tenant scope before returning an in-flight session creation', async () => { const service = makeService(); const session = makeSession(); internals(service).creating.set(CONVERSATION_ID, Promise.resolve(session)); await expect( service.createSession(CONVERSATION_ID, { userId: FOREIGN_SCOPE.userId, tenantId: FOREIGN_SCOPE.tenantId, }), ).rejects.toBeInstanceOf(ForbiddenException); await expect( service.createSession(CONVERSATION_ID, { userId: OWNER_SCOPE.userId, tenantId: OWNER_SCOPE.tenantId, }), ).resolves.toBe(session); }); });