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>
333 lines
13 KiB
TypeScript
333 lines
13 KiB
TypeScript
/**
|
|
* 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');
|
|
});
|
|
});
|
|
});
|