/** * Integration tests for W3C DID endpoints (Phase 3 Workstream 2). * Uses a real Postgres test DB and Redis test instance. * * Endpoints under test: * GET /.well-known/did.json — unauthenticated * GET /api/v1/agents/:agentId/did — unauthenticated * GET /api/v1/agents/:agentId/did/resolve — requires auth + agents:read scope * GET /api/v1/agents/:agentId/did/card — unauthenticated */ import crypto from 'crypto'; import request from 'supertest'; import { Application } from 'express'; import { v4 as uuidv4 } from 'uuid'; import { Pool } from 'pg'; // ─── Environment setup BEFORE app import ───────────────────────────────────── 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'; process.env['DEFAULT_ORG_ID'] = 'org_system'; process.env['DID_WEB_DOMAIN'] = 'test.sentryagent.local'; // ─── App + utilities ────────────────────────────────────────────────────────── import { createApp } from '../../src/app'; import { signToken } from '../../src/utils/jwt'; import { closePool } from '../../src/db/pool'; import { closeRedisClient } from '../../src/cache/redis'; const TEST_DOMAIN = 'test.sentryagent.local'; const CALLER_ID = uuidv4(); function makeToken(sub: string = CALLER_ID, scope = 'agents:read'): string { return signToken({ sub, client_id: sub, scope, jti: uuidv4() }, privateKey); } // ─── Suite ──────────────────────────────────────────────────────────────────── describe('DID Endpoints Integration Tests', () => { let app: Application; let pool: Pool; let agentId: string; beforeAll(async () => { app = await createApp(); pool = new Pool({ connectionString: process.env['DATABASE_URL'] }); // ── Create all required tables ────────────────────────────────────────── const migrations: string[] = [ // tracking `CREATE TABLE IF NOT EXISTS schema_migrations ( name VARCHAR(255) PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )`, // organizations (FK dependency for agents) `CREATE TABLE IF NOT EXISTS organizations ( organization_id VARCHAR(40) PRIMARY KEY, name VARCHAR(100) NOT NULL, slug VARCHAR(50) NOT NULL UNIQUE, plan_tier VARCHAR(20) NOT NULL DEFAULT 'free', max_agents INTEGER NOT NULL DEFAULT 100, max_tokens_per_month INTEGER NOT NULL DEFAULT 10000, status VARCHAR(20) NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )`, // seed system org `INSERT INTO organizations (organization_id, name, slug, plan_tier, max_agents, max_tokens_per_month, status) VALUES ('org_system', 'System', 'system', 'enterprise', 999999, 999999999, 'active') ON CONFLICT (organization_id) DO NOTHING`, // agents (with DID columns added in migration 013) `CREATE TABLE IF NOT EXISTS agents ( agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id VARCHAR(40) NOT NULL DEFAULT 'org_system' REFERENCES organizations(organization_id), 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', did TEXT, did_created_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )`, // credentials `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 )`, // audit_events `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() )`, // token_revocations `CREATE TABLE IF NOT EXISTS token_revocations ( jti UUID PRIMARY KEY, expires_at TIMESTAMPTZ NOT NULL, revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )`, // agent_did_keys (migration 012) `CREATE TABLE IF NOT EXISTS agent_did_keys ( key_id VARCHAR(40) PRIMARY KEY, agent_id UUID NOT NULL REFERENCES agents(agent_id) ON DELETE CASCADE, organization_id VARCHAR(40) NOT NULL, public_key_jwk JSONB NOT NULL, vault_key_path TEXT NOT NULL, key_type VARCHAR(16) NOT NULL DEFAULT 'EC', curve VARCHAR(16) NOT NULL DEFAULT 'P-256', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), rotated_at TIMESTAMPTZ )`, ]; for (const sql of migrations) { await pool.query(sql); } // ── Seed a test agent with a DID and key ──────────────────────────────── const agentResult = await pool.query<{ agent_id: string }>( `INSERT INTO agents (email, agent_type, version, capabilities, owner, deployment_env, status, did, did_created_at) VALUES ($1, 'orchestrator', '1.0.0', ARRAY['task-planning'], 'test-team', 'production', 'active', $2, NOW()) RETURNING agent_id`, [ `did-test-agent-${uuidv4()}@sentryagent.test`, `did:web:${TEST_DOMAIN}:agents:__PLACEHOLDER__`, ], ); agentId = agentResult.rows[0].agent_id; // Update DID to use the real agent_id const did = `did:web:${TEST_DOMAIN}:agents:${agentId}`; await pool.query(`UPDATE agents SET did = $1 WHERE agent_id = $2`, [did, agentId]); // Insert a key for the agent await pool.query( `INSERT INTO agent_did_keys (key_id, agent_id, organization_id, public_key_jwk, vault_key_path, key_type, curve) VALUES ($1, $2, 'org_system', $3, 'dev:no-vault', 'EC', 'P-256')`, [ `key_${uuidv4().replace(/-/g, '')}`, agentId, JSON.stringify({ kty: 'EC', crv: 'P-256', x: 'test_x', y: 'test_y' }), ], ); }); afterEach(async () => { // Clear Redis to ensure no stale cached documents between tests // We cannot easily flush Redis here without the client, so tests are designed to be order-independent }); afterAll(async () => { await pool.query('DELETE FROM agent_did_keys'); await pool.query('DELETE FROM audit_events'); await pool.query('DELETE FROM credentials'); await pool.query('DELETE FROM agents'); await pool.query(`DELETE FROM organizations WHERE organization_id != 'org_system'`); await pool.end(); await closePool(); await closeRedisClient(); }); // ─── GET /.well-known/did.json ──────────────────────────────────────────── describe('GET /.well-known/did.json', () => { it('should return 200 with a W3C DID Document for the instance', async () => { const res = await request(app).get('/.well-known/did.json'); expect(res.status).toBe(200); expect(res.body['@context']).toContain('https://www.w3.org/ns/did/v1'); expect(res.body.id).toBe(`did:web:${TEST_DOMAIN}`); }); it('should not require authentication', async () => { const res = await request(app).get('/.well-known/did.json'); expect(res.status).toBe(200); }); it('should include a verificationMethod array', async () => { const res = await request(app).get('/.well-known/did.json'); expect(res.body.verificationMethod).toBeInstanceOf(Array); expect(res.body.verificationMethod.length).toBeGreaterThan(0); }); it('should include a service endpoint of type AgentIdentityProvider', async () => { const res = await request(app).get('/.well-known/did.json'); expect(res.body.service).toBeInstanceOf(Array); const svc = res.body.service.find( (s: { type: string }) => s.type === 'AgentIdentityProvider', ); expect(svc).toBeDefined(); }); it('should NEVER expose private key material', async () => { const res = await request(app).get('/.well-known/did.json'); const body = JSON.stringify(res.body); expect(body).not.toContain('privateKeyPem'); expect(body).not.toContain('PRIVATE KEY'); }); }); // ─── GET /api/v1/agents/:agentId/did ───────────────────────────────────── describe('GET /api/v1/agents/:agentId/did', () => { it('should return 200 with the agent DID Document', async () => { const res = await request(app).get(`/api/v1/agents/${agentId}/did`); expect(res.status).toBe(200); expect(res.body['@context']).toContain('https://www.w3.org/ns/did/v1'); expect(res.body.id).toBe(`did:web:${TEST_DOMAIN}:agents:${agentId}`); }); it('should not require authentication', async () => { const res = await request(app).get(`/api/v1/agents/${agentId}/did`); expect(res.status).toBe(200); }); it('should include AGNTCY extension fields', async () => { const res = await request(app).get(`/api/v1/agents/${agentId}/did`); expect(res.body.agntcy).toBeDefined(); expect(res.body.agntcy.agentId).toBe(agentId); expect(res.body.agntcy.agentType).toBe('orchestrator'); }); it('should return 404 for a non-existent agent', async () => { const nonExistentId = uuidv4(); const res = await request(app).get(`/api/v1/agents/${nonExistentId}/did`); expect(res.status).toBe(404); expect(res.body.code).toBe('AGENT_NOT_FOUND'); }); it('should return 410 for a decommissioned agent', async () => { // Create a decommissioned agent const decommResult = await pool.query<{ agent_id: string }>( `INSERT INTO agents (email, agent_type, version, capabilities, owner, deployment_env, status) VALUES ($1, 'screener', '1.0.0', ARRAY['scan'], 'test-team', 'staging', 'decommissioned') RETURNING agent_id`, [`decommissioned-${uuidv4()}@sentryagent.test`], ); const decommId = decommResult.rows[0].agent_id; const res = await request(app).get(`/api/v1/agents/${decommId}/did`); expect(res.status).toBe(410); expect(res.body.code).toBe('AGENT_DECOMMISSIONED'); await pool.query('DELETE FROM agents WHERE agent_id = $1', [decommId]); }); it('should NEVER expose private key material', async () => { const res = await request(app).get(`/api/v1/agents/${agentId}/did`); const body = JSON.stringify(res.body); expect(body).not.toContain('privateKeyPem'); expect(body).not.toContain('PRIVATE KEY'); }); }); // ─── GET /api/v1/agents/:agentId/did/resolve ───────────────────────────── describe('GET /api/v1/agents/:agentId/did/resolve', () => { it('should return 200 with a W3C DID Resolution result when authenticated', async () => { const token = makeToken(); const res = await request(app) .get(`/api/v1/agents/${agentId}/did/resolve`) .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect(res.body.didDocument).toBeDefined(); expect(res.body.didDocumentMetadata).toBeDefined(); expect(res.body.didResolutionMetadata).toBeDefined(); }); it('should return 401 without authentication', async () => { const res = await request(app).get(`/api/v1/agents/${agentId}/did/resolve`); expect(res.status).toBe(401); }); it('should set Content-Type to application/ld+json with DID resolution profile', async () => { const token = makeToken(); const res = await request(app) .get(`/api/v1/agents/${agentId}/did/resolve`) .set('Authorization', `Bearer ${token}`); expect(res.headers['content-type']).toContain('application/ld+json'); }); it('should include the DID document id in the resolution result', async () => { const token = makeToken(); const res = await request(app) .get(`/api/v1/agents/${agentId}/did/resolve`) .set('Authorization', `Bearer ${token}`); expect(res.body.didDocument.id).toBe(`did:web:${TEST_DOMAIN}:agents:${agentId}`); }); it('should return 404 for a non-existent agent', async () => { const token = makeToken(); const nonExistentId = uuidv4(); const res = await request(app) .get(`/api/v1/agents/${nonExistentId}/did/resolve`) .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(404); }); it('should include ISO timestamps in didDocumentMetadata', async () => { const token = makeToken(); const res = await request(app) .get(`/api/v1/agents/${agentId}/did/resolve`) .set('Authorization', `Bearer ${token}`); expect(res.body.didDocumentMetadata.created).toMatch(/^\d{4}-\d{2}-\d{2}T/); expect(res.body.didDocumentMetadata.updated).toMatch(/^\d{4}-\d{2}-\d{2}T/); }); it('should NEVER expose private key material', async () => { const token = makeToken(); const res = await request(app) .get(`/api/v1/agents/${agentId}/did/resolve`) .set('Authorization', `Bearer ${token}`); const body = JSON.stringify(res.body); expect(body).not.toContain('privateKeyPem'); expect(body).not.toContain('PRIVATE KEY'); }); }); // ─── GET /api/v1/agents/:agentId/did/card ──────────────────────────────── describe('GET /api/v1/agents/:agentId/did/card', () => { it('should return 200 with an AGNTCY agent card', async () => { const res = await request(app).get(`/api/v1/agents/${agentId}/did/card`); expect(res.status).toBe(200); expect(res.body.did).toBe(`did:web:${TEST_DOMAIN}:agents:${agentId}`); expect(res.body.agentType).toBe('orchestrator'); expect(res.body.capabilities).toEqual(['task-planning']); expect(res.body.owner).toBe('test-team'); expect(res.body.identityProvider).toBe('https://idp.sentryagent.ai'); }); it('should not require authentication', async () => { const res = await request(app).get(`/api/v1/agents/${agentId}/did/card`); expect(res.status).toBe(200); }); it('should include a valid ISO issuedAt timestamp', async () => { const res = await request(app).get(`/api/v1/agents/${agentId}/did/card`); expect(res.body.issuedAt).toBeDefined(); expect(() => new Date(res.body.issuedAt as string)).not.toThrow(); expect(new Date(res.body.issuedAt as string).toISOString()).toBe(res.body.issuedAt); }); it('should return 404 for a non-existent agent', async () => { const nonExistentId = uuidv4(); const res = await request(app).get(`/api/v1/agents/${nonExistentId}/did/card`); expect(res.status).toBe(404); expect(res.body.code).toBe('AGENT_NOT_FOUND'); }); it('should NEVER expose private key material', async () => { const res = await request(app).get(`/api/v1/agents/${agentId}/did/card`); const body = JSON.stringify(res.body); expect(body).not.toContain('privateKeyPem'); expect(body).not.toContain('PRIVATE KEY'); }); }); });