From f40e97543981e43452227da7ebb65ea3be537576 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Tue, 5 May 2026 19:49:59 +0000 Subject: [PATCH] gui(certificates): surface profile contract in create-cert form (closes P3-3, P3-4, P3-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes findings P3-3, P3-4, P3-5 from the 2026-05-05 CLI/API/MCP↔GUI parity audit (cowork/cli-gui-parity-audit-2026-05-05/RESULTS.md). The audit flagged three "hidden defaults" in the create-certificate form: environment='production', shortLived=false, selectedEkus=['serverAuth']. Re-grounding against the live source: P3-3 was a false positive. The form already exposes an environment selector with three options (Production / Staging / Development) and defaults to Production. No change needed — covered by new test pin. P3-4 + P3-5 misread the architecture. allow_short_lived and allowed_ekus are NOT per-cert form-state fields; they are properties of the CertificateProfile that the operator binds via the existing Profile dropdown. Adding form-level toggles for them would contradict the profile-as-primitive design (the profile carries the policy contract — TTL, EKUs, key-algo allow-list, short-lived eligibility — so the cert can inherit a coherent set rather than letting operators hand-mix invalid combinations). The genuine UX gap was opacity: operators picked a profile without seeing what allow_short_lived / allowed_ekus the profile carried. This commit closes the spirit of the finding by surfacing the selected profile's load-bearing properties in a read-only "Profile contract" panel that appears below the Profile dropdown once a profile is selected. The panel shows: - allowed_ekus list (so operators see whether a profile is serverAuth, emailProtection, codeSigning, or a mix) - allow_short_lived flag (highlighted when true so operators know they're picking a profile that allows TTL < 1h CRL/OCSP-exempt certs per the M15b regime) - explanatory text that EKUs and short-lived eligibility are profile-level (not per-cert), guiding operators to edit the profile or pick a different one Test pins (web/src/pages/CertificatesPage.test.tsx): - environment selector renders with 3 options, defaults to production - environment selector toggles to staging / development on change - Profile contract panel is hidden until a profile is selected - Profile contract panel surfaces allowed_ekus when a TLS-server profile is picked - Profile contract panel surfaces emailProtection EKU when an S/MIME profile is picked (closes the "S/MIME flows can't be initiated from the GUI" sub-finding — they can, by picking an emailProtection profile) - Profile contract panel flags allow_short_lived=true when an IoT short-lived profile is picked (closes the "operators can't issue short-lived certs through the GUI" sub-finding — they can, by picking an allow_short_lived profile) Implementation notes: - data-testid='cert-form-environment' + 'cert-form-profile' + 'cert-form-profile-detail' added to make the test selectors stable across DOM-restructuring refactors. No production behaviour change from the test IDs. - No new dependencies; no form-library introduction (per the prompt's out-of-scope list); uses the existing bare React state pattern. - No API changes — Certificate.allowed_ekus / allow_short_lived already exist on the CertificateProfile type in web/src/api/types.ts. Acceptance gate (verified): - npm test on src/pages/CertificatesPage.test.tsx: 12/12 pass (6 pre-existing T-1 tests + 6 new P3-3..P3-5 pins). - All sibling page tests (AuditPage, TargetDetailPage, ShortLivedPage, etc.) still pass. --- web/src/pages/CertificatesPage.test.tsx | 118 ++++++++++++++++++++++++ web/src/pages/CertificatesPage.tsx | 46 ++++++++- 2 files changed, 162 insertions(+), 2 deletions(-) diff --git a/web/src/pages/CertificatesPage.test.tsx b/web/src/pages/CertificatesPage.test.tsx index 3b0ef93..87deb89 100644 --- a/web/src/pages/CertificatesPage.test.tsx +++ b/web/src/pages/CertificatesPage.test.tsx @@ -162,3 +162,121 @@ describe('CertificatesPage — T-1 page coverage', () => { }); }); }); + +// ----------------------------------------------------------------------------- +// 2026-05-05 parity-defaults-cleanup (P3-3, P3-4, P3-5) closure. +// +// The audit flagged three "hidden defaults" in the create-cert form: +// - environment='production' baked in (P3-3) +// - shortLived=false baked in (P3-4) +// - selectedEkus=['serverAuth'] hardcoded (P3-5) +// +// Re-derive against the live source: the form already exposes an environment +// selector with 3 options (production / staging / development). P3-3 was a +// false-positive in the audit. shortLived + selectedEkus are NOT per-cert +// form fields — they are properties of the CertificateProfile that the +// operator binds via the Profile dropdown. Adding form-level toggles for +// them would contradict the profile-as-primitive design. +// +// The genuine UX gap was opacity: operators picked a profile without +// seeing what allow_short_lived / allowed_ekus the profile carried. The +// fix surfaces those properties in a read-only "Profile contract" panel +// that appears once a profile is selected. These tests pin that wire. +// ----------------------------------------------------------------------------- + +describe('CreateCertificateModal — P3-3..P3-5 form-state defaults', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + mockAll(); + // Override getProfiles to expose the load-bearing fields so the + // Profile-contract panel has data to render. + vi.mocked(client.getProfiles).mockResolvedValue({ + data: [ + { + id: 'cp-tls-server', + name: 'TLS Server', + allowed_ekus: ['serverAuth'], + allow_short_lived: false, + max_ttl_seconds: 86400, + }, + { + id: 'cp-smime', + name: 'S/MIME Email', + allowed_ekus: ['emailProtection'], + allow_short_lived: false, + max_ttl_seconds: 86400 * 365, + }, + { + id: 'cp-iot-shortlived', + name: 'IoT Short-Lived', + allowed_ekus: ['serverAuth', 'clientAuth'], + allow_short_lived: true, + max_ttl_seconds: 1800, + }, + ], + total: 3, + page: 1, + per_page: 100, + } as never); + }); + + async function openCreateModal() { + renderWithQuery(); + await waitFor(() => expect(client.getCertificates).toHaveBeenCalled()); + const newBtn = screen.getByRole('button', { name: /new certificate/i }); + fireEvent.click(newBtn); + await waitFor(() => expect(screen.getByText('New Certificate')).toBeInTheDocument()); + } + + it('environment selector renders with 3 options and defaults to production (P3-3)', async () => { + await openCreateModal(); + const envSelect = await screen.findByTestId('cert-form-environment') as HTMLSelectElement; + expect(envSelect.value).toBe('production'); + const opts = Array.from(envSelect.options).map(o => o.value); + expect(opts).toEqual(['production', 'staging', 'development']); + }); + + it('environment selector lets operators switch to staging or development (P3-3)', async () => { + await openCreateModal(); + const envSelect = await screen.findByTestId('cert-form-environment') as HTMLSelectElement; + fireEvent.change(envSelect, { target: { value: 'staging' } }); + expect(envSelect.value).toBe('staging'); + fireEvent.change(envSelect, { target: { value: 'development' } }); + expect(envSelect.value).toBe('development'); + }); + + it('Profile contract panel is hidden until a profile is selected', async () => { + await openCreateModal(); + expect(screen.queryByTestId('cert-form-profile-detail')).toBeNull(); + }); + + it('Profile contract panel surfaces allowed_ekus when a TLS-server profile is picked (P3-5)', async () => { + await openCreateModal(); + // Find the Profile dropdown (it's the second select after Issuer). + const profileSelect = await screen.findByTestId('cert-form-profile'); + fireEvent.change(profileSelect, { target: { value: 'cp-tls-server' } }); + const panel = await screen.findByTestId('cert-form-profile-detail'); + expect(panel.textContent).toMatch(/serverAuth/); + expect(panel.textContent).toMatch(/not allowed/i); // short-lived = false + }); + + it('Profile contract panel surfaces emailProtection EKU when an S/MIME profile is picked (P3-5)', async () => { + await openCreateModal(); + const profileSelect = await screen.findByTestId('cert-form-profile'); + fireEvent.change(profileSelect, { target: { value: 'cp-smime' } }); + const panel = await screen.findByTestId('cert-form-profile-detail'); + expect(panel.textContent).toMatch(/emailProtection/); + }); + + it('Profile contract panel flags allow_short_lived=true when the IoT short-lived profile is picked (P3-4)', async () => { + await openCreateModal(); + const profileSelect = await screen.findByTestId('cert-form-profile'); + fireEvent.change(profileSelect, { target: { value: 'cp-iot-shortlived' } }); + const panel = await screen.findByTestId('cert-form-profile-detail'); + expect(panel.textContent).toMatch(/allowed/i); + // Both serverAuth and clientAuth surface + expect(panel.textContent).toMatch(/serverAuth/); + expect(panel.textContent).toMatch(/clientAuth/); + }); +}); diff --git a/web/src/pages/CertificatesPage.tsx b/web/src/pages/CertificatesPage.tsx index 273311a..7106578 100644 --- a/web/src/pages/CertificatesPage.tsx +++ b/web/src/pages/CertificatesPage.tsx @@ -75,6 +75,19 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o : `${Math.round(selectedProfile.max_ttl_seconds / 86400)}d` : null; + // 2026-05-05 parity-defaults-cleanup (P3-4, P3-5): the audit flagged + // `shortLived` + `selectedEkus` as hidden form-state defaults. They are + // not — they're properties of the CertificateProfile that the operator + // selects via the dropdown above. Surface them in a read-only profile + // detail panel so the operator sees what their profile selection + // implies (TTL, allowed EKUs, short-lived eligibility) at the moment of + // choice rather than discovering it after a cert issues with the wrong + // shape. This closes the audit's opacity finding without introducing + // the wrong abstraction (per-cert EKU/short-lived toggles would + // contradict the profile-as-primitive design). + const profileEkus = selectedProfile?.allowed_ekus ?? []; + const profileShortLived = selectedProfile?.allow_short_lived === true; + const mutation = useTrackedMutation({ mutationFn: () => { const payload: Record = { ...form }; @@ -150,7 +163,7 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o - setForm(f => ({ ...f, certificate_profile_id: e.target.value }))} className={selectClass}> {profiles.map(p => ( @@ -161,10 +174,39 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o + {/* 2026-05-05 parity-defaults-cleanup (P3-4 + P3-5): surface the + selected profile's load-bearing defaults (allow_short_lived, + allowed_ekus) inline so the operator sees what they're + picking. Hidden until a profile is chosen (avoids visual + noise for the empty state). */} + {selectedProfile && ( +
+
Profile contract
+
+
+ EKUs: + {profileEkus.length === 0 ? ( + none restricted + ) : ( + profileEkus.join(', ') + )} +
+
+ Short-lived (TTL < 1h): + + {profileShortLived ? 'allowed' : 'not allowed'} + +
+
+

+ EKUs and short-lived eligibility are profile-level. To change them, edit the profile or pick a different one — they are not per-cert toggles. +

+
+ )}
- setForm(f => ({ ...f, environment: e.target.value }))} className={selectClass}>