Files
certctl/web/src/__tests__/multi-page-flows.test.tsx
T
shankar0123 6c00f7b0d3 fix(web): Hotfix #11 — CodeQL #36 js/regex/missing-regexp-anchor in multi-page-flows test
CodeQL alert #36 (severity: HIGH, rule: js/regex/missing-regexp-anchor)
fired on commit a9e229b:

  web/src/__tests__/multi-page-flows.test.tsx:161
    Missing regular expression anchor
    When this is used as a regular expression on a URL, it may
    match anywhere, and arbitrary hosts may come before or after it.

Root cause:
  Phase 8's TEST-M1 multi-page-flow test verifies the
  CertificateDetailPage surfaces the same common_name the list row
  showed. The original assertion used a case-insensitive regex
  matcher:

    screen.getAllByText(/api\.example\.com/i)

  CodeQL's heuristic flagged this as URL-shaped (literal-dot
  pattern with TLD structure) and missing `^`/`$` anchors. The
  rule exists because unanchored URL regexes are dangerous in
  security contexts (host-allowlist sanitizers). This is a test
  file matching DOM text content — not URL sanitization — so the
  alert is technically a false positive in semantic terms.

  But CodeQL is correct that the pattern READS as a URL regex,
  and a future engineer copy-pasting this matcher into actual
  validation code would inherit the vuln. Best to remove the
  unanchored-regex pattern from the codebase at the source.

Fix:
  Switch from a regex matcher to testing-library's function
  matcher with a plain-string `.includes()`. Same case-insensitive
  substring semantics, zero regex for CodeQL to flag:

    screen.getAllByText((content) =>
      content.toLowerCase().includes('api.example.com'),
    )

  The function form is also more accurate for what the test
  actually checks: the detail page may render the cn inside a
  labelled cell ("Common name: api.example.com"), so substring
  match is the intended semantic. Comment block above the
  assertion documents the rationale so a future refactor doesn't
  re-introduce a URL-shaped regex.

  Other unanchored regexes elsewhere in the test suite
  (`screen.getByText(/UTC/)`, `/2026/`, `/Enabled/`, etc.) do
  NOT pattern-match as URL-shaped and have passed prior CodeQL
  scans — not touching them. Over-reach has its own cost.

Verification:
  • npx tsc --noEmit — exit 0
  • npx vitest run src/__tests__/multi-page-flows.test.tsx — 3/3 pass
  • npx vite build — ✓ built in 3.31s
  • All 48 CI guards pass
  • origin/master ground-truthed via GitHub API (4909691) BEFORE
    commit per the operating rule

Falsifiable proof: CodeQL re-scan on push should auto-close #36
(rule no longer has a matching pattern at multi-page-flows.test.tsx:161).
2026-05-14 18:58:22 +00:00

