mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 18:58:56 +00:00
6c00f7b0d3
CodeQL alert #36 (severity: HIGH, rule: js/regex/missing-regexp-anchor) fired on commita9e229b: 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).
227 lines
8.7 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|
|
|
|
});
|