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