feat(ui): show the attack path in the Issues panel and rename the menu entry (#13195)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro BailoandClaude Opus 5.5 authored and GitHub committed 2026-10-29 10:43:42 +01:00
1 parent 814ae54d6a
commit ca6ed48664
20 files changed
+461 -109

No files matched your search

+2 -2
View File
@@ -13,8 +13,8 @@ vi.mock("@/actions/issues", () => ({ getIssue }));
vi.mock("@/components/shadcn/content-layout", () => ({
ContentLayout: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@/components/shared/feature-flag-gate", () => ({
FeatureFlagGate: ({ children }: { children: ReactNode }) => children,
vi.mock("@/components/shared/feature-flag-route", () => ({
FeatureFlagRoute: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@/components/issues/issue-detail", () => ({
IssueDetail: ({ scanId }: { scanId?: string }) => (
+3 -10
View File
@@ -9,7 +9,7 @@ import { Button } from "@/components/shadcn/button/button";
import { Card } from "@/components/shadcn/card/card";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { EntityInfo } from "@/components/shadcn/entities";
import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
import { FeatureFlagRoute } from "@/components/shared/feature-flag-route";
import { FEATURE_FLAG } from "@/lib/feature-flags";
import { VERDICTS } from "@/lib/issues/verdicts";
import { isCloud } from "@/lib/shared/env";
@@ -34,14 +34,7 @@ export default async function IssuePage({
return (
<ContentLayout title="Issue" icon={<ShieldAlert />}>
<FeatureFlagGate
flag={FEATURE_FLAG.ISSUES}
fallback={
<p className="text-text-neutral-tertiary text-sm">
Issues is not available for this account yet.
</p>
}
>
<FeatureFlagRoute flag={FEATURE_FLAG.ISSUES}>
<div className="flex min-w-0 flex-col gap-4">
<Button asChild variant="link" size="link-sm" className="self-start">
<Link href="/issues">← All issues</Link>
@@ -70,7 +63,7 @@ export default async function IssuePage({
/>
</Card>
</div>
</FeatureFlagGate>
</FeatureFlagRoute>
</ContentLayout>
);
}
+5 -12
View File
@@ -7,7 +7,7 @@ import { getAllProviders } from "@/actions/providers";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
import { FeatureFlagRoute } from "@/components/shared/feature-flag-route";
import { FEATURE_FLAG } from "@/lib/feature-flags";
import { isCloud } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
@@ -29,16 +29,9 @@ export default async function Issues({
return (
<ContentLayout title="Issues" icon={<ShieldAlert />}>
{/* Deployment gate above (notFound); per-account rollout here. The route
stays reachable by URL, so the off state explains itself. */}
<FeatureFlagGate
flag={FEATURE_FLAG.ISSUES}
fallback={
<p className="text-text-neutral-tertiary text-sm">
Issues is not available for this account yet.
</p>
}
>
{/* Deployment gate above (notFound); per-account rollout here, also a
redirect to the overview when off, like the hidden menu entry. */}
<FeatureFlagRoute flag={FEATURE_FLAG.ISSUES}>
<Suspense
fallback={
<p className="text-text-neutral-tertiary text-sm">
@@ -62,7 +55,7 @@ export default async function Issues({
}
/>
</Suspense>
</FeatureFlagGate>
</FeatureFlagRoute>
</ContentLayout>
);
}
@@ -0,0 +1,52 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { IssueAttributes } from "@/types/issues";
const { getIssue, getIssueFindings } = vi.hoisted(() => ({
getIssue: vi.fn(),
getIssueFindings: vi.fn(),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }) }));
vi.mock("@/actions/issues", () => ({ getIssue, getIssueFindings }));
vi.mock("@/components/issues/issue-graph", () => ({
IssueGraph: () => <p>graph</p>,
}));
import { IssueAttackPath } from "./issue-attack-path";
const issue = { legs: {} } as IssueAttributes;
describe("IssueAttackPath", () => {
it("says there is nothing to draw, not a load error, without resources", async () => {
// Given: no resources, and findings that cannot load without a scan
getIssue.mockResolvedValue({
data: { attributes: { resource_uids: [], finding_uids: ["f1"] } },
});
// When
render(<IssueAttackPath id="issue-1" issue={issue} />);
// Then
expect(
await screen.findByText("This issue has no affected resources to draw."),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
});
it("offers a retry when the findings for the drawn resources fail", async () => {
// Given
getIssue.mockResolvedValue({
data: { attributes: { resource_uids: ["bucket"], finding_uids: ["f1"] } },
});
// When
render(<IssueAttackPath id="issue-1" issue={issue} />);
// Then
expect(
await screen.findByText("Could not load the attack path for this issue."),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
});
});
@@ -0,0 +1,59 @@
"use client";
import { IssueGraph } from "@/components/issues/issue-graph";
import { IssuesLoadError } from "@/components/issues/issues-load-error";
import { useIssueEvidence } from "@/hooks/use-issue-evidence";
import { IssueAttributes } from "@/types/issues";
interface IssueAttackPathProps {
id: string;
issue: IssueAttributes;
scanId?: string;
providerId?: string;
}
/** The issue's graph for the side panel; findings open on the full page. */
export const IssueAttackPath = ({
id,
issue,
scanId,
providerId,
}: IssueAttackPathProps) => {
const { state, retry } = useIssueEvidence(id, scanId, providerId);
if (state.status === "loading") {
return (
<p role="status" className="text-text-neutral-tertiary text-sm">
Loading the attack path…
</p>
);
}
// Nothing to draw beats a findings error: without resources there is no
// graph for the findings to land on.
if (state.status === "loaded" && state.resources.length === 0) {
return (
<p className="text-text-neutral-tertiary text-sm">
This issue has no affected resources to draw.
</p>
);
}
if (state.status === "error" || state.findings === undefined) {
return (
<IssuesLoadError
message="Could not load the attack path for this issue."
onRetry={retry}
/>
);
}
return (
<IssueGraph
issue={issue}
resourceUids={state.resources}
findings={state.findings}
readOnly
{...(state.providerId && state.scanId
? { providerId: state.providerId, scanId: state.scanId }
: { providerId: undefined, scanId: undefined })}
/>
);
};
+3 -63
View File
@@ -2,9 +2,8 @@
import { Send } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useState } from "react";
import { getIssue, getIssueFindings } from "@/actions/issues";
import { IssueCompliance } from "@/components/issues/issue-compliance";
import { IssueFindings } from "@/components/issues/issue-findings";
import { IssueGraph } from "@/components/issues/issue-graph";
@@ -15,6 +14,7 @@ import { Button } from "@/components/shadcn/button/button";
import { Card } from "@/components/shadcn/card/card";
import { CodeSnippet } from "@/components/shadcn/code-snippet";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import { useIssueEvidence } from "@/hooks/use-issue-evidence";
import { riskFactors } from "@/lib/issues/risk-factors";
import {
LEG_LABELS,
@@ -27,10 +27,8 @@ import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
import {
ATTACKER_START_LABEL,
ISSUE_FINDINGS_LIMIT,
ISSUE_OUTCOME_LABEL,
IssueAttributes,
IssueFinding,
LegName,
} from "@/types/issues";
@@ -52,19 +50,6 @@ interface IssueDetailProps {
providerId?: string;
}
type ResourcesState =
| { status: "loading" }
| { status: "error" }
| {
status: "loaded";
resources: string[];
findings: IssueFinding[] | undefined;
/** `getIssueFindings` capped the preview at ISSUE_FINDINGS_LIMIT. */
findingsTruncated: boolean;
scanId?: string;
providerId?: string;
};
export const IssueDetail = ({
id,
issue,
@@ -75,52 +60,7 @@ export const IssueDetail = ({
(state) => state.openJiraDispatch,
);
const [visibleResources, setVisibleResources] = useState(RESOURCE_PAGE_SIZE);
const [attempt, setAttempt] = useState(0);
// Resource UIDs are only included by the detail endpoint, fetched on expansion.
const [state, setState] = useState<ResourcesState>({ status: "loading" });
useEffect(() => {
let cancelled = false;
getIssue(id)
.then(async (detail) => {
if (cancelled) return;
const attributes = detail?.data?.attributes;
if (!attributes?.resource_uids) {
setState({ status: "error" });
return;
}
const detailScanId =
detail?.data.relationships?.scan?.data?.id ?? scanId;
const detailProviderId =
detail?.data.relationships?.provider?.data?.id ?? providerId;
const findingUids = attributes.finding_uids ?? [];
const findings = detailScanId
? await getIssueFindings(detailScanId, findingUids)
: findingUids.length === 0
? []
: undefined;
if (!cancelled)
setState({
status: "loaded",
resources: attributes.resource_uids,
findings,
findingsTruncated: new Set(findingUids).size > ISSUE_FINDINGS_LIMIT,
scanId: detailScanId,
providerId: detailProviderId,
});
})
.catch(() => {
if (!cancelled) setState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [id, scanId, providerId, attempt]);
const retry = () => {
setState({ status: "loading" });
setAttempt((value) => value + 1);
};
const { state, retry } = useIssueEvidence(id, scanId, providerId);
const activeScanId = state.status === "loaded" ? state.scanId : scanId;
const factors = riskFactors(issue);
const uniqueFindings =
+25 -2
View File
@@ -28,11 +28,11 @@ vi.mock("@/components/attack-paths/graph/attack-path-graph", () => ({
onNodeClick,
}: {
data: { nodes: GraphNode[] };
onNodeClick: (node: GraphNode) => void;
onNodeClick?: (node: GraphNode) => void;
}) => (
<button
onClick={() =>
onNodeClick(
onNodeClick?.(
data.nodes.find((node) => node.labels.includes("ProwlerFinding")) ?? {
id: "finding",
labels: ["ProwlerFinding"],
@@ -67,6 +67,29 @@ describe("IssueGraph finding lookup", () => {
navigateToFinding.mockResolvedValue(undefined);
});
it("leaves findings closed in a read-only graph", async () => {
// Given: a host that cannot hold a second detail view, such as the panel
render(
<IssueGraph
issue={issue}
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
readOnly
/>,
);
// When
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
// Then
expect(navigateToFinding).not.toHaveBeenCalled();
expect(getFindings).not.toHaveBeenCalled();
expect(
screen.getByText(/Open the full path to inspect a finding/),
).toBeInTheDocument();
});
it("opens a folded finding by id without another lookup", async () => {
// Given
render(
+8 -1
View File
@@ -21,6 +21,11 @@ interface IssueGraphBaseProps {
issue: IssueAttributes;
resourceUids: string[];
findings?: IssueFinding[];
/**
* No finding drawer on click: for hosts inside the side panel, where a
* second detail view would replace the one holding the graph.
*/
readOnly?: boolean;
}
interface ScopedIssueGraphProps extends IssueGraphBaseProps {
@@ -46,6 +51,7 @@ export const IssueGraph = ({
findings = [],
providerId,
scanId,
readOnly = false,
}: IssueGraphProps) => {
const finding = useFindingDetails();
const canKeepReadableZoom = useMediaQuery(READABLE_ZOOM_MEDIA_QUERY);
@@ -139,6 +145,7 @@ export const IssueGraph = ({
{resourceUids.length} affected resources, plus supporting checks cited
by the assessment. These links show evidence associations, not confirmed
attack paths.
{readOnly && " Open the full path to inspect a finding."}
</p>
{loading && (
<p role="status" className="text-sm">
@@ -162,7 +169,7 @@ export const IssueGraph = ({
data={{ nodes: view.nodes, edges: view.edges }}
view={view}
expandedResources={expandedResources}
onNodeClick={handleNodeClick}
onNodeClick={readOnly ? undefined : handleNodeClick}
minFitZoom={canKeepReadableZoom ? ISSUE_GRAPH_MIN_ZOOM : undefined}
/>
</Card>
+14
View File
@@ -2,6 +2,7 @@
import Link from "next/link";
import { IssueAttackPath } from "@/components/issues/issue-attack-path";
import { IssueTriage } from "@/components/issues/issue-triage";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn";
import { Badge } from "@/components/shadcn/badge/badge";
@@ -38,6 +39,7 @@ export const IssuePanel = ({ issue, open, onOpenChange }: IssuePanelProps) => {
const { attributes } = issue;
const verdict = VERDICTS.find((item) => item.id === attributes.verdict);
const scanId = issue.relationships?.scan?.data?.id;
const providerId = issue.relationships?.provider?.data?.id;
return (
<DetailSidePanel
@@ -78,6 +80,7 @@ export const IssuePanel = ({ issue, open, onOpenChange }: IssuePanelProps) => {
<div className="mb-4 shrink-0">
<TabsList>
<TabsTrigger value="exploitability">Exploitability</TabsTrigger>
<TabsTrigger value="attack-path">Attack path</TabsTrigger>
<TabsTrigger value="remediation">Remediation</TabsTrigger>
<TabsTrigger value="triage">Triage</TabsTrigger>
</TabsList>
@@ -124,6 +127,17 @@ export const IssuePanel = ({ issue, open, onOpenChange }: IssuePanelProps) => {
</ul>
</TabsContent>
{/* Mounted only while selected, so the graph's requests run when
someone asks for it. */}
<TabsContent value="attack-path">
<IssueAttackPath
id={issue.id}
issue={attributes}
scanId={scanId}
providerId={providerId}
/>
</TabsContent>
<TabsContent value="remediation" className="flex flex-col gap-2">
<p className="text-text-neutral-secondary text-sm">
{attributes.fix}
+38 -2
View File
@@ -7,7 +7,10 @@ import type { IssueResponse } from "@/types/issues";
import { IssuesTable } from "./issues-table";
const { push } = vi.hoisted(() => ({ push: vi.fn() }));
const { push, getIssue } = vi.hoisted(() => ({
push: vi.fn(),
getIssue: vi.fn(),
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/issues",
useRouter: () => ({ push, refresh: vi.fn() }),
@@ -15,7 +18,25 @@ vi.mock("next/navigation", () => ({
}));
vi.mock("@/lib", () => ({ getPaginationInfo: vi.fn() }));
vi.mock("@/contexts", () => ({ useFilterTransitionOptional: () => null }));
vi.mock("@/actions/issues", () => ({ getIssueNotes: async () => [] }));
vi.mock("@/actions/issues", () => ({
getIssue,
getIssueFindings: async () => [],
getIssueNotes: async () => [],
}));
vi.mock("@/components/issues/issue-graph", () => ({
IssueGraph: ({
resourceUids,
readOnly,
}: {
resourceUids: string[];
readOnly?: boolean;
}) => (
<p>
Graph of {resourceUids.join(", ")}
{readOnly ? " (read-only)" : ""}
</p>
),
}));
// The real panel portals into the app shell's side panel, absent here.
vi.mock("@/components/side-panel/detail-side-panel", () => ({
DetailSidePanel: ({
@@ -143,6 +164,21 @@ describe("IssuesTable", () => {
"/findings?filter%5Bscan__in%5D=scan-1&filter%5Bstatus__in%5D=FAIL",
);
// When
getIssue.mockResolvedValue({
data: {
...issue,
attributes: { ...issue.attributes, resource_uids: ["bucket"] },
},
});
await user.click(within(panel).getByRole("tab", { name: "Attack path" }));
// Then: the graph loads on demand and leaves finding drill-down to the page
expect(
await within(panel).findByText("Graph of bucket (read-only)"),
).toBeInTheDocument();
expect(getIssue).toHaveBeenCalledWith("issue-1");
// When
await user.click(within(panel).getByRole("tab", { name: "Triage" }));
@@ -257,6 +257,17 @@ describe("Issues responsive layout", () => {
await expect
.element(screen.getByText("Block Public Access is disabled."))
.toBeVisible();
// When: the attack path tab asks for the graph
await screen.getByRole("tab", { name: "Attack path" }).click();
// Then
await expect
.element(screen.getByRole("region", { name: "Finding evidence" }))
.toBeVisible();
await expect
.element(screen.getByTestId("attack-path-finding-node"))
.toBeVisible();
});
it("resets the Details tab when another row opens", async () => {
@@ -130,10 +130,9 @@ describe("AppSidebarContent", () => {
render(<AppSidebarContent />);
// Then
expect(screen.getByRole("link", { name: /Issues/ })).toHaveAttribute(
"href",
"/issues",
);
expect(
screen.getByRole("link", { name: /Exploitability issues/ }),
).toHaveAttribute("href", "/issues");
});
it("hides Issues navigation in Cloud while the feature flag is off", () => {
@@ -143,7 +142,7 @@ describe("AppSidebarContent", () => {
// Then
expect(
screen.queryByRole("link", { name: /Issues/ }),
screen.queryByRole("link", { name: /Exploitability issues/ }),
).not.toBeInTheDocument();
});
@@ -119,7 +119,7 @@ describe("getNavigationConfig", () => {
.map((item) => item.label);
// Then
expect(labels).not.toContain("Issues");
expect(labels).not.toContain("Exploitability issues");
});
it("never shows Issues in Local Server, even with the flag on", () => {
@@ -136,7 +136,7 @@ describe("getNavigationConfig", () => {
.map((item) => item.label);
// Then
expect(labels).not.toContain("Issues");
expect(labels).not.toContain("Exploitability issues");
});
it("uses Cloud destinations and keeps Configuration children unbadged", () => {
@@ -158,7 +158,9 @@ describe("getNavigationConfig", () => {
// Then
expect(sections[0]?.items.map((item) => item.label)).toEqual(["Overview"]);
expect(items.find((item) => item.label === "Issues")).toMatchObject({
expect(
items.find((item) => item.label === "Exploitability issues"),
).toMatchObject({
href: "/issues",
});
expect(configuration.children).not.toEqual(
@@ -168,7 +168,7 @@ export function getNavigationConfig({
{
kind: NAVIGATION_ITEM_KIND.LINK,
href: "/issues",
label: "Issues",
label: "Exploitability issues",
icon: Crosshair,
active: isRouteActive(pathname, "/issues"),
highlight: true,
@@ -0,0 +1,67 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { useFeatureFlagStateMock, redirect } = vi.hoisted(() => ({
useFeatureFlagStateMock: vi.fn(),
redirect: vi.fn(() => {
throw new Error("REDIRECT");
}),
}));
vi.mock("next/navigation", () => ({ redirect }));
vi.mock("@/lib/feature-flags", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/feature-flags")>()),
useFeatureFlagState: useFeatureFlagStateMock,
}));
import { FEATURE_FLAG, FEATURE_FLAG_STATE } from "@/lib/feature-flags";
import { FeatureFlagRoute } from "./feature-flag-route";
const renderRoute = () =>
render(
<FeatureFlagRoute flag={FEATURE_FLAG.ISSUES}>
<p>route content</p>
</FeatureFlagRoute>,
);
describe("FeatureFlagRoute", () => {
beforeEach(() => {
useFeatureFlagStateMock.mockReset();
redirect.mockClear();
});
it("sends the user to the overview once the flag resolves off", () => {
// Given
useFeatureFlagStateMock.mockReturnValue(FEATURE_FLAG_STATE.OFF);
vi.spyOn(console, "error").mockImplementation(() => {});
// When / Then
expect(renderRoute).toThrow("REDIRECT");
expect(redirect).toHaveBeenCalledWith("/");
});
it("renders the route once the flag resolves on", () => {
// Given
useFeatureFlagStateMock.mockReturnValue(FEATURE_FLAG_STATE.ON);
// When
renderRoute();
// Then
expect(screen.getByText("route content")).toBeInTheDocument();
expect(redirect).not.toHaveBeenCalled();
});
it("renders nothing while the flag is unresolved", () => {
// Given
useFeatureFlagStateMock.mockReturnValue(FEATURE_FLAG_STATE.PENDING);
// When
const { container } = renderRoute();
// Then
expect(container).toBeEmptyDOMElement();
expect(redirect).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,25 @@
"use client";
import { redirect } from "next/navigation";
import type { ReactNode } from "react";
import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
import type { FeatureFlag } from "@/lib/feature-flags";
const ToOverview = (): never => redirect("/");
interface FeatureFlagRouteProps {
flag: FeatureFlag;
children: ReactNode;
}
// A route behind a flag: off sends the user to the overview, pending renders
// nothing. Same client evaluation as the menu entry, so the link and the
// route never disagree.
export function FeatureFlagRoute({ flag, children }: FeatureFlagRouteProps) {
return (
<FeatureFlagGate flag={flag} fallback={<ToOverview />}>
{children}
</FeatureFlagGate>
);
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import { useEffect, useState } from "react";
import { getIssue, getIssueFindings } from "@/actions/issues";
import { ISSUE_FINDINGS_LIMIT, IssueFinding } from "@/types/issues";
export type IssueEvidenceState =
| { status: "loading" }
| { status: "error" }
| {
status: "loaded";
resources: string[];
findings: IssueFinding[] | undefined;
/** `getIssueFindings` capped the preview at ISSUE_FINDINGS_LIMIT. */
findingsTruncated: boolean;
scanId?: string;
providerId?: string;
};
/**
* The issue's resources and findings. Only the detail endpoint carries the
* resource uids, so this fetches it, then the findings of the issue's scan.
*/
export const useIssueEvidence = (
id: string,
scanId?: string,
providerId?: string,
) => {
const [attempt, setAttempt] = useState(0);
const [state, setState] = useState<IssueEvidenceState>({
status: "loading",
});
useEffect(() => {
let cancelled = false;
getIssue(id)
.then(async (detail) => {
if (cancelled) return;
const attributes = detail?.data?.attributes;
if (!attributes?.resource_uids) {
setState({ status: "error" });
return;
}
const detailScanId =
detail?.data.relationships?.scan?.data?.id ?? scanId;
const detailProviderId =
detail?.data.relationships?.provider?.data?.id ?? providerId;
const findingUids = attributes.finding_uids ?? [];
const findings = detailScanId
? await getIssueFindings(detailScanId, findingUids)
: findingUids.length === 0
? []
: undefined;
if (!cancelled)
setState({
status: "loaded",
resources: attributes.resource_uids,
findings,
findingsTruncated: new Set(findingUids).size > ISSUE_FINDINGS_LIMIT,
scanId: detailScanId,
providerId: detailProviderId,
});
})
.catch(() => {
if (!cancelled) setState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [id, scanId, providerId, attempt]);
const retry = () => {
setState({ status: "loading" });
setAttempt((value) => value + 1);
};
return { state, retry };
};
+40 -2
View File
@@ -1,13 +1,17 @@
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { useFeatureFlagEnabledMock } = vi.hoisted(() => ({
const { useFeatureFlagEnabledMock, runtimeConfig } = vi.hoisted(() => ({
useFeatureFlagEnabledMock: vi.fn(),
runtimeConfig: { posthogKey: "phc_key" as string | null },
}));
vi.mock("posthog-js/react", () => ({
useFeatureFlagEnabled: useFeatureFlagEnabledMock,
}));
vi.mock("@/hooks/use-runtime-config", () => ({
useRuntimeConfig: () => runtimeConfig,
}));
import { useFeatureFlag, useFeatureFlagState } from "./client";
import { FEATURE_FLAG, FEATURE_FLAG_STATE } from "./shared";
@@ -15,6 +19,7 @@ import { FEATURE_FLAG, FEATURE_FLAG_STATE } from "./shared";
describe("useFeatureFlagState", () => {
beforeEach(() => {
useFeatureFlagEnabledMock.mockReset();
runtimeConfig.posthogKey = "phc_key";
});
it("should pass the flag key through to PostHog", () => {
@@ -29,7 +34,7 @@ describe("useFeatureFlagState", () => {
["on", true, FEATURE_FLAG_STATE.ON],
["off", false, FEATURE_FLAG_STATE.OFF],
[
"pending (not initialized or still loading)",
"pending while PostHog loads flags",
undefined,
FEATURE_FLAG_STATE.PENDING,
],
@@ -44,6 +49,39 @@ describe("useFeatureFlagState", () => {
});
});
describe("useFeatureFlagState without PostHog", () => {
beforeEach(() => {
useFeatureFlagEnabledMock.mockReset();
useFeatureFlagEnabledMock.mockReturnValue(undefined);
});
it("should report off once mounted when no PostHog key is configured", () => {
// Given: nothing will ever init PostHog (OSS, or PostHog disabled)
runtimeConfig.posthogKey = null;
// When
const { result } = renderHook(() =>
useFeatureFlagState(FEATURE_FLAG.ISSUES),
);
// Then: a flag nobody can turn on is off, not pending forever
expect(result.current).toBe(FEATURE_FLAG_STATE.OFF);
});
it("should keep waiting while a configured PostHog has not loaded yet", () => {
// Given: a key is set, so some client will init PostHog, maybe lazily
runtimeConfig.posthogKey = "phc_key";
// When
const { result } = renderHook(() =>
useFeatureFlagState(FEATURE_FLAG.ISSUES),
);
// Then
expect(result.current).toBe(FEATURE_FLAG_STATE.PENDING);
});
});
describe("useFeatureFlag", () => {
beforeEach(() => {
useFeatureFlagEnabledMock.mockReset();
+17 -3
View File
@@ -1,6 +1,10 @@
"use client";
import { useFeatureFlagEnabled } from "posthog-js/react";
import { useState } from "react";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { useRuntimeConfig } from "@/hooks/use-runtime-config";
import {
FEATURE_FLAG_STATE,
@@ -15,11 +19,21 @@ import {
// OSS builds that never init PostHog resolve every read to its fallback.
// Boolean flag with its resolution state. PostHog reports `undefined` until
// the flags request lands (or forever when it was never initialized), `true`
// or `false` afterwards; a flag missing from the project resolves to OFF.
// the flags request lands, `true` or `false` afterwards; a flag missing from
// the project resolves to OFF. Every PostHog init (the cloud root, the lazy
// feedback survey) needs the runtime key, so without one, once mounted (the
// first hydration render still sees the empty config), nothing will ever
// load flags: the flag is OFF instead of pending forever.
export function useFeatureFlagState(flag: FeatureFlag): FeatureFlagState {
const value = useFeatureFlagEnabled(flag);
if (value === undefined) return FEATURE_FLAG_STATE.PENDING;
const { posthogKey } = useRuntimeConfig();
const [mounted, setMounted] = useState(false);
useMountEffect(() => setMounted(true));
if (value === undefined) {
return mounted && !posthogKey
? FEATURE_FLAG_STATE.OFF
: FEATURE_FLAG_STATE.PENDING;
}
return value ? FEATURE_FLAG_STATE.ON : FEATURE_FLAG_STATE.OFF;
}
+3 -3
View File
@@ -16,9 +16,9 @@ export const FEATURE_FLAG = {
export type FeatureFlag = (typeof FEATURE_FLAG)[keyof typeof FEATURE_FLAG];
// Resolution state of a boolean flag. PENDING covers both "flags still
// loading" and "PostHog never initialized" (OSS builds): neither case can
// tell whether the flag is on, so gates render neither branch yet.
// Resolution state of a boolean flag. PENDING means PostHog is still loading
// flags, so gates render neither branch yet. Without a PostHog key nothing
// initializes it, so the flag resolves to OFF once mounted.
export const FEATURE_FLAG_STATE = {
PENDING: "pending",
ON: "on",