mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 21:28:53 +00:00
0987e222dd
CI failure on Phase 3 commit (e761ae40):
FAIL src/pages/auth/UsersPage.test.tsx > 8 tests (all)
Error: useLocation() may be used only in the context of a <Router> component.
Root cause:
Phase 3 wired <Breadcrumbs /> into PageHeader (UX-M5 closure). UsersPage
renders PageHeader at the top of its tree. UsersPage.test.tsx was the
only auth-page test file whose renderWithProviders helper lacked a
MemoryRouter wrapper — every other sibling (BreakglassPage, KeysPage,
OIDCProvidersPage, SessionsPage, RolesPage, AuthSettingsPage,
ApprovalsPage, etc.) already wraps in MemoryRouter. The 2026-05-11
MED-11 closure that shipped UsersPage + 8 tests predated Phase 3 and so
predated the need for Router context in test trees.
Fix is two-layered:
(1) Targeted — add MemoryRouter to UsersPage.test.tsx renderWithProviders
so the test tree has the same Router context the production tree gets
from <BrowserRouter> in main.tsx.
(2) Defensive — Breadcrumbs.tsx now gates useLocation() behind
useInRouterContext(). If a future test mounts PageHeader (or any
other Breadcrumbs consumer) without a Router wrapper, the component
renders null instead of crashing. The actual useLocation() + render
work moves into a BreadcrumbsInner sub-component called only after
the Router-context check passes. This prevents the same class of
failure ever happening again — any new auth-page test author who
forgets MemoryRouter will see a missing breadcrumb (cosmetic),
not 8 red test failures.
Verification (sandbox):
• TypeScript clean — npx tsc --noEmit exits 0
• UsersPage suite — 8/8 green (was 0/8 in CI)
• Breadcrumbs suite — 8/8 green
• All sibling auth tests — 72/72 green (BreakglassPage 6 + KeysPage 7
+ OIDCProvidersPage 13 + SessionsPage 11 + RolesPage 6 +
AuthSettingsPage 6 + ApprovalsPage 23). Unchanged because they
already had MemoryRouter; pinned to confirm defensive guard didn't
regress them.
CI expectation: web-test job goes from red to green on next push.
No behavior change to production — Breadcrumbs still renders identically
under <BrowserRouter> at runtime; useInRouterContext returns true and
delegates to BreadcrumbsInner unchanged.
Touches:
web/src/components/Breadcrumbs.tsx (+14 / -2)
web/src/pages/auth/UsersPage.test.tsx (+8 / -1)
177 lines
6.1 KiB
TypeScript
177 lines
6.1 KiB
TypeScript
// Copyright 2026 certctl LLC. All rights reserved.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
//
|
|
// Breadcrumbs — Phase 3 closure for UX-M5 (zero breadcrumb component,
|
|
// zero navigate(-1), 3-deep routes like issuers/:id/hierarchy have no
|
|
// wayfinding).
|
|
//
|
|
// Implementation note: the audit prompt suggested useMatches() + per-
|
|
// route handle.crumb. That requires React Router v6's data-router
|
|
// (createBrowserRouter), but the certctl app currently uses the JSX
|
|
// <BrowserRouter> form. Migrating the router config is its own
|
|
// phase-sized effort with non-trivial blast radius (every Route
|
|
// element, every test's MemoryRouter wrapper). Instead, this version
|
|
// uses useLocation() to read the current pathname + walks the
|
|
// segments, mapping each one to a label via the static
|
|
// pathSegmentLabels lookup below. Limitations: only the top-level +
|
|
// detail-route segments get a label (anything matching /:id/.../ at a
|
|
// depth > 2 falls back to the literal segment). Sufficient for the
|
|
// 3-deep routes the audit flagged (e.g. /issuers/:id/hierarchy);
|
|
// upgrading to data-router-driven crumbs is a future task once the
|
|
// router migration ships.
|
|
|
|
import { Link, useLocation, useInRouterContext } from 'react-router-dom';
|
|
import { ChevronRight } from 'lucide-react';
|
|
|
|
// pathSegmentLabels — map first-segment URL keys to human labels.
|
|
// Add entries here as new top-level routes land. Lookup is exact-
|
|
// match on the first path segment; subsequent segments are heuristics
|
|
// (see crumbsFor below).
|
|
const pathSegmentLabels: Record<string, string> = {
|
|
certificates: 'Certificates',
|
|
issuers: 'Issuers',
|
|
agents: 'Agents',
|
|
targets: 'Targets',
|
|
jobs: 'Jobs',
|
|
notifications: 'Notifications',
|
|
policies: 'Policies',
|
|
'renewal-policies': 'Renewal Policies',
|
|
profiles: 'Profiles',
|
|
owners: 'Owners',
|
|
teams: 'Teams',
|
|
'agent-groups': 'Agent Groups',
|
|
audit: 'Audit Trail',
|
|
'short-lived': 'Short-Lived',
|
|
fleet: 'Fleet Overview',
|
|
discovery: 'Discovery',
|
|
'network-scans': 'Network Scans',
|
|
'health-monitor': 'Health Monitor',
|
|
digest: 'Digest',
|
|
observability: 'Observability',
|
|
scep: 'SCEP Admin',
|
|
est: 'EST Admin',
|
|
auth: 'Access',
|
|
};
|
|
|
|
// Auth-subtree subsegments (e.g. /auth/oidc/providers).
|
|
const authSubsegmentLabels: Record<string, string> = {
|
|
oidc: 'OIDC',
|
|
providers: 'Providers',
|
|
sessions: 'Sessions',
|
|
users: 'Users',
|
|
roles: 'Roles',
|
|
keys: 'API Keys',
|
|
approvals: 'Approvals',
|
|
breakglass: 'Break-glass',
|
|
settings: 'Auth Settings',
|
|
};
|
|
|
|
interface Crumb {
|
|
pathname: string;
|
|
label: string;
|
|
isLast: boolean;
|
|
}
|
|
|
|
function crumbsFor(pathname: string): Crumb[] {
|
|
// Dashboard root produces no breadcrumb trail — the title alone
|
|
// suffices.
|
|
if (pathname === '/' || pathname === '') return [];
|
|
|
|
const segments = pathname.split('/').filter(Boolean);
|
|
if (segments.length === 0) return [];
|
|
|
|
// The Dashboard ("Home") crumb is always the first hop.
|
|
const out: Crumb[] = [{ pathname: '/', label: 'Home', isLast: false }];
|
|
|
|
// First segment — top-level route.
|
|
const first = segments[0]!;
|
|
const firstLabel = pathSegmentLabels[first] ?? first;
|
|
out.push({
|
|
pathname: '/' + first,
|
|
label: firstLabel,
|
|
isLast: segments.length === 1,
|
|
});
|
|
|
|
// Subsequent segments — heuristics:
|
|
// - /auth/<sub>[/...] uses authSubsegmentLabels for each piece
|
|
// - any other segment that looks like an :id (starts with a
|
|
// known prefix or is hex/random) becomes "Detail"
|
|
// - terminal /hierarchy on /issuers/:id/hierarchy → "Hierarchy"
|
|
let acc = '/' + first;
|
|
for (let i = 1; i < segments.length; i++) {
|
|
const seg = segments[i]!;
|
|
acc += '/' + seg;
|
|
let label: string;
|
|
if (first === 'auth') {
|
|
label = authSubsegmentLabels[seg] ?? seg;
|
|
} else if (seg === 'hierarchy') {
|
|
label = 'Hierarchy';
|
|
} else if (looksLikeID(seg)) {
|
|
label = 'Detail';
|
|
} else {
|
|
label = seg;
|
|
}
|
|
out.push({ pathname: acc, label, isLast: i === segments.length - 1 });
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
/** ID-shape heuristic — certctl IDs look like cert-001, iss-vault, t-iis-prod. */
|
|
function looksLikeID(s: string): boolean {
|
|
// Anything with a hyphen is treated as an ID for breadcrumb purposes.
|
|
// Hyphenated segments that aren't IDs (renewal-policies, agent-groups,
|
|
// network-scans, health-monitor, short-lived) are top-level routes
|
|
// resolved by pathSegmentLabels BEFORE this heuristic fires.
|
|
return s.includes('-') || /^[a-f0-9]{8,}$/i.test(s);
|
|
}
|
|
|
|
// Breadcrumbs is the public entry. Defensive against missing Router
|
|
// context (a test that mounts a PageHeader without a <MemoryRouter>
|
|
// wrapper used to crash here). useLocation() throws an invariant
|
|
// error if there's no Router; gate it behind useInRouterContext()
|
|
// + render the actual logic in a sibling so useLocation() is only
|
|
// called when we know the context is present.
|
|
export default function Breadcrumbs() {
|
|
const inRouter = useInRouterContext();
|
|
if (!inRouter) return null;
|
|
return <BreadcrumbsInner />;
|
|
}
|
|
|
|
function BreadcrumbsInner() {
|
|
const { pathname } = useLocation();
|
|
const crumbs = crumbsFor(pathname);
|
|
|
|
if (crumbs.length === 0) return null;
|
|
|
|
return (
|
|
<nav aria-label="Breadcrumb" className="mb-1">
|
|
<ol className="flex items-center gap-1 text-xs text-ink-muted">
|
|
{crumbs.map((c, i) => (
|
|
<li key={c.pathname} className="flex items-center gap-1">
|
|
{i > 0 && (
|
|
<ChevronRight
|
|
className="w-3 h-3 text-ink-faint shrink-0"
|
|
strokeWidth={1.5}
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
{c.isLast ? (
|
|
<span aria-current="page" className="text-ink font-medium">
|
|
{c.label}
|
|
</span>
|
|
) : (
|
|
<Link
|
|
to={c.pathname}
|
|
className="hover:text-brand-500 hover:underline transition-colors"
|
|
>
|
|
{c.label}
|
|
</Link>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</nav>
|
|
);
|
|
}
|