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}>