Implements agent-to-agent delegation chains: - Migration 024: delegation_chains table with HMAC signature, TTL, revocation - DelegationCrypto: HMAC-SHA256 sign/verify, UUID token generation - DelegationService: create (scope subset validation, self-delegation guard, same-tenant delegatee check), verify (returns valid: false on expired/revoked, never throws), revoke (delegator-only, conflict guard) - DelegationController + router at /oauth2/token/delegate (POST/DELETE) and /oauth2/token/verify-delegation (POST) - Feature-flagged behind A2A_ENABLED env var (default on) - Prometheus metrics: delegations_created/verified/revoked_total - 33 tests (unit + integration): all pass, DelegationService 87.5%+ branch coverage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
285 lines
9.9 KiB
TypeScript
285 lines
9.9 KiB
TypeScript
/**
|
|
* 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<typeof AuditService>;
|
|
|
|
// 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<AuditService>;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
// Set delegation secret for tests
|
|
process.env['DELEGATION_SECRET'] = 'test-secret';
|
|
auditService = new MockAuditService({} as never) as jest.Mocked<AuditService>;
|
|
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);
|
|
});
|
|
});
|
|
});
|