mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-30 07:14:20 +00:00
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:
1 parent
814ae54d6a
commit
ca6ed48664
20 files changed
+461
-109
No files matched your search
@@ -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 }) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user