godcrm/backend/services/__tests__/ToolApprovalService.test.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

363 lines
15 KiB
JavaScript

/**
* ToolApprovalService Tests — Ticket #74073: Tool Approval Flow
*
* Tests for the tool approval gate system:
* - requiresApproval() checks tool_approval_rules and respects auto-approve
* - createApprovalRequest() updates message with pending status
* - waitForDecision() polls DB and handles timeout
* - approveToolExecution() sets approved status, optionally creates always-allow rule
* - rejectToolExecution() sets rejected status and stores reason
* - getPendingApprovals() returns pending tool_call messages
* - getApprovalRules() returns all rules
* - updateApprovalRule() updates allowed fields
* - matchGlob() pattern matching utility
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ─── Mocks ──────────────────────────────────────────────────────────────────
const { mockDbRun, mockDbGet, mockDbAll, mockIsPostgres } = vi.hoisted(() => {
const mockDbRun = vi.fn();
const mockDbGet = vi.fn();
const mockDbAll = vi.fn();
const mockIsPostgres = vi.fn(() => false);
return { mockDbRun, mockDbGet, mockDbAll, mockIsPostgres };
});
vi.mock('../../database/connection.js', () => ({
dbRun: (...args) => mockDbRun(...args),
dbGet: (...args) => mockDbGet(...args),
dbAll: (...args) => mockDbAll(...args),
isPostgres: () => mockIsPostgres(),
}));
vi.mock('../../utils/logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// ─── Import SUT after mocks ────────────────────────────────────────────────
import {
requiresApproval,
createApprovalRequest,
waitForDecision,
approveToolExecution,
rejectToolExecution,
getPendingApprovals,
getApprovalRules,
updateApprovalRule,
getTimeoutForTool,
invalidateRulesCache,
} from '../ToolApprovalService.js';
// ─── Helpers ────────────────────────────────────────────────────────────────
const MOCK_RULES = [
{ id: 1, tool_name: 'write_file', tool_pattern: null, risk_level: 'dangerous', requires_approval: true, auto_approve_for_agent_ids: null, timeout_seconds: 300 },
{ id: 2, tool_name: 'delete_row', tool_pattern: null, risk_level: 'dangerous', requires_approval: true, auto_approve_for_agent_ids: null, timeout_seconds: 300 },
{ id: 3, tool_name: 'execute_sql', tool_pattern: null, risk_level: 'dangerous', requires_approval: true, auto_approve_for_agent_ids: [18, 20], timeout_seconds: 600 },
{ id: 4, tool_name: '*', tool_pattern: 'mcp__*', risk_level: 'medium', requires_approval: true, auto_approve_for_agent_ids: null, timeout_seconds: 120 },
{ id: 5, tool_name: 'get_workspace_info', tool_pattern: null, risk_level: 'safe', requires_approval: false, auto_approve_for_agent_ids: null, timeout_seconds: 300 },
];
// ─── Tests ──────────────────────────────────────────────────────────────────
describe('ToolApprovalService', () => {
beforeEach(() => {
vi.clearAllMocks();
// Invalidate the rules cache so each test starts fresh
invalidateRulesCache();
// Default: dbAll returns the mock rules
mockDbAll.mockResolvedValue(MOCK_RULES);
});
// ─── requiresApproval ──────────────────────────────────────────
describe('requiresApproval()', () => {
it('should return true for a tool with requires_approval=true', async () => {
const result = await requiresApproval('write_file', null);
expect(result).toBe(true);
});
it('should return false for a tool with requires_approval=false', async () => {
const result = await requiresApproval('get_workspace_info', null);
expect(result).toBe(false);
});
it('should return false for a tool not in any rule (no wildcard)', async () => {
// Override with rules that don't have a wildcard '*' catch-all
invalidateRulesCache();
mockDbAll.mockResolvedValue([
{ id: 1, tool_name: 'write_file', tool_pattern: null, risk_level: 'dangerous', requires_approval: true, auto_approve_for_agent_ids: null, timeout_seconds: 300 },
{ id: 2, tool_name: 'delete_row', tool_pattern: null, risk_level: 'dangerous', requires_approval: true, auto_approve_for_agent_ids: null, timeout_seconds: 300 },
]);
const result = await requiresApproval('list_tables', null);
expect(result).toBe(false);
});
it('should match wildcard * rule for unmatched tools', async () => {
// With the default rules, the '*' catch-all matches any tool
const result = await requiresApproval('list_tables', null);
expect(result).toBe(true);
});
it('should return true for a glob pattern match (mcp__*)', async () => {
const result = await requiresApproval('mcp__some_tool', null);
expect(result).toBe(true);
});
it('should return false when agent is in auto_approve_for_agent_ids', async () => {
// Agent 18 (ORCHESTRATOR) is in auto_approve list for execute_sql
const result = await requiresApproval('execute_sql', 18);
expect(result).toBe(false);
});
it('should return true when agent is NOT in auto_approve_for_agent_ids', async () => {
// Agent 21 (FRONTEND) is NOT in auto_approve list for execute_sql
const result = await requiresApproval('execute_sql', 21);
expect(result).toBe(true);
});
it('should handle auto_approve_for_agent_ids as JSON string (SQLite)', async () => {
invalidateRulesCache();
mockDbAll.mockResolvedValue([
{ id: 1, tool_name: 'write_file', tool_pattern: null, risk_level: 'dangerous', requires_approval: true, auto_approve_for_agent_ids: '[19, 20]', timeout_seconds: 300 },
]);
const result = await requiresApproval('write_file', 19);
expect(result).toBe(false);
});
it('should return false (fail-open) when DB query fails', async () => {
invalidateRulesCache();
mockDbAll.mockRejectedValue(new Error('DB connection error'));
const result = await requiresApproval('write_file', null);
expect(result).toBe(false);
});
});
// ─── getTimeoutForTool ──────────────────────────────────────────
describe('getTimeoutForTool()', () => {
it('should return the timeout for a matching tool', async () => {
const timeout = await getTimeoutForTool('execute_sql');
expect(timeout).toBe(600);
});
it('should return default 300 for non-matching tool', async () => {
const timeout = await getTimeoutForTool('unknown_tool');
expect(timeout).toBe(300);
});
it('should match glob patterns', async () => {
const timeout = await getTimeoutForTool('mcp__some_tool');
expect(timeout).toBe(120);
});
});
// ─── createApprovalRequest ──────────────────────────────────────
describe('createApprovalRequest()', () => {
it('should update message with pending approval status', async () => {
mockDbRun.mockResolvedValue({ changes: 1 });
mockDbGet.mockResolvedValue({
id: 42, approval_status: 'pending', content_type: 'tool_call', content: 'write_file',
});
const result = await createApprovalRequest(1, 42, 'write_file', { path: '/test' }, 19);
expect(mockDbRun).toHaveBeenCalled();
expect(result).toEqual(expect.objectContaining({ id: 42, approval_status: 'pending' }));
});
it('should throw on DB error', async () => {
mockDbRun.mockRejectedValue(new Error('DB error'));
await expect(createApprovalRequest(1, 42, 'write_file', {}, 19))
.rejects.toThrow('DB error');
});
});
// ─── waitForDecision ──────────────────────────────────────────
describe('waitForDecision()', () => {
it('should return "approved" when message gets approved', async () => {
// First poll: pending, second poll: approved
mockDbGet
.mockResolvedValueOnce({ approval_status: 'pending' })
.mockResolvedValueOnce({ approval_status: 'approved' });
const result = await waitForDecision(42, 5000);
expect(result).toBe('approved');
});
it('should return "rejected" when message gets rejected', async () => {
mockDbGet
.mockResolvedValueOnce({ approval_status: 'pending' })
.mockResolvedValueOnce({ approval_status: 'rejected' });
const result = await waitForDecision(42, 5000);
expect(result).toBe('rejected');
});
it('should return "timeout" after timeout period', async () => {
mockDbGet.mockResolvedValue({ approval_status: 'pending' });
mockDbRun.mockResolvedValue({ changes: 1 });
// Use a very short timeout for the test
const result = await waitForDecision(42, 100);
expect(result).toBe('timeout');
});
});
// ─── approveToolExecution ──────────────────────────────────────
describe('approveToolExecution()', () => {
it('should set approval_status to approved', async () => {
mockDbGet
.mockResolvedValueOnce({ id: 42, approval_status: 'pending', content_type: 'tool_call', content: 'write_file', metadata: null })
.mockResolvedValueOnce({ id: 42, approval_status: 'approved', approved_by: 1 });
mockDbRun.mockResolvedValue({ changes: 1 });
const result = await approveToolExecution(42, 1);
expect(result).toEqual(expect.objectContaining({ approval_status: 'approved' }));
});
it('should throw if message is not pending', async () => {
mockDbGet.mockResolvedValue({ id: 42, approval_status: 'approved' });
await expect(approveToolExecution(42, 1))
.rejects.toThrow('not pending');
});
it('should throw if message not found', async () => {
mockDbGet.mockResolvedValue(null);
await expect(approveToolExecution(42, 1))
.rejects.toThrow('not found');
});
it('should create always-allow rule when alwaysAllow=true', async () => {
mockDbGet
.mockResolvedValueOnce({
id: 42, approval_status: 'pending', content_type: 'tool_call',
content: 'write_file', metadata: JSON.stringify({ approval_tool: 'write_file' }),
})
.mockResolvedValueOnce({ id: 1, tool_name: 'write_file' }) // existing rule check
.mockResolvedValueOnce({ id: 42, approval_status: 'approved' }); // final fetch
mockDbRun.mockResolvedValue({ changes: 1 });
const result = await approveToolExecution(42, 1, true);
// Should have called dbRun to update the existing rule
expect(mockDbRun).toHaveBeenCalledTimes(2); // 1 for approve, 1 for rule update
});
});
// ─── rejectToolExecution ──────────────────────────────────────
describe('rejectToolExecution()', () => {
it('should set approval_status to rejected', async () => {
mockDbGet
.mockResolvedValueOnce({ id: 42, approval_status: 'pending' })
.mockResolvedValueOnce({ id: 42, approval_status: 'rejected', approved_by: 1 });
mockDbRun.mockResolvedValue({ changes: 1 });
const result = await rejectToolExecution(42, 1);
expect(result).toEqual(expect.objectContaining({ approval_status: 'rejected' }));
});
it('should store rejection reason in metadata', async () => {
mockDbGet
.mockResolvedValueOnce({ id: 42, approval_status: 'pending' })
.mockResolvedValueOnce({ id: 42, approval_status: 'rejected' });
mockDbRun.mockResolvedValue({ changes: 1 });
await rejectToolExecution(42, 1, 'Too risky');
// Verify dbRun was called with reason metadata
const calls = mockDbRun.mock.calls;
const hasReasonUpdate = calls.some(call =>
typeof call[1]?.[0] === 'string' && call[1][0].includes('rejection_reason')
);
expect(hasReasonUpdate).toBe(true);
});
it('should throw if message not found', async () => {
mockDbGet.mockResolvedValue(null);
await expect(rejectToolExecution(42, 1))
.rejects.toThrow('not found');
});
it('should throw if message is not pending', async () => {
mockDbGet.mockResolvedValue({ id: 42, approval_status: 'approved' });
await expect(rejectToolExecution(42, 1))
.rejects.toThrow('not pending');
});
});
// ─── getPendingApprovals ──────────────────────────────────────
describe('getPendingApprovals()', () => {
it('should return pending tool_call messages for a conversation', async () => {
const pending = [
{ id: 42, conversation_id: 1, approval_status: 'pending', content_type: 'tool_call' },
{ id: 43, conversation_id: 1, approval_status: 'pending', content_type: 'tool_call' },
];
mockDbAll.mockResolvedValue(pending);
const result = await getPendingApprovals(1);
expect(result).toEqual(pending);
expect(result).toHaveLength(2);
});
it('should return empty array on DB error', async () => {
mockDbAll.mockRejectedValue(new Error('DB error'));
const result = await getPendingApprovals(1);
expect(result).toEqual([]);
});
});
// ─── getApprovalRules ──────────────────────────────────────
describe('getApprovalRules()', () => {
it('should return all rules', async () => {
const result = await getApprovalRules();
expect(result).toEqual(MOCK_RULES);
expect(mockDbAll).toHaveBeenCalled();
});
});
// ─── updateApprovalRule ──────────────────────────────────────
describe('updateApprovalRule()', () => {
it('should update allowed fields', async () => {
mockDbGet
.mockResolvedValueOnce({ id: 1, tool_name: 'write_file', risk_level: 'dangerous', requires_approval: true })
.mockResolvedValueOnce({ id: 1, tool_name: 'write_file', risk_level: 'medium', requires_approval: false });
mockDbRun.mockResolvedValue({ changes: 1 });
const result = await updateApprovalRule(1, { risk_level: 'medium', requires_approval: false });
expect(result).toEqual(expect.objectContaining({ risk_level: 'medium' }));
});
it('should throw if rule not found', async () => {
mockDbGet.mockResolvedValue(null);
await expect(updateApprovalRule(999, { risk_level: 'safe' }))
.rejects.toThrow('not found');
});
it('should return unchanged rule if no valid fields provided', async () => {
const existingRule = { id: 1, tool_name: 'write_file', risk_level: 'dangerous' };
mockDbGet.mockResolvedValue(existingRule);
const result = await updateApprovalRule(1, { invalid_field: 'value' });
expect(result).toEqual(existingRule);
// dbRun should NOT be called for update
expect(mockDbRun).not.toHaveBeenCalled();
});
});
});