WS3 — Advanced Analytics Dashboard: - DB migration: analytics_events table (tenant_id, date, metric_type, count) - AnalyticsService: recordEvent (fire-and-forget), getTokenTrend, getAgentActivity, getAgentUsageSummary - Analytics hooks in OAuth2Service (token_issued) and AgentService (agent_registered/deactivated) - AnalyticsController + routes/analytics.ts (gated by ANALYTICS_ENABLED flag) - Portal: TokenTrendChart (recharts LineChart), AgentHeatmap (recharts heatmap), /analytics page WS4 — API Gateway Tiers: - DB migration: tenant_tiers table; src/config/tiers.ts (free/pro/enterprise limits) - TierService: getStatus, initiateUpgrade (Stripe), applyUpgrade; TierLimitError in errors.ts - tierEnforcement middleware (Redis-backed daily call/token counters; TIER_ENFORCEMENT flag) - Agent count enforcement in AgentService.create() - Stripe webhook updated to call TierService.applyUpgrade() on checkout.session.completed - TierController + routes/tiers.ts; Portal: /settings/tier page with upgrade flow WS6 — AGNTCY Compliance Certification: - ComplianceService: generateReport() (Redis-cached 5 min), exportAgentCards() - Compliance sections: agent-identity (DID + credential expiry checks), audit-trail (Merkle chain) - ComplianceController updated with getComplianceReport, exportAgentCards handlers - routes/compliance.ts: new AGNTCY routes (gated by COMPLIANCE_ENABLED flag); SOC2 routes unaffected QA: - 28 new unit tests: AnalyticsService (8), TierService (9), ComplianceService (11) — all pass - 673 total unit tests passing; 0 TypeScript errors across API and portal - AGNTCY conformance test suite at tests/agntcy-conformance/ (4 protocol tests) - Portal builds cleanly: 9 routes including /analytics and /settings/tier - Feature flags verified: ANALYTICS_ENABLED, TIER_ENFORCEMENT, COMPLIANCE_ENABLED Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
272 lines
11 KiB
TypeScript
272 lines
11 KiB
TypeScript
/**
|
|
* Unit tests for src/services/ComplianceService.ts
|
|
*/
|
|
|
|
import { Pool, QueryResult } from 'pg';
|
|
import type { RedisClientType } from 'redis';
|
|
import { ComplianceService } from '../../../src/services/ComplianceService';
|
|
|
|
// ── Mock AuditVerificationService (instantiated internally by ComplianceService) ──
|
|
|
|
jest.mock('../../../src/services/AuditVerificationService', () => {
|
|
return {
|
|
AuditVerificationService: jest.fn().mockImplementation(() => ({
|
|
verifyChain: jest.fn().mockResolvedValue({
|
|
verified: true,
|
|
checkedCount: 42,
|
|
brokenAtEventId: null,
|
|
}),
|
|
})),
|
|
};
|
|
});
|
|
|
|
// ── Re-import after mock is established ──────────────────────────────────────
|
|
import { AuditVerificationService } from '../../../src/services/AuditVerificationService';
|
|
|
|
const MockAuditVerificationService = AuditVerificationService as jest.MockedClass<
|
|
typeof AuditVerificationService
|
|
>;
|
|
|
|
// ── Mock helpers ──────────────────────────────────────────────────────────────
|
|
|
|
function makePool(queryFn: jest.Mock): Pool {
|
|
return { query: queryFn } as unknown as Pool;
|
|
}
|
|
|
|
function makeRedis(overrides: Partial<{
|
|
getFn: jest.Mock;
|
|
setFn: jest.Mock;
|
|
}>): RedisClientType {
|
|
return {
|
|
get: overrides.getFn ?? jest.fn().mockResolvedValue(null),
|
|
set: overrides.setFn ?? jest.fn().mockResolvedValue('OK'),
|
|
} as unknown as RedisClientType;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// ComplianceService
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
describe('ComplianceService', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
|
|
// Reset AuditVerificationService mock to default passing behaviour
|
|
MockAuditVerificationService.mockImplementation(
|
|
() =>
|
|
({
|
|
verifyChain: jest.fn().mockResolvedValue({
|
|
verified: true,
|
|
checkedCount: 42,
|
|
brokenAtEventId: null,
|
|
}),
|
|
}) as unknown as InstanceType<typeof AuditVerificationService>,
|
|
);
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// generateReport() — cache miss
|
|
// ────────────────────────────────────────────────────────────────
|
|
|
|
describe('generateReport() — cache miss', () => {
|
|
it('should build a report, store it in Redis, and return IComplianceReport structure', async () => {
|
|
// Cache miss → null
|
|
const getFn = jest.fn().mockResolvedValue(null);
|
|
const setFn = jest.fn().mockResolvedValue('OK');
|
|
|
|
// Pool returns empty agents list → agent-identity section passes trivially
|
|
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
|
|
|
|
const service = new ComplianceService(makePool(mockQuery), makeRedis({ getFn, setFn }));
|
|
const report = await service.generateReport('tenant-1');
|
|
|
|
// Redis cache miss check
|
|
expect(getFn).toHaveBeenCalledWith('compliance:report:tenant-1');
|
|
|
|
// Report stored in Redis after build
|
|
expect(setFn).toHaveBeenCalledWith(
|
|
'compliance:report:tenant-1',
|
|
expect.any(String),
|
|
expect.objectContaining({ EX: 300 }),
|
|
);
|
|
|
|
// IComplianceReport structure
|
|
expect(report).toMatchObject({
|
|
tenant_id: 'tenant-1',
|
|
agntcy_schema_version: '1.0',
|
|
sections: expect.any(Array),
|
|
overall_status: expect.stringMatching(/^(pass|fail|warn)$/),
|
|
generated_at: expect.any(String),
|
|
});
|
|
|
|
// from_cache should be absent on a freshly built report
|
|
expect(report.from_cache).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// generateReport() — cache hit
|
|
// ────────────────────────────────────────────────────────────────
|
|
|
|
describe('generateReport() — cache hit', () => {
|
|
it('should return cached data with from_cache: true', async () => {
|
|
const cachedReport = {
|
|
generated_at: '2026-04-04T00:00:00.000Z',
|
|
tenant_id: 'tenant-cache',
|
|
agntcy_schema_version: '1.0',
|
|
sections: [{ name: 'agent-identity', status: 'pass', details: 'All good.' }],
|
|
overall_status: 'pass',
|
|
};
|
|
|
|
const getFn = jest.fn().mockResolvedValue(JSON.stringify(cachedReport));
|
|
const setFn = jest.fn();
|
|
const mockQuery = jest.fn();
|
|
|
|
const service = new ComplianceService(makePool(mockQuery), makeRedis({ getFn, setFn }));
|
|
const report = await service.generateReport('tenant-cache');
|
|
|
|
expect(report.from_cache).toBe(true);
|
|
expect(report.tenant_id).toBe('tenant-cache');
|
|
expect(report.overall_status).toBe('pass');
|
|
// No DB queries should be made on a cache hit
|
|
expect(mockQuery).not.toHaveBeenCalled();
|
|
// Redis set should not be called either
|
|
expect(setFn).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// generateReport() — overall_status rollup
|
|
// ────────────────────────────────────────────────────────────────
|
|
|
|
describe('generateReport() — overall_status', () => {
|
|
it('should return overall_status: pass when all sections pass', async () => {
|
|
const getFn = jest.fn().mockResolvedValue(null);
|
|
const setFn = jest.fn().mockResolvedValue('OK');
|
|
|
|
// No agents → agent-identity passes trivially
|
|
// AuditVerificationService mock returns verified: true (default in beforeEach)
|
|
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
|
|
|
|
const service = new ComplianceService(makePool(mockQuery), makeRedis({ getFn, setFn }));
|
|
const report = await service.generateReport('tenant-all-pass');
|
|
|
|
expect(report.overall_status).toBe('pass');
|
|
});
|
|
|
|
it('should return overall_status: fail when any section fails', async () => {
|
|
const getFn = jest.fn().mockResolvedValue(null);
|
|
const setFn = jest.fn().mockResolvedValue('OK');
|
|
|
|
// Override AuditVerificationService to simulate broken chain → audit-trail fails
|
|
MockAuditVerificationService.mockImplementation(
|
|
() =>
|
|
({
|
|
verifyChain: jest.fn().mockResolvedValue({
|
|
verified: false,
|
|
checkedCount: 10,
|
|
brokenAtEventId: 'event-uuid-broken',
|
|
}),
|
|
}) as unknown as InstanceType<typeof AuditVerificationService>,
|
|
);
|
|
|
|
// No agents so agent-identity is 'pass'; audit-trail will be 'fail'
|
|
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
|
|
|
|
const service = new ComplianceService(makePool(mockQuery), makeRedis({ getFn, setFn }));
|
|
const report = await service.generateReport('tenant-fail');
|
|
|
|
expect(report.overall_status).toBe('fail');
|
|
const auditSection = report.sections.find((s) => s.name === 'audit-trail');
|
|
expect(auditSection?.status).toBe('fail');
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// exportAgentCards()
|
|
// ────────────────────────────────────────────────────────────────
|
|
|
|
describe('exportAgentCards()', () => {
|
|
it('should return an array of IAgentCard objects with correct fields', async () => {
|
|
const createdAt = new Date('2026-01-15T12:00:00Z');
|
|
const agentRows = [
|
|
{
|
|
agent_id: 'agent-uuid-1',
|
|
owner: 'team-alpha',
|
|
capabilities: ['agents:read', 'tokens:issue'],
|
|
created_at: createdAt,
|
|
did: 'did:web:sentryagent.ai:agent-uuid-1',
|
|
},
|
|
{
|
|
agent_id: 'agent-uuid-2',
|
|
owner: 'team-beta',
|
|
capabilities: ['agents:read'],
|
|
created_at: createdAt,
|
|
did: null, // no DID — id should fall back to agent_id
|
|
},
|
|
];
|
|
|
|
const mockQuery = jest.fn().mockResolvedValue({
|
|
rows: agentRows,
|
|
} as unknown as QueryResult);
|
|
|
|
const service = new ComplianceService(
|
|
makePool(mockQuery),
|
|
makeRedis({ getFn: jest.fn(), setFn: jest.fn() }),
|
|
);
|
|
const cards = await service.exportAgentCards('tenant-1');
|
|
|
|
expect(cards).toHaveLength(2);
|
|
|
|
// Card with DID uses DID as id
|
|
expect(cards[0]).toEqual({
|
|
id: 'did:web:sentryagent.ai:agent-uuid-1',
|
|
name: 'team-alpha',
|
|
capabilities: ['agents:read', 'tokens:issue'],
|
|
endpoint: 'https://api.sentryagent.ai/agents/agent-uuid-1',
|
|
created_at: createdAt.toISOString(),
|
|
agntcy_schema_version: '1.0',
|
|
});
|
|
|
|
// Card without DID falls back to agent_id as id
|
|
expect(cards[1]).toEqual({
|
|
id: 'agent-uuid-2',
|
|
name: 'team-beta',
|
|
capabilities: ['agents:read'],
|
|
endpoint: 'https://api.sentryagent.ai/agents/agent-uuid-2',
|
|
created_at: createdAt.toISOString(),
|
|
agntcy_schema_version: '1.0',
|
|
});
|
|
});
|
|
|
|
it('should return an empty array when no active agents exist', async () => {
|
|
const mockQuery = jest.fn().mockResolvedValue({
|
|
rows: [],
|
|
} as unknown as QueryResult);
|
|
|
|
const service = new ComplianceService(
|
|
makePool(mockQuery),
|
|
makeRedis({ getFn: jest.fn(), setFn: jest.fn() }),
|
|
);
|
|
const cards = await service.exportAgentCards('tenant-empty');
|
|
|
|
expect(cards).toEqual([]);
|
|
});
|
|
|
|
it('should query only non-decommissioned agents scoped to the tenantId', async () => {
|
|
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
|
|
|
|
const service = new ComplianceService(
|
|
makePool(mockQuery),
|
|
makeRedis({ getFn: jest.fn(), setFn: jest.fn() }),
|
|
);
|
|
await service.exportAgentCards('tenant-scope-test');
|
|
|
|
expect(mockQuery).toHaveBeenCalledWith(
|
|
expect.stringContaining("status != 'decommissioned'"),
|
|
['tenant-scope-test'],
|
|
);
|
|
});
|
|
});
|
|
});
|