feat(phase-3): workstream 2 — W3C DIDs
Implements W3C DID Core 1.0 per-agent identity for every registered agent: Schema: - agent_did_keys table: stores EC P-256 public key JWK + Vault path for private key - agents.did + agents.did_created_at columns Key management: - EC P-256 key pair generated on every agent registration via Node.js crypto - Private key stored in Vault KV v2 (dev:no-vault marker when Vault not configured) - Public key JWK stored in PostgreSQL agent_did_keys table API (4 new endpoints): - GET /.well-known/did.json — instance DID Document (public, cached) - GET /api/v1/agents/:id/did — per-agent DID Document (public, 410 for decommissioned) - GET /api/v1/agents/:id/did/resolve — W3C DID Resolution result (agents:read scope) - GET /api/v1/agents/:id/did/card — AGNTCY agent card (public) Implementation: - DIDService: DID construction, key generation, Redis caching (TTL configurable) - DIDController: 410 Gone for decommissioned agents, correct Content-Type on resolve - AgentService: calls DIDService.generateDIDForAgent on every new registration Tests: 429 passing, DIDService 98.93% coverage, private key absence verified in all responses Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
417
tests/integration/did.test.ts
Normal file
417
tests/integration/did.test.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user