diff --git a/web/src/pages/AuditPage.test.tsx b/web/src/pages/AuditPage.test.tsx new file mode 100644 index 0000000..26fb5da --- /dev/null +++ b/web/src/pages/AuditPage.test.tsx @@ -0,0 +1,104 @@ +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 } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +// ----------------------------------------------------------------------------- +// M-029 Pass 3 (Audit M-026): AuditPage XSS-hardening + render coverage. +// +// AuditPage renders audit-event rows with action / actor / actor_type / +// resource_type / resource_id / details fields. Audit events are written by +// the server but their detail fields can contain operator-supplied content +// (e.g., reason strings on certificate_revoked events). H-008 / M-022 already +// ship the redactor that scrubs PII + credentials from audit details, but the +// rendering path also has to be XSS-safe in case a non-PII free-text field +// (action, resource_type, etc.) reflects attacker-controllable bytes. +// +// Pins: +// 1. Page renders. +// 2. Audit events containing literal '; + +const xssEvent = { + id: 'ae-xss-001', + action: xssPayload, + actor: xssPayload, + actor_type: xssPayload, + resource_type: xssPayload, + resource_id: xssPayload, + details: { note: xssPayload }, + timestamp: new Date().toISOString(), +}; + +describe('AuditPage — 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 audit events resolve', async () => { + vi.mocked(client.getAuditEvents).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never); + renderWithQuery(); + await waitFor(() => { + expect(screen.getByText(/Audit/)).toBeInTheDocument(); + }); + }); + + it('does NOT execute '; + vi.mocked(client.previewDigest).mockResolvedValue(xssPayload as never); + + renderWithQuery(); + await waitFor(() => { + // Wait for the preview surface to render (the page settles into + // either the preview pane or an error pane — either way the + // page-load cycle is done by the time the header text appears). + expect(screen.getByText('Certificate Digest')).toBeInTheDocument(); + }); + + // No live script with our marker may be attached to the DOM, AND no + // global side-effect from the script body may have run. + const liveScripts = document.querySelectorAll('script[data-xss="digest-preview"]'); + expect(liveScripts.length, 'previewDigest payload must not inject a live ` as the key. +// React's JSX text-interpolation escapes by default, so the payload should +// render as literal text with no script execution; this test pins that +// invariant against future refactors that might switch to +// dangerouslySetInnerHTML or v-html-style rendering. +// +// Pins: +// 1. The login form renders. +// 2. An auth error containing a literal '; +let mockError: string | null = null; + +vi.mock('../components/AuthProvider', () => ({ + useAuth: () => ({ + loading: false, + authRequired: true, + authenticated: false, + authType: 'api-key', + user: '', + admin: false, + login: vi.fn(), + logout: vi.fn(), + error: mockError, + }), +})); + +import LoginPage from './LoginPage'; + +function renderWithRouter(ui: ReactNode) { + return render({ui}); +} + +describe('LoginPage — render + XSS hardening (M-026 / M-029 Pass 3)', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + mockError = null; + delete (window as unknown as { __xss_pwned__?: number }).__xss_pwned__; + }); + + it('renders the login form', () => { + renderWithRouter(); + expect(screen.getByLabelText('API Key')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Sign In/i })).toBeInTheDocument(); + }); + + it('does NOT execute a '; + +describe('ObservabilityPage — 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 metrics + health resolve', async () => { + vi.mocked(client.getMetrics).mockResolvedValue({ + uptime: { uptime_seconds: 3600, server_started: new Date().toISOString() }, + } as never); + vi.mocked(client.getHealth).mockResolvedValue({ status: 'ok' } as never); + vi.mocked(client.getPrometheusMetrics).mockResolvedValue('# HELP up The current up state\nup 1\n' as never); + + renderWithQuery(); + await waitFor(() => { + expect(screen.getByText('Observability')).toBeInTheDocument(); + }); + }); + + it('does NOT execute '; + +const xssCert = { + id: 'mc-xss-001', + name: xssPayload, + common_name: xssPayload, + status: 'Active', + environment: xssPayload, + issuer_id: xssPayload, + certificate_profile_id: 'cp-shortlived', + expires_at: inOneHour, + created_at: new Date().toISOString(), +}; + +describe('ShortLivedPage — 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 certs resolve', async () => { + vi.mocked(client.getCertificates).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never); + vi.mocked(client.getProfiles).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never); + renderWithQuery(); + await waitFor(() => { + expect(screen.getByText('Short-Lived Credentials')).toBeInTheDocument(); + }); + }); + + it('does NOT execute