import { Logger } from '@nestjs/common'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { CommandExecutorService } from './command-executor.service.js'; import type { SlashCommandPayload } from '@mosaicstack/types'; // Minimal mock implementations const mockRegistry = { getManifest: vi.fn(() => ({ version: 1, commands: [ { name: 'provider', aliases: [], scope: 'agent', execution: 'hybrid', available: true }, { name: 'mission', aliases: [], scope: 'agent', execution: 'socket', available: true }, { name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true }, { name: 'prdy', aliases: [], scope: 'agent', execution: 'socket', available: true }, { name: 'tools', aliases: [], scope: 'agent', execution: 'socket', available: true }, { name: 'mcp', aliases: [], scope: 'agent', execution: 'socket', available: true }, ], skills: [], })), }; const mockAgentService = { getSession: vi.fn(() => undefined), applyAgentConfig: vi.fn(), updateSessionModel: vi.fn(), }; const mockSystemOverride = { set: vi.fn(), get: vi.fn(), clear: vi.fn(), renew: vi.fn(), }; const mockSessionGC = { sweepOrphans: vi.fn(() => ({ orphanedSessions: 0, totalCleaned: [], duration: 0 })), }; const mockRedis = { set: vi.fn().mockResolvedValue('OK'), get: vi.fn(), del: vi.fn(), }; // Mock agent config returned by brain.agents.findByName for "my-agent-id" const mockAgentConfig = { id: 'my-agent-id', name: 'my-agent-id', model: 'claude-sonnet-4-6', provider: 'anthropic', systemPrompt: null, allowedTools: null, isSystem: false, ownerId: 'user-123', status: 'idle', createdAt: new Date(), updatedAt: new Date(), }; const mockBrain = { agents: { // findByName resolves with the agent when name matches, undefined otherwise findByName: vi.fn((name: string) => Promise.resolve(name === 'my-agent-id' ? mockAgentConfig : undefined), ), findById: vi.fn((id: string) => Promise.resolve(id === 'my-agent-id' ? mockAgentConfig : undefined), ), create: vi.fn(), }, }; const mockChatGateway = { broadcastSessionInfo: vi.fn(), }; const mockMcpClient = { reconnectServer: vi.fn().mockResolvedValue(undefined), getServerStatuses: vi.fn(() => []), getToolDefinitions: vi.fn(() => []), }; const allowAuthorization = { authorize: vi.fn().mockResolvedValue({ allowed: true }), }; function buildService( redis: typeof mockRedis | null = mockRedis, mcpClient: { reconnectServer: ReturnType; getServerStatuses: ReturnType; getToolDefinitions: ReturnType; } = mockMcpClient, ): CommandExecutorService { return new CommandExecutorService( mockRegistry as never, mockAgentService as never, mockSystemOverride as never, mockSessionGC as never, redis as never, mockBrain as never, null, mockChatGateway as never, mcpClient as never, allowAuthorization as never, ); } describe('CommandExecutorService — P8-012 commands', () => { let service: CommandExecutorService; const userId = 'user-123'; const userScope = { userId, tenantId: userId }; const conversationId = 'conv-456'; beforeEach(() => { vi.clearAllMocks(); service = buildService(); }); // /provider login — missing provider name it('/provider login with no provider name returns usage error', async () => { const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId }; const result = await service.execute(payload, userScope); expect(result.success).toBe(false); expect(result.message).toContain('Usage: /provider login'); expect(result.command).toBe('provider'); }); // /provider login anthropic — no bearer token or auth URL reaches chat output it('/provider login keeps its one-time token out of chat output', async () => { const payload: SlashCommandPayload = { command: 'provider', args: 'login anthropic', conversationId, }; const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('provider'); expect(result.message).toContain('anthropic'); expect(result.message).not.toContain('http'); expect(result.message).not.toContain('token='); expect(result.data).toEqual({ provider: 'anthropic' }); // Verify Valkey was called expect(mockRedis.set).toHaveBeenCalledOnce(); const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number]; expect(key).toContain('mosaic:auth:poll:'); const stored = JSON.parse(value) as { status: string; provider: string; userId: string }; expect(stored.status).toBe('pending'); expect(stored.provider).toBe('anthropic'); expect(stored.userId).toBe(userId); expect(ttl).toBe(300); }); it('/provider login remains available without Redis on the local tier', async () => { const localService = buildService(null); const payload: SlashCommandPayload = { command: 'provider', args: 'login anthropic', conversationId, }; const result = await localService.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).not.toContain('token='); expect(result.data).toEqual({ provider: 'anthropic' }); expect(mockRedis.set).not.toHaveBeenCalled(); }); // /provider with no args — returns usage it('/provider with no args returns usage message', async () => { const payload: SlashCommandPayload = { command: 'provider', conversationId }; const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('Usage: /provider'); }); // /provider list it('/provider list returns success', async () => { const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId }; const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('provider'); }); // /provider logout with no name — usage error it('/provider logout with no name returns error', async () => { const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId }; const result = await service.execute(payload, userScope); expect(result.success).toBe(false); expect(result.message).toContain('Usage: /provider logout'); }); // /provider unknown subcommand it('/provider unknown subcommand returns error', async () => { const payload: SlashCommandPayload = { command: 'provider', args: 'unknown', conversationId, }; const result = await service.execute(payload, userScope); expect(result.success).toBe(false); expect(result.message).toContain('Unknown subcommand'); }); // /mission status it('/mission status returns stub message', async () => { const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId }; const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('mission'); expect(result.message).toContain('Mission status'); }); // /mission with no args it('/mission with no args returns status stub', async () => { const payload: SlashCommandPayload = { command: 'mission', conversationId }; const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('Mission status'); }); // /mission set it('/mission set returns confirmation', async () => { const payload: SlashCommandPayload = { command: 'mission', args: 'set my-mission-123', conversationId, }; const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('my-mission-123'); }); // /agent list it('/agent list returns stub message', async () => { const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId }; const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('agent'); expect(result.message).toContain('agent'); }); // /agent with no args it('/agent with no args returns usage', async () => { const payload: SlashCommandPayload = { command: 'agent', conversationId }; const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('Usage: /agent'); }); // /agent — switch it('/agent returns switch confirmation', async () => { const payload: SlashCommandPayload = { command: 'agent', args: 'my-agent-id', conversationId, }; const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('my-agent-id'); }); // /prdy it('/prdy returns PRD wizard message', async () => { const payload: SlashCommandPayload = { command: 'prdy', conversationId }; const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('prdy'); expect(result.message).toContain('mosaic prdy'); }); // /tools it('/tools returns tools stub message', async () => { const payload: SlashCommandPayload = { command: 'tools', conversationId }; const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('tools'); expect(result.message).toContain('tools'); }); // Top-level catch sanitization (P3-4 re-review finding #1): a rejected // Redis `set` inside /provider login is the only reachable path into the // top-level catch in `execute()`. The raw exception must be logged // server-side but never handed back to the socket client. it('sanitizes the top-level command catch, logging the raw exception but never returning it to the client', async () => { const distinctiveRawFailure = 'ECONNREFUSED distinctive-raw-redis-failure-token-9f31'; const rawError = new Error(distinctiveRawFailure); const failingRedis = { set: vi.fn().mockRejectedValue(rawError), get: vi.fn(), del: vi.fn(), }; const failingService = buildService(failingRedis as unknown as typeof mockRedis); const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); const payload: SlashCommandPayload = { command: 'provider', args: 'login anthropic', conversationId, }; const result = await failingService.execute(payload, userScope); expect(result.success).toBe(false); expect(result.command).toBe('provider'); expect(result.message).toBe('Command failed due to an internal error.'); expect(result.message).not.toContain(distinctiveRawFailure); expect(result.message).not.toContain('ECONNREFUSED'); // The real exception is still logged server-side, as the raw Error // object itself (not stringified/interpolated into the log message). expect(loggerErrorSpy).toHaveBeenCalled(); const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(rawError)); expect(loggedRawError).toBe(true); loggerErrorSpy.mockRestore(); }); // Inner catch sanitization (P3-5 operator ruling): every catch in // command-executor.service.ts that returns a SlashCommandResultPayload // must sanitize the client-facing message the same way the top-level // catch does, while still logging the raw exception server-side. it('/agent new sanitizes agent-creation failures, logging the raw exception but never returning it to the client', async () => { const marker = new Error('distinctive-agent-create-failure-token-A17f'); mockBrain.agents.create.mockRejectedValueOnce(marker); const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); const payload: SlashCommandPayload = { command: 'agent', args: 'new my-new-agent', conversationId, }; const result = await service.execute(payload, userScope); expect(result.success).toBe(false); expect(result.command).toBe('agent'); expect(result.message).toBe('Failed to create agent due to an internal error.'); expect(result.message).not.toContain('distinctive-agent-create-failure-token-A17f'); expect(loggerErrorSpy).toHaveBeenCalled(); const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker)); expect(loggedRawError).toBe(true); loggerErrorSpy.mockRestore(); }); it('/agent switch sanitizes agent-lookup failures, logging the raw exception but never returning it to the client', async () => { const marker = new Error('distinctive-agent-switch-failure-token-B29c'); mockBrain.agents.findByName.mockRejectedValueOnce(marker); const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); const payload: SlashCommandPayload = { command: 'agent', args: 'some-other-agent', conversationId, }; const result = await service.execute(payload, userScope); expect(result.success).toBe(false); expect(result.command).toBe('agent'); expect(result.message).toBe('Failed to switch agent due to an internal error.'); expect(result.message).not.toContain('distinctive-agent-switch-failure-token-B29c'); expect(loggerErrorSpy).toHaveBeenCalled(); const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker)); expect(loggedRawError).toBe(true); loggerErrorSpy.mockRestore(); }); it('/mcp reconnect sanitizes MCP client failures, logging the raw exception but never returning it to the client', async () => { const marker = new Error('distinctive-mcp-reconnect-failure-token-C33e'); const mockMcpClient = { reconnectServer: vi.fn().mockRejectedValue(marker), getServerStatuses: vi.fn(() => []), getToolDefinitions: vi.fn(() => []), }; const mcpService = buildService(mockRedis, mockMcpClient); const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); const payload: SlashCommandPayload = { command: 'mcp', args: 'reconnect my-server', conversationId, }; const result = await mcpService.execute(payload, userScope); expect(result.success).toBe(false); expect(result.command).toBe('mcp'); expect(result.message).toBe( 'Failed to reconnect MCP server "my-server" due to an internal error.', ); expect(result.message).not.toContain('distinctive-mcp-reconnect-failure-token-C33e'); expect(loggerErrorSpy).toHaveBeenCalled(); const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker)); expect(loggedRawError).toBe(true); loggerErrorSpy.mockRestore(); }); });