397 lines
15 KiB
TypeScript
397 lines
15 KiB
TypeScript
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(() => []),
|
|
};
|
|
|
|
function buildService(
|
|
redis: typeof mockRedis | null = mockRedis,
|
|
mcpClient: {
|
|
reconnectServer: ReturnType<typeof vi.fn>;
|
|
getServerStatuses: ReturnType<typeof vi.fn>;
|
|
getToolDefinitions: ReturnType<typeof vi.fn>;
|
|
} = 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,
|
|
);
|
|
}
|
|
|
|
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 <name> 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 <id>
|
|
it('/mission set <id> 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 <id> — switch
|
|
it('/agent <id> 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 <name> 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();
|
|
});
|
|
});
|