Files
certctl/web/src/api/client.test.ts
T
shankar0123 be5f70b669 refactor(web): drop unused imports (CodeQL #5 + #10)
Two CodeQL js/unused-local-variable alerts in one sweep — both
Note severity, both pure dead-import cleanup.

Alert #10 (web/src/pages/NotificationsPage.tsx:8):
  formatDateTime imported but only timeAgo used. Verified via
  repo-wide grep — formatDateTime appears on the import line only.
  Drop from the import statement; leave timeAgo in place.

Alert #5 (web/src/api/client.test.ts:2):
  Five unused imports in the test file's import block (the test
  file imports nearly the full API client surface):
    - acknowledgeHealthCheck
    - createPolicy
    - deleteHealthCheck
    - getHealthCheckHistory
    - updateHealthCheck
  Each appears only on the import line — verified via grep -c.
  Removing them doesn't change test coverage (the corresponding
  client functions are exported and exercised in their own tests
  elsewhere, but the integration covered by client.test.ts doesn't
  reach them yet).

Verified locally:
  tsc --noEmit: exit 0.
  grep -c on each removed symbol in its file: 0 occurrences.
  No behavioral change — pure import-list cleanup.

References:
  https://github.com/certctl-io/certctl/security/code-scanning/10
  https://github.com/certctl-io/certctl/security/code-scanning/5
Closes both alerts.
2026-05-04 05:11:23 +00:00

