/** * Unit tests for src/services/DelegationService.ts */ import { Pool } from 'pg'; import { DelegationService } from '../../../src/services/DelegationService'; import { AuditService } from '../../../src/services/AuditService'; import { ValidationError, AgentNotFoundError, AuthorizationError, CredentialAlreadyRevokedError, } from '../../../src/utils/errors'; jest.mock('../../../src/services/AuditService'); const MockAuditService = AuditService as jest.MockedClass; // Mock Pool const mockQuery = jest.fn(); const mockPool = { query: mockQuery } as unknown as Pool; const TENANT_ID = 'org_system'; const DELEGATOR_ID = 'delegator-uuid-1234'; const DELEGATEE_ID = 'delegatee-uuid-5678'; const DELEGATOR_SCOPES = ['agents:read', 'tokens:read', 'audit:read']; const IP = '127.0.0.1'; const UA = 'test-agent/1.0'; const MOCK_CHAIN_ROW = { id: 'chain-uuid-1234', tenant_id: TENANT_ID, delegator_agent_id: DELEGATOR_ID, delegatee_agent_id: DELEGATEE_ID, scopes: ['agents:read'], delegation_token: 'token-uuid-abcd', signature: 'mock-signature', ttl_seconds: 3600, issued_at: new Date('2026-04-03T00:00:00Z'), expires_at: new Date(Date.now() + 3600_000), revoked_at: null, created_at: new Date('2026-04-03T00:00:00Z'), }; describe('DelegationService', () => { let service: DelegationService; let auditService: jest.Mocked; beforeEach(() => { jest.clearAllMocks(); // Set delegation secret for tests process.env['DELEGATION_SECRET'] = 'test-secret'; auditService = new MockAuditService({} as never) as jest.Mocked; auditService.logEvent = jest.fn().mockResolvedValue({}); service = new DelegationService(mockPool, auditService); }); afterEach(() => { delete process.env['DELEGATION_SECRET']; }); // ──────────────────────────────────────────────────────────────── // createDelegation // ──────────────────────────────────────────────────────────────── describe('createDelegation', () => { it('creates a delegation chain successfully', async () => { // delegatee exists mockQuery.mockResolvedValueOnce({ rows: [{ agent_id: DELEGATEE_ID }] }); // insert returns row mockQuery.mockResolvedValueOnce({ rows: [MOCK_CHAIN_ROW] }); const result = await service.createDelegation( TENANT_ID, DELEGATOR_ID, DELEGATOR_SCOPES, { delegateeAgentId: DELEGATEE_ID, scopes: ['agents:read'], ttlSeconds: 3600 }, IP, UA, ); expect(result.delegatorAgentId).toBe(DELEGATOR_ID); expect(result.delegateeAgentId).toBe(DELEGATEE_ID); expect(auditService.logEvent).toHaveBeenCalledWith( DELEGATOR_ID, 'delegation.created', 'success', IP, UA, expect.objectContaining({ chainId: expect.any(String), delegateeAgentId: DELEGATEE_ID }), TENANT_ID, ); }); it('throws ValidationError when scope escalation is attempted', async () => { await expect( service.createDelegation( TENANT_ID, DELEGATOR_ID, DELEGATOR_SCOPES, { delegateeAgentId: DELEGATEE_ID, scopes: ['admin:orgs'], ttlSeconds: 3600 }, IP, UA, ), ).rejects.toThrow(ValidationError); }); it('throws ValidationError on self-delegation', async () => { await expect( service.createDelegation( TENANT_ID, DELEGATOR_ID, DELEGATOR_SCOPES, { delegateeAgentId: DELEGATOR_ID, scopes: ['agents:read'], ttlSeconds: 3600 }, IP, UA, ), ).rejects.toThrow(ValidationError); }); it('throws ValidationError when ttlSeconds is below minimum', async () => { await expect( service.createDelegation( TENANT_ID, DELEGATOR_ID, DELEGATOR_SCOPES, { delegateeAgentId: DELEGATEE_ID, scopes: ['agents:read'], ttlSeconds: 30 }, IP, UA, ), ).rejects.toThrow(ValidationError); }); it('throws ValidationError when ttlSeconds exceeds maximum', async () => { await expect( service.createDelegation( TENANT_ID, DELEGATOR_ID, DELEGATOR_SCOPES, { delegateeAgentId: DELEGATEE_ID, scopes: ['agents:read'], ttlSeconds: 99999 }, IP, UA, ), ).rejects.toThrow(ValidationError); }); it('throws AgentNotFoundError when delegatee is in a different tenant', async () => { // delegatee not found in this tenant mockQuery.mockResolvedValueOnce({ rows: [] }); await expect( service.createDelegation( TENANT_ID, DELEGATOR_ID, DELEGATOR_SCOPES, { delegateeAgentId: 'other-tenant-agent', scopes: ['agents:read'], ttlSeconds: 3600 }, IP, UA, ), ).rejects.toThrow(AgentNotFoundError); }); }); // ──────────────────────────────────────────────────────────────── // verifyDelegation // ──────────────────────────────────────────────────────────────── describe('verifyDelegation', () => { it('returns valid: true for an active, non-expired chain', async () => { // Need a real signature to pass verification const { signDelegationPayload } = await import('../../../src/utils/delegationCrypto'); const payload = { chainId: MOCK_CHAIN_ROW.id, tenantId: MOCK_CHAIN_ROW.tenant_id, delegatorAgentId: MOCK_CHAIN_ROW.delegator_agent_id, delegateeAgentId: MOCK_CHAIN_ROW.delegatee_agent_id, scopes: MOCK_CHAIN_ROW.scopes, issuedAt: MOCK_CHAIN_ROW.issued_at.toISOString(), expiresAt: MOCK_CHAIN_ROW.expires_at.toISOString(), }; const realSignature = signDelegationPayload(payload, 'test-secret'); mockQuery.mockResolvedValueOnce({ rows: [{ ...MOCK_CHAIN_ROW, signature: realSignature }], }); const result = await service.verifyDelegation('token-uuid-abcd', DELEGATEE_ID, IP, UA); expect(result.valid).toBe(true); expect(result.chainId).toBe(MOCK_CHAIN_ROW.id); expect(auditService.logEvent).toHaveBeenCalledWith( DELEGATEE_ID, 'delegation.verified', 'success', IP, UA, expect.objectContaining({ valid: true }), TENANT_ID, ); }); it('returns valid: false (not throws) for an expired chain', async () => { const expiredRow = { ...MOCK_CHAIN_ROW, expires_at: new Date(Date.now() - 1000), // past signature: 'any-sig', }; mockQuery.mockResolvedValueOnce({ rows: [expiredRow] }); const result = await service.verifyDelegation('token-uuid-abcd', DELEGATEE_ID, IP, UA); expect(result.valid).toBe(false); }); it('returns valid: false (not throws) for a revoked chain', async () => { const revokedRow = { ...MOCK_CHAIN_ROW, revoked_at: new Date(), signature: 'any-sig', }; mockQuery.mockResolvedValueOnce({ rows: [revokedRow] }); const result = await service.verifyDelegation('token-uuid-abcd', DELEGATEE_ID, IP, UA); expect(result.valid).toBe(false); expect(result.revokedAt).toBeDefined(); }); it('throws AgentNotFoundError when no chain exists for the token', async () => { mockQuery.mockResolvedValueOnce({ rows: [] }); await expect( service.verifyDelegation('nonexistent-token', DELEGATEE_ID, IP, UA), ).rejects.toThrow(AgentNotFoundError); }); }); // ──────────────────────────────────────────────────────────────── // revokeDelegation // ──────────────────────────────────────────────────────────────── describe('revokeDelegation', () => { it('revokes a delegation chain when called by the delegator', async () => { mockQuery.mockResolvedValueOnce({ rows: [MOCK_CHAIN_ROW] }); // fetch chain mockQuery.mockResolvedValueOnce({ rows: [] }); // update revoked_at await service.revokeDelegation(MOCK_CHAIN_ROW.id, DELEGATOR_ID, IP, UA); expect(auditService.logEvent).toHaveBeenCalledWith( DELEGATOR_ID, 'delegation.revoked', 'success', IP, UA, expect.objectContaining({ chainId: MOCK_CHAIN_ROW.id }), TENANT_ID, ); }); it('throws AuthorizationError when a non-delegator attempts revocation', async () => { mockQuery.mockResolvedValueOnce({ rows: [MOCK_CHAIN_ROW] }); await expect( service.revokeDelegation(MOCK_CHAIN_ROW.id, 'other-agent-id', IP, UA), ).rejects.toThrow(AuthorizationError); }); it('throws CredentialAlreadyRevokedError when chain is already revoked', async () => { const alreadyRevoked = { ...MOCK_CHAIN_ROW, revoked_at: new Date() }; mockQuery.mockResolvedValueOnce({ rows: [alreadyRevoked] }); await expect( service.revokeDelegation(MOCK_CHAIN_ROW.id, DELEGATOR_ID, IP, UA), ).rejects.toThrow(CredentialAlreadyRevokedError); }); it('throws AgentNotFoundError when chain does not exist', async () => { mockQuery.mockResolvedValueOnce({ rows: [] }); await expect( service.revokeDelegation('nonexistent-chain-id', DELEGATOR_ID, IP, UA), ).rejects.toThrow(AgentNotFoundError); }); }); });