Files
sentryagent-idp/tests/integration/credentials.test.ts
SentryAgent.ai Developer d3530285b9 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>
2026-03-28 09:14:41 +00:00

264 lines
8.9 KiB
TypeScript

/**
* 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);
});
});
});