import { describe, it, expect, beforeEach, vi } from 'vitest'; import { setApiKey, getApiKey, getCertificates, getCertificate, getCertificateVersions, createCertificate, triggerRenewal, triggerDeployment, updateCertificate, archiveCertificate, revokeCertificate, exportCertificatePEM, downloadCertificatePEM, exportCertificatePKCS12, getAgents, getAgent, registerAgent, getJobs, cancelJob, approveRenewal, rejectRenewal, getNotifications, markNotificationRead, getAuditEvents, getPolicies, createPolicy, updatePolicy, deletePolicy, getPolicyViolations, 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, getCRL, getOCSPStatus, updateIssuer, updateTarget, getPolicy, } 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); }); }); // ─── 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); }); 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' }); }); }); // ─── 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'); }); }); // ─── 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'); }); }); // ─── 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'); }); }); // ─── 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', () => { it('exportCertificatePEM fetches PEM data as JSON', async () => { const pemResult = { cert_pem: 'CERT', chain_pem: 'CHAIN', full_pem: 'FULL' }; mockFetch.mockReturnValueOnce(mockJsonResponse(pemResult)); const result = await exportCertificatePEM('mc-1'); const [url] = mockFetch.mock.calls[0]; expect(url).toBe('/api/v1/certificates/mc-1/export/pem'); expect(result.cert_pem).toBe('CERT'); expect(result.full_pem).toBe('FULL'); }); 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 = 'Digest Preview'; 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'); }); it('getCRL sends GET to /crl', async () => { mockFetch.mockReturnValueOnce(mockJsonResponse({ entries: [], total: 0 })); await getCRL(); expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/crl'); }); it('getOCSPStatus sends GET 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('/api/v1/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'); }); }); });