Files
certctl/web/src/hooks/useListParams.test.tsx
T
Shankar 6993e4484c fix(bundle-8): Frontend Hardening — 2 audit findings closed + 3 partial
Closes Audit-2026-04-25 L-015 (Low) and L-019 (Low) — both
verified-already-clean at HEAD; new CI regression guards prevent
regression. Partial closures for M-009, M-010, M-026 — Bundle 8 ships
the helpers + contract tests + a soft CI budget guard, defers the
long-tail per-page migrations to a new tracker ID M-029.

What changed
- web/src/utils/safeHtml.ts (NEW) — sanitizeHtml() chokepoint for
  any future code that genuinely needs dangerouslySetInnerHTML.
  Bundle-8 placeholder body throws — DOMPurify dependency is the
  activation procedure documented in the file header.
- web/src/components/ExternalLink.tsx (NEW) — single chokepoint for
  target="_blank" anchors. Hardcodes rel="noopener noreferrer".
- web/src/hooks/useListParams.ts (NEW) — URL-state hook for filter /
  sort / pagination state on list pages. Canonicalises the existing
  DashboardPage useSearchParams pattern. Per-page migrations of the
  ~14 remaining list pages tracked as M-029.
- web/src/hooks/useTrackedMutation.ts (NEW) — useMutation wrapper
  enforcing the M-009 invalidation contract via discriminated-union
  type: caller MUST declare invalidates: QueryKey[] OR
  invalidates: 'noop' + noopReason: string.
- 4 new Vitest test files — full unit coverage for ExternalLink
  (target/rel preservation), safeHtml (placeholder throws + activation
  hint), useListParams (URL contract / defaults / filter-resets-page),
  useTrackedMutation (invalidate-then-onSuccess / noop variant).
- .github/workflows/ci.yml — three new regression guards:
    Bundle-8 / L-015: greps for any target="_blank" outside ExternalLink
      that lacks rel="noopener noreferrer"; clean at HEAD.
    Bundle-8 / L-019: greps for any dangerouslySetInnerHTML outside
      safeHtml.ts; clean at HEAD (0 sites).
    Bundle-8 / M-009: SOFT budget guard — useMutation sites must not
      exceed invalidation sites + 5. At HEAD: 61 mutations vs 82
      invalidations + 5 = 87 budget. Stricter per-site enforcement
      tracked as M-029.

Verification at HEAD
- web/src/ target=_blank sites: 3 (all in OnboardingWizard.tsx)
  — all three already carry rel="noopener noreferrer". L-015 closed.
- web/src/ dangerouslySetInnerHTML sites: 0. L-019 closed.
- useMutation sites: 61 / invalidateQueries: 82 (M-009 budget healthy)

Per-finding mapping
- L-015 closed (CWE-1022) — verified-already-clean + ExternalLink
  component + CI grep guard.
- L-019 closed (CWE-79) — verified-already-clean + safeHtml chokepoint
  + CI grep guard.
- M-009 partial — useTrackedMutation wrapper authored; soft CI budget
  guard. Migrating the 56 existing useMutation sites to the wrapper
  tracked as M-029.
- M-010 partial — useListParams hook authored + tested. Per-page
  migration of the ~14 list pages tracked as M-029.
- M-026 partial — bundle-prompt called for XSS-hardening tests on the
  T-1 deferred allowlist of 14 pages. Bundle 8 ships the testing
  pattern via the new helpers but does NOT execute the per-page
  migrations — tracked as M-029.

NOT addressed in this bundle (deferred to M-029)
- Migrating existing 56 useMutation sites to useTrackedMutation
- Migrating ~14 list pages from local useState to useListParams
- Adding XSS-hardening tests to the 14 T-1-deferred pages

Verification
- npx tsc --noEmit                                     → clean
- npx vitest run on the 4 new Bundle-8 test files     → 15/15 pass
- L-015 grep guard simulation                          → clean
- L-019 grep guard simulation                          → clean
- M-009 budget simulation                              → 61 ≤ 87 (clean)
- go vet ./...                                         → clean (no backend changes)
- python3 yaml.safe_load(api/openapi.yaml)             → clean
- python3 yaml.safe_load(.github/workflows/ci.yml)     → clean