1518 lines
60 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
setApiKey,
getApiKey,
checkAuth,
getCertificates,
getCertificate,
getCertificateVersions,
createCertificate,
triggerRenewal,
triggerDeployment,
updateCertificate,
archiveCertificate,
revokeCertificate,
bulkRevokeCertificates,
downloadCertificatePEM,
exportCertificatePKCS12,
getAgents,
getAgent,
registerAgent,
retireAgent,
listRetiredAgents,
getJobs,
cancelJob,
approveRenewal,
rejectRenewal,
getNotifications,
markNotificationRead,
getAuditEvents,
getPolicies,
updatePolicy,
deletePolicy,
getPolicyViolations,
getRenewalPolicies,
createRenewalPolicy,
updateRenewalPolicy,
deleteRenewalPolicy,
getIssuers,
createIssuer,
testIssuerConnection,
deleteIssuer,
getTargets,
createTarget,
deleteTarget,
testTargetConnection,
getProfiles,
getProfile,
createProfile,
updateProfile,
deleteProfile,
getOwners,
getOwner,
createOwner,
updateOwner,
deleteOwner,
getTeams,
getTeam,
createTeam,
updateTeam,
deleteTeam,
getAgentGroups,
getAgentGroup,
createAgentGroup,
updateAgentGroup,
deleteAgentGroup,
getAgentGroupMembers,
getHealth,
getDashboardSummary,
getCertificatesByStatus,
getExpirationTimeline,
getJobTrends,
getIssuanceRate,
getMetrics,
getDiscoveredCertificates,
getDiscoveredCertificate,
claimDiscoveredCertificate,
dismissDiscoveredCertificate,
getDiscoveryScans,
getDiscoverySummary,
getNetworkScanTargets,
getNetworkScanTarget,
createNetworkScanTarget,
updateNetworkScanTarget,
deleteNetworkScanTarget,
triggerNetworkScan,
previewDigest,
sendDigest,
getJob,
getJobVerification,
getIssuer,
getTarget,
getPrometheusMetrics,
getCertificateDeployments,
getOCSPStatus,
updateIssuer,
updateTarget,
getPolicy,
listHealthChecks,
getHealthCheck,
createHealthCheck,
getHealthCheckSummary,
} from './client';
// Mock global fetch
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;
function mockJsonResponse(data: unknown, status = 200) {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(data),
statusText: 'OK',
} as Response);
}
function mockErrorResponse(status: number, body: { message?: string; error?: string } = {}) {
return Promise.resolve({
ok: false,
status,
json: () => Promise.resolve(body),
statusText: 'Error',
} as Response);
}
describe('API Client', () => {
beforeEach(() => {
mockFetch.mockReset();
setApiKey(null);
});
// ─── Auth ───────────────────────────────────────────
describe('API Key management', () => {
it('stores and retrieves API key', () => {
expect(getApiKey()).toBeNull();
setApiKey('test-key-123');
expect(getApiKey()).toBe('test-key-123');
});
it('clears API key', () => {
setApiKey('test-key');
setApiKey(null);
expect(getApiKey()).toBeNull();
});
});
describe('Auth headers', () => {
it('sends Authorization header when API key is set', async () => {
setApiKey('my-secret-key');
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getCertificates();
const [, init] = mockFetch.mock.calls[0];
expect(init.headers['Authorization']).toBe('Bearer my-secret-key');
expect(init.headers['Content-Type']).toBe('application/json');
});
it('omits Authorization header when no API key', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getCertificates();
const [, init] = mockFetch.mock.calls[0];
expect(init.headers['Authorization']).toBeUndefined();
expect(init.headers['Content-Type']).toBe('application/json');
});
it('dispatches auth-required event on 401', async () => {
const listener = vi.fn();
window.addEventListener('certctl:auth-required', listener);
mockFetch.mockReturnValueOnce(mockErrorResponse(401));
await expect(getCertificates()).rejects.toThrow('Authentication required');
expect(listener).toHaveBeenCalled();
window.removeEventListener('certctl:auth-required', listener);
});
});
// ─── checkAuth (M-003: surfaces user + admin) ──────
describe('checkAuth', () => {
// Post-M-003 /auth/check returns {status, user, admin}. The admin flag drives
// GUI gating of admin-only affordances (bulk revoke, etc.). Authoritative
// enforcement lives server-side — this test only pins the contract the
// AuthProvider depends on.
it('returns {status, user, admin} shape and sends Bearer token', async () => {
mockFetch.mockReturnValueOnce(
mockJsonResponse({ status: 'authenticated', user: 'ops-admin', admin: true }),
);
const resp = await checkAuth('test-api-key');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/auth/check');
expect(init.headers['Authorization']).toBe('Bearer test-api-key');
expect(init.headers['Content-Type']).toBe('application/json');
expect(resp.status).toBe('authenticated');
expect(resp.user).toBe('ops-admin');
expect(resp.admin).toBe(true);
});
it('returns admin=false for non-admin callers', async () => {
mockFetch.mockReturnValueOnce(
mockJsonResponse({ status: 'authenticated', user: 'alice', admin: false }),
);
const resp = await checkAuth('alice-key');
expect(resp.user).toBe('alice');
expect(resp.admin).toBe(false);
});
it('throws on invalid API key', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(401));
await expect(checkAuth('bad-key')).rejects.toThrow('Invalid API key');
});
});
// ─── Error handling ─────────────────────────────────
describe('Error handling', () => {
it('throws with server error message', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(400, { message: 'Invalid request' }));
await expect(getCertificates()).rejects.toThrow('Invalid request');
});
it('throws with error field from response', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(500, { error: 'Internal error' }));
await expect(getCertificates()).rejects.toThrow('Internal error');
});
it('falls back to HTTP status text', async () => {
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: false,
status: 503,
json: () => Promise.reject(new Error('not json')),
statusText: 'Service Unavailable',
} as Response),
);
await expect(getCertificates()).rejects.toThrow('Service Unavailable');
});
});
// ─── Certificates ───────────────────────────────────
describe('Certificates', () => {
it('getCertificates sends GET with default pagination', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getCertificates();
expect(mockFetch).toHaveBeenCalledWith(
'/api/v1/certificates?page=1&per_page=50',
expect.objectContaining({ headers: expect.any(Object) }),
);
});
it('getCertificates passes filter params', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getCertificates({ status: 'Active', environment: 'production' });
const url = mockFetch.mock.calls[0][0] as string;
expect(url).toContain('status=Active');
expect(url).toContain('environment=production');
});
it('getCertificate fetches single cert by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'mc-test', common_name: 'test.com' }));
const cert = await getCertificate('mc-test');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/certificates/mc-test');
expect(cert.id).toBe('mc-test');
});
it('getCertificateVersions fetches versions', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getCertificateVersions('mc-test');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/certificates/mc-test/versions');
});
it('createCertificate sends POST with body', async () => {
const certData = { common_name: 'new.example.com', issuer_id: 'iss-local' };
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'mc-new', ...certData }));
await createCertificate(certData);
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/certificates');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual(certData);
});
// C-001 scope-expansion regression: the OnboardingWizard CertificateStep
// and the CertificatesPage CreateCertificateModal must both ship the full
// six-field required payload (name, common_name, renewal_policy_id,
// issuer_id, owner_id, team_id) — the handler's ValidateRequired contract
// rejects anything less with HTTP 400. This test pins the wire shape so
// that accidentally dropping a field from either UI surface fails CI
// rather than only surfacing as a 400 at runtime.
it('createCertificate accepts and transmits all six required fields', async () => {
const wizardPayload = {
name: 'API Production Cert',
common_name: 'api.example.com',
sans: ['www.example.com'],
issuer_id: 'iss-local',
owner_id: 'o-alice',
team_id: 't-platform',
renewal_policy_id: 'rp-standard',
environment: 'production',
};
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'mc-new', ...wizardPayload }));
await createCertificate(wizardPayload);
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/certificates');
expect(init.method).toBe('POST');
const body = JSON.parse(init.body);
// Assert every required field is present and intact
expect(body.name).toBe('API Production Cert');
expect(body.common_name).toBe('api.example.com');
expect(body.issuer_id).toBe('iss-local');
expect(body.owner_id).toBe('o-alice');
expect(body.team_id).toBe('t-platform');
expect(body.renewal_policy_id).toBe('rp-standard');
});
it('updateCertificate sends PUT', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'mc-test', status: 'Active' }));
await updateCertificate('mc-test', { status: 'Active' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/certificates/mc-test');
expect(init.method).toBe('PUT');
});
it('archiveCertificate sends DELETE', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'archived' }));
await archiveCertificate('mc-test');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/certificates/mc-test');
expect(init.method).toBe('DELETE');
});
it('triggerRenewal sends POST', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'renewal triggered' }));
await triggerRenewal('mc-test');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/certificates/mc-test/renew');
expect(init.method).toBe('POST');
});
it('triggerDeployment sends POST with target_id', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deployment triggered' }));
await triggerDeployment('mc-test', 't-nginx');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/certificates/mc-test/deploy');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({ target_id: 't-nginx' });
});
it('revokeCertificate sends POST with reason', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ status: 'revoked' }));
await revokeCertificate('mc-test', 'keyCompromise');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/certificates/mc-test/revoke');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({ reason: 'keyCompromise' });
});
it('bulkRevokeCertificates sends POST with criteria', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ total_matched: 3, total_revoked: 2, total_skipped: 1, total_failed: 0 }));
await bulkRevokeCertificates({ reason: 'keyCompromise', profile_id: 'prof-tls', certificate_ids: ['mc-1', 'mc-2'] });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/certificates/bulk-revoke');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({ reason: 'keyCompromise', profile_id: 'prof-tls', certificate_ids: ['mc-1', 'mc-2'] });
});
});
// ─── Agents ─────────────────────────────────────────
describe('Agents', () => {
it('getAgents sends GET with pagination', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getAgents();
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/agents?page=1&per_page=50');
});
it('getAgent fetches by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'a-web01', name: 'web01' }));
const agent = await getAgent('a-web01');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/agents/a-web01');
expect(agent.id).toBe('a-web01');
});
it('registerAgent sends POST', async () => {
const agentData = { name: 'new-agent', hostname: 'host01' };
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'a-new', ...agentData }));
await registerAgent(agentData);
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/agents');
expect(init.method).toBe('POST');
});
});
// ─── Agent Retirement (I-004) ───────────────────────
//
// These tests pin the GUI's retirement contract against what the backend
// will add in Phase 2b: soft-retire via DELETE, force-cascade via
// ?force=true&reason=..., idempotent 204 on already-retired, 409 blocked
// payload with counts, and a GET /agents/retired listing surface.
//
// All compile-fail until client.ts exports retireAgent + listRetiredAgents
// — the shape of those exports is pinned here rather than assumed.
describe('Agent Retirement (I-004)', () => {
it('retireAgent sends DELETE without query when no force/reason', async () => {
mockFetch.mockReturnValueOnce(
mockJsonResponse({
retired_at: '2026-04-18T12:00:00Z',
already_retired: false,
cascade: false,
}),
);
await retireAgent('ag-1');
const [url, init] = mockFetch.mock.calls[0];
// Default soft-retire: bare path, no stray ? suffix.
expect(url).toBe('/api/v1/agents/ag-1');
expect(init.method).toBe('DELETE');
});
it('retireAgent propagates force+reason as URL query parameters', async () => {
mockFetch.mockReturnValueOnce(
mockJsonResponse({
retired_at: '2026-04-18T12:00:00Z',
already_retired: false,
cascade: true,
counts: { active_targets: 3, active_certificates: 7, pending_jobs: 2 },
}),
);
await retireAgent('ag-1', { force: true, reason: 'decommissioning rack 7' });
const [url, init] = mockFetch.mock.calls[0];
// URLSearchParams encodes space as "+"; "decommissioning rack 7" → "decommissioning+rack+7"
expect(url).toBe(
'/api/v1/agents/ag-1?force=true&reason=decommissioning+rack+7',
);
expect(init.method).toBe('DELETE');
});
it('retireAgent omits force=false even when reason is supplied', async () => {
// Client-side guard: the server's 400 ErrForceReasonRequired is the
// fallback; the GUI should never silently promote reason-without-force
// into a force call. Pins that reason-only still hits the soft path.
mockFetch.mockReturnValueOnce(
mockJsonResponse({
retired_at: '2026-04-18T12:00:00Z',
already_retired: false,
cascade: false,
}),
);
await retireAgent('ag-1', { reason: 'routine decommission' });
const [url] = mockFetch.mock.calls[0];
// force defaults to false → query carries reason only.
expect(url).toBe('/api/v1/agents/ag-1?reason=routine+decommission');
});
it('retireAgent surfaces the 409 dependency error message to the caller', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(409, {
message: 'agent has 3 active targets, 7 active certificates, 2 pending jobs',
}),
);
await expect(retireAgent('ag-1')).rejects.toThrow(
/active targets|active certificates|pending jobs/,
);
});
it('retireAgent treats 204 (already-retired) as success with empty body', async () => {
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 204,
json: () => Promise.reject(new Error('204 has no body')),
statusText: 'No Content',
} as Response),
);
// fetchJSON normalises 204 to {} — caller must not crash.
const result = await retireAgent('ag-1');
expect(result).toBeDefined();
});
it('listRetiredAgents sends GET /agents/retired with default pagination', async () => {
mockFetch.mockReturnValueOnce(
mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }),
);
await listRetiredAgents();
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/agents/retired?page=1&per_page=50');
// Default is GET — no explicit method means fetchJSON falls through.
expect(init.method ?? 'GET').toBe('GET');
});
it('listRetiredAgents forwards page/per_page overrides', async () => {
mockFetch.mockReturnValueOnce(
mockJsonResponse({ data: [], total: 0, page: 2, per_page: 100 }),
);
await listRetiredAgents({ page: '2', per_page: '100' });
const [url] = mockFetch.mock.calls[0];
expect(url).toContain('page=2');
expect(url).toContain('per_page=100');
});
});
// ─── Jobs ───────────────────────────────────────────
describe('Jobs', () => {
it('getJobs sends GET with filters', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getJobs({ status: 'Pending', type: 'Renewal' });
const url = mockFetch.mock.calls[0][0] as string;
expect(url).toContain('status=Pending');
expect(url).toContain('type=Renewal');
});
it('cancelJob sends POST', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'cancelled' }));
await cancelJob('job-123');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/jobs/job-123/cancel');
expect(init.method).toBe('POST');
});
});
// ─── Notifications ──────────────────────────────────
describe('Notifications', () => {
it('getNotifications sends GET', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getNotifications();
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/notifications');
});
it('markNotificationRead sends POST with auth headers', async () => {
setApiKey('test-key');
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'marked as read' }));
await markNotificationRead('notif-123');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/notifications/notif-123/read');
expect(init.method).toBe('POST');
expect(init.headers['Authorization']).toBe('Bearer test-key');
});
});
// ─── Policies ───────────────────────────────────────
describe('Policies', () => {
it('getPolicies sends GET', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getPolicies();
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/policies');
});
it('updatePolicy sends PUT with partial data', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'pol-1', enabled: false }));
await updatePolicy('pol-1', { enabled: false });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/policies/pol-1');
expect(init.method).toBe('PUT');
expect(JSON.parse(init.body)).toEqual({ enabled: false });
});
it('deletePolicy sends DELETE', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deleted' }));
await deletePolicy('pol-1');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/policies/pol-1');
expect(init.method).toBe('DELETE');
});
});
// ─── Renewal Policies (G-1) ─────────────────────────
// Distinct from compliance Policies above. Populates the
// `renewal_policy_id` dropdown on OnboardingWizard + CertificatesPage +
// CertificateDetailPage.InlinePolicyEditor. Hits `/api/v1/renewal-policies`.
describe('RenewalPolicies', () => {
it('getRenewalPolicies sends GET', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getRenewalPolicies();
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/renewal-policies');
});
it('createRenewalPolicy sends POST with body', async () => {
mockFetch.mockReturnValueOnce(
mockJsonResponse({
id: 'rp-new',
name: 'New Policy',
renewal_window_days: 30,
max_retries: 3,
retry_interval_seconds: 3600,
auto_renew: true,
}),
);
await createRenewalPolicy({
name: 'New Policy',
renewal_window_days: 30,
max_retries: 3,
retry_interval_seconds: 3600,
auto_renew: true,
});
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/renewal-policies');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body).name).toBe('New Policy');
});
it('updateRenewalPolicy sends PUT with partial data', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'rp-default', name: 'Renamed' }));
await updateRenewalPolicy('rp-default', { name: 'Renamed' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/renewal-policies/rp-default');
expect(init.method).toBe('PUT');
expect(JSON.parse(init.body)).toEqual({ name: 'Renamed' });
});
it('deleteRenewalPolicy sends DELETE', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deleted' }));
await deleteRenewalPolicy('rp-default');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/renewal-policies/rp-default');
expect(init.method).toBe('DELETE');
});
});
// ─── Issuers ────────────────────────────────────────
describe('Issuers', () => {
it('getIssuers sends GET', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getIssuers();
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/issuers');
});
it('testIssuerConnection sends POST', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'ok' }));
await testIssuerConnection('iss-local');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/issuers/iss-local/test');
expect(init.method).toBe('POST');
});
it('deleteIssuer sends DELETE', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deleted' }));
await deleteIssuer('iss-local');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/issuers/iss-local');
expect(init.method).toBe('DELETE');
});
});
// ─── Targets ────────────────────────────────────────
describe('Targets', () => {
it('getTargets sends GET', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getTargets();
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/targets');
});
it('createTarget sends POST', async () => {
const targetData = { name: 'nginx-01', type: 'nginx', hostname: 'web01.example.com' };
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-new', ...targetData }));
await createTarget(targetData);
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/targets');
expect(init.method).toBe('POST');
});
it('deleteTarget sends DELETE', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deleted' }));
await deleteTarget('t-nginx');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/targets/t-nginx');
expect(init.method).toBe('DELETE');
});
it('testTargetConnection sends POST', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ status: 'success', message: 'Agent is online' }));
await testTargetConnection('t-nginx');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/targets/t-nginx/test');
expect(init.method).toBe('POST');
});
});
// ─── Approval ──────────────────────────────────────
describe('Renewal Approvals', () => {
it('approveRenewal sends POST', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'approved' }));
await approveRenewal('job-123');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/jobs/job-123/approve');
expect(init.method).toBe('POST');
});
it('rejectRenewal sends POST with reason', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'rejected' }));
await rejectRenewal('job-123', 'not authorized');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/jobs/job-123/reject');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({ reason: 'not authorized' });
});
});
// ─── Profiles ────────────────────────────────────────
describe('Profiles', () => {
it('getProfiles sends GET', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getProfiles();
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/profiles');
});
it('getProfile fetches by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'prof-1', name: 'Standard' }));
const profile = await getProfile('prof-1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/profiles/prof-1');
expect(profile.id).toBe('prof-1');
});
it('createProfile sends POST', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'prof-new', name: 'New Profile' }));
await createProfile({ name: 'New Profile' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/profiles');
expect(init.method).toBe('POST');
});
it('updateProfile sends PUT', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'prof-1', name: 'Updated' }));
await updateProfile('prof-1', { name: 'Updated' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/profiles/prof-1');
expect(init.method).toBe('PUT');
});
it('deleteProfile sends DELETE', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deleted' }));
await deleteProfile('prof-1');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/profiles/prof-1');
expect(init.method).toBe('DELETE');
});
});
// ─── Owners ──────────────────────────────────────────
describe('Owners', () => {
it('getOwners sends GET', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getOwners();
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/owners');
});
it('getOwner fetches by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'o-alice', name: 'Alice' }));
const owner = await getOwner('o-alice');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/owners/o-alice');
expect(owner.name).toBe('Alice');
});
it('createOwner sends POST', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'o-new', name: 'Bob' }));
await createOwner({ name: 'Bob', email: 'bob@example.com' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/owners');
expect(init.method).toBe('POST');
});
it('updateOwner sends PUT', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'o-alice', name: 'Alice Updated' }));
await updateOwner('o-alice', { name: 'Alice Updated' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/owners/o-alice');
expect(init.method).toBe('PUT');
});
it('deleteOwner sends DELETE', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deleted' }));
await deleteOwner('o-alice');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/owners/o-alice');
expect(init.method).toBe('DELETE');
});
});
// ─── Teams ───────────────────────────────────────────
describe('Teams', () => {
it('getTeams sends GET', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getTeams();
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/teams');
});
it('getTeam fetches by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-platform', name: 'Platform' }));
const team = await getTeam('t-platform');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/teams/t-platform');
expect(team.name).toBe('Platform');
});
it('createTeam sends POST', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-new', name: 'New Team' }));
await createTeam({ name: 'New Team' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/teams');
expect(init.method).toBe('POST');
});
it('updateTeam sends PUT', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-platform', name: 'Updated' }));
await updateTeam('t-platform', { name: 'Updated' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/teams/t-platform');
expect(init.method).toBe('PUT');
});
it('deleteTeam sends DELETE', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deleted' }));
await deleteTeam('t-platform');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/teams/t-platform');
expect(init.method).toBe('DELETE');
});
});
// ─── Agent Groups ────────────────────────────────────
describe('Agent Groups', () => {
it('getAgentGroups sends GET', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getAgentGroups();
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/agent-groups');
});
it('getAgentGroup fetches by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'ag-linux', name: 'Linux Servers' }));
const group = await getAgentGroup('ag-linux');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/agent-groups/ag-linux');
expect(group.name).toBe('Linux Servers');
});
it('createAgentGroup sends POST', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'ag-new', name: 'New Group' }));
await createAgentGroup({ name: 'New Group' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/agent-groups');
expect(init.method).toBe('POST');
});
it('updateAgentGroup sends PUT', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'ag-linux', name: 'Updated' }));
await updateAgentGroup('ag-linux', { name: 'Updated' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/agent-groups/ag-linux');
expect(init.method).toBe('PUT');
});
it('deleteAgentGroup sends DELETE', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deleted' }));
await deleteAgentGroup('ag-linux');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/agent-groups/ag-linux');
expect(init.method).toBe('DELETE');
});
it('getAgentGroupMembers fetches members', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getAgentGroupMembers('ag-linux');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/agent-groups/ag-linux/members');
});
});
// ─── Policy Violations ───────────────────────────────
describe('Policy Violations', () => {
it('getPolicyViolations sends GET', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getPolicyViolations('pol-1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/policies/pol-1/violations');
});
});
// ─── Issuer Create ───────────────────────────────────
describe('Issuer Create', () => {
it('createIssuer sends POST', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-new', name: 'New Issuer' }));
await createIssuer({ name: 'New Issuer', type: 'local_ca' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/issuers');
expect(init.method).toBe('POST');
});
it('createIssuer sends correct payload for VaultPKI type', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-vault', name: 'Vault PKI' }));
const vaultPayload = {
name: 'Vault PKI',
type: 'VaultPKI',
config: {
addr: 'https://vault.internal:8200',
token: 'hvs.test-token',
mount: 'pki',
role: 'web-certs',
ttl: '8760h',
},
};
await createIssuer(vaultPayload);
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/issuers');
expect(init.method).toBe('POST');
const body = JSON.parse(init.body);
expect(body.type).toBe('VaultPKI');
expect(body.config.addr).toBe('https://vault.internal:8200');
expect(body.config.role).toBe('web-certs');
});
it('createIssuer sends correct payload for DigiCert type', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-digicert', name: 'DigiCert' }));
const digicertPayload = {
name: 'DigiCert CertCentral',
type: 'DigiCert',
config: {
api_key: 'test-api-key',
org_id: '12345',
product_type: 'ssl_basic',
},
};
await createIssuer(digicertPayload);
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/issuers');
expect(init.method).toBe('POST');
const body = JSON.parse(init.body);
expect(body.type).toBe('DigiCert');
expect(body.config.org_id).toBe('12345');
expect(body.config.product_type).toBe('ssl_basic');
});
it('createIssuer sends correct payload for ACME with profile', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-acme-shortlived', name: 'ACME Shortlived' }));
const acmePayload = {
name: 'ACME Shortlived',
type: 'acme',
config: {
directory_url: 'https://acme-v02.api.letsencrypt.org/directory',
email: 'admin@example.com',
challenge_type: 'http-01',
profile: 'shortlived',
},
};
await createIssuer(acmePayload);
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/issuers');
expect(init.method).toBe('POST');
const body = JSON.parse(init.body);
expect(body.type).toBe('acme');
expect(body.config.profile).toBe('shortlived');
expect(body.config.directory_url).toBe('https://acme-v02.api.letsencrypt.org/directory');
});
});
// ─── Audit ──────────────────────────────────────────
describe('Audit', () => {
it('getAuditEvents sends GET with filters', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getAuditEvents({ resource_type: 'certificate' });
const url = mockFetch.mock.calls[0][0] as string;
expect(url).toContain('resource_type=certificate');
});
});
// ─── Stats ─────────────────────────────────────────
describe('Stats', () => {
it('getDashboardSummary calls /api/v1/stats/summary', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ total_certificates: 10 }));
const result = await getDashboardSummary();
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/stats/summary');
expect(result.total_certificates).toBe(10);
});
it('getCertificatesByStatus calls /api/v1/stats/certificates-by-status', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse([{ status: 'Active', count: 5 }]));
const result = await getCertificatesByStatus();
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/stats/certificates-by-status');
expect(result).toHaveLength(1);
});
it('getExpirationTimeline calls with days parameter', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse([]));
await getExpirationTimeline(60);
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/stats/expiration-timeline?days=60');
});
it('getExpirationTimeline uses default 30 days', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse([]));
await getExpirationTimeline();
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/stats/expiration-timeline?days=30');
});
it('getJobTrends calls with days parameter', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse([]));
await getJobTrends(14);
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/stats/job-trends?days=14');
});
it('getIssuanceRate calls with days parameter', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse([]));
await getIssuanceRate(7);
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/stats/issuance-rate?days=7');
});
it('getMetrics calls /api/v1/metrics', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({
gauge: { certificate_total: 10 },
counter: { job_completed_total: 5 },
uptime: { uptime_seconds: 3600 },
}));
const result = await getMetrics();
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/metrics');
expect(result.gauge.certificate_total).toBe(10);
});
});
// ─── Health ─────────────────────────────────────────
describe('Health', () => {
it('getHealth calls /health endpoint', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ status: 'ok' }));
const result = await getHealth();
expect(mockFetch.mock.calls[0][0]).toBe('/health');
expect(result.status).toBe('ok');
});
});
// ─── Discovery ────────────────────────────────────
describe('Discovery', () => {
it('getDiscoveredCertificates calls with params', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getDiscoveredCertificates({ status: 'Unmanaged' });
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/discovered-certificates');
expect(mockFetch.mock.calls[0][0]).toContain('status=Unmanaged');
});
it('getDiscoveredCertificate calls with id', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'dc-1', common_name: 'test.example.com' }));
const result = await getDiscoveredCertificate('dc-1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/discovered-certificates/dc-1');
expect(result.common_name).toBe('test.example.com');
});
it('claimDiscoveredCertificate sends POST with managed cert id', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'claimed' }));
await claimDiscoveredCertificate('dc-1', 'mc-api-prod');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/discovered-certificates/dc-1/claim');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({ managed_certificate_id: 'mc-api-prod' });
});
it('dismissDiscoveredCertificate sends POST', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'dismissed' }));
await dismissDiscoveredCertificate('dc-1');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/discovered-certificates/dc-1/dismiss');
expect(init.method).toBe('POST');
});
it('getDiscoveryScans calls endpoint', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getDiscoveryScans();
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/discovery-scans');
});
it('getDiscoverySummary calls endpoint', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ Unmanaged: 5, Managed: 3, Dismissed: 1 }));
const result = await getDiscoverySummary();
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/discovery-summary');
expect(result.Unmanaged).toBe(5);
});
});
// ─── Network Scan Targets ────────────────────────
describe('Network Scan Targets', () => {
it('getNetworkScanTargets calls endpoint', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getNetworkScanTargets();
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/network-scan-targets');
});
it('getNetworkScanTarget calls with id', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'nst-1', name: 'DMZ' }));
const result = await getNetworkScanTarget('nst-1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/network-scan-targets/nst-1');
expect(result.name).toBe('DMZ');
});
it('createNetworkScanTarget sends POST', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'nst-new', name: 'Production' }));
await createNetworkScanTarget({ name: 'Production', cidrs: ['10.0.0.0/24'], ports: [443] });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/network-scan-targets');
expect(init.method).toBe('POST');
const body = JSON.parse(init.body);
expect(body.name).toBe('Production');
expect(body.cidrs).toEqual(['10.0.0.0/24']);
});
it('updateNetworkScanTarget sends PUT', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'nst-1', enabled: false }));
await updateNetworkScanTarget('nst-1', { enabled: false });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/network-scan-targets/nst-1');
expect(init.method).toBe('PUT');
});
it('deleteNetworkScanTarget sends DELETE', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({}, 204));
await deleteNetworkScanTarget('nst-1');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/network-scan-targets/nst-1');
expect(init.method).toBe('DELETE');
});
it('triggerNetworkScan sends POST to scan endpoint', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'scan triggered' }));
await triggerNetworkScan('nst-1');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/network-scan-targets/nst-1/scan');
expect(init.method).toBe('POST');
});
});
// ─── Certificate Export ────────────────────────────────
describe('Certificate Export', () => {
// B-1 closure (cat-b-9b97ffb35ef7): exportCertificatePEM was removed
// from client.ts as a dead duplicate of downloadCertificatePEM.
it('downloadCertificatePEM fetches blob with download=true', async () => {
const mockBlob = new Blob(['pem-data'], { type: 'application/x-pem-file' });
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
blob: () => Promise.resolve(mockBlob),
} as Response)
);
const blob = await downloadCertificatePEM('mc-1');
const [url] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/certificates/mc-1/export/pem?download=true');
expect(blob).toBeInstanceOf(Blob);
});
it('downloadCertificatePEM includes auth header', async () => {
setApiKey('export-key');
const mockBlob = new Blob(['data']);
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
blob: () => Promise.resolve(mockBlob),
} as Response)
);
await downloadCertificatePEM('mc-1');
const [, init] = mockFetch.mock.calls[0];
expect(init.headers['Authorization']).toBe('Bearer export-key');
});
it('exportCertificatePKCS12 sends POST with password', async () => {
const mockBlob = new Blob([new Uint8Array([0x30])], { type: 'application/x-pkcs12' });
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
blob: () => Promise.resolve(mockBlob),
} as Response)
);
const blob = await exportCertificatePKCS12('mc-1', 'mypass');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/certificates/mc-1/export/pkcs12');
expect(init.method).toBe('POST');
const body = JSON.parse(init.body);
expect(body.password).toBe('mypass');
expect(blob).toBeInstanceOf(Blob);
});
it('exportCertificatePKCS12 uses empty password by default', async () => {
const mockBlob = new Blob([new Uint8Array([0x30])]);
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
blob: () => Promise.resolve(mockBlob),
} as Response)
);
await exportCertificatePKCS12('mc-1');
const [, init] = mockFetch.mock.calls[0];
const body = JSON.parse(init.body);
expect(body.password).toBe('');
});
});
// ─── Profile (EKU / S/MIME) ─────────────────────────────
describe('Profile for EKU Display', () => {
it('getProfile fetches profile by ID with EKU data', async () => {
const profileData = {
id: 'prof-smime',
name: 'S/MIME Email',
allowed_ekus: ['emailProtection'],
max_ttl_seconds: 31536000,
enabled: true,
};
mockFetch.mockReturnValueOnce(mockJsonResponse(profileData));
const result = await getProfile('prof-smime');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/profiles/prof-smime');
expect(result.allowed_ekus).toEqual(['emailProtection']);
});
it('getProfile returns profile with multiple EKUs', async () => {
const profileData = {
id: 'prof-tls',
name: 'TLS Server',
allowed_ekus: ['serverAuth', 'clientAuth'],
max_ttl_seconds: 7776000,
enabled: true,
};
mockFetch.mockReturnValueOnce(mockJsonResponse(profileData));
const result = await getProfile('prof-tls');
expect(result.allowed_ekus).toHaveLength(2);
expect(result.allowed_ekus).toContain('serverAuth');
expect(result.allowed_ekus).toContain('clientAuth');
});
});
// ─── Job Verification Fields ─────────────────────────────
describe('Job Verification', () => {
it('getJobs returns jobs with verification fields', async () => {
const jobData = {
data: [{
id: 'job-1',
certificate_id: 'mc-1',
type: 'Deployment',
status: 'Completed',
verification_status: 'success',
verified_at: '2026-03-28T12:00:00Z',
verification_fingerprint: 'abc123',
verification_error: '',
attempts: 1,
max_attempts: 3,
scheduled_at: '2026-03-28T11:00:00Z',
completed_at: '2026-03-28T11:05:00Z',
created_at: '2026-03-28T11:00:00Z',
}],
total: 1,
page: 1,
per_page: 50,
};
mockFetch.mockReturnValueOnce(mockJsonResponse(jobData));
const result = await getJobs({ certificate_id: 'mc-1' });
expect(result.data[0].verification_status).toBe('success');
expect(result.data[0].verified_at).toBe('2026-03-28T12:00:00Z');
expect(result.data[0].verification_fingerprint).toBe('abc123');
});
it('getJobs handles jobs without verification data', async () => {
const jobData = {
data: [{
id: 'job-2',
certificate_id: 'mc-2',
type: 'Issuance',
status: 'Completed',
attempts: 1,
max_attempts: 3,
scheduled_at: '2026-03-28T11:00:00Z',
completed_at: '2026-03-28T11:05:00Z',
created_at: '2026-03-28T11:00:00Z',
}],
total: 1,
page: 1,
per_page: 50,
};
mockFetch.mockReturnValueOnce(mockJsonResponse(jobData));
const result = await getJobs({});
expect(result.data[0].verification_status).toBeUndefined();
expect(result.data[0].verified_at).toBeUndefined();
});
});
// ─── Digest ─────────────────────────────
describe('Digest', () => {
it('previewDigest fetches HTML preview', async () => {
const html = '<html><body>Digest Preview</body></html>';
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve(html),
} as Response)
);
const result = await previewDigest();
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/digest/preview');
expect(result).toBe(html);
});
it('previewDigest throws on error', async () => {
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: false,
status: 503,
text: () => Promise.resolve('not configured'),
} as Response)
);
await expect(previewDigest()).rejects.toThrow('Digest preview failed: 503');
});
it('sendDigest sends POST request', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'digest sent' }));
const result = await sendDigest();
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/digest/send');
expect(init.method).toBe('POST');
expect(result.message).toBe('digest sent');
});
});
// ─── Job Detail ────────────────────────────
describe('Job Detail', () => {
it('getJob fetches single job by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'job-1', type: 'Deployment', status: 'Completed' }));
const result = await getJob('job-1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/jobs/job-1');
expect(result.id).toBe('job-1');
expect(result.type).toBe('Deployment');
});
it('getJobVerification fetches verification result', async () => {
const verificationData = {
job_id: 'job-1',
target_id: 't-nginx1',
verified: true,
actual_fingerprint: 'abc123',
expected_fingerprint: 'abc123',
verified_at: '2026-03-28T12:00:00Z',
};
mockFetch.mockReturnValueOnce(mockJsonResponse(verificationData));
const result = await getJobVerification('job-1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/jobs/job-1/verification');
expect(result.verified).toBe(true);
expect(result.actual_fingerprint).toBe('abc123');
});
});
// ─── Issuer Detail ─────────────────────────
describe('Issuer Detail', () => {
it('getIssuer fetches single issuer by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-local', name: 'Local CA', type: 'local_ca', status: 'active' }));
const result = await getIssuer('iss-local');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/issuers/iss-local');
expect(result.name).toBe('Local CA');
expect(result.type).toBe('local_ca');
});
});
// ─── Target Detail ─────────────────────────
describe('Target Detail', () => {
it('getTarget fetches single target by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-nginx1', name: 'Web Server', type: 'nginx', hostname: 'web1.example.com' }));
const result = await getTarget('t-nginx1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/targets/t-nginx1');
expect(result.name).toBe('Web Server');
expect(result.type).toBe('nginx');
});
});
// ─── Prometheus Metrics ────────────────────
describe('Prometheus Metrics', () => {
it('getPrometheusMetrics fetches text format', async () => {
const metricsText = '# HELP certctl_certificate_total Total certificates\ncertctl_certificate_total 10';
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve(metricsText),
} as Response)
);
const result = await getPrometheusMetrics();
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/metrics/prometheus');
expect(result).toContain('certctl_certificate_total');
});
it('getPrometheusMetrics throws on error', async () => {
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: false,
status: 500,
text: () => Promise.resolve('error'),
} as Response)
);
await expect(getPrometheusMetrics()).rejects.toThrow('Prometheus metrics failed: 500');
});
it('getPrometheusMetrics includes auth header', async () => {
setApiKey('prom-key');
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve('metrics'),
} as Response)
);
await getPrometheusMetrics();
const [, init] = mockFetch.mock.calls[0];
expect(init.headers['Authorization']).toBe('Bearer prom-key');
});
});
describe('Frontend Audit: New API Functions', () => {
it('getCertificateDeployments sends GET with cert ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0 }));
await getCertificateDeployments('mc-1');
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/certificates/mc-1/deployments');
});
// M-006: JSON CRL endpoint (`GET /api/v1/crl`) removed entirely — RFC 5280
// defines only the DER wire format, which is now served unauthenticated at
// `/.well-known/pki/crl/{issuer_id}` (fetched directly, no GUI wrapper).
// OCSP likewise relocated to `/.well-known/pki/ocsp/{issuer_id}/{serial}`
// per RFC 8615.
it('getOCSPStatus sends GET to /.well-known/pki/ocsp with issuer and serial', async () => {
const buf = new ArrayBuffer(8);
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
arrayBuffer: () => Promise.resolve(buf),
} as Response)
);
await getOCSPStatus('iss-local', 'ABC123');
expect(mockFetch.mock.calls[0][0]).toBe('/.well-known/pki/ocsp/iss-local/ABC123');
});
it('updateIssuer sends PUT with data', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-1', name: 'Updated' }));
await updateIssuer('iss-1', { name: 'Updated' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/issuers/iss-1');
expect(init.method).toBe('PUT');
});
it('updateTarget sends PUT with data', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-1', name: 'Updated' }));
await updateTarget('t-1', { name: 'Updated' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/targets/t-1');
expect(init.method).toBe('PUT');
});
it('getPolicy sends GET with policy ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'pol-1', name: 'Test' }));
await getPolicy('pol-1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/policies/pol-1');
});
});
describe('Health Checks (M48)', () => {
it('listHealthChecks sends GET with optional filters', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
const result = await listHealthChecks({ status: 'degraded' });
expect(result.total).toBe(0);
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/health-checks');
expect(mockFetch.mock.calls[0][0]).toContain('status=degraded');
});
it('getHealthCheck sends GET with health check ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'hc-1', endpoint: 'example.com:443' }));
const result = await getHealthCheck('hc-1');
expect(result.id).toBe('hc-1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/health-checks/hc-1');
});
it('createHealthCheck sends POST with data', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'hc-1', endpoint: 'example.com:443' }));
const result = await createHealthCheck({ endpoint: 'example.com:443' });
expect(result.id).toBe('hc-1');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toContain('/api/v1/health-checks');
expect(init.method).toBe('POST');
});
it('getHealthCheckSummary sends GET to /health-checks/summary', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ healthy: 5, degraded: 1, down: 0, cert_mismatch: 0, unknown: 2, total: 8 }));
const result = await getHealthCheckSummary();
expect(result.healthy).toBe(5);
expect(result.total).toBe(8);
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/health-checks/summary');
});
});
});