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>
242 lines
8.0 KiB
TypeScript
242 lines
8.0 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|
|
});
|