227 lines
8.7 KiB
TypeScript

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Phase 8 closure for TEST-M1 — full-flow happy-path tests at the
// Vitest layer using MemoryRouter for 2-3-page navigation. These are
// cheap relative to Playwright (no real browser, no webServer startup
// cost — ~200ms each) and catch the dominant regression class for
// route-level + cross-page-state bugs that per-page tests miss by
// construction.
//
// Why this layer matters:
// • Per-page tests mount one page in isolation. They miss "click on
// a row in page A navigates to page B which loads data X".
// • Playwright catches everything but at 5-second startup cost per
// run. Reserving Playwright for the 5 priority customer flows
// (Phase 8 TEST-H1) keeps CI runtime sane.
// • Vitest MemoryRouter flows hit the React Router + TanStack Query
// wiring that pure unit tests skip. If a route's `enabled:` gate
// or a queryKey shape regresses, this layer screams.
//
// Mocking posture: same as the per-page tests — vi.mock the api/client
// module and resolve fixtures synchronously. The flows differ from
// per-page tests in WHAT they assert (cross-page transitions + data
// continuity) not in HOW they mock.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import type { ReactNode } from 'react';
// Mock the api/client module by inheriting all real exports via
// importActual + overriding the network-touching functions with
// vi.fn(). This avoids the whack-a-mole of listing every export the
// imported pages happen to touch (each page transitively pulls more
// functions than the flow under test actually uses). The imported
// pages compile + run; only network functions are mocked.
vi.mock('../api/client', async () => {
const actual = await vi.importActual<typeof import('../api/client')>('../api/client');
// Replace every fn-shaped export with a vi.fn so the test can
// override return values per-case. Non-fn exports (types, constants
// like REVOCATION_REASONS) pass through unchanged.
const mocked: Record<string, unknown> = { ...actual };
for (const [k, v] of Object.entries(actual)) {
if (typeof v === 'function') {
mocked[k] = vi.fn().mockResolvedValue(undefined);
}
}
// getApiKey is not a network fn — keep a sync stub.
mocked.getApiKey = vi.fn(() => 'mock-api-key');
return mocked;
});
vi.mock('../hooks/useAuthMe', () => ({
useAuthMe: () => ({
data: {
id: 'actor-admin',
display_name: 'Admin',
effective_permissions: ['*'],
},
isLoading: false,
error: null,
}),
}));
import * as client from '../api/client';
import CertificatesPage from '../pages/CertificatesPage';
import CertificateDetailPage from '../pages/CertificateDetailPage';
import IssuersPage from '../pages/IssuersPage';
import IssuerDetailPage from '../pages/IssuerDetailPage';
function renderWithRouter(ui: ReactNode, initialEntries: string[]) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={initialEntries}>
{ui}
</MemoryRouter>
</QueryClientProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
const baseIssuer = {
id: 'iss-vault',
name: 'HashiCorp Vault',
type: 'vault',
enabled: true,
status: 'Active',
source: 'user',
config: {},
created_at: '2026-01-01T00:00:00Z',
} as never;
// Cast to never to bypass exhaustive-interface checks — test fixtures
// only need the fields the page rendering touches, not the full surface
// of the live API type.
const baseCert = {
id: 'cert-001',
name: 'Production API',
common_name: 'api.example.com',
status: 'Active',
issuer_id: 'iss-vault',
owner_id: 'o-alice',
team_id: 't-platform',
renewal_policy_id: 'rp-default',
environment: 'production',
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
expires_at: '2027-05-01T00:00:00Z',
not_after: '2027-05-01T00:00:00Z',
not_before: '2026-05-01T00:00:00Z',
certificate_profile_id: null,
sans: [],
tags: [],
} as never;
describe('Multi-page Vitest flows — Phase 8 TEST-M1', () => {
describe('Certificates list → detail row click → CertificateDetailPage data continuity', () => {
it('clicking a certificate row navigates to /certificates/:id and the detail page loads the same cert', async () => {
vi.mocked(client.getCertificates).mockResolvedValue({
data: [baseCert],
total: 1,
page: 1,
per_page: 25,
});
vi.mocked(client.getCertificate).mockResolvedValue(baseCert);
vi.mocked(client.getCertificateVersions).mockResolvedValue([] as never);
vi.mocked(client.getTargets).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 25 });
vi.mocked(client.getJobs).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 25 });
vi.mocked(client.getProfile).mockResolvedValue(undefined as never);
renderWithRouter(
<Routes>
<Route path="/certificates" element={<CertificatesPage />} />
<Route path="/certificates/:id" element={<CertificateDetailPage />} />
</Routes>,
['/certificates'],
);
// 1. List page renders the row.
await waitFor(() => expect(screen.getAllByText('api.example.com')[0]).toBeInTheDocument());
expect(vi.mocked(client.getCertificates)).toHaveBeenCalled();
// 2. Click the row — DataTable wires onRowClick to navigate.
fireEvent.click(screen.getAllByText('api.example.com')[0]);
// 3. Detail page mounted with the same id → calls getCertificate('cert-001').
await waitFor(() => {
expect(vi.mocked(client.getCertificate)).toHaveBeenCalledWith('cert-001');
});
// 4. Detail page surfaces the same common_name the list showed.
// Function matcher (NOT regex) — closes CodeQL alert #36
// (js/regex/missing-regexp-anchor). Same case-insensitive
// substring semantics as the original /api\.example\.com/i but
// no regex for CodeQL to flag. Function form also tolerates the
// detail page rendering the cn inside a labelled cell ("Common
// name: api.example.com") where exact-match string would fail.
await waitFor(() => {
expect(
screen.getAllByText((content) =>
content.toLowerCase().includes('api.example.com'),
).length,
).toBeGreaterThan(0);
});
});
it('navigation preserves the cert id from URL — direct deep-link to /certificates/:id works without a list pre-fetch', async () => {
vi.mocked(client.getCertificate).mockResolvedValue(baseCert);
vi.mocked(client.getCertificateVersions).mockResolvedValue([] as never);
vi.mocked(client.getTargets).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 25 });
vi.mocked(client.getJobs).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 25 });
vi.mocked(client.getProfile).mockResolvedValue(undefined as never);
renderWithRouter(
<Routes>
<Route path="/certificates/:id" element={<CertificateDetailPage />} />
</Routes>,
['/certificates/cert-001'],
);
await waitFor(() => {
expect(vi.mocked(client.getCertificate)).toHaveBeenCalledWith('cert-001');
});
expect(vi.mocked(client.getCertificates)).not.toHaveBeenCalled();
});
});
describe('Issuers list → row click → IssuerDetailPage data continuity', () => {
it('clicking an issuer row navigates to /issuers/:id and the detail page loads the same issuer', async () => {
vi.mocked(client.getIssuers).mockResolvedValue({
data: [baseIssuer],
total: 1,
page: 1,
per_page: 25,
});
vi.mocked(client.getIssuer).mockResolvedValue(baseIssuer);
vi.mocked(client.getCertificates).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 25 });
renderWithRouter(
<Routes>
<Route path="/issuers" element={<IssuersPage />} />
<Route path="/issuers/:id" element={<IssuerDetailPage />} />
</Routes>,
['/issuers'],
);
await waitFor(() => expect(screen.getByText('HashiCorp Vault')).toBeInTheDocument());
expect(vi.mocked(client.getIssuers)).toHaveBeenCalled();
fireEvent.click(screen.getByText('HashiCorp Vault'));
await waitFor(() => {
expect(vi.mocked(client.getIssuer)).toHaveBeenCalledWith('iss-vault');
});
});
});
});