Backwards compatibility
- All 4 new helper files are additive; no existing call sites were
  modified. Existing list pages keep their useState pagination until
  M-029 ships per-page migrations.

Bundle 8 of the 2026-04-25 comprehensive audit. Per-page migration
backlog tracked as new audit finding M-029.
2026-04-26 15:10:32 +00:00

98 lines
3.7 KiB
TypeScript

// Bundle-8 / Audit M-010:
// regression coverage for useListParams. Exercises the URL contract
// (page/page_size/sort/filter[*]), default omission (defaults stay out
// of the URL), filter-resets-page invariant, and resetParams.
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { MemoryRouter, useSearchParams } from 'react-router-dom';
import { useListParams } from './useListParams';
import type { ReactNode } from 'react';
function wrapper(initialEntries: string[]) {
return ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={initialEntries}>{children}</MemoryRouter>
);
}
describe('useListParams — Bundle-8 / M-010', () => {
it('reads defaults when URL is empty', () => {
const { result } = renderHook(() => useListParams(), { wrapper: wrapper(['/']) });
expect(result.current.params.page).toBe(1);
expect(result.current.params.pageSize).toBe(25);
expect(result.current.params.sort).toBe('');
expect(result.current.params.filters).toEqual({});
});
it('parses page/page_size/sort/filter[*] from the URL', () => {
const { result } = renderHook(() => useListParams(), {
wrapper: wrapper(['/?page=3&page_size=50&sort=-created_at&filter[status]=active&filter[team_id]=t-platform']),
});
expect(result.current.params.page).toBe(3);
expect(result.current.params.pageSize).toBe(50);
expect(result.current.params.sort).toBe('-created_at');
expect(result.current.params.filters).toEqual({
status: 'active',
team_id: 't-platform',
});
});
it('honours per-call defaults overrides', () => {
const { result } = renderHook(
() => useListParams({ pageSize: 100, sort: 'name' }),
{ wrapper: wrapper(['/']) },
);
expect(result.current.params.pageSize).toBe(100);
expect(result.current.params.sort).toBe('name');
});
it('rejects garbage page values and falls back to default', () => {
const { result } = renderHook(() => useListParams(), {
wrapper: wrapper(['/?page=not-a-number&page_size=-5']),
});
expect(result.current.params.page).toBe(1);
expect(result.current.params.pageSize).toBe(25);
});
it('omits defaults from the URL on update', () => {
function Hookrunner() {
const [params] = useSearchParams();
const list = useListParams();
return { params, list };
}
const { result } = renderHook(() => Hookrunner(), { wrapper: wrapper(['/']) });
act(() => result.current.list.setPage(2));
expect(result.current.params.get('page')).toBe('2');
act(() => result.current.list.setPage(1));
expect(result.current.params.get('page')).toBeNull(); // default omitted
});
it('filter changes reset page to 1', () => {
function Hookrunner() {
const [params] = useSearchParams();
const list = useListParams();
return { params, list };
}
const { result } = renderHook(() => Hookrunner(), { wrapper: wrapper(['/?page=5']) });
expect(result.current.params.get('page')).toBe('5');
act(() => result.current.list.setFilter('status', 'active'));
// page key removed because the setter resets pagination on filter change
expect(result.current.params.get('page')).toBeNull();
expect(result.current.params.get('filter[status]')).toBe('active');
});
it('resetParams clears every search param', () => {
function Hookrunner() {
const [params] = useSearchParams();
const list = useListParams();
return { params, list };
}
const { result } = renderHook(() => Hookrunner(), {
wrapper: wrapper(['/?page=2&filter[status]=active']),
});
act(() => result.current.list.resetParams());
expect(Array.from(result.current.params.keys())).toEqual([]);
});
});