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:
332
tests/unit/controllers/DIDController.test.ts
Normal file
332
tests/unit/controllers/DIDController.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
591
tests/unit/services/DIDService.test.ts
Normal file
591
tests/unit/services/DIDService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user