diff --git a/ui/app/(prowler)/issues/[id]/page.test.tsx b/ui/app/(prowler)/issues/[id]/page.test.tsx index 1564ae0818..ef7a0174fe 100644 --- a/ui/app/(prowler)/issues/[id]/page.test.tsx +++ b/ui/app/(prowler)/issues/[id]/page.test.tsx @@ -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 }) => ( diff --git a/ui/app/(prowler)/issues/[id]/page.tsx b/ui/app/(prowler)/issues/[id]/page.tsx index b94e3cb06e..dcb6cc0540 100644 --- a/ui/app/(prowler)/issues/[id]/page.tsx +++ b/ui/app/(prowler)/issues/[id]/page.tsx @@ -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 ( }> - - Issues is not available for this account yet. -

- } - > +
-
+
); } diff --git a/ui/app/(prowler)/issues/page.tsx b/ui/app/(prowler)/issues/page.tsx index 9cf4e42018..6697a04c4b 100644 --- a/ui/app/(prowler)/issues/page.tsx +++ b/ui/app/(prowler)/issues/page.tsx @@ -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 ( }> - {/* Deployment gate above (notFound); per-account rollout here. The route - stays reachable by URL, so the off state explains itself. */} - - Issues is not available for this account yet. -

- } - > + {/* Deployment gate above (notFound); per-account rollout here, also a + redirect to the overview when off, like the hidden menu entry. */} + @@ -62,7 +55,7 @@ export default async function Issues({ } /> -
+
); } diff --git a/ui/components/issues/issue-attack-path.test.tsx b/ui/components/issues/issue-attack-path.test.tsx new file mode 100644 index 0000000000..df6d818c47 --- /dev/null +++ b/ui/components/issues/issue-attack-path.test.tsx @@ -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: () =>

graph

, +})); + +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(); + + // 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(); + + // Then + expect( + await screen.findByText("Could not load the attack path for this issue."), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + }); +}); diff --git a/ui/components/issues/issue-attack-path.tsx b/ui/components/issues/issue-attack-path.tsx new file mode 100644 index 0000000000..af10850f4b --- /dev/null +++ b/ui/components/issues/issue-attack-path.tsx @@ -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 ( +

+ Loading the attack path… +

+ ); + } + // 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 ( +

+ This issue has no affected resources to draw. +

+ ); + } + if (state.status === "error" || state.findings === undefined) { + return ( + + ); + } + return ( + + ); +}; diff --git a/ui/components/issues/issue-detail.tsx b/ui/components/issues/issue-detail.tsx index f67cf9127f..5c35ef22f1 100644 --- a/ui/components/issues/issue-detail.tsx +++ b/ui/components/issues/issue-detail.tsx @@ -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({ 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 = diff --git a/ui/components/issues/issue-graph.test.tsx b/ui/components/issues/issue-graph.test.tsx index 92362685a0..777e69c413 100644 --- a/ui/components/issues/issue-graph.test.tsx +++ b/ui/components/issues/issue-graph.test.tsx @@ -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; }) => (