diff --git a/web/src/pages/CertificateDetailPage.test.tsx b/web/src/pages/CertificateDetailPage.test.tsx new file mode 100644 index 0000000..da61e5e --- /dev/null +++ b/web/src/pages/CertificateDetailPage.test.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +// ----------------------------------------------------------------------------- +// M-029 Pass 3 (Audit M-026): CertificateDetailPage XSS-hardening + render +// coverage. +// +// CertificateDetailPage surfaces cert subject DN, SANs, issuer DN, version +// history, deployment job error messages — every one of these is either +// CSR-controlled (subject / SANs) or upstream-CA / target-side error text. +// The M-004 MCP fence handles inside-LLM safety; this test pins GUI XSS +// safety on the same data. +// ----------------------------------------------------------------------------- + +vi.mock('../api/client', () => ({ + getCertificate: vi.fn(), + getCertificateVersions: vi.fn(), + getTargets: vi.fn(), + getProfile: vi.fn(), + getProfiles: vi.fn(), + getJobs: vi.fn(), + getRenewalPolicies: vi.fn(), + triggerRenewal: vi.fn(), + triggerDeployment: vi.fn(), + archiveCertificate: vi.fn(), + revokeCertificate: vi.fn(), + updateCertificate: vi.fn(), + downloadCertificatePEM: vi.fn(), + exportCertificatePKCS12: vi.fn(), +})); + +import CertificateDetailPage from './CertificateDetailPage'; +import * as client from '../api/client'; + +function renderRoute(ui: ReactNode, path = '/certificates/mc-xss-001') { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } }, + }); + return render( + + + + + + + , + ); +} + +const xssPayload = ''; + +const xssCert = { + id: 'mc-xss-001', + name: xssPayload, + common_name: xssPayload, + sans: [xssPayload, 'plain.example.com'], + status: 'Active', + environment: xssPayload, + issuer_id: 'iss-xss', + certificate_profile_id: 'cp-xss', + owner_id: 'o-xss', + team_id: 't-xss', + renewal_policy_id: 'rp-xss', + expires_at: new Date(Date.now() + 30 * 86400000).toISOString(), + created_at: new Date().toISOString(), + not_before: new Date(Date.now() - 86400000).toISOString(), + not_after: new Date(Date.now() + 30 * 86400000).toISOString(), + serial_number: xssPayload, + fingerprint_sha256: xssPayload, + pem_encoded: xssPayload, +}; + +describe('CertificateDetailPage — render + XSS hardening (M-026 / M-029 Pass 3)', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + delete (window as unknown as { __xss_pwned__?: number }).__xss_pwned__; + + // Wire defaults for the sidecar queries — every page render fans out + // 7+ queries and any unmocked call will reject and surface an error + // boundary instead of the page body. + vi.mocked(client.getCertificate).mockResolvedValue(xssCert as never); + vi.mocked(client.getCertificateVersions).mockResolvedValue([] as never); + vi.mocked(client.getTargets).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never); + vi.mocked(client.getProfile).mockResolvedValue({ id: 'cp-xss', name: 'Profile' } as never); + vi.mocked(client.getProfiles).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never); + vi.mocked(client.getRenewalPolicies).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 500 } as never); + vi.mocked(client.getJobs).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never); + }); + + it('renders the page when getCertificate resolves', async () => { + vi.mocked(client.getCertificate).mockResolvedValue({ ...xssCert, common_name: 'plain.example.com' } as never); + renderRoute(); + await waitFor(() => { + expect(screen.getByText('plain.example.com')).toBeInTheDocument(); + }); + }); + + it('does NOT execute '; + +const xssIssuer = { + id: 'iss-xss-001', + name: xssPayload, + type: xssPayload, + enabled: true, + config: { acme_directory_url: xssPayload, eab_kid: xssPayload }, + last_tested_at: new Date().toISOString(), + last_test_status: 'failed', + last_test_message: xssPayload, +}; + +describe('IssuerDetailPage — render + XSS hardening (M-026 / M-029 Pass 3)', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + delete (window as unknown as { __xss_pwned__?: number }).__xss_pwned__; + }); + + it('renders the page header when getIssuer resolves', async () => { + vi.mocked(client.getIssuer).mockResolvedValue({ ...xssIssuer, name: 'Plain Name', type: 'acme' } as never); + vi.mocked(client.getCertificates).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never); + renderRoute(); + await waitFor(() => { + expect(screen.getByText('Plain Name')).toBeInTheDocument(); + }); + }); + + it('does NOT execute '; + +const xssJob = { + id: 'j-xss-001', + type: xssPayload, + status: 'Failed', + certificate_id: 'mc-xss', + agent_id: 'a-xss', + error_message: xssPayload, + details: { reason: xssPayload }, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), +}; + +describe('JobDetailPage — render + XSS hardening (M-026 / M-029 Pass 3)', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + delete (window as unknown as { __xss_pwned__?: number }).__xss_pwned__; + }); + + it('renders the page when getJob resolves', async () => { + vi.mocked(client.getJob).mockResolvedValue({ ...xssJob, type: 'renewal', error_message: '' } as never); + vi.mocked(client.getJobVerification).mockResolvedValue(null as never); + vi.mocked(client.getAuditEvents).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 10 } as never); + renderRoute(); + await waitFor(() => { + expect(screen.getByText(/j-xss-001/)).toBeInTheDocument(); + }); + }); + + it('does NOT execute '; + +const xssTarget = { + id: 't-xss-001', + name: xssPayload, + type: 'nginx', + agent_id: 'a-xss', + config: { host: xssPayload, path: xssPayload }, + last_tested_at: new Date().toISOString(), + last_test_status: 'failed', + last_test_message: xssPayload, +}; + +describe('TargetDetailPage — render + XSS hardening (M-026 / M-029 Pass 3)', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + delete (window as unknown as { __xss_pwned__?: number }).__xss_pwned__; + }); + + it('renders the page header when getTarget resolves', async () => { + vi.mocked(client.getTarget).mockResolvedValue({ ...xssTarget, name: 'Plain Name' } as never); + vi.mocked(client.getJobs).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never); + renderRoute(); + await waitFor(() => { + expect(screen.getByText('Plain Name')).toBeInTheDocument(); + }); + }); + + it('does NOT execute