mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
73c6bd1416
Bug fix: - markNotificationRead was using raw fetch() without auth headers, bypassing the shared client's Authorization header. Moved to api/client.ts to use fetchJSON with proper auth. New action buttons: - CertificatesPage: "New Certificate" modal with form fields - CertificateDetailPage: "Deploy" button with target selector modal, "Archive" button with confirmation - IssuersPage: "Test Connection" and "Delete" per-row actions - TargetsPage: "Delete" per-row action - PoliciesPage: "Enable/Disable" toggle and "Delete" per-row actions New API client functions: - updateCertificate, archiveCertificate, registerAgent, createPolicy, updatePolicy, deletePolicy, getPolicyViolations, createIssuer, testIssuerConnection, deleteIssuer, createTarget, deleteTarget, markNotificationRead Frontend tests (53 tests, 2 files): - client.test.ts: 35 tests covering all API endpoints, auth headers, 401 handling, error parsing, HTTP methods, request bodies - utils.test.ts: 18 tests covering formatDate, formatDateTime, timeAgo, daysUntil, expiryColor CI: Added "Run Frontend Tests" step to frontend-build job Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
382 lines
15 KiB
TypeScript
382 lines
15 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import {
|
|
setApiKey,
|
|
getApiKey,
|
|
getCertificates,
|
|
getCertificate,
|
|
getCertificateVersions,
|
|
createCertificate,
|
|
triggerRenewal,
|
|
triggerDeployment,
|
|
updateCertificate,
|
|
archiveCertificate,
|
|
getAgents,
|
|
getAgent,
|
|
registerAgent,
|
|
getJobs,
|
|
cancelJob,
|
|
getNotifications,
|
|
markNotificationRead,
|
|
getAuditEvents,
|
|
getPolicies,
|
|
updatePolicy,
|
|
deletePolicy,
|
|
getIssuers,
|
|
testIssuerConnection,
|
|
deleteIssuer,
|
|
getTargets,
|
|
createTarget,
|
|
deleteTarget,
|
|
getHealth,
|
|
} 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' });
|
|
});
|
|
});
|
|
|
|
// ─── 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');
|
|
});
|
|
});
|
|
|
|
// ─── 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');
|
|
});
|
|
});
|
|
|
|
// ─── 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');
|
|
});
|
|
});
|
|
});
|