- Add prom-client 15; shared registry in src/metrics/registry.ts (7 metrics) - HTTP request counter + duration histogram via metricsMiddleware - DB query duration histogram wrapping pg Pool.query - Redis command duration histogram via typed instrumentRedisMethod wrapper - agentidp_tokens_issued_total in OAuth2Service - agentidp_agents_registered_total in AgentService - GET /metrics unauthenticated endpoint (Prometheus text format) - docker-compose.monitoring.yml overlay (Prometheus + Grafana) - Grafana auto-provisioned datasource + pre-built AgentIdP dashboard - docs/devops/operations.md monitoring section added - 36/36 unit tests passing, 100% coverage on new metrics code - Fix pre-existing unused import in tests/integration/agents.test.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
283 lines
9.1 KiB
TypeScript
283 lines
9.1 KiB
TypeScript
/**
|
|
* Integration tests for Agent Registry endpoints.
|
|
* Uses a real Postgres test DB and Redis test instance.
|
|
*/
|
|
|
|
import crypto from 'crypto';
|
|
import request from 'supertest';
|
|
import { Application } from 'express';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { Pool } from 'pg';
|
|
|
|
// Set test environment variables before importing app
|
|
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
|
|
modulusLength: 2048,
|
|
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
});
|
|
|
|
process.env['DATABASE_URL'] = process.env['TEST_DATABASE_URL'] ?? 'postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp_test';
|
|
process.env['REDIS_URL'] = process.env['TEST_REDIS_URL'] ?? 'redis://localhost:6379/1';
|
|
process.env['JWT_PRIVATE_KEY'] = privateKey;
|
|
process.env['JWT_PUBLIC_KEY'] = publicKey;
|
|
process.env['NODE_ENV'] = 'test';
|
|
|
|
import { createApp } from '../../src/app';
|
|
import { signToken } from '../../src/utils/jwt';
|
|
import { closePool } from '../../src/db/pool';
|
|
import { closeRedisClient } from '../../src/cache/redis';
|
|
|
|
const AGENT_ID = uuidv4();
|
|
const SCOPE = 'agents:read agents:write';
|
|
|
|
function makeToken(sub: string = AGENT_ID, scope: string = SCOPE): string {
|
|
return signToken({ sub, client_id: sub, scope, jti: uuidv4() }, privateKey);
|
|
}
|
|
|
|
describe('Agent Registry Integration Tests', () => {
|
|
let app: Application;
|
|
let pool: Pool;
|
|
|
|
beforeAll(async () => {
|
|
app = await createApp();
|
|
pool = new Pool({
|
|
connectionString: process.env['DATABASE_URL'],
|
|
});
|
|
|
|
// Run migrations
|
|
const migrations = [
|
|
`CREATE TABLE IF NOT EXISTS schema_migrations (name VARCHAR(255) PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`,
|
|
`CREATE TABLE IF NOT EXISTS agents (
|
|
agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
email VARCHAR(255) NOT NULL UNIQUE,
|
|
agent_type VARCHAR(32) NOT NULL,
|
|
version VARCHAR(64) NOT NULL,
|
|
capabilities TEXT[] NOT NULL DEFAULT '{}',
|
|
owner VARCHAR(128) NOT NULL,
|
|
deployment_env VARCHAR(16) NOT NULL,
|
|
status VARCHAR(24) NOT NULL DEFAULT 'active',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS credentials (
|
|
credential_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
client_id UUID NOT NULL,
|
|
secret_hash VARCHAR(255) NOT NULL,
|
|
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
expires_at TIMESTAMPTZ,
|
|
revoked_at TIMESTAMPTZ
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS audit_events (
|
|
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
agent_id UUID NOT NULL,
|
|
action VARCHAR(32) NOT NULL,
|
|
outcome VARCHAR(16) NOT NULL,
|
|
ip_address VARCHAR(64) NOT NULL,
|
|
user_agent TEXT NOT NULL,
|
|
metadata JSONB NOT NULL DEFAULT '{}',
|
|
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS token_revocations (
|
|
jti UUID PRIMARY KEY,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
)`,
|
|
];
|
|
|
|
for (const sql of migrations) {
|
|
await pool.query(sql);
|
|
}
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await pool.query('DELETE FROM audit_events');
|
|
await pool.query('DELETE FROM credentials');
|
|
await pool.query('DELETE FROM agents');
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await pool.end();
|
|
await closePool();
|
|
await closeRedisClient();
|
|
});
|
|
|
|
const validAgent = {
|
|
email: 'test-agent@sentryagent.ai',
|
|
agentType: 'screener',
|
|
version: '1.0.0',
|
|
capabilities: ['resume:read'],
|
|
owner: 'test-team',
|
|
deploymentEnv: 'development',
|
|
};
|
|
|
|
describe('POST /api/v1/agents', () => {
|
|
it('should register a new agent and return 201', async () => {
|
|
const token = makeToken();
|
|
const res = await request(app)
|
|
.post('/api/v1/agents')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send(validAgent);
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.agentId).toBeDefined();
|
|
expect(res.body.email).toBe(validAgent.email);
|
|
expect(res.body.status).toBe('active');
|
|
});
|
|
|
|
it('should return 401 without a token', async () => {
|
|
const res = await request(app).post('/api/v1/agents').send(validAgent);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('should return 400 for invalid request body', async () => {
|
|
const token = makeToken();
|
|
const res = await request(app)
|
|
.post('/api/v1/agents')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ email: 'not-an-email' });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('should return 409 for duplicate email', async () => {
|
|
const token = makeToken();
|
|
await request(app)
|
|
.post('/api/v1/agents')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send(validAgent);
|
|
|
|
const res = await request(app)
|
|
.post('/api/v1/agents')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send(validAgent);
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.code).toBe('AGENT_ALREADY_EXISTS');
|
|
});
|
|
});
|
|
|
|
describe('GET /api/v1/agents', () => {
|
|
it('should return a paginated list of agents', async () => {
|
|
const token = makeToken();
|
|
await request(app)
|
|
.post('/api/v1/agents')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send(validAgent);
|
|
|
|
const res = await request(app)
|
|
.get('/api/v1/agents')
|
|
.set('Authorization', `Bearer ${token}`);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.data).toBeInstanceOf(Array);
|
|
expect(res.body.total).toBeGreaterThanOrEqual(1);
|
|
expect(res.body.page).toBe(1);
|
|
});
|
|
|
|
it('should support filtering by status', async () => {
|
|
const token = makeToken();
|
|
await request(app)
|
|
.post('/api/v1/agents')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send(validAgent);
|
|
|
|
const res = await request(app)
|
|
.get('/api/v1/agents?status=active')
|
|
.set('Authorization', `Bearer ${token}`);
|
|
|
|
expect(res.status).toBe(200);
|
|
res.body.data.forEach((a: { status: string }) => expect(a.status).toBe('active'));
|
|
});
|
|
|
|
it('should return 401 without a token', async () => {
|
|
const res = await request(app).get('/api/v1/agents');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/v1/agents/:agentId', () => {
|
|
it('should return an agent by ID', async () => {
|
|
const token = makeToken();
|
|
const created = await request(app)
|
|
.post('/api/v1/agents')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send(validAgent);
|
|
|
|
const res = await request(app)
|
|
.get(`/api/v1/agents/${created.body.agentId}`)
|
|
.set('Authorization', `Bearer ${token}`);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.agentId).toBe(created.body.agentId);
|
|
});
|
|
|
|
it('should return 404 for unknown agentId', async () => {
|
|
const token = makeToken();
|
|
const res = await request(app)
|
|
.get(`/api/v1/agents/${uuidv4()}`)
|
|
.set('Authorization', `Bearer ${token}`);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('PATCH /api/v1/agents/:agentId', () => {
|
|
it('should update the agent and return 200', async () => {
|
|
const token = makeToken();
|
|
const created = await request(app)
|
|
.post('/api/v1/agents')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send(validAgent);
|
|
|
|
const res = await request(app)
|
|
.patch(`/api/v1/agents/${created.body.agentId}`)
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ version: '2.0.0' });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.version).toBe('2.0.0');
|
|
});
|
|
|
|
it('should return 404 for unknown agentId', async () => {
|
|
const token = makeToken();
|
|
const res = await request(app)
|
|
.patch(`/api/v1/agents/${uuidv4()}`)
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ version: '2.0.0' });
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('DELETE /api/v1/agents/:agentId', () => {
|
|
it('should decommission the agent and return 204', async () => {
|
|
const token = makeToken();
|
|
const created = await request(app)
|
|
.post('/api/v1/agents')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send(validAgent);
|
|
|
|
const res = await request(app)
|
|
.delete(`/api/v1/agents/${created.body.agentId}`)
|
|
.set('Authorization', `Bearer ${token}`);
|
|
|
|
expect(res.status).toBe(204);
|
|
});
|
|
|
|
it('should return 409 if already decommissioned', async () => {
|
|
const token = makeToken();
|
|
const created = await request(app)
|
|
.post('/api/v1/agents')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send(validAgent);
|
|
|
|
await request(app)
|
|
.delete(`/api/v1/agents/${created.body.agentId}`)
|
|
.set('Authorization', `Bearer ${token}`);
|
|
|
|
const res = await request(app)
|
|
.delete(`/api/v1/agents/${created.body.agentId}`)
|
|
.set('Authorization', `Bearer ${token}`);
|
|
|
|
expect(res.status).toBe(409);
|
|
});
|
|
});
|
|
});
|