mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 23:01:30 +00:00
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:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user