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:
SentryAgent.ai Developer
2026-03-30 00:47:59 +00:00
parent d252097f71
commit 3d1fff15f6
15 changed files with 2171 additions and 14 deletions

View 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');
});
});
});

View File

@@ -0,0 +1,332 @@
/**
* Unit tests for src/controllers/DIDController.ts
* DIDService is fully mocked. Handlers are invoked with mock req/res/next.
*/
import { Request, Response, NextFunction } from 'express';
import { DIDController } from '../../../src/controllers/DIDController';
import { DIDService } from '../../../src/services/DIDService';
import { AgentRepository } from '../../../src/repositories/AgentRepository';
import { AgentNotFoundError } from '../../../src/utils/errors';
import { IDIDDocument, IDIDResolutionResult, IAgentCard } from '../../../src/types/did';
// ─── Mocks ────────────────────────────────────────────────────────────────────
jest.mock('../../../src/services/DIDService');
jest.mock('../../../src/repositories/AgentRepository');
const MockDIDService = DIDService as jest.MockedClass<typeof DIDService>;
const MockAgentRepository = AgentRepository as jest.MockedClass<typeof AgentRepository>;
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const AGENT_ID = 'agt_test_ctrl';
const TEST_DOMAIN = 'test.example.com';
const INSTANCE_DID = `did:web:${TEST_DOMAIN}`;
const AGENT_DID = `did:web:${TEST_DOMAIN}:agents:${AGENT_ID}`;
const MOCK_INSTANCE_DOC: IDIDDocument = {
'@context': ['https://www.w3.org/ns/did/v1'],
id: INSTANCE_DID,
controller: INSTANCE_DID,
verificationMethod: [
{
id: `${INSTANCE_DID}#keys-1`,
type: 'JsonWebKey2020',
controller: INSTANCE_DID,
publicKeyJwk: { kty: 'EC', crv: 'P-256', use: 'sig' },
},
],
authentication: [`${INSTANCE_DID}#keys-1`],
};
const MOCK_AGENT_DOC: IDIDDocument = {
'@context': ['https://www.w3.org/ns/did/v1', 'https://w3id.org/agntcy/v1'],
id: AGENT_DID,
controller: INSTANCE_DID,
verificationMethod: [],
authentication: [],
};
const MOCK_RESOLUTION_RESULT: IDIDResolutionResult = {
didDocument: MOCK_AGENT_DOC,
didDocumentMetadata: {
created: '2026-01-01T00:00:00.000Z',
updated: '2026-01-02T00:00:00.000Z',
deactivated: false,
},
didResolutionMetadata: {
contentType: 'application/did+ld+json',
retrieved: new Date().toISOString(),
},
};
const MOCK_AGENT_CARD: IAgentCard = {
did: AGENT_DID,
name: 'test@example.com',
agentType: 'orchestrator',
capabilities: ['task-planning'],
owner: 'acme',
version: '1.0.0',
deploymentEnv: 'production',
identityProvider: 'https://idp.sentryagent.ai',
issuedAt: '2026-01-01T00:00:00.000Z',
};
// ─── Request/Response builder ─────────────────────────────────────────────────
function buildMocks(params: Record<string, string> = {}): {
req: Partial<Request>;
res: Partial<Response>;
next: NextFunction;
} {
const res: Partial<Response> = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
};
return {
req: {
params,
body: {},
query: {},
headers: {},
},
res,
next: jest.fn() as NextFunction,
};
}
// ─── Suite ────────────────────────────────────────────────────────────────────
describe('DIDController', () => {
let didService: jest.Mocked<DIDService>;
let agentRepo: jest.Mocked<AgentRepository>;
let controller: DIDController;
beforeEach(() => {
jest.clearAllMocks();
didService = new MockDIDService({} as never, null, {} as never) as jest.Mocked<DIDService>;
agentRepo = new MockAgentRepository({} as never) as jest.Mocked<AgentRepository>;
controller = new DIDController(didService, agentRepo);
});
// ─── getInstanceDIDDocument ──────────────────────────────────────────────
describe('getInstanceDIDDocument()', () => {
it('should return 200 with the instance DID Document', async () => {
didService.buildInstanceDIDDocument.mockResolvedValueOnce(MOCK_INSTANCE_DOC);
const { req, res, next } = buildMocks();
await controller.getInstanceDIDDocument(req as Request, res as Response, next);
expect(res.json).toHaveBeenCalledWith(MOCK_INSTANCE_DOC);
expect(next).not.toHaveBeenCalled();
});
it('should call next with the error when DIDService throws', async () => {
const err = new Error('DID build failed');
didService.buildInstanceDIDDocument.mockRejectedValueOnce(err);
const { req, res, next } = buildMocks();
await controller.getInstanceDIDDocument(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(err);
expect(res.json).not.toHaveBeenCalled();
});
it('should not include private key material in the response', async () => {
didService.buildInstanceDIDDocument.mockResolvedValueOnce(MOCK_INSTANCE_DOC);
const { req, res, next } = buildMocks();
await controller.getInstanceDIDDocument(req as Request, res as Response, next);
const callArg = (res.json as jest.Mock).mock.calls[0][0] as IDIDDocument;
const serialised = JSON.stringify(callArg);
expect(serialised).not.toContain('privateKeyPem');
expect(serialised).not.toContain('PRIVATE KEY');
});
});
// ─── getAgentDIDDocument ─────────────────────────────────────────────────
describe('getAgentDIDDocument()', () => {
it('should return 200 with the agent DID Document for an active agent', async () => {
didService.buildAgentDIDDocument.mockResolvedValueOnce({
document: MOCK_AGENT_DOC,
deactivated: false,
createdAt: new Date(),
updatedAt: new Date(),
});
const { req, res, next } = buildMocks({ agentId: AGENT_ID });
await controller.getAgentDIDDocument(req as Request, res as Response, next);
expect(res.json).toHaveBeenCalledWith(MOCK_AGENT_DOC);
expect(res.status).not.toHaveBeenCalled();
expect(next).not.toHaveBeenCalled();
});
it('should return 410 with AGENT_DECOMMISSIONED code for a decommissioned agent', async () => {
didService.buildAgentDIDDocument.mockResolvedValueOnce({
document: MOCK_AGENT_DOC,
deactivated: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const { req, res, next } = buildMocks({ agentId: AGENT_ID });
await controller.getAgentDIDDocument(req as Request, res as Response, next);
expect(res.status).toHaveBeenCalledWith(410);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ code: 'AGENT_DECOMMISSIONED' }),
);
expect(next).not.toHaveBeenCalled();
});
it('should call next with AgentNotFoundError when agent does not exist', async () => {
const err = new AgentNotFoundError(AGENT_ID);
didService.buildAgentDIDDocument.mockRejectedValueOnce(err);
const { req, res, next } = buildMocks({ agentId: AGENT_ID });
await controller.getAgentDIDDocument(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(err);
expect(res.json).not.toHaveBeenCalled();
});
it('should call next with generic error when DIDService throws', async () => {
const err = new Error('Unexpected DB error');
didService.buildAgentDIDDocument.mockRejectedValueOnce(err);
const { req, res, next } = buildMocks({ agentId: AGENT_ID });
await controller.getAgentDIDDocument(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(err);
});
it('should not include private key material in any response', async () => {
didService.buildAgentDIDDocument.mockResolvedValueOnce({
document: MOCK_AGENT_DOC,
deactivated: false,
createdAt: new Date(),
updatedAt: new Date(),
});
const { req, res, next } = buildMocks({ agentId: AGENT_ID });
await controller.getAgentDIDDocument(req as Request, res as Response, next);
const callArg = (res.json as jest.Mock).mock.calls[0][0] as IDIDDocument;
const serialised = JSON.stringify(callArg);
expect(serialised).not.toContain('privateKeyPem');
expect(serialised).not.toContain('PRIVATE KEY');
});
});
// ─── resolveAgentDID ─────────────────────────────────────────────────────
describe('resolveAgentDID()', () => {
it('should return 200 with the DID Resolution result', async () => {
didService.buildResolutionResult.mockResolvedValueOnce(MOCK_RESOLUTION_RESULT);
const { req, res, next } = buildMocks({ agentId: AGENT_ID });
await controller.resolveAgentDID(req as Request, res as Response, next);
expect(res.json).toHaveBeenCalledWith(MOCK_RESOLUTION_RESULT);
expect(next).not.toHaveBeenCalled();
});
it('should set Content-Type to application/ld+json with DID resolution profile', async () => {
didService.buildResolutionResult.mockResolvedValueOnce(MOCK_RESOLUTION_RESULT);
const { req, res, next } = buildMocks({ agentId: AGENT_ID });
await controller.resolveAgentDID(req as Request, res as Response, next);
expect(res.set).toHaveBeenCalledWith(
'Content-Type',
'application/ld+json;profile="https://w3id.org/did-resolution"',
);
});
it('should call next with AgentNotFoundError when agent does not exist', async () => {
const err = new AgentNotFoundError(AGENT_ID);
didService.buildResolutionResult.mockRejectedValueOnce(err);
const { req, res, next } = buildMocks({ agentId: AGENT_ID });
await controller.resolveAgentDID(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(err);
});
it('should call next with error when DIDService throws unexpectedly', async () => {
const err = new Error('Redis connection lost');
didService.buildResolutionResult.mockRejectedValueOnce(err);
const { req, res, next } = buildMocks({ agentId: AGENT_ID });
await controller.resolveAgentDID(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(err);
expect(res.json).not.toHaveBeenCalled();
});
it('should not include private key material in the resolution response', async () => {
didService.buildResolutionResult.mockResolvedValueOnce(MOCK_RESOLUTION_RESULT);
const { req, res, next } = buildMocks({ agentId: AGENT_ID });
await controller.resolveAgentDID(req as Request, res as Response, next);
const callArg = (res.json as jest.Mock).mock.calls[0][0] as IDIDResolutionResult;
const serialised = JSON.stringify(callArg);
expect(serialised).not.toContain('privateKeyPem');
expect(serialised).not.toContain('PRIVATE KEY');
});
});
// ─── getAgentCard ─────────────────────────────────────────────────────────
describe('getAgentCard()', () => {
it('should return 200 with the AGNTCY agent card', async () => {
didService.buildAgentCard.mockResolvedValueOnce(MOCK_AGENT_CARD);
const { req, res, next } = buildMocks({ agentId: AGENT_ID });
await controller.getAgentCard(req as Request, res as Response, next);
expect(res.json).toHaveBeenCalledWith(MOCK_AGENT_CARD);
expect(next).not.toHaveBeenCalled();
});
it('should call next with AgentNotFoundError when agent does not exist', async () => {
const err = new AgentNotFoundError(AGENT_ID);
didService.buildAgentCard.mockRejectedValueOnce(err);
const { req, res, next } = buildMocks({ agentId: AGENT_ID });
await controller.getAgentCard(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(err);
expect(res.json).not.toHaveBeenCalled();
});
it('should call next with generic error when DIDService throws', async () => {
const err = new Error('Service unavailable');
didService.buildAgentCard.mockRejectedValueOnce(err);
const { req, res, next } = buildMocks({ agentId: AGENT_ID });
await controller.getAgentCard(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(err);
});
it('should not include private key material in the agent card response', async () => {
didService.buildAgentCard.mockResolvedValueOnce(MOCK_AGENT_CARD);
const { req, res, next } = buildMocks({ agentId: AGENT_ID });
await controller.getAgentCard(req as Request, res as Response, next);
const callArg = (res.json as jest.Mock).mock.calls[0][0] as IAgentCard;
const serialised = JSON.stringify(callArg);
expect(serialised).not.toContain('privateKeyPem');
expect(serialised).not.toContain('PRIVATE KEY');
});
});
});

View File

@@ -0,0 +1,591 @@
/**
* Unit tests for src/services/DIDService.ts
* All public methods are tested. pg Pool, node-vault, and Redis are mocked.
*/
import { DIDService } from '../../../src/services/DIDService';
import { AgentNotFoundError } from '../../../src/utils/errors';
import { IPublicKeyJwk } from '../../../src/types/did';
// ─── Mock node-vault ─────────────────────────────────────────────────────────
jest.mock('node-vault', () => {
return jest.fn(() => ({
write: jest.fn().mockResolvedValue({}),
read: jest.fn().mockResolvedValue({}),
}));
});
// ─── Mock pg Pool ─────────────────────────────────────────────────────────────
const mockQuery = jest.fn();
const mockPool = {
query: mockQuery,
} as never;
// ─── Mock Redis client ────────────────────────────────────────────────────────
const mockRedisGet = jest.fn();
const mockRedisSet = jest.fn();
const mockRedis = {
get: mockRedisGet,
set: mockRedisSet,
} as never;
// ─── Constants ────────────────────────────────────────────────────────────────
const TEST_DOMAIN = 'test.example.com';
const AGENT_ID = 'agt_test';
const ORG_ID = 'org_system';
const MOCK_PUBLIC_KEY_JWK: IPublicKeyJwk = {
kty: 'EC',
crv: 'P-256',
x: 'abc123',
y: 'def456',
};
const MOCK_AGENT_ROW = {
agent_id: AGENT_ID,
organization_id: ORG_ID,
email: 'test@example.com',
agent_type: 'orchestrator',
version: '1.0.0',
capabilities: ['task-planning'],
owner: 'acme',
deployment_env: 'production',
status: 'active',
created_at: new Date('2026-01-01T00:00:00Z'),
updated_at: new Date('2026-01-02T00:00:00Z'),
did: `did:web:${TEST_DOMAIN}:agents:${AGENT_ID}`,
did_created_at: new Date('2026-01-01T00:00:00Z'),
key_id: 'key_test',
public_key_jwk: MOCK_PUBLIC_KEY_JWK,
vault_key_path: 'dev:no-vault',
key_type: 'EC',
curve: 'P-256',
key_created_at: new Date('2026-01-01T00:00:00Z'),
};
const MOCK_DECOMMISSIONED_ROW = {
...MOCK_AGENT_ROW,
status: 'decommissioned',
};
/** Row simulating an agent that has no key yet (LEFT JOIN returns NULLs for key columns). */
const MOCK_AGENT_ROW_NO_KEY = {
agent_id: AGENT_ID,
organization_id: ORG_ID,
email: 'test@example.com',
agent_type: 'orchestrator',
version: '1.0.0',
capabilities: ['task-planning'],
owner: 'acme',
deployment_env: 'production',
status: 'active',
created_at: new Date('2026-01-01T00:00:00Z'),
updated_at: new Date('2026-01-02T00:00:00Z'),
did: null,
did_created_at: null,
key_id: null,
public_key_jwk: null,
vault_key_path: null,
key_type: null,
curve: null,
key_created_at: null,
};
// ─── Helper ───────────────────────────────────────────────────────────────────
function buildService(): DIDService {
return new DIDService(mockPool, null, mockRedis);
}
/** Reset all mocks to clean state before each test. */
function resetMocks(): void {
jest.clearAllMocks();
mockRedisGet.mockResolvedValue(null);
mockRedisSet.mockResolvedValue('OK');
// Default pool.query returns empty result (agent not found)
mockQuery.mockResolvedValue({ rows: [] });
}
// ─── Suite ────────────────────────────────────────────────────────────────────
describe('DIDService', () => {
beforeAll(() => {
process.env['DID_WEB_DOMAIN'] = TEST_DOMAIN;
});
afterAll(() => {
delete process.env['DID_WEB_DOMAIN'];
delete process.env['VAULT_ADDR'];
delete process.env['VAULT_TOKEN'];
});
beforeEach(() => {
resetMocks();
});
// ─── generateDIDForAgent ──────────────────────────────────────────────────
describe('generateDIDForAgent()', () => {
it('should return a did:web DID and public key JWK', async () => {
mockQuery
.mockResolvedValueOnce({ rows: [] }) // INSERT agent_did_keys
.mockResolvedValueOnce({ rows: [] }); // UPDATE agents
const service = buildService();
const { did, publicKeyJwk } = await service.generateDIDForAgent(AGENT_ID, ORG_ID);
expect(did).toBe(`did:web:${TEST_DOMAIN}:agents:${AGENT_ID}`);
expect(publicKeyJwk).toBeDefined();
expect(publicKeyJwk.kty).toBe('EC');
expect(publicKeyJwk.crv).toBe('P-256');
});
it('should call pool.query twice (INSERT + UPDATE)', async () => {
mockQuery
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [] });
const service = buildService();
await service.generateDIDForAgent(AGENT_ID, ORG_ID);
expect(mockQuery).toHaveBeenCalledTimes(2);
});
it('should INSERT into agent_did_keys with correct columns', async () => {
mockQuery
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [] });
const service = buildService();
await service.generateDIDForAgent(AGENT_ID, ORG_ID);
const insertCall = mockQuery.mock.calls[0] as [string, unknown[]];
expect(insertCall[0]).toContain('INSERT INTO agent_did_keys');
expect(insertCall[1]).toContain(AGENT_ID);
expect(insertCall[1]).toContain(ORG_ID);
});
it('should UPDATE agents table with the generated DID', async () => {
mockQuery
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [] });
const service = buildService();
const { did } = await service.generateDIDForAgent(AGENT_ID, ORG_ID);
const updateCall = mockQuery.mock.calls[1] as [string, unknown[]];
expect(updateCall[0]).toContain('UPDATE agents');
expect(updateCall[1]).toContain(did);
expect(updateCall[1]).toContain(AGENT_ID);
});
it('should use dev:no-vault marker when Vault env vars are not set', async () => {
delete process.env['VAULT_ADDR'];
delete process.env['VAULT_TOKEN'];
mockQuery
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [] });
const service = buildService();
await service.generateDIDForAgent(AGENT_ID, ORG_ID);
const insertCall = mockQuery.mock.calls[0] as [string, unknown[]];
// vault_key_path is the 5th param ($5)
expect(insertCall[1][4]).toBe('dev:no-vault');
});
it('should call vault.write when VAULT_ADDR and VAULT_TOKEN are set', async () => {
process.env['VAULT_ADDR'] = 'http://localhost:8200';
process.env['VAULT_TOKEN'] = 'test-token';
const nodeVault = require('node-vault');
const mockVaultInstance = { write: jest.fn().mockResolvedValue({}) };
(nodeVault as jest.Mock).mockReturnValueOnce(mockVaultInstance);
mockQuery
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [] });
const service = buildService();
await service.generateDIDForAgent(AGENT_ID, ORG_ID);
expect(mockVaultInstance.write).toHaveBeenCalledTimes(1);
const [vaultPath, payload] = mockVaultInstance.write.mock.calls[0] as [
string,
{ data: { privateKeyPem: string } },
];
expect(vaultPath).toContain(AGENT_ID);
expect(payload.data.privateKeyPem).toBeDefined();
expect(payload.data.privateKeyPem).toContain('-----BEGIN');
delete process.env['VAULT_ADDR'];
delete process.env['VAULT_TOKEN'];
});
it('should NEVER include the private key PEM in the returned object', async () => {
mockQuery
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [] });
const service = buildService();
const result = await service.generateDIDForAgent(AGENT_ID, ORG_ID);
const serialised = JSON.stringify(result);
expect(serialised).not.toContain('privateKeyPem');
expect(serialised).not.toContain('PRIVATE KEY');
});
});
// ─── buildInstanceDIDDocument ─────────────────────────────────────────────
describe('buildInstanceDIDDocument()', () => {
it('should return a W3C DID Document with correct @context', async () => {
const service = buildService();
const doc = await service.buildInstanceDIDDocument();
expect(doc['@context']).toContain('https://www.w3.org/ns/did/v1');
});
it('should set the DID id to did:web:{domain}', async () => {
const service = buildService();
const doc = await service.buildInstanceDIDDocument();
expect(doc.id).toBe(`did:web:${TEST_DOMAIN}`);
});
it('should include a verificationMethod of type JsonWebKey2020', async () => {
const service = buildService();
const doc = await service.buildInstanceDIDDocument();
expect(doc.verificationMethod).toHaveLength(1);
expect(doc.verificationMethod[0].type).toBe('JsonWebKey2020');
expect(doc.verificationMethod[0].controller).toBe(`did:web:${TEST_DOMAIN}`);
});
it('should include an AgentIdentityProvider service endpoint', async () => {
const service = buildService();
const doc = await service.buildInstanceDIDDocument();
expect(doc.service).toBeDefined();
const svc = doc.service![0];
expect(svc.type).toBe('AgentIdentityProvider');
expect(svc.serviceEndpoint).toContain(TEST_DOMAIN);
});
it('should cache the document in Redis on first call', async () => {
const service = buildService();
await service.buildInstanceDIDDocument();
expect(mockRedisSet).toHaveBeenCalledWith(
'did:doc:instance',
expect.any(String),
{ EX: expect.any(Number) },
);
});
it('should return cached document on second call without storing again', async () => {
const service = buildService();
// First call — cache miss, builds and stores
const doc = await service.buildInstanceDIDDocument();
// Reset and simulate cache hit on second call
resetMocks();
mockRedisGet.mockResolvedValueOnce(JSON.stringify(doc));
await service.buildInstanceDIDDocument();
// Only called once total for the second call
expect(mockRedisGet).toHaveBeenCalledTimes(1);
// set should NOT be called on cache hit
expect(mockRedisSet).not.toHaveBeenCalled();
});
it('should throw an error when DID_WEB_DOMAIN is not set', async () => {
delete process.env['DID_WEB_DOMAIN'];
const service = buildService();
await expect(service.buildInstanceDIDDocument()).rejects.toThrow(
'DID_WEB_DOMAIN environment variable is required',
);
process.env['DID_WEB_DOMAIN'] = TEST_DOMAIN;
});
});
// ─── buildAgentDIDDocument ────────────────────────────────────────────────
describe('buildAgentDIDDocument()', () => {
it('should return a DID Document for an active agent', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });
const service = buildService();
const result = await service.buildAgentDIDDocument(AGENT_ID);
expect(result.deactivated).toBe(false);
expect(result.document.id).toBe(`did:web:${TEST_DOMAIN}:agents:${AGENT_ID}`);
});
it('should include DID contexts for active agent', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });
const service = buildService();
const { document } = await service.buildAgentDIDDocument(AGENT_ID);
expect(document['@context']).toContain('https://www.w3.org/ns/did/v1');
expect(document['@context']).toContain('https://w3id.org/agntcy/v1');
});
it('should include the public key in verificationMethod', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });
const service = buildService();
const { document } = await service.buildAgentDIDDocument(AGENT_ID);
expect(document.verificationMethod).toHaveLength(1);
expect(document.verificationMethod[0].publicKeyJwk).toEqual(MOCK_PUBLIC_KEY_JWK);
});
it('should include AGNTCY extension with agent fields', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });
const service = buildService();
const { document } = await service.buildAgentDIDDocument(AGENT_ID);
expect(document.agntcy).toBeDefined();
expect(document.agntcy!.agentId).toBe(AGENT_ID);
expect(document.agntcy!.agentType).toBe('orchestrator');
expect(document.agntcy!.capabilities).toEqual(['task-planning']);
});
it('should return deactivated=true for a decommissioned agent', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_DECOMMISSIONED_ROW] });
const service = buildService();
const result = await service.buildAgentDIDDocument(AGENT_ID);
expect(result.deactivated).toBe(true);
});
it('should include AgentStatus service endpoint for decommissioned agent', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_DECOMMISSIONED_ROW] });
const service = buildService();
const { document } = await service.buildAgentDIDDocument(AGENT_ID);
expect(document.service).toBeDefined();
const statusSvc = document.service!.find((s) => s.type === 'AgentStatus');
expect(statusSvc).toBeDefined();
expect(statusSvc!.serviceEndpoint).toBe('decommissioned');
});
it('should NOT cache document for decommissioned agent', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_DECOMMISSIONED_ROW] });
const service = buildService();
await service.buildAgentDIDDocument(AGENT_ID);
expect(mockRedisSet).not.toHaveBeenCalled();
});
it('should cache document for active agent', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });
const service = buildService();
await service.buildAgentDIDDocument(AGENT_ID);
expect(mockRedisSet).toHaveBeenCalledWith(
`did:doc:${AGENT_ID}`,
expect.any(String),
{ EX: expect.any(Number) },
);
});
it('should use cached document on second call for active agent', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });
const service = buildService();
const { document: firstDoc } = await service.buildAgentDIDDocument(AGENT_ID);
// Reset and simulate cache hit for second call
resetMocks();
mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });
mockRedisGet.mockResolvedValueOnce(JSON.stringify(firstDoc));
const { document: secondDoc } = await service.buildAgentDIDDocument(AGENT_ID);
expect(secondDoc).toEqual(firstDoc);
// set should NOT be called on cache hit
expect(mockRedisSet).not.toHaveBeenCalled();
});
it('should throw AgentNotFoundError when agent does not exist', async () => {
// Default mock already returns { rows: [] } from resetMocks
const service = buildService();
await expect(service.buildAgentDIDDocument('nonexistent-id')).rejects.toThrow(
AgentNotFoundError,
);
});
it('should handle agent with no key (empty verificationMethod)', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW_NO_KEY] });
const service = buildService();
const { document } = await service.buildAgentDIDDocument(AGENT_ID);
expect(document.verificationMethod).toHaveLength(0);
expect(document.authentication).toHaveLength(0);
expect(document.assertionMethod).toHaveLength(0);
});
it('should NEVER include private key material in the DID Document', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });
const service = buildService();
const { document } = await service.buildAgentDIDDocument(AGENT_ID);
const serialised = JSON.stringify(document);
expect(serialised).not.toContain('privateKeyPem');
expect(serialised).not.toContain('PRIVATE KEY');
});
});
// ─── buildResolutionResult ────────────────────────────────────────────────
describe('buildResolutionResult()', () => {
it('should return a W3C DID Resolution result with all required sections', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });
const service = buildService();
const result = await service.buildResolutionResult(AGENT_ID);
expect(result.didDocument).toBeDefined();
expect(result.didDocumentMetadata).toBeDefined();
expect(result.didResolutionMetadata).toBeDefined();
});
it('should set deactivated=false in metadata for active agent', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });
const service = buildService();
const result = await service.buildResolutionResult(AGENT_ID);
expect(result.didDocumentMetadata.deactivated).toBe(false);
});
it('should set deactivated=true in metadata for decommissioned agent', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_DECOMMISSIONED_ROW] });
const service = buildService();
const result = await service.buildResolutionResult(AGENT_ID);
expect(result.didDocumentMetadata.deactivated).toBe(true);
});
it('should set contentType to application/did+ld+json in resolutionMetadata', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });
const service = buildService();
const result = await service.buildResolutionResult(AGENT_ID);
expect(result.didResolutionMetadata.contentType).toBe('application/did+ld+json');
});
it('should include ISO timestamps for created, updated, and retrieved', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });
const service = buildService();
const result = await service.buildResolutionResult(AGENT_ID);
expect(() => new Date(result.didDocumentMetadata.created)).not.toThrow();
expect(() => new Date(result.didDocumentMetadata.updated)).not.toThrow();
expect(() => new Date(result.didResolutionMetadata.retrieved)).not.toThrow();
});
it('should throw AgentNotFoundError when agent does not exist', async () => {
// Default mock already returns { rows: [] } from resetMocks
const service = buildService();
await expect(service.buildResolutionResult('nonexistent-id')).rejects.toThrow(
AgentNotFoundError,
);
});
it('should NEVER include private key material in the resolution result', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });
const service = buildService();
const result = await service.buildResolutionResult(AGENT_ID);
const serialised = JSON.stringify(result);
expect(serialised).not.toContain('privateKeyPem');
expect(serialised).not.toContain('PRIVATE KEY');
});
});
// ─── buildAgentCard ───────────────────────────────────────────────────────
describe('buildAgentCard()', () => {
it('should return an AGNTCY agent card with correct fields', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });
const service = buildService();
const card = await service.buildAgentCard(AGENT_ID);
expect(card.did).toBe(`did:web:${TEST_DOMAIN}:agents:${AGENT_ID}`);
expect(card.name).toBe('test@example.com');
expect(card.agentType).toBe('orchestrator');
expect(card.capabilities).toEqual(['task-planning']);
expect(card.owner).toBe('acme');
expect(card.version).toBe('1.0.0');
expect(card.deploymentEnv).toBe('production');
expect(card.identityProvider).toBe('https://idp.sentryagent.ai');
});
it('should include a valid ISO issuedAt timestamp', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });
const service = buildService();
const card = await service.buildAgentCard(AGENT_ID);
expect(() => new Date(card.issuedAt)).not.toThrow();
expect(new Date(card.issuedAt).toISOString()).toBe(card.issuedAt);
});
it('should use agent.createdAt as issuedAt when key_created_at is null', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW_NO_KEY] });
const service = buildService();
const card = await service.buildAgentCard(AGENT_ID);
expect(card.issuedAt).toBe(MOCK_AGENT_ROW_NO_KEY.created_at.toISOString());
});
it('should use key_created_at as issuedAt when present', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });
const service = buildService();
const card = await service.buildAgentCard(AGENT_ID);
expect(card.issuedAt).toBe(MOCK_AGENT_ROW.key_created_at.toISOString());
});
it('should throw AgentNotFoundError when agent does not exist', async () => {
// Default mock already returns { rows: [] } from resetMocks
const service = buildService();
await expect(service.buildAgentCard('nonexistent-id')).rejects.toThrow(AgentNotFoundError);
});
it('should NEVER include private key material in the agent card', async () => {
mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });
const service = buildService();
const card = await service.buildAgentCard(AGENT_ID);
const serialised = JSON.stringify(card);
expect(serialised).not.toContain('privateKeyPem');
expect(serialised).not.toContain('PRIVATE KEY');
});
});
});