feat: add frontend action buttons, fix notification auth bug, add 53 Vitest tests

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>
This commit is contained in:
Shankar
2026-03-16 00:05:21 -04:00
parent 86580deab5
commit ff10c85c68
15 changed files with 2034 additions and 29 deletions
+381
View File
@@ -0,0 +1,381 @@
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');
});
});
});
+40 -1
View File
@@ -1,4 +1,4 @@
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, Issuer, Target, PaginatedResponse } from './types';
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, Issuer, Target, PaginatedResponse } from './types';
const BASE = '/api/v1';
@@ -70,6 +70,12 @@ export const createCertificate = (data: Partial<Certificate>) =>
export const triggerRenewal = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/certificates/${id}/renew`, { method: 'POST' });
export const updateCertificate = (id: string, data: Partial<Certificate>) =>
fetchJSON<Certificate>(`${BASE}/certificates/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const archiveCertificate = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/certificates/${id}`, { method: 'DELETE' });
export const triggerDeployment = (id: string, targetId: string) =>
fetchJSON<{ message: string }>(`${BASE}/certificates/${id}/deploy`, {
method: 'POST',
@@ -85,6 +91,9 @@ export const getAgents = (params: Record<string, string> = {}) => {
export const getAgent = (id: string) =>
fetchJSON<Agent>(`${BASE}/agents/${id}`);
export const registerAgent = (data: Partial<Agent>) =>
fetchJSON<Agent>(`${BASE}/agents`, { method: 'POST', body: JSON.stringify(data) });
// Jobs
export const getJobs = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
@@ -100,6 +109,9 @@ export const getNotifications = (params: Record<string, string> = {}) => {
return fetchJSON<PaginatedResponse<Notification>>(`${BASE}/notifications?${qs}`);
};
export const markNotificationRead = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/notifications/${id}/read`, { method: 'POST' });
// Audit
export const getAuditEvents = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
@@ -112,17 +124,44 @@ export const getPolicies = (params: Record<string, string> = {}) => {
return fetchJSON<PaginatedResponse<PolicyRule>>(`${BASE}/policies?${qs}`);
};
export const createPolicy = (data: Partial<PolicyRule>) =>
fetchJSON<PolicyRule>(`${BASE}/policies`, { method: 'POST', body: JSON.stringify(data) });
export const updatePolicy = (id: string, data: Partial<PolicyRule>) =>
fetchJSON<PolicyRule>(`${BASE}/policies/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const deletePolicy = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/policies/${id}`, { method: 'DELETE' });
export const getPolicyViolations = (id: string) =>
fetchJSON<PaginatedResponse<PolicyViolation>>(`${BASE}/policies/${id}/violations`);
// Issuers
export const getIssuers = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
return fetchJSON<PaginatedResponse<Issuer>>(`${BASE}/issuers?${qs}`);
};
export const createIssuer = (data: Partial<Issuer>) =>
fetchJSON<Issuer>(`${BASE}/issuers`, { method: 'POST', body: JSON.stringify(data) });
export const testIssuerConnection = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/issuers/${id}/test`, { method: 'POST' });
export const deleteIssuer = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/issuers/${id}`, { method: 'DELETE' });
// Targets
export const getTargets = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
return fetchJSON<PaginatedResponse<Target>>(`${BASE}/targets?${qs}`);
};
export const createTarget = (data: Partial<Target>) =>
fetchJSON<Target>(`${BASE}/targets`, { method: 'POST', body: JSON.stringify(data) });
export const deleteTarget = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/targets/${id}`, { method: 'DELETE' });
// Health
export const getHealth = () => fetchJSON<{ status: string }>('/health');
+110
View File
@@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { formatDate, formatDateTime, timeAgo, daysUntil, expiryColor } from './utils';
describe('Utility functions', () => {
describe('formatDate', () => {
it('returns dash for empty string', () => {
expect(formatDate('')).toBe('—');
});
it('formats ISO date string', () => {
const result = formatDate('2026-06-15T12:00:00Z');
expect(result).toContain('Jun');
expect(result).toContain('15');
expect(result).toContain('2026');
});
});
describe('formatDateTime', () => {
it('returns dash for empty string', () => {
expect(formatDateTime('')).toBe('—');
});
it('formats ISO datetime string with time', () => {
const result = formatDateTime('2026-06-15T14:30:00Z');
expect(result).toContain('Jun');
expect(result).toContain('15');
});
});
describe('timeAgo', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-15T12:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('returns dash for empty string', () => {
expect(timeAgo('')).toBe('—');
});
it('returns "just now" for recent times', () => {
expect(timeAgo('2026-03-15T11:59:45Z')).toBe('just now');
});
it('returns minutes ago', () => {
expect(timeAgo('2026-03-15T11:45:00Z')).toBe('15m ago');
});
it('returns hours ago', () => {
expect(timeAgo('2026-03-15T09:00:00Z')).toBe('3h ago');
});
it('returns days ago', () => {
expect(timeAgo('2026-03-12T12:00:00Z')).toBe('3d ago');
});
it('returns formatted date for old dates', () => {
const result = timeAgo('2025-01-15T12:00:00Z');
expect(result).toContain('2025');
});
});
describe('daysUntil', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-15T12:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('returns 0 for empty string', () => {
expect(daysUntil('')).toBe(0);
});
it('returns positive days for future date', () => {
expect(daysUntil('2026-03-25T12:00:00Z')).toBe(10);
});
it('returns negative days for past date', () => {
expect(daysUntil('2026-03-10T12:00:00Z')).toBeLessThan(0);
});
});
describe('expiryColor', () => {
it('returns red for expired (0 days)', () => {
expect(expiryColor(0)).toContain('red');
});
it('returns red for <= 7 days', () => {
expect(expiryColor(5)).toContain('red');
});
it('returns amber for <= 14 days', () => {
expect(expiryColor(12)).toContain('amber');
});
it('returns amber for <= 30 days', () => {
expect(expiryColor(25)).toContain('amber');
});
it('returns green for > 30 days', () => {
expect(expiryColor(60)).toContain('emerald');
});
});
});