import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; import { ApprovalService } from '../src/services/approval.js'; // Mock logger to avoid console noise during tests jest.mock('../src/utils/logger'); jest.mock('../src/utils/safe-logger'); describe('ApprovalService', () => { let service: ApprovalService; beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); service = new ApprovalService(60); // 60 second timeout for tests }); afterEach(() => { service.shutdown(); jest.useRealTimers(); }); describe('createApprovalRequest', () => { it('should create a pending approval request', () => { const request = service.createApprovalRequest( 'TestDuck', 'filesystem', 'read_file', { path: '/tmp/test.txt' } ); expect(request.id).toBeDefined(); expect(request.duckName).toBe('TestDuck'); expect(request.mcpServer).toBe('filesystem'); expect(request.toolName).toBe('read_file'); expect(request.status).toBe('pending'); expect(request.arguments).toEqual({ path: '/tmp/test.txt' }); }); it('should set expiration time based on timeout', () => { const before = Date.now(); const request = service.createApprovalRequest( 'TestDuck', 'filesystem', 'read_file', {} ); const after = Date.now(); // 60 seconds timeout = 60000 ms expect(request.expiresAt).toBeGreaterThanOrEqual(before + 60000); expect(request.expiresAt).toBeLessThanOrEqual(after + 60000); }); it('should generate unique IDs for each request', () => { const request1 = service.createApprovalRequest('Duck1', 'server', 'tool', {}); const request2 = service.createApprovalRequest('Duck2', 'server', 'tool', {}); expect(request1.id).not.toBe(request2.id); }); }); describe('getApprovalRequest', () => { it('should return existing request', () => { const created = service.createApprovalRequest('TestDuck', 'server', 'tool', {}); const retrieved = service.getApprovalRequest(created.id); expect(retrieved).toBeDefined(); expect(retrieved?.id).toBe(created.id); }); it('should return undefined for non-existent request', () => { const retrieved = service.getApprovalRequest('non-existent-id'); expect(retrieved).toBeUndefined(); }); it('should mark expired requests when retrieved', () => { const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {}); // Advance time past expiration jest.advanceTimersByTime(61000); const retrieved = service.getApprovalRequest(request.id); expect(retrieved?.status).toBe('expired'); }); }); describe('getApprovalStatus', () => { it('should return status of existing request', () => { const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {}); expect(service.getApprovalStatus(request.id)).toBe('pending'); }); it('should return undefined for non-existent request', () => { expect(service.getApprovalStatus('non-existent')).toBeUndefined(); }); }); describe('approveRequest', () => { it('should approve pending request', () => { const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {}); const result = service.approveRequest(request.id); expect(result).toBe(true); expect(service.getApprovalStatus(request.id)).toBe('approved'); }); it('should set approvedBy field', () => { const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {}); service.approveRequest(request.id, 'admin'); const retrieved = service.getApprovalRequest(request.id); expect(retrieved?.approvedBy).toBe('admin'); }); it('should default approvedBy to user', () => { const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {}); service.approveRequest(request.id); const retrieved = service.getApprovalRequest(request.id); expect(retrieved?.approvedBy).toBe('user'); }); it('should return false for non-existent request', () => { const result = service.approveRequest('non-existent'); expect(result).toBe(false); }); it('should return false for already approved request', () => { const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {}); service.approveRequest(request.id); const result = service.approveRequest(request.id); expect(result).toBe(false); }); it('should return false for expired request', () => { const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {}); // Advance time past expiration jest.advanceTimersByTime(61000); const result = service.approveRequest(request.id); expect(result).toBe(false); expect(service.getApprovalStatus(request.id)).toBe('expired'); }); it('should detect expiration via Date.now check even if status is still pending', () => { const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {}); // Request is created with status 'pending' expect(request.status).toBe('pending'); // Directly set expiresAt to be in the past (without triggering cleanup timer) // This simulates the case where time has passed but cleanup hasn't run request.expiresAt = Date.now() - 1000; // Status is still 'pending' because cleanup timer hasn't run expect(request.status).toBe('pending'); // approveRequest should detect expiration via Date.now() check const result = service.approveRequest(request.id); expect(result).toBe(false); expect(request.status).toBe('expired'); }); it('should add tool to session approvals', () => { const request = service.createApprovalRequest('TestDuck', 'filesystem', 'read_file', {}); service.approveRequest(request.id); expect(service.isToolApprovedForSession('TestDuck', 'filesystem', 'read_file', {})).toBe(true); }); }); describe('denyRequest', () => { it('should deny pending request', () => { const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {}); const result = service.denyRequest(request.id); expect(result).toBe(true); expect(service.getApprovalStatus(request.id)).toBe('denied'); }); it('should set denial reason when provided', () => { const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {}); service.denyRequest(request.id, 'Security concern'); const retrieved = service.getApprovalRequest(request.id); expect(retrieved?.deniedReason).toBe('Security concern'); }); it('should return false for non-existent request', () => { const result = service.denyRequest('non-existent'); expect(result).toBe(false); }); it('should return false for already denied request', () => { const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {}); service.denyRequest(request.id); const result = service.denyRequest(request.id); expect(result).toBe(false); }); it('should return false for approved request', () => { const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {}); service.approveRequest(request.id); const result = service.denyRequest(request.id); expect(result).toBe(false); }); }); describe('getPendingApprovals', () => { it('should return only pending requests', () => { service.createApprovalRequest('Duck1', 'server', 'tool', {}); const approved = service.createApprovalRequest('Duck2', 'server', 'tool', {}); service.approveRequest(approved.id); const denied = service.createApprovalRequest('Duck3', 'server', 'tool', {}); service.denyRequest(denied.id); const pending = service.getPendingApprovals(); expect(pending).toHaveLength(1); expect(pending[0].duckName).toBe('Duck1'); }); it('should return empty array when no pending requests', () => { const pending = service.getPendingApprovals(); expect(pending).toHaveLength(0); }); }); describe('getAllApprovals', () => { it('should return all requests', () => { service.createApprovalRequest('Duck1', 'server', 'tool', {}); const approved = service.createApprovalRequest('Duck2', 'server', 'tool', {}); service.approveRequest(approved.id); const denied = service.createApprovalRequest('Duck3', 'server', 'tool', {}); service.denyRequest(denied.id); const all = service.getAllApprovals(); expect(all).toHaveLength(3); }); }); describe('getApprovalsByDuck', () => { it('should filter requests by duck name', () => { service.createApprovalRequest('Duck1', 'server', 'tool1', {}); service.createApprovalRequest('Duck1', 'server', 'tool2', {}); service.createApprovalRequest('Duck2', 'server', 'tool3', {}); const duck1Requests = service.getApprovalsByDuck('Duck1'); const duck2Requests = service.getApprovalsByDuck('Duck2'); const nonExistent = service.getApprovalsByDuck('NonExistent'); expect(duck1Requests).toHaveLength(2); expect(duck2Requests).toHaveLength(1); expect(nonExistent).toHaveLength(0); }); }); describe('cleanupExpired', () => { it('should mark expired pending requests as expired', () => { const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {}); // Advance time past expiration jest.advanceTimersByTime(61000); const cleanedCount = service.cleanupExpired(); expect(cleanedCount).toBe(1); expect(service.getApprovalStatus(request.id)).toBe('expired'); }); it('should not affect already approved/denied requests', () => { const approved = service.createApprovalRequest('Duck1', 'server', 'tool', {}); service.approveRequest(approved.id); const denied = service.createApprovalRequest('Duck2', 'server', 'tool', {}); service.denyRequest(denied.id); // Advance time past expiration jest.advanceTimersByTime(61000); const cleanedCount = service.cleanupExpired(); expect(cleanedCount).toBe(0); expect(service.getApprovalStatus(approved.id)).toBe('approved'); expect(service.getApprovalStatus(denied.id)).toBe('denied'); }); it('should be called by cleanup timer', () => { service.createApprovalRequest('TestDuck', 'server', 'tool', {}); // Advance time past cleanup interval (60 seconds + expiration) jest.advanceTimersByTime(121000); const pending = service.getPendingApprovals(); expect(pending).toHaveLength(0); }); }); describe('session approvals', () => { it('should track tool approvals for session', () => { expect(service.isToolApprovedForSession('Duck', 'server', 'tool', {})).toBe(false); service.markToolAsApprovedForSession('Duck', 'server', 'tool', {}); expect(service.isToolApprovedForSession('Duck', 'server', 'tool', {})).toBe(true); }); it('should differentiate between duck/server/tool combinations', () => { service.markToolAsApprovedForSession('Duck1', 'server', 'tool', {}); expect(service.isToolApprovedForSession('Duck1', 'server', 'tool', {})).toBe(true); expect(service.isToolApprovedForSession('Duck2', 'server', 'tool', {})).toBe(false); expect(service.isToolApprovedForSession('Duck1', 'other', 'tool', {})).toBe(false); expect(service.isToolApprovedForSession('Duck1', 'server', 'other', {})).toBe(false); }); it('should clear session approvals', () => { service.markToolAsApprovedForSession('Duck1', 'server', 'tool1', {}); service.markToolAsApprovedForSession('Duck2', 'server', 'tool2', {}); service.clearSessionApprovals(); expect(service.isToolApprovedForSession('Duck1', 'server', 'tool1', {})).toBe(false); expect(service.isToolApprovedForSession('Duck2', 'server', 'tool2', {})).toBe(false); }); it('should get all session approvals', () => { service.markToolAsApprovedForSession('Duck1', 'server', 'tool1', {}); service.markToolAsApprovedForSession('Duck2', 'server', 'tool2', {}); const approvals = service.getSessionApprovals(); // Keys now embed an args hash + the provider-name principal, so do not assert // the legacy `Duck:server:tool` string — assert membership by recompute instead. expect(approvals).toHaveLength(2); expect(service.isToolApprovedForSession('Duck1', 'server', 'tool1', {})).toBe(true); expect(service.isToolApprovedForSession('Duck2', 'server', 'tool2', {})).toBe(true); }); // AC-R5S9MH.2 — args-hash keying: a session approval for argsX must NOT // auto-approve the same principal/server/tool with materially different argsY. it('should not approve when args differ from the approved args', () => { const argsX = { path: '/safe.txt' }; const argsY = { path: '/etc/shadow' }; service.markToolAsApprovedForSession('openai', 'filesystem', 'read_file', argsX); expect(service.isToolApprovedForSession('openai', 'filesystem', 'read_file', argsX)).toBe(true); expect(service.isToolApprovedForSession('openai', 'filesystem', 'read_file', argsY)).toBe(false); }); // AC-R5S9MH.2 — TTL expiry: a session approval expires after approval_timeout. it('should expire a session approval after the approval timeout (TTL)', () => { const args = { path: '/safe.txt' }; service.markToolAsApprovedForSession('openai', 'filesystem', 'read_file', args); expect(service.isToolApprovedForSession('openai', 'filesystem', 'read_file', args)).toBe(true); // Advance beyond the 60s approval_timeout configured in beforeEach. jest.advanceTimersByTime(61000); expect(service.isToolApprovedForSession('openai', 'filesystem', 'read_file', args)).toBe(false); }); // AC-R5S9MH.2 — principal scoping: the principal is the provider NAME, not the // nickname. Marking for provider 'openai' must not authorize provider 'google' // even when both share the same server/tool/args. it('should scope approvals to the provider-name principal', () => { const args = { path: '/safe.txt' }; service.markToolAsApprovedForSession('openai', 'filesystem', 'read_file', args); expect(service.isToolApprovedForSession('openai', 'filesystem', 'read_file', args)).toBe(true); expect(service.isToolApprovedForSession('google', 'filesystem', 'read_file', args)).toBe(false); }); }); describe('getStats', () => { it('should return accurate statistics', () => { service.createApprovalRequest('Duck1', 'server', 'tool', {}); const approved = service.createApprovalRequest('Duck2', 'server', 'tool', {}); service.approveRequest(approved.id); const denied = service.createApprovalRequest('Duck3', 'server', 'tool', {}); service.denyRequest(denied.id); const expiring = service.createApprovalRequest('Duck4', 'server', 'tool', {}); // Expire one request jest.advanceTimersByTime(61000); service.getApprovalRequest(expiring.id); // Trigger expiration check const stats = service.getStats(); expect(stats.total).toBe(4); expect(stats.pending).toBe(0); // The first one also expired since time advanced expect(stats.approved).toBe(1); expect(stats.denied).toBe(1); expect(stats.expired).toBe(2); }); it('should return zeros when no requests exist', () => { const stats = service.getStats(); expect(stats.total).toBe(0); expect(stats.pending).toBe(0); expect(stats.approved).toBe(0); expect(stats.denied).toBe(0); expect(stats.expired).toBe(0); }); }); describe('shutdown', () => { it('should stop cleanup timer', () => { service.shutdown(); // Should not throw when advancing timers after shutdown expect(() => jest.advanceTimersByTime(120000)).not.toThrow(); }); it('should be safe to call multiple times', () => { service.shutdown(); expect(() => service.shutdown()).not.toThrow(); }); }); describe('custom timeout', () => { it('should use custom timeout value', () => { const customService = new ApprovalService(30); // 30 seconds const request = customService.createApprovalRequest('Duck', 'server', 'tool', {}); // After 25 seconds, should still be pending jest.advanceTimersByTime(25000); expect(customService.getApprovalStatus(request.id)).toBe('pending'); // After 31 seconds total, should be expired jest.advanceTimersByTime(6000); expect(customService.getApprovalStatus(request.id)).toBe('expired'); customService.shutdown(); }); it('should use default timeout when not specified', () => { const defaultService = new ApprovalService(); const request = defaultService.createApprovalRequest('Duck', 'server', 'tool', {}); // Default is 300 seconds (5 minutes) jest.advanceTimersByTime(299000); expect(defaultService.getApprovalStatus(request.id)).toBe('pending'); jest.advanceTimersByTime(2000); expect(defaultService.getApprovalStatus(request.id)).toBe('expired'); defaultService.shutdown(); }); }); });