mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
36885da2da
(Profiles + Recent Activity + Trust Bundle tabs) + CLI subcommand
family `certctl-cli est {cacerts,csrattrs,enroll,reenroll,
serverkeygen,test}` + 6 MCP tools.
Phase 8 — ESTAdminPage tabbed GUI:
- web/src/pages/ESTAdminPage.tsx mirrors SCEPAdminPage's three-tab
surface. Profiles tab renders per-profile cards with auth-mode
badges (mTLS / Basic / ServerKeygen), mTLS trust-anchor expiry
countdown (good ≥30d / warn 7-30d / bad <7d / EXPIRED), 12-cell
counter grid (success_simpleenroll/.../internal_error), and the
admin-gated "Reload trust anchor" action. Recent Activity tab
merges the four EST audit actions (est_simple_enroll +
est_simple_reenroll + est_server_keygen + est_auth_failed) across
four parallel useQuery calls with chip filters for All/Enrollment/
Re-enrollment/ServerKeygen/AuthFailure. Trust Bundle tab renders
per-mTLS-profile cert subjects + expiries.
- M-009 useTrackedMutation guard: every mutation routes through
the tracked hook so audit/progress hooks fire.
- Page-level admin gate renders "Admin access required" banner for
non-admin callers + skips underlying API requests so the server
never sees a 403-prone request. Server-side enforcement is the
M-008 admin gate; this is a UX hint.
- Wired into web/src/main.tsx at /est; nav link added to Layout.tsx.
- New web/src/api/types.ts types ESTStatsSnapshot +
ESTTrustAnchorInfo + ESTProfilesResponse + ESTReloadTrustResponse
mirror service.ESTStatsSnapshot 1:1.
- New web/src/api/client.ts helpers getAdminESTProfiles +
reloadAdminESTTrust.
- 14 Vitest cases (admin gate non-admin / non-auth-required deploy /
default tab / tab switch / deep-link tab / per-profile card render
+ counter cells / reload-button mTLS-only / trust-expiry badge
band / reload modal Confirm-Cancel-Error paths / Trust Bundle
empty-state / Activity filter chip toggle).
Phase 9.1 — CLI subcommands:
- internal/cli/est.go adds 6 subcommands: cacerts / csrattrs /
enroll / reenroll / serverkeygen / test. CSR input via --csr
with file-path or '-' for stdin; multipart serverkeygen response
is parsed by stdlib mime/multipart and split into <prefix>.cert.pem
+ <prefix>.key.enveloped so the operator can decrypt the key with
openssl smime. EST `test` smoke-tests cacerts + csrattrs + emits
one-line OK/FAIL diagnostics.
- cmd/cli/main.go grows the `est` dispatch + Usage entries.
Phase 9.2 — MCP tools:
- internal/mcp/tools_est.go adds 6 tools mapped to the EST endpoints
+ admin observability: est_list_profiles + est_admin_stats (alias)
+ est_get_cacerts + est_get_csrattrs + est_enroll + est_reenroll.
Tool count grew from 87 → 93 (verified via the registered-vs-
covered guard in tools_per_tool_test.go); the per-tool happy/error-
path table grew with 6 matching entries so the future-tool-no-test
CI guard stays green.
- internal/mcp/client.go grows PostRaw — non-JSON POST helper that
the EST enroll/reenroll tools use to ship raw application/pkcs10
CSR bytes through the MCP fence-wrapped response.
- estRawResultJSON wraps the raw response body in a JSON envelope
the MCP consumer can structurally consume (content_type +
body_base64 + body_size_bytes). Mirrors the CRL/OCSP MCP tools'
binary-DER envelope.
Phase 9.3 — Tests:
- internal/cli/est_test.go: 8 cases pinning the wire-shape contract
on the CLI side without dragging the full ESTHandler into the
test build.
- internal/mcp/tools_est_test.go: path-builder + JSON-envelope unit
tests + end-to-end tool exercise that pins all 5 captured request
paths through a fake API.
Pre-commit verification (sandbox): gofmt clean, go vet clean
(excluding repository/postgres which the sandbox can't build —
pre-existing testcontainers limit), staticcheck clean across
cli/mcp/cmd/cli, go test -short -count=1 green for every non-
postgres Go package, Vitest green for ESTAdminPage (14) +
SCEPAdminPage (20) — 34 page tests total. G-3 docs-drift guard
reproduced locally clean (Phases 8-9 added zero new env vars).
Spec preserved at cowork/est-rfc7030-hardening-prompt.md. Phases
10-13 (libest sidecar e2e / bulk revocation + audit codes /
docs/est.md / release prep + tag) remain — post-2.1.0 work.
275 lines
10 KiB
TypeScript
275 lines
10 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
|
import type { ReactNode } from 'react';
|
|
|
|
// EST RFC 7030 hardening master bundle Phase 8.4 — Vitest coverage for
|
|
// the EST Administration page. Mirrors SCEPAdminPage.test.tsx's
|
|
// structure verbatim. Pins:
|
|
// 1. Admin gate — non-admin sees the gated banner; admin requests are
|
|
// never issued.
|
|
// 2. Tab navigation — Profiles is the default; clicking each tab
|
|
// switches surface; ?tab=activity / ?tab=trust deep-links land
|
|
// correctly.
|
|
// 3. Profiles tab — per-profile cards; status badges reflect mTLS +
|
|
// Basic + ServerKeygen; trust-anchor expiry badge tone bands
|
|
// (good ≥30d / warn 7-30d / bad <7d / EXPIRED); per-counter cells
|
|
// render the correct value; "Reload trust anchor" only renders for
|
|
// mTLS-enabled profiles AND opens the modal on click.
|
|
// 4. Reload modal — Confirm calls mutation / Cancel skips mutation /
|
|
// Error keeps modal open + surfaces the error message.
|
|
// 5. Recent Activity tab — merges all four EST audit actions across
|
|
// four parallel useQuery calls; filter chips narrow to the
|
|
// requested subset.
|
|
// 6. Trust Bundle tab — only mTLS profiles render; non-mTLS deploy
|
|
// sees the empty-state banner.
|
|
// 7. Error path — surfaces ErrorState on the active tab.
|
|
|
|
vi.mock('../api/client', () => ({
|
|
getAdminESTProfiles: vi.fn(),
|
|
reloadAdminESTTrust: vi.fn(),
|
|
getAuditEvents: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../components/AuthProvider', () => ({
|
|
useAuth: vi.fn(),
|
|
}));
|
|
|
|
import ESTAdminPage from './ESTAdminPage';
|
|
import * as client from '../api/client';
|
|
import { useAuth } from '../components/AuthProvider';
|
|
|
|
function renderWithRoute(initialPath: string, ui: ReactNode) {
|
|
const qc = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
|
|
});
|
|
return render(
|
|
<QueryClientProvider client={qc}>
|
|
<MemoryRouter initialEntries={[initialPath]}>
|
|
<Routes>
|
|
<Route path="/est" element={ui} />
|
|
</Routes>
|
|
</MemoryRouter>
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
function setAuth(opts: { authRequired: boolean; admin: boolean }) {
|
|
vi.mocked(useAuth).mockReturnValue({
|
|
loading: false,
|
|
authRequired: opts.authRequired,
|
|
authenticated: true,
|
|
authType: 'apikey',
|
|
user: 'tester',
|
|
admin: opts.admin,
|
|
login: async () => {},
|
|
logout: () => {},
|
|
error: null,
|
|
});
|
|
}
|
|
|
|
const corpProfile = {
|
|
path_id: 'corp',
|
|
issuer_id: 'iss-corp',
|
|
profile_id: 'prof-corp',
|
|
counters: {
|
|
success_simpleenroll: 42,
|
|
success_simplereenroll: 7,
|
|
success_serverkeygen: 3,
|
|
auth_failed_basic: 1,
|
|
auth_failed_mtls: 0,
|
|
auth_failed_channel_binding: 0,
|
|
csr_invalid: 0,
|
|
csr_policy_violation: 0,
|
|
csr_signature_mismatch: 0,
|
|
rate_limited: 2,
|
|
issuer_error: 0,
|
|
internal_error: 0,
|
|
},
|
|
mtls_enabled: true,
|
|
basic_auth_configured: true,
|
|
server_keygen_enabled: true,
|
|
trust_anchors: [
|
|
{
|
|
subject: 'corp-bootstrap-ca',
|
|
not_before: '2026-01-01T00:00:00Z',
|
|
not_after: '2027-01-01T00:00:00Z',
|
|
days_to_expiry: 250,
|
|
expired: false,
|
|
},
|
|
],
|
|
trust_anchor_path: '/etc/certctl/est-mtls-corp.pem',
|
|
now: '2026-04-29T15:00:00Z',
|
|
};
|
|
|
|
const iotProfile = {
|
|
path_id: 'iot',
|
|
issuer_id: 'iss-iot',
|
|
counters: {
|
|
success_simpleenroll: 9,
|
|
auth_failed_basic: 0,
|
|
} as Record<string, number>,
|
|
mtls_enabled: false,
|
|
basic_auth_configured: false,
|
|
server_keygen_enabled: false,
|
|
now: '2026-04-29T15:00:00Z',
|
|
};
|
|
|
|
const profilesResponse = {
|
|
profiles: [corpProfile, iotProfile],
|
|
profile_count: 2,
|
|
generated_at: '2026-04-29T15:00:00Z',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(client.getAdminESTProfiles).mockResolvedValue(profilesResponse as any);
|
|
vi.mocked(client.getAuditEvents).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as any);
|
|
setAuth({ authRequired: true, admin: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
// React's afterEach is implicit in this scope via Vitest; the explicit
|
|
// cleanup() above is safe to call even when no render happened.
|
|
function afterEach(fn: () => void) {
|
|
// re-export from vitest globals — vitest's globals expose `afterEach`
|
|
// automatically when test config has globals: true. Our config does, so
|
|
// the import is unnecessary; this thin shim documents the call site.
|
|
(globalThis as any).afterEach?.(fn);
|
|
}
|
|
|
|
describe('ESTAdminPage — admin gate', () => {
|
|
it('non-admin sees the gated banner; admin requests never fire', async () => {
|
|
setAuth({ authRequired: true, admin: false });
|
|
renderWithRoute('/est', <ESTAdminPage />);
|
|
expect(await screen.findByText(/Admin access required/i)).toBeInTheDocument();
|
|
await waitFor(() => {
|
|
expect(client.getAdminESTProfiles).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('non-auth-required deploy lets the page render and fires admin request', async () => {
|
|
setAuth({ authRequired: false, admin: false });
|
|
renderWithRoute('/est', <ESTAdminPage />);
|
|
await waitFor(() => {
|
|
expect(client.getAdminESTProfiles).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('ESTAdminPage — tab navigation', () => {
|
|
it('defaults to the Profiles tab', async () => {
|
|
renderWithRoute('/est', <ESTAdminPage />);
|
|
expect(await screen.findByTestId('est-tab-profiles')).toHaveAttribute('aria-pressed', 'true');
|
|
});
|
|
|
|
it('clicking Recent Activity switches the tab', async () => {
|
|
renderWithRoute('/est', <ESTAdminPage />);
|
|
fireEvent.click(await screen.findByTestId('est-tab-activity'));
|
|
expect(screen.getByTestId('est-tab-activity')).toHaveAttribute('aria-pressed', 'true');
|
|
});
|
|
|
|
it('?tab=trust deep-link lands on Trust Bundle', async () => {
|
|
renderWithRoute('/est?tab=trust', <ESTAdminPage />);
|
|
expect(await screen.findByTestId('est-tab-trust')).toHaveAttribute('aria-pressed', 'true');
|
|
});
|
|
});
|
|
|
|
describe('ESTAdminPage — Profiles tab', () => {
|
|
it('renders one card per profile with the right badges + counters', async () => {
|
|
renderWithRoute('/est', <ESTAdminPage />);
|
|
expect(await screen.findByTestId('est-profile-summary-corp')).toBeInTheDocument();
|
|
expect(screen.getByTestId('est-profile-summary-iot')).toBeInTheDocument();
|
|
// Per-profile counter cells render with the snapshot value.
|
|
expect(screen.getByTestId('est-counter-corp-success_simpleenroll')).toHaveTextContent('42');
|
|
expect(screen.getByTestId('est-counter-corp-rate_limited')).toHaveTextContent('2');
|
|
expect(screen.getByTestId('est-counter-iot-success_simpleenroll')).toHaveTextContent('9');
|
|
// Counters that don't appear in the iot snapshot default to 0 in the cell.
|
|
expect(screen.getByTestId('est-counter-iot-internal_error')).toHaveTextContent('0');
|
|
});
|
|
|
|
it('reload-trust button only appears for mTLS profiles', async () => {
|
|
renderWithRoute('/est', <ESTAdminPage />);
|
|
expect(await screen.findByTestId('est-reload-trust-corp')).toBeInTheDocument();
|
|
expect(screen.queryByTestId('est-reload-trust-iot')).toBeNull();
|
|
});
|
|
|
|
it('shows mTLS trust expiry badge tone bands', async () => {
|
|
renderWithRoute('/est', <ESTAdminPage />);
|
|
const badge = await screen.findByTestId('est-trust-expiry-badge-corp');
|
|
expect(badge).toHaveTextContent(/250d remaining/);
|
|
});
|
|
});
|
|
|
|
describe('ESTAdminPage — reload modal', () => {
|
|
it('Confirm calls mutation', async () => {
|
|
vi.mocked(client.reloadAdminESTTrust).mockResolvedValue({
|
|
reloaded: true,
|
|
path_id: 'corp',
|
|
reloaded_at: '2026-04-29T15:00:01Z',
|
|
});
|
|
renderWithRoute('/est', <ESTAdminPage />);
|
|
fireEvent.click(await screen.findByTestId('est-reload-trust-corp'));
|
|
fireEvent.click(await screen.findByTestId('est-reload-confirm'));
|
|
await waitFor(() => {
|
|
expect(client.reloadAdminESTTrust).toHaveBeenCalledWith('corp');
|
|
});
|
|
});
|
|
|
|
it('Cancel skips mutation', async () => {
|
|
renderWithRoute('/est', <ESTAdminPage />);
|
|
fireEvent.click(await screen.findByTestId('est-reload-trust-corp'));
|
|
fireEvent.click(await screen.findByTestId('est-reload-cancel'));
|
|
await waitFor(() => {
|
|
expect(screen.queryByTestId('est-reload-confirm')).toBeNull();
|
|
});
|
|
expect(client.reloadAdminESTTrust).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('Error keeps the modal open + surfaces the message', async () => {
|
|
vi.mocked(client.reloadAdminESTTrust).mockRejectedValue(
|
|
new Error('Trust anchor reload failed: trustanchor: cert in /etc/est-corp.pem expired'),
|
|
);
|
|
renderWithRoute('/est', <ESTAdminPage />);
|
|
fireEvent.click(await screen.findByTestId('est-reload-trust-corp'));
|
|
fireEvent.click(await screen.findByTestId('est-reload-confirm'));
|
|
expect(await screen.findByTestId('est-reload-error')).toHaveTextContent(/expired/);
|
|
// Modal stays open — Confirm button still rendered.
|
|
expect(screen.getByTestId('est-reload-confirm')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('ESTAdminPage — Trust Bundle tab', () => {
|
|
it('renders only mTLS profiles + skips non-mTLS', async () => {
|
|
renderWithRoute('/est?tab=trust', <ESTAdminPage />);
|
|
expect(await screen.findByTestId('est-trust-card-corp')).toBeInTheDocument();
|
|
expect(screen.queryByTestId('est-trust-card-iot')).toBeNull();
|
|
});
|
|
|
|
it('shows the empty-state banner when no profile has mTLS', async () => {
|
|
vi.mocked(client.getAdminESTProfiles).mockResolvedValue({
|
|
profiles: [iotProfile],
|
|
profile_count: 1,
|
|
generated_at: '2026-04-29T15:00:00Z',
|
|
} as any);
|
|
renderWithRoute('/est?tab=trust', <ESTAdminPage />);
|
|
expect(await screen.findByText(/No EST profiles have mTLS enabled/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('ESTAdminPage — Recent Activity tab', () => {
|
|
it('renders filter chips + reacts to selection', async () => {
|
|
renderWithRoute('/est?tab=activity', <ESTAdminPage />);
|
|
const allChip = await screen.findByTestId('est-activity-filter-all');
|
|
expect(allChip).toHaveAttribute('aria-pressed', 'true');
|
|
fireEvent.click(screen.getByTestId('est-activity-filter-enroll'));
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('est-activity-filter-enroll')).toHaveAttribute('aria-pressed', 'true');
|
|
});
|
|
});
|
|
});
|