feat: M13 — GUI operations (bulk ops, deployment timeline, policy editor, target wizard, audit export, short-lived creds)

Bulk certificate operations: multi-select checkboxes on certificates list with
bulk action bar for triggering renewal, revocation (with RFC 5280 reason modal
and progress bar), and owner reassignment across selected certificates.

Deployment status timeline: visual 4-step lifecycle pipeline (Requested → Issued
→ Deploying → Active) on certificate detail page, powered by per-cert job queries
with animated status indicators for active steps and failure states.

Inline policy editor: edit/save/cancel interface on certificate detail page for
changing renewal policy and certificate profile assignments via dropdown selectors
with lazy-loaded policy and profile lists.

Target connector configuration wizard: 3-step modal (Select Type → Configure →
Review) with type-specific configuration fields for NGINX, Apache, HAProxy, F5
BIG-IP, and IIS targets including required field validation.

Audit trail export: CSV and JSON download buttons on audit page with applied
filters preserved in export. Added action filter input for narrower searches.

Short-lived credentials dashboard: new page at /short-lived showing ephemeral
certificates (profile TTL < 1 hour) with live TTL countdown, auto-refresh every
10 seconds, profile lookup, and stats bar (active/expired/profiles).

DataTable enhanced with optional selectable/selectedKeys/onSelectionChange props
for checkbox multi-select with select-all toggle and row highlighting.

Frontend tests expanded from 53 to 79: full API client endpoint coverage for
profiles, owners, teams, agent groups, revocation, approval/rejection, policy
violations, and issuer creation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-03-22 15:07:10 -04:00
parent 762c523d59
commit 8af4e42f44
11 changed files with 1206 additions and 30 deletions
+249
View File
@@ -10,23 +10,50 @@ import {
triggerDeployment,
updateCertificate,
archiveCertificate,
revokeCertificate,
getAgents,
getAgent,
registerAgent,
getJobs,
cancelJob,
approveRenewal,
rejectRenewal,
getNotifications,
markNotificationRead,
getAuditEvents,
getPolicies,
createPolicy,
updatePolicy,
deletePolicy,
getPolicyViolations,
getIssuers,
createIssuer,
testIssuerConnection,
deleteIssuer,
getTargets,
createTarget,
deleteTarget,
getProfiles,
getProfile,
createProfile,
updateProfile,
deleteProfile,
getOwners,
getOwner,
createOwner,
updateOwner,
deleteOwner,
getTeams,
getTeam,
createTeam,
updateTeam,
deleteTeam,
getAgentGroups,
getAgentGroup,
createAgentGroup,
updateAgentGroup,
deleteAgentGroup,
getAgentGroupMembers,
getHealth,
} from './client';
@@ -209,6 +236,15 @@ describe('API Client', () => {
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 ─────────────────────────────────────────
@@ -357,6 +393,219 @@ describe('API Client', () => {
});
});
// ─── 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');
});
});
// ─── Audit ──────────────────────────────────────────
describe('Audit', () => {