diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts index e06b06d29d..d22b0e0f75 100644 --- a/ui/actions/compliances/compliances.ts +++ b/ui/actions/compliances/compliances.ts @@ -2,6 +2,12 @@ import { apiBaseUrl, getAuthHeaders } from "@/lib"; import { handleApiResponse } from "@/lib/server-actions-helper"; +import type { ApiResult } from "@/types/server-actions"; + +import type { + ComplianceOverviewApiResponse, + ComplianceRequirementsApiResponse, +} from "./types"; export const getCompliancesOverview = async ({ scanId, @@ -11,7 +17,7 @@ export const getCompliancesOverview = async ({ scanId?: string; region?: string | string[]; filters?: Record; -} = {}) => { +} = {}): Promise | undefined> => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/compliance-overviews`); @@ -115,7 +121,7 @@ export const getComplianceRequirements = async ({ complianceId: string; scanId: string; region?: string | string[]; -}) => { +}): Promise | undefined> => { const headers = await getAuthHeaders({ contentType: false }); try { diff --git a/ui/actions/compliances/index.ts b/ui/actions/compliances/index.ts index 77a4046d1a..1b6872d65d 100644 --- a/ui/actions/compliances/index.ts +++ b/ui/actions/compliances/index.ts @@ -1,6 +1,11 @@ export * from "./compliances"; export * from "./compliances.adapter"; +export { COMPLIANCE_OVERVIEW_RESOURCE_TYPE } from "./types"; export type { + ComplianceOverviewApiResponse, + ComplianceOverviewTaskResource, + ComplianceOverviewTaskResponse, ComplianceOverviewsResponse, + ComplianceRequirementsApiResponse, EnrichedComplianceOverview, } from "./types"; diff --git a/ui/actions/compliances/types.ts b/ui/actions/compliances/types.ts index c20f3a1fe3..d72f40464e 100644 --- a/ui/actions/compliances/types.ts +++ b/ui/actions/compliances/types.ts @@ -1,6 +1,14 @@ import { StaticImageData } from "next/image"; -import { ComplianceOverviewData } from "@/types/compliance"; +import type { + ComplianceOverviewData, + RequirementsData, +} from "@/types/compliance"; +import type { TaskAttributes } from "@/types/tasks"; + +export const COMPLIANCE_OVERVIEW_RESOURCE_TYPE = { + TASK: "tasks", +} as const; /** * Raw API response from /compliance-overviews endpoint @@ -16,6 +24,24 @@ export interface ComplianceOverviewsResponse { }; } +export interface ComplianceOverviewTaskResource { + id: string; + type: typeof COMPLIANCE_OVERVIEW_RESOURCE_TYPE.TASK; + attributes?: TaskAttributes; +} + +export interface ComplianceOverviewTaskResponse { + data: ComplianceOverviewTaskResource; +} + +export type ComplianceOverviewApiResponse = + | ComplianceOverviewsResponse + | ComplianceOverviewTaskResponse; + +export type ComplianceRequirementsApiResponse = + | RequirementsData + | ComplianceOverviewTaskResponse; + /** * Enriched compliance overview with computed fields */ diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.test.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.test.tsx new file mode 100644 index 0000000000..7a68931bbc --- /dev/null +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.test.tsx @@ -0,0 +1,237 @@ +import { render, screen } from "@testing-library/react"; +import { + Children, + isValidElement, + Suspense, + type ReactElement, + type ReactNode, +} from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import ComplianceDetail from "./page"; + +const { + getComplianceAttributesMock, + getComplianceOverviewMetadataInfoMock, + getComplianceRequirementsMock, + getScanMock, + mapComplianceDataMock, +} = vi.hoisted(() => ({ + getComplianceAttributesMock: vi.fn(), + getComplianceOverviewMetadataInfoMock: vi.fn(), + getComplianceRequirementsMock: vi.fn(), + getScanMock: vi.fn(), + mapComplianceDataMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + notFound: vi.fn(() => { + throw new Error("notFound"); + }), + redirect: vi.fn(() => { + throw new Error("redirect"); + }), +})); + +vi.mock("@/actions/compliances", () => ({ + COMPLIANCE_OVERVIEW_RESOURCE_TYPE: { TASK: "tasks" }, + getComplianceAttributes: getComplianceAttributesMock, + getComplianceOverviewMetadataInfo: getComplianceOverviewMetadataInfoMock, + getComplianceRequirements: getComplianceRequirementsMock, + getCompliancesOverview: vi.fn(), +})); + +vi.mock("@/actions/overview", () => ({ + getThreatScore: vi.fn(), +})); + +vi.mock("@/actions/scans", () => ({ + getScan: getScanMock, +})); + +vi.mock("@/components/compliance", () => ({ + ClientAccordionWrapper: () =>
Empty requirements
, + ComplianceDownloadContainer: () => null, + ComplianceHeader: () => null, + ComplianceWarming: () => null, + RequirementsStatusCard: ({ + pass, + fail, + manual, + }: { + pass: number; + fail: number; + manual: number; + }) => ( +
+ Requirements: {pass} pass, {fail} fail, {manual} manual +
+ ), + RequirementsStatusCardSkeleton: () => null, + SkeletonAccordion: () => null, + ThreatScoreBreakdownCard: () => null, + ThreatScoreBreakdownCardSkeleton: () => null, + TopFailedSectionsCard: () => null, + TopFailedSectionsCardSkeleton: () => null, +})); + +vi.mock("@/components/icons/compliance/IconCompliance", () => ({ + getComplianceIcon: vi.fn(), +})); + +vi.mock("@/components/lighthouse/context-contributor", () => ({ + LighthouseContextContributor: () => null, +})); + +vi.mock("@/components/shadcn/button/button", () => ({ + Button: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock("@/components/shadcn/card/card", () => ({ + Card: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock("@/components/shadcn/content-layout", () => ({ + ContentLayout: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock("@/lib/compliance/compliance-mapper", () => ({ + getComplianceMapper: () => ({ + getTopFailedSections: vi.fn(() => []), + mapComplianceData: mapComplianceDataMock, + toAccordionItems: vi.fn(() => []), + }), +})); + +vi.mock("@/lib/compliance/compliance-report-types", () => ({ + getReportTypeForCompliance: vi.fn(), + pickLatestCisPerProvider: vi.fn(() => new Set()), +})); + +vi.mock("@/lib/shared/env", () => ({ + isCloud: () => false, +})); + +vi.mock("../_components/cross-account-detail", () => ({ + CrossAccountDetail: () => null, +})); + +vi.mock("../_components/cross-provider-detail", () => ({ + CrossProviderDetail: () => null, +})); + +vi.mock("../_lib/cross-provider-frameworks", () => ({ + resolveCrossProviderFramework: vi.fn(), +})); + +vi.mock("../_lib/search-params-key", () => ({ + buildSearchParamsKey: vi.fn(() => "search-params"), +})); + +interface ContentLayoutTestProps { + children: ReactNode; +} + +type AsyncServerComponent = ( + props: Record, +) => Promise; + +const renderPerScanContent = async () => { + const page = (await ComplianceDetail({ + params: Promise.resolve({ compliancetitle: "ISO 27001" }), + searchParams: Promise.resolve({ + complianceId: "iso27001_2022_aws", + scanId: "scan-1", + }), + })) as ReactElement; + const suspense = Children.toArray(page.props.children).find( + (child) => isValidElement(child) && child.type === Suspense, + ); + + if (!isValidElement<{ children: ReactElement }>(suspense)) { + throw new Error("Expected the per-scan compliance Suspense boundary"); + } + + const content = suspense.props.children as ReactElement< + Record, + AsyncServerComponent + >; + render(await content.type(content.props)); +}; + +describe("Compliance detail task response", () => { + beforeEach(() => { + vi.clearAllMocks(); + getComplianceOverviewMetadataInfoMock.mockResolvedValue({ + data: { attributes: { regions: [] } }, + }); + getComplianceAttributesMock.mockResolvedValue({ + data: [ + { + id: "iso27001_2022_aws", + type: "compliance-overview-attributes", + attributes: { + compliance_name: "ISO 27001", + framework: "ISO27001", + }, + }, + ], + }); + getScanMock.mockResolvedValue(undefined); + mapComplianceDataMock.mockImplementation( + (_attributesData, requirementsData) => { + const requirements = requirementsData.data; + requirements.forEach(() => undefined); + return []; + }, + ); + }); + + it("renders an empty detail while requirements are being generated", async () => { + // Given - the requirements endpoint returned a JSON:API task resource + getComplianceRequirementsMock.mockResolvedValue({ + data: { + id: "task-1", + type: "tasks", + attributes: { state: "executing" }, + }, + }); + + // When - the server-rendered detail handles the pending response + await renderPerScanContent(); + + // Then - the task never reaches the requirements array mapper + expect( + screen.getByText("Requirements: 0 pass, 0 fail, 0 manual"), + ).toBeInTheDocument(); + }); + + it("maps a completed requirements collection", async () => { + // Given - the requirements endpoint returned its normal collection + getComplianceRequirementsMock.mockResolvedValue({ + data: [ + { + id: "requirement-1", + type: "compliance-overview-requirements", + attributes: { status: "PASS" }, + }, + ], + }); + mapComplianceDataMock.mockReturnValue([ + { + name: "ISO 27001", + pass: 1, + fail: 0, + manual: 0, + }, + ]); + + // When - the server-rendered detail handles the completed response + await renderPerScanContent(); + + // Then - normal mapper output is still rendered + expect( + screen.getByText("Requirements: 1 pass, 0 fail, 0 manual"), + ).toBeInTheDocument(); + }); +}); diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index 9ac9304068..418334c9ab 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -3,6 +3,7 @@ import { notFound, redirect } from "next/navigation"; import { Suspense } from "react"; import { + COMPLIANCE_OVERVIEW_RESOURCE_TYPE, getComplianceAttributes, getComplianceOverviewMetadataInfo, getComplianceRequirements, @@ -397,9 +398,14 @@ const SSRComplianceContent = async ({ scanId, region, }); - const type = requirementsData?.data?.[0]?.type; + const requirements = requirementsData?.data; + const type = Array.isArray(requirements) ? undefined : requirements?.type; - if (!scanId || type === "tasks") { + if ( + !scanId || + type === COMPLIANCE_OVERVIEW_RESOURCE_TYPE.TASK || + !Array.isArray(requirements) + ) { return (
@@ -416,7 +422,7 @@ const SSRComplianceContent = async ({ const mapper = getComplianceMapper(framework); const data = mapper.mapComplianceData( attributesData, - requirementsData, + { data: requirements }, filter, ); // const categoryHeatmapData = mapper.calculateCategoryHeatmapData(data); diff --git a/ui/app/(prowler)/compliance/_components/cross-account-overview-section.test.tsx b/ui/app/(prowler)/compliance/_components/cross-account-overview-section.test.tsx index a3f9ce0732..fc79d9a8d5 100644 --- a/ui/app/(prowler)/compliance/_components/cross-account-overview-section.test.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-account-overview-section.test.tsx @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getCompliancesOverview } from "@/actions/compliances"; import { getAllProviders } from "@/actions/providers"; import { getScans } from "@/actions/scans"; +import type { ComplianceOverviewData } from "@/types/compliance"; import { CrossAccountOverviewSection } from "./cross-account-overview-section"; @@ -63,6 +64,23 @@ const scansFor = (scans: Array<{ id: string; providerId: string }>) => ({ ], }); +const complianceOverview = ( + id: string, + framework: string, + version: string, +): ComplianceOverviewData => ({ + id, + type: "compliance-overviews", + attributes: { + framework, + version, + requirements_passed: 0, + requirements_failed: 0, + requirements_manual: 0, + total_requirements: 0, + }, +}); + const renderSection = async ( searchParams: Record = {}, ) => render(await CrossAccountOverviewSection({ searchParams })); @@ -106,20 +124,15 @@ describe("CrossAccountOverviewSection", () => { ); vi.mocked(getCompliancesOverview).mockResolvedValue({ data: [ - { - id: "cis_2.0_aws", - attributes: { framework: "CIS", version: "2.0" }, - }, + complianceOverview("cis_2.0_aws", "CIS", "2.0"), // Universal frameworks have their own cross-provider cards above. - { - id: "csa_ccm_4.0", - attributes: { framework: "CSA-CCM", version: "4.0" }, - }, + complianceOverview("csa_ccm_4.0", "CSA-CCM", "4.0"), // ThreatScore is excluded, matching the per-scan grid. - { - id: "prowler_threatscore_aws", - attributes: { framework: "ProwlerThreatScore", version: "1.0" }, - }, + complianceOverview( + "prowler_threatscore_aws", + "ProwlerThreatScore", + "1.0", + ), ], }); @@ -191,12 +204,7 @@ describe("CrossAccountOverviewSection", () => { scansFor([{ id: "scan-1", providerId: "aws-1" }]), ); vi.mocked(getCompliancesOverview).mockResolvedValue({ - data: [ - { - id: "cis_2.0_aws", - attributes: { framework: "CIS", version: "2.0" }, - }, - ], + data: [complianceOverview("cis_2.0_aws", "CIS", "2.0")], }); // When @@ -234,12 +242,7 @@ describe("CrossAccountOverviewSection", () => { return scansFor([]); }); vi.mocked(getCompliancesOverview).mockResolvedValue({ - data: [ - { - id: "framework-1", - attributes: { framework: "Framework", version: "1.0" }, - }, - ], + data: [complianceOverview("framework-1", "Framework", "1.0")], }); // When diff --git a/ui/app/(prowler)/compliance/page.test.tsx b/ui/app/(prowler)/compliance/page.test.tsx index f905d7efb1..759ac4f32a 100644 --- a/ui/app/(prowler)/compliance/page.test.tsx +++ b/ui/app/(prowler)/compliance/page.test.tsx @@ -2,7 +2,132 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { + Children, + isValidElement, + Suspense, + type ReactElement, + type ReactNode, +} from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import Compliance from "./page"; + +const { + complianceOverviewGridSpy, + getComplianceOverviewMetadataInfoMock, + getCompliancesOverviewMock, + getScansMock, + getThreatScoreMock, + loadComplianceWatchlistContextMock, +} = vi.hoisted(() => ({ + complianceOverviewGridSpy: vi.fn(), + getComplianceOverviewMetadataInfoMock: vi.fn(), + getCompliancesOverviewMock: vi.fn(), + getScansMock: vi.fn(), + getThreatScoreMock: vi.fn(), + loadComplianceWatchlistContextMock: vi.fn(), +})); + +vi.mock("@/actions/compliances", () => ({ + COMPLIANCE_OVERVIEW_RESOURCE_TYPE: { TASK: "tasks" }, + getComplianceOverviewMetadataInfo: getComplianceOverviewMetadataInfoMock, + getCompliancesOverview: getCompliancesOverviewMock, +})); + +vi.mock("@/actions/overview", () => ({ + getThreatScore: getThreatScoreMock, +})); + +vi.mock("@/actions/scans", () => ({ + getScans: getScansMock, + getScansByState: vi.fn(), +})); + +vi.mock("@/lib/shared/env", () => ({ + isCloud: () => false, +})); + +vi.mock("./_lib/watchlist-context", () => ({ + loadComplianceWatchlistContext: loadComplianceWatchlistContextMock, +})); + +vi.mock("@/components/shadcn/content-layout", () => ({ + ContentLayout: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock("./_components/compliance-page-tabs", () => ({ + CompliancePageTabs: ({ perScanContent }: { perScanContent: ReactNode }) => ( + <>{perScanContent} + ), +})); + +vi.mock("./_components/cross-account-overview-section", () => ({ + CrossAccountOverviewSection: () =>
Cross-account overview
, +})); + +vi.mock("./_components/cross-provider-overview", () => ({ + CrossProviderOverview: () =>
Cross-provider overview
, +})); + +vi.mock("./_components/multiple-scans-skeleton", () => ({ + CrossAccountOverviewSkeleton: () =>
Cross-account loading
, + CrossProviderOverviewSkeleton: () =>
Cross-provider loading
, +})); + +vi.mock("@/components/compliance", () => ({ + ComplianceSkeletonGrid: () =>
Loading compliance data
, + NoScansAvailable: () =>
No scans available
, + ThreatScoreBadge: () =>
Threat score
, +})); + +vi.mock("@/components/compliance/compliance-header/compliance-filters", () => ({ + ComplianceFilters: () =>
Compliance filters
, +})); + +vi.mock("@/components/compliance/compliance-overview-grid", () => ({ + ComplianceOverviewGrid: (props: { frameworks: Array<{ id: string }> }) => { + complianceOverviewGridSpy(props); + return
Compliance overview grid
; + }, +})); + +vi.mock("@/components/compliance/watchlist/watchlist-controls", () => ({ + WatchlistControls: () =>
Watchlist controls
, +})); + +interface ComplianceTabsTestProps { + perScanContent: ReactElement<{ children: ReactNode }>; +} + +interface ContentLayoutTestProps { + children: ReactElement; +} + +type AsyncServerComponent = ( + props: Record, +) => Promise; + +const renderPerScanGrid = async () => { + const page = (await Compliance({ + searchParams: Promise.resolve({ scanId: "scan-1" }), + })) as ReactElement; + const perScanContent = page.props.children.props.perScanContent; + const suspense = Children.toArray(perScanContent.props.children).find( + (child) => isValidElement(child) && child.type === Suspense, + ); + + if (!isValidElement<{ children: ReactElement }>(suspense)) { + throw new Error("Expected the per-scan compliance Suspense boundary"); + } + + const grid = suspense.props.children as ReactElement< + Record, + AsyncServerComponent + >; + render(await grid.type(grid.props)); +}; describe("Compliance overview page", () => { const currentDir = path.dirname(fileURLToPath(import.meta.url)); @@ -19,3 +144,107 @@ describe("Compliance overview page", () => { expect(source).toContain(""); }); }); + +describe("Compliance overview task response", () => { + beforeEach(() => { + vi.clearAllMocks(); + getScansMock.mockResolvedValue({ + data: [ + { + id: "scan-1", + attributes: { + name: "Production scan", + completed_at: "2026-08-05T17:00:00Z", + }, + relationships: { + provider: { data: { id: "provider-1" } }, + }, + }, + ], + included: [ + { + id: "provider-1", + type: "providers", + attributes: { + provider: "aws", + uid: "123456789012", + alias: "Production", + }, + }, + ], + }); + getComplianceOverviewMetadataInfoMock.mockResolvedValue({ + data: { attributes: { regions: [] } }, + }); + getThreatScoreMock.mockResolvedValue({ data: [] }); + loadComplianceWatchlistContextMock.mockResolvedValue({ + entries: [], + canManage: false, + }); + }); + + it("shows a pending state while compliance data is being generated", async () => { + // Given - API returned the JSON:API task resource from its HTTP 202 response + getCompliancesOverviewMock.mockResolvedValue({ + data: { + id: "task-1", + type: "tasks", + attributes: { state: "executing" }, + }, + }); + + // When - the server-rendered compliance page handles the response + await renderPerScanGrid(); + + // Then - the request remains renderable instead of throwing on data.filter + expect( + await screen.findByText( + "Compliance data is still being generated. Please try again shortly.", + ), + ).toBeInTheDocument(); + }); + + it("renders framework arrays after removing ThreatScore", async () => { + // Given - API returned its normal compliance overview collection + getCompliancesOverviewMock.mockResolvedValue({ + data: [ + { + id: "prowler_threatscore_aws", + type: "compliance-overviews", + attributes: { framework: "ProwlerThreatScore" }, + }, + { + id: "cis_1.5_aws", + type: "compliance-overviews", + attributes: { framework: "CIS", version: "1.5" }, + }, + ], + }); + + // When - the server-rendered compliance grid handles the collection + await renderPerScanGrid(); + + // Then - normal rendering remains unchanged + expect(screen.getByText("Compliance overview grid")).toBeInTheDocument(); + expect(complianceOverviewGridSpy).toHaveBeenCalledWith( + expect.objectContaining({ + frameworks: [expect.objectContaining({ id: "cis_1.5_aws" })], + }), + ); + }); + + it("shows the invalid scan message for a JSON:API error response", async () => { + // Given - handleApiResponse converted a client error to its error result + getCompliancesOverviewMock.mockResolvedValue({ + error: "Invalid scan ID.", + errors: [{ detail: "Invalid scan ID." }], + status: 400, + }); + + // When - the server-rendered compliance grid handles the error + await renderPerScanGrid(); + + // Then - the intended error state is reachable + expect(screen.getByText("Provide a valid scan ID.")).toBeInTheDocument(); + }); +}); diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index 69047b9e34..448fc39dda 100644 --- a/ui/app/(prowler)/compliance/page.tsx +++ b/ui/app/(prowler)/compliance/page.tsx @@ -2,6 +2,7 @@ import { Info } from "lucide-react"; import { Suspense } from "react"; import { + COMPLIANCE_OVERVIEW_RESOURCE_TYPE, getComplianceOverviewMetadataInfo, getCompliancesOverview, } from "@/actions/compliances"; @@ -301,21 +302,37 @@ const SSRComplianceGrid = async ({ }) : { data: [], errors: [] }; - const type = compliancesData?.data?.type; - const frameworks = compliancesData?.data - ?.filter((compliance: ComplianceOverviewData) => { - return compliance.attributes.framework !== "ProwlerThreatScore"; - }) - .sort((a: ComplianceOverviewData, b: ComplianceOverviewData) => - a.attributes.framework.localeCompare(b.attributes.framework), - ); + const complianceData = compliancesData?.data; if ( - !compliancesData || - !compliancesData.data || - compliancesData.data.length === 0 || - type === "tasks" + compliancesData && + "errors" in compliancesData && + compliancesData.errors && + compliancesData.errors.length > 0 ) { + return ( + + + Provide a valid scan ID. + + ); + } + + if ( + !Array.isArray(complianceData) && + complianceData?.type === COMPLIANCE_OVERVIEW_RESOURCE_TYPE.TASK + ) { + return ( + + + + Compliance data is still being generated. Please try again shortly. + + + ); + } + + if (!Array.isArray(complianceData) || complianceData.length === 0) { return ( @@ -327,20 +344,17 @@ const SSRComplianceGrid = async ({ ); } - if (compliancesData?.errors?.length > 0) { - return ( - - - Provide a valid scan ID. - + const frameworks = complianceData + .filter((compliance: ComplianceOverviewData) => { + return compliance.attributes.framework !== "ProwlerThreatScore"; + }) + .sort((a: ComplianceOverviewData, b: ComplianceOverviewData) => + a.attributes.framework.localeCompare(b.attributes.framework), ); - } // Backend only generates CIS PDFs for the latest version per provider. const latestCisIds = pickLatestCisPerProvider( - compliancesData.data.map( - (compliance: ComplianceOverviewData) => compliance.id, - ), + complianceData.map((compliance: ComplianceOverviewData) => compliance.id), ); return ( diff --git a/ui/changelog.d/compliance-task-response.fixed.md b/ui/changelog.d/compliance-task-response.fixed.md new file mode 100644 index 0000000000..c691464485 --- /dev/null +++ b/ui/changelog.d/compliance-task-response.fixed.md @@ -0,0 +1 @@ +`/compliance` no longer fails while compliance overview data is still being generated diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx index da8eddea72..e73f8e520d 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx @@ -1174,6 +1174,7 @@ describe("ResourceDetailDrawerContent — CVE recommendation button", () => { describe("ResourceDetailDrawerContent — compliance navigation", () => { afterEach(() => { vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); it("should resolve the clicked framework against the selected scan and navigate to compliance detail", async () => { @@ -1377,6 +1378,49 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => { "noopener,noreferrer", ); }); + + it("should not navigate while compliance data is being generated", async () => { + // Given + const user = userEvent.setup(); + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + vi.stubGlobal("open", mockWindowOpen); + mockSearchParamsState.value = "filter[scan__in]=scan-selected"; + mockGetCompliancesOverview.mockResolvedValue({ + data: { + id: "task-1", + type: "tasks", + attributes: { state: "executing" }, + }, + }); + + render( + , + ); + + // When + await user.click( + screen.getByRole("button", { + name: "Open PCI-DSS compliance details", + }), + ); + + // Then + expect(mockWindowOpen).not.toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); }); describe("ResourceDetailDrawerContent — other findings mute refresh", () => { diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx index ea9b43078f..f9aa7c064c 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx @@ -510,10 +510,10 @@ export function ResourceDetailDrawerContent({ const compliancesOverview = await getCompliancesOverview({ scanId: complianceScanId, }); - const complianceMatch = resolveComplianceMatch( - compliancesOverview?.data, - framework, - ); + const complianceData = Array.isArray(compliancesOverview?.data) + ? compliancesOverview.data + : undefined; + const complianceMatch = resolveComplianceMatch(complianceData, framework); if (!complianceMatch) { return;