/** * Integration tests for A2A delegation endpoints. * Tests POST /oauth2/token/delegate, POST /oauth2/token/verify-delegation, * DELETE /oauth2/token/delegate/:chainId * * Requires a running PostgreSQL instance and valid JWT environment variables. * Uses supertest to test the full Express request pipeline. */ import request from 'supertest'; import express from 'express'; import { DelegationController } from '../../src/controllers/DelegationController'; import { DelegationService } from '../../src/services/DelegationService'; import { createDelegationRouter } from '../../src/routes/delegation'; import { Pool } from 'pg'; import { AuditService } from '../../src/services/AuditService'; // Mock heavy dependencies for integration-level tests jest.mock('../../src/services/AuditService'); const MockAuditService = AuditService as jest.MockedClass; const mockQuery = jest.fn(); const mockPool = { query: mockQuery } as unknown as Pool; // Auth middleware that injects a test user const testAuth = (req: express.Request, _res: express.Response, next: express.NextFunction) => { req.user = { sub: 'delegator-agent-uuid', client_id: 'delegator-agent-uuid', scope: 'agents:read tokens:read audit:read', jti: 'test-jti', iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 3600, organization_id: 'org_system', }; next(); }; const DELEGATEE_ID = 'delegatee-agent-uuid'; const CHAIN_ID = 'chain-uuid-1234'; const DELEGATION_TOKEN = 'delegation-token-uuid'; const MOCK_CHAIN = { id: CHAIN_ID, tenant_id: 'org_system', delegator_agent_id: 'delegator-agent-uuid', delegatee_agent_id: DELEGATEE_ID, scopes: ['agents:read'], delegation_token: DELEGATION_TOKEN, signature: 'mock-sig', ttl_seconds: 3600, issued_at: new Date(), expires_at: new Date(Date.now() + 3600_000), revoked_at: null, created_at: new Date(), }; function buildApp() { const app = express(); app.use(express.json()); const auditService = new MockAuditService({} as never) as jest.Mocked; auditService.logEvent = jest.fn().mockResolvedValue({}); process.env['DELEGATION_SECRET'] = 'test-secret'; const delegationService = new DelegationService(mockPool, auditService); const controller = new DelegationController(delegationService); app.use('/api/v1', createDelegationRouter(controller, testAuth)); // Error handler app.use( ( err: { httpStatus?: number; code?: string; message?: string }, _req: express.Request, res: express.Response, _next: express.NextFunction, ) => { res.status(err.httpStatus ?? 500).json({ code: err.code ?? 'ERROR', message: err.message }); }, ); return app; } describe('Delegation Endpoints', () => { let app: express.Application; beforeEach(() => { jest.clearAllMocks(); app = buildApp(); }); afterEach(() => { delete process.env['DELEGATION_SECRET']; }); // ──────────────────────────────────────────────────────────────── // POST /api/v1/oauth2/token/delegate // ──────────────────────────────────────────────────────────────── describe('POST /api/v1/oauth2/token/delegate', () => { it('returns 201 with delegation token on success', async () => { // delegatee exists in same tenant mockQuery.mockResolvedValueOnce({ rows: [{ agent_id: DELEGATEE_ID }] }); // insert returns chain row mockQuery.mockResolvedValueOnce({ rows: [MOCK_CHAIN] }); const res = await request(app) .post('/api/v1/oauth2/token/delegate') .send({ delegateeAgentId: DELEGATEE_ID, scopes: ['agents:read'], ttlSeconds: 3600 }); expect(res.status).toBe(201); expect(res.body).toHaveProperty('delegationToken'); expect(res.body).toHaveProperty('chainId', CHAIN_ID); expect(res.body).toHaveProperty('scopes'); }); it('returns 400 when scopes exceed delegator permissions', async () => { const res = await request(app) .post('/api/v1/oauth2/token/delegate') .send({ delegateeAgentId: DELEGATEE_ID, scopes: ['admin:orgs'], ttlSeconds: 3600 }); expect(res.status).toBe(400); }); it('returns 422 on self-delegation', async () => { const res = await request(app) .post('/api/v1/oauth2/token/delegate') .send({ delegateeAgentId: 'delegator-agent-uuid', // same as req.user.sub scopes: ['agents:read'], ttlSeconds: 3600, }); expect(res.status).toBe(400); }); it('returns 404 when delegatee does not exist', async () => { mockQuery.mockResolvedValueOnce({ rows: [] }); const res = await request(app) .post('/api/v1/oauth2/token/delegate') .send({ delegateeAgentId: 'nonexistent-agent', scopes: ['agents:read'], ttlSeconds: 3600, }); expect(res.status).toBe(404); }); it('returns 400 when delegateeAgentId is missing', async () => { const res = await request(app) .post('/api/v1/oauth2/token/delegate') .send({ scopes: ['agents:read'], ttlSeconds: 3600 }); expect(res.status).toBe(400); }); }); // ──────────────────────────────────────────────────────────────── // POST /api/v1/oauth2/token/verify-delegation // ──────────────────────────────────────────────────────────────── describe('POST /api/v1/oauth2/token/verify-delegation', () => { it('returns 200 with valid: true for an active chain', async () => { const { signDelegationPayload } = await import('../../src/utils/delegationCrypto'); const payload = { chainId: MOCK_CHAIN.id, tenantId: MOCK_CHAIN.tenant_id, delegatorAgentId: MOCK_CHAIN.delegator_agent_id, delegateeAgentId: MOCK_CHAIN.delegatee_agent_id, scopes: MOCK_CHAIN.scopes, issuedAt: MOCK_CHAIN.issued_at.toISOString(), expiresAt: MOCK_CHAIN.expires_at.toISOString(), }; const realSignature = signDelegationPayload(payload, 'test-secret'); mockQuery.mockResolvedValueOnce({ rows: [{ ...MOCK_CHAIN, signature: realSignature }], }); const res = await request(app) .post('/api/v1/oauth2/token/verify-delegation') .send({ delegationToken: DELEGATION_TOKEN }); expect(res.status).toBe(200); expect(res.body.valid).toBe(true); expect(res.body).toHaveProperty('chainId'); expect(res.body).toHaveProperty('scopes'); }); it('returns 200 with valid: false for an expired chain', async () => { const expiredChain = { ...MOCK_CHAIN, expires_at: new Date(Date.now() - 1000), }; mockQuery.mockResolvedValueOnce({ rows: [expiredChain] }); const res = await request(app) .post('/api/v1/oauth2/token/verify-delegation') .send({ delegationToken: DELEGATION_TOKEN }); expect(res.status).toBe(200); expect(res.body.valid).toBe(false); }); it('returns 200 with valid: false for a revoked chain', async () => { const revokedChain = { ...MOCK_CHAIN, revoked_at: new Date() }; mockQuery.mockResolvedValueOnce({ rows: [revokedChain] }); const res = await request(app) .post('/api/v1/oauth2/token/verify-delegation') .send({ delegationToken: DELEGATION_TOKEN }); expect(res.status).toBe(200); expect(res.body.valid).toBe(false); expect(res.body.revokedAt).toBeTruthy(); }); it('returns 404 when no chain found for the token', async () => { mockQuery.mockResolvedValueOnce({ rows: [] }); const res = await request(app) .post('/api/v1/oauth2/token/verify-delegation') .send({ delegationToken: 'nonexistent-token' }); expect(res.status).toBe(404); }); }); // ──────────────────────────────────────────────────────────────── // DELETE /api/v1/oauth2/token/delegate/:chainId // ──────────────────────────────────────────────────────────────── describe('DELETE /api/v1/oauth2/token/delegate/:chainId', () => { it('returns 204 when delegator revokes their own chain', async () => { mockQuery.mockResolvedValueOnce({ rows: [MOCK_CHAIN] }); // fetch mockQuery.mockResolvedValueOnce({ rows: [] }); // update const res = await request(app).delete(`/api/v1/oauth2/token/delegate/${CHAIN_ID}`); expect(res.status).toBe(204); }); it('returns 403 when non-delegator attempts revocation', async () => { const chainWithDifferentDelegator = { ...MOCK_CHAIN, delegator_agent_id: 'some-other-agent', }; mockQuery.mockResolvedValueOnce({ rows: [chainWithDifferentDelegator] }); const res = await request(app).delete(`/api/v1/oauth2/token/delegate/${CHAIN_ID}`); expect(res.status).toBe(403); }); it('returns 409 when chain is already revoked', async () => { const alreadyRevoked = { ...MOCK_CHAIN, revoked_at: new Date() }; mockQuery.mockResolvedValueOnce({ rows: [alreadyRevoked] }); const res = await request(app).delete(`/api/v1/oauth2/token/delegate/${CHAIN_ID}`); expect(res.status).toBe(409); }); it('returns 404 when chain does not exist', async () => { mockQuery.mockResolvedValueOnce({ rows: [] }); const res = await request(app).delete(`/api/v1/oauth2/token/delegate/nonexistent-chain`); expect(res.status).toBe(404); }); }); });