feat: Phase 1 MVP — complete AgentIdP implementation
Implements all P0 features per OpenSpec change phase-1-mvp-implementation: - Agent Registry Service (CRUD) — full lifecycle management - OAuth 2.0 Token Service (Client Credentials flow) - Credential Management (generate, rotate, revoke) - Immutable Audit Log Service Tech: Node.js 18+, TypeScript 5.3+ strict, Express 4.18+, PostgreSQL 14+, Redis 7+ Standards: OpenAPI 3.0 specs, DRY/SOLID, zero `any` types Quality: 18 unit test suites, 244 tests passing, 97%+ coverage OpenAPI: 4 complete specs (14 endpoints total) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
283
tests/integration/agents.test.ts
Normal file
283
tests/integration/agents.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* 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';
|
||||
import { createClient } from 'redis';
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
241
tests/integration/audit.test.ts
Normal file
241
tests/integration/audit.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Integration tests for Audit Log Service endpoints.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import request from 'supertest';
|
||||
import { Application } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
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';
|
||||
|
||||
function makeToken(sub: string, scope: string = 'audit:read'): string {
|
||||
return signToken({ sub, client_id: sub, scope, jti: uuidv4() }, privateKey);
|
||||
}
|
||||
|
||||
describe('Audit Log Service Integration Tests', () => {
|
||||
let app: Application;
|
||||
let pool: Pool;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createApp();
|
||||
pool = new Pool({ connectionString: process.env['DATABASE_URL'] });
|
||||
|
||||
const migrations = [
|
||||
`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();
|
||||
});
|
||||
|
||||
async function insertAuditEvent(
|
||||
agentId: string,
|
||||
action: string = 'token.issued',
|
||||
outcome: string = 'success',
|
||||
): Promise<string> {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO audit_events (event_id, agent_id, action, outcome, ip_address, user_agent, metadata)
|
||||
VALUES ($1, $2, $3, $4, '127.0.0.1', 'test/1.0', '{}')
|
||||
RETURNING event_id`,
|
||||
[uuidv4(), agentId, action, outcome],
|
||||
);
|
||||
return result.rows[0].event_id;
|
||||
}
|
||||
|
||||
describe('GET /api/v1/audit', () => {
|
||||
it('should return a paginated list of audit events', async () => {
|
||||
const agentId = uuidv4();
|
||||
await insertAuditEvent(agentId);
|
||||
|
||||
const token = makeToken(agentId);
|
||||
const res = await request(app)
|
||||
.get('/api/v1/audit')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toBeInstanceOf(Array);
|
||||
expect(res.body.total).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should filter by agentId', async () => {
|
||||
const agentId = uuidv4();
|
||||
await insertAuditEvent(agentId);
|
||||
await insertAuditEvent(uuidv4()); // different agent
|
||||
|
||||
const token = makeToken(agentId);
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/audit?agentId=${agentId}`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
res.body.data.forEach((e: { agentId: string }) =>
|
||||
expect(e.agentId).toBe(agentId),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by action', async () => {
|
||||
const agentId = uuidv4();
|
||||
await insertAuditEvent(agentId, 'token.issued');
|
||||
await insertAuditEvent(agentId, 'auth.failed');
|
||||
|
||||
const token = makeToken(agentId);
|
||||
const res = await request(app)
|
||||
.get('/api/v1/audit?action=token.issued')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
res.body.data.forEach((e: { action: string }) =>
|
||||
expect(e.action).toBe('token.issued'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by outcome', async () => {
|
||||
const agentId = uuidv4();
|
||||
await insertAuditEvent(agentId, 'token.issued', 'success');
|
||||
await insertAuditEvent(agentId, 'auth.failed', 'failure');
|
||||
|
||||
const token = makeToken(agentId);
|
||||
const res = await request(app)
|
||||
.get('/api/v1/audit?outcome=failure')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
res.body.data.forEach((e: { outcome: string }) =>
|
||||
expect(e.outcome).toBe('failure'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 401 without a token', async () => {
|
||||
const res = await request(app).get('/api/v1/audit');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should return 403 without audit:read scope', async () => {
|
||||
const token = makeToken(uuidv4(), 'agents:read');
|
||||
const res = await request(app)
|
||||
.get('/api/v1/audit')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('should return 400 for fromDate outside 90-day retention window', async () => {
|
||||
const token = makeToken(uuidv4());
|
||||
const oldDate = new Date();
|
||||
oldDate.setDate(oldDate.getDate() - 100);
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/audit?fromDate=${oldDate.toISOString()}`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('RETENTION_WINDOW_EXCEEDED');
|
||||
});
|
||||
|
||||
it('should apply default pagination', async () => {
|
||||
const token = makeToken(uuidv4());
|
||||
const res = await request(app)
|
||||
.get('/api/v1/audit')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.page).toBe(1);
|
||||
expect(res.body.limit).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/audit/:eventId', () => {
|
||||
it('should return a single audit event', async () => {
|
||||
const agentId = uuidv4();
|
||||
const eventId = await insertAuditEvent(agentId);
|
||||
|
||||
const token = makeToken(agentId);
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/audit/${eventId}`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.eventId).toBe(eventId);
|
||||
});
|
||||
|
||||
it('should return 404 for unknown eventId', async () => {
|
||||
const token = makeToken(uuidv4());
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/audit/${uuidv4()}`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 403 without audit:read scope', async () => {
|
||||
const agentId = uuidv4();
|
||||
const eventId = await insertAuditEvent(agentId);
|
||||
const token = makeToken(agentId, 'agents:read');
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/audit/${eventId}`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
263
tests/integration/credentials.test.ts
Normal file
263
tests/integration/credentials.test.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Integration tests for Credential Management endpoints.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import request from 'supertest';
|
||||
import { Application } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
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';
|
||||
|
||||
function makeToken(sub: string, scope: string = 'agents:read agents:write'): string {
|
||||
return signToken({ sub, client_id: sub, scope, jti: uuidv4() }, privateKey);
|
||||
}
|
||||
|
||||
describe('Credential Management Integration Tests', () => {
|
||||
let app: Application;
|
||||
let pool: Pool;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createApp();
|
||||
pool = new Pool({ connectionString: process.env['DATABASE_URL'] });
|
||||
|
||||
const migrations = [
|
||||
`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();
|
||||
});
|
||||
|
||||
async function createAgent(): Promise<string> {
|
||||
const agentId = uuidv4();
|
||||
await pool.query(
|
||||
`INSERT INTO agents (agent_id, email, agent_type, version, capabilities, owner, deployment_env, status)
|
||||
VALUES ($1, $2, 'screener', '1.0.0', '{"agents:read"}', 'test', 'development', 'active')`,
|
||||
[agentId, `agent-${agentId}@test.ai`],
|
||||
);
|
||||
return agentId;
|
||||
}
|
||||
|
||||
describe('POST /api/v1/agents/:agentId/credentials', () => {
|
||||
it('should generate a credential and return 201 with clientSecret', async () => {
|
||||
const agentId = await createAgent();
|
||||
const token = makeToken(agentId);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/v1/agents/${agentId}/credentials`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.credentialId).toBeDefined();
|
||||
expect(res.body.clientSecret).toMatch(/^sk_live_[0-9a-f]{64}$/);
|
||||
expect(res.body.status).toBe('active');
|
||||
});
|
||||
|
||||
it('should return 401 without a token', async () => {
|
||||
const agentId = await createAgent();
|
||||
const res = await request(app)
|
||||
.post(`/api/v1/agents/${agentId}/credentials`)
|
||||
.send({});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should return 403 when managing another agent credentials', async () => {
|
||||
const agentId = await createAgent();
|
||||
const otherAgent = makeToken(uuidv4()); // different sub
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/v1/agents/${agentId}/credentials`)
|
||||
.set('Authorization', `Bearer ${otherAgent}`)
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('should return 404 for unknown agentId', async () => {
|
||||
const fakeId = uuidv4();
|
||||
const token = makeToken(fakeId);
|
||||
const res = await request(app)
|
||||
.post(`/api/v1/agents/${fakeId}/credentials`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/agents/:agentId/credentials', () => {
|
||||
it('should list credentials (no clientSecret)', async () => {
|
||||
const agentId = await createAgent();
|
||||
const token = makeToken(agentId);
|
||||
|
||||
// Generate first
|
||||
await request(app)
|
||||
.post(`/api/v1/agents/${agentId}/credentials`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/agents/${agentId}/credentials`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toHaveLength(1);
|
||||
expect(res.body.data[0].clientSecret).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/agents/:agentId/credentials/:credentialId/rotate', () => {
|
||||
it('should rotate a credential and return new clientSecret', async () => {
|
||||
const agentId = await createAgent();
|
||||
const token = makeToken(agentId);
|
||||
|
||||
const generated = await request(app)
|
||||
.post(`/api/v1/agents/${agentId}/credentials`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({});
|
||||
|
||||
const credentialId = generated.body.credentialId;
|
||||
const oldSecret = generated.body.clientSecret;
|
||||
|
||||
const rotated = await request(app)
|
||||
.post(`/api/v1/agents/${agentId}/credentials/${credentialId}/rotate`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({});
|
||||
|
||||
expect(rotated.status).toBe(200);
|
||||
expect(rotated.body.clientSecret).toMatch(/^sk_live_[0-9a-f]{64}$/);
|
||||
expect(rotated.body.clientSecret).not.toBe(oldSecret);
|
||||
});
|
||||
|
||||
it('should return 409 for rotating a revoked credential', async () => {
|
||||
const agentId = await createAgent();
|
||||
const token = makeToken(agentId);
|
||||
|
||||
const generated = await request(app)
|
||||
.post(`/api/v1/agents/${agentId}/credentials`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({});
|
||||
|
||||
const credentialId = generated.body.credentialId;
|
||||
|
||||
// Revoke first
|
||||
await request(app)
|
||||
.delete(`/api/v1/agents/${agentId}/credentials/${credentialId}`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
// Try to rotate
|
||||
const res = await request(app)
|
||||
.post(`/api/v1/agents/${agentId}/credentials/${credentialId}/rotate`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.code).toBe('CREDENTIAL_ALREADY_REVOKED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/agents/:agentId/credentials/:credentialId', () => {
|
||||
it('should revoke a credential and return 204', async () => {
|
||||
const agentId = await createAgent();
|
||||
const token = makeToken(agentId);
|
||||
|
||||
const generated = await request(app)
|
||||
.post(`/api/v1/agents/${agentId}/credentials`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({});
|
||||
|
||||
const credentialId = generated.body.credentialId;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/v1/agents/${agentId}/credentials/${credentialId}`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it('should return 409 for revoking an already-revoked credential', async () => {
|
||||
const agentId = await createAgent();
|
||||
const token = makeToken(agentId);
|
||||
|
||||
const generated = await request(app)
|
||||
.post(`/api/v1/agents/${agentId}/credentials`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({});
|
||||
|
||||
const credentialId = generated.body.credentialId;
|
||||
await request(app)
|
||||
.delete(`/api/v1/agents/${agentId}/credentials/${credentialId}`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/v1/agents/${agentId}/credentials/${credentialId}`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
});
|
||||
261
tests/integration/token.test.ts
Normal file
261
tests/integration/token.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Integration tests for OAuth2 Token Service endpoints.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import request from 'supertest';
|
||||
import { Application } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
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';
|
||||
|
||||
function makeToken(sub: string, scope: string = 'agents:read tokens:read'): string {
|
||||
return signToken({ sub, client_id: sub, scope, jti: uuidv4() }, privateKey);
|
||||
}
|
||||
|
||||
describe('OAuth2 Token Service Integration Tests', () => {
|
||||
let app: Application;
|
||||
let pool: Pool;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createApp();
|
||||
pool = new Pool({ connectionString: process.env['DATABASE_URL'] });
|
||||
|
||||
const migrations = [
|
||||
`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 token_revocations');
|
||||
await pool.query('DELETE FROM credentials');
|
||||
await pool.query('DELETE FROM agents');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
await closePool();
|
||||
await closeRedisClient();
|
||||
});
|
||||
|
||||
async function createAgentWithCredentials(): Promise<{ agentId: string; clientSecret: string }> {
|
||||
const agentId = uuidv4();
|
||||
const token = makeToken(agentId, 'agents:read agents:write tokens:read');
|
||||
|
||||
// Create agent directly in DB
|
||||
await pool.query(
|
||||
`INSERT INTO agents (agent_id, email, agent_type, version, capabilities, owner, deployment_env, status)
|
||||
VALUES ($1, $2, 'screener', '1.0.0', '{"agents:read"}', 'test', 'development', 'active')`,
|
||||
[agentId, `agent-${agentId}@test.ai`],
|
||||
);
|
||||
|
||||
// Generate credentials via API
|
||||
const credRes = await request(app)
|
||||
.post(`/api/v1/agents/${agentId}/credentials`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({});
|
||||
|
||||
return { agentId, clientSecret: credRes.body.clientSecret };
|
||||
}
|
||||
|
||||
describe('POST /api/v1/token', () => {
|
||||
it('should issue a token for valid credentials', async () => {
|
||||
const { agentId, clientSecret } = await createAgentWithCredentials();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/token')
|
||||
.type('form')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: agentId,
|
||||
client_secret: clientSecret,
|
||||
scope: 'agents:read',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.access_token).toBeDefined();
|
||||
expect(res.body.token_type).toBe('Bearer');
|
||||
expect(res.body.expires_in).toBe(3600);
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
});
|
||||
|
||||
it('should return 400 for missing grant_type', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/token')
|
||||
.type('form')
|
||||
.send({ client_id: uuidv4(), client_secret: 'secret' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('invalid_request');
|
||||
});
|
||||
|
||||
it('should return 400 for unsupported grant_type', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/token')
|
||||
.type('form')
|
||||
.send({ grant_type: 'authorization_code' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('unsupported_grant_type');
|
||||
});
|
||||
|
||||
it('should return 401 for invalid credentials', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/token')
|
||||
.type('form')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: uuidv4(),
|
||||
client_secret: 'wrong-secret',
|
||||
scope: 'agents:read',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe('invalid_client');
|
||||
});
|
||||
|
||||
it('should return 400 for invalid scope', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/token')
|
||||
.type('form')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: uuidv4(),
|
||||
client_secret: 'secret',
|
||||
scope: 'admin:all',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('invalid_scope');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/token/introspect', () => {
|
||||
it('should return active:true for a valid token', async () => {
|
||||
const { agentId, clientSecret } = await createAgentWithCredentials();
|
||||
const scope = 'agents:read tokens:read';
|
||||
|
||||
const issued = await request(app)
|
||||
.post('/api/v1/token')
|
||||
.type('form')
|
||||
.send({ grant_type: 'client_credentials', client_id: agentId, client_secret: clientSecret, scope });
|
||||
|
||||
const callerToken = issued.body.access_token;
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/token/introspect')
|
||||
.set('Authorization', `Bearer ${callerToken}`)
|
||||
.type('form')
|
||||
.send({ token: callerToken });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.active).toBe(true);
|
||||
});
|
||||
|
||||
it('should return active:false for an invalid token', async () => {
|
||||
const callerToken = makeToken(uuidv4(), 'tokens:read');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/token/introspect')
|
||||
.set('Authorization', `Bearer ${callerToken}`)
|
||||
.type('form')
|
||||
.send({ token: 'not.a.real.token' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.active).toBe(false);
|
||||
});
|
||||
|
||||
it('should return 401 without Bearer token', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/token/introspect')
|
||||
.type('form')
|
||||
.send({ token: 'some.token' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/token/revoke', () => {
|
||||
it('should revoke a token and return 200', async () => {
|
||||
const { agentId, clientSecret } = await createAgentWithCredentials();
|
||||
|
||||
const issued = await request(app)
|
||||
.post('/api/v1/token')
|
||||
.type('form')
|
||||
.send({ grant_type: 'client_credentials', client_id: agentId, client_secret: clientSecret, scope: 'agents:read' });
|
||||
|
||||
const token = issued.body.access_token;
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/token/revoke')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.type('form')
|
||||
.send({ token });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should return 401 without Bearer token', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/token/revoke')
|
||||
.type('form')
|
||||
.send({ token: 'some.token' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user