feat(phase-5): WS2 — A2A Authorization
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>
This commit is contained in:
275
tests/integration/delegation.test.ts
Normal file
275
tests/integration/delegation.test.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* 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<typeof AuditService>;
|
||||
|
||||
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>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user