From ee59e35bc20ceda4e0aefba2c773e54c5b73bb3b Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:09:33 +0200 Subject: [PATCH] fix(ui): stop offering reports and compliance for partial scans (#12880) --- ui/app/(prowler)/compliance/page.test.tsx | 148 +++++++++++++++++- ui/app/(prowler)/compliance/page.tsx | 40 ++++- .../table/scan-jobs-row-actions.test.tsx | 30 ++++ .../scans/table/scan-jobs-row-actions.tsx | 31 ++-- 4 files changed, 232 insertions(+), 17 deletions(-) diff --git a/ui/app/(prowler)/compliance/page.test.tsx b/ui/app/(prowler)/compliance/page.test.tsx index 759ac4f32a..8f5e89a023 100644 --- a/ui/app/(prowler)/compliance/page.test.tsx +++ b/ui/app/(prowler)/compliance/page.test.tsx @@ -15,18 +15,24 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import Compliance from "./page"; const { + complianceFiltersSpy, complianceOverviewGridSpy, getComplianceOverviewMetadataInfoMock, getCompliancesOverviewMock, + getScanMock, getScansMock, getThreatScoreMock, + isCloudMock, loadComplianceWatchlistContextMock, } = vi.hoisted(() => ({ + complianceFiltersSpy: vi.fn(), complianceOverviewGridSpy: vi.fn(), getComplianceOverviewMetadataInfoMock: vi.fn(), getCompliancesOverviewMock: vi.fn(), + getScanMock: vi.fn(), getScansMock: vi.fn(), getThreatScoreMock: vi.fn(), + isCloudMock: vi.fn(() => false), loadComplianceWatchlistContextMock: vi.fn(), })); @@ -41,12 +47,13 @@ vi.mock("@/actions/overview", () => ({ })); vi.mock("@/actions/scans", () => ({ + getScan: getScanMock, getScans: getScansMock, getScansByState: vi.fn(), })); vi.mock("@/lib/shared/env", () => ({ - isCloud: () => false, + isCloud: isCloudMock, })); vi.mock("./_lib/watchlist-context", () => ({ @@ -83,7 +90,10 @@ vi.mock("@/components/compliance", () => ({ })); vi.mock("@/components/compliance/compliance-header/compliance-filters", () => ({ - ComplianceFilters: () =>
Compliance filters
, + ComplianceFilters: (props: { scans: Array<{ id: string }> }) => { + complianceFiltersSpy(props); + return
Compliance filters
; + }, })); vi.mock("@/components/compliance/compliance-overview-grid", () => ({ @@ -148,6 +158,8 @@ describe("Compliance overview page", () => { describe("Compliance overview task response", () => { beforeEach(() => { vi.clearAllMocks(); + isCloudMock.mockReturnValue(false); + getScanMock.mockResolvedValue(undefined); getScansMock.mockResolvedValue({ data: [ { @@ -233,6 +245,138 @@ describe("Compliance overview task response", () => { ); }); + it("keeps partial scans out of the per-scan selector", async () => { + // Given - a Cloud partial scan among the completed scans; it computes no + // compliance, so selecting it would show nothing and its downloads fail + isCloudMock.mockReturnValue(true); + getScansMock.mockResolvedValue({ + data: [ + { + id: "scan-1", + attributes: { + name: "Production scan", + completed_at: "2026-08-05T17:00:00Z", + }, + relationships: { provider: { data: { id: "provider-1" } } }, + }, + { + id: "scan-partial", + attributes: { + name: "Re-check", + completed_at: "2026-08-06T09:00:00Z", + is_partial: true, + }, + relationships: { provider: { data: { id: "provider-1" } } }, + }, + ], + included: [ + { + type: "providers", + id: "provider-1", + attributes: { provider: "aws", uid: "123456789012", alias: "prod" }, + }, + ], + }); + getCompliancesOverviewMock.mockResolvedValue({ data: [] }); + + // When + const page = await Compliance({ + searchParams: Promise.resolve({ scanId: "scan-1" }), + }); + render(page as ReactElement); + + // Then - only the full scan reaches the selector, and the API is asked + // for the flag that tells them apart + expect(complianceFiltersSpy).toHaveBeenCalledWith( + expect.objectContaining({ + scans: [expect.objectContaining({ id: "scan-1" })], + }), + ); + expect(getScansMock).toHaveBeenCalledWith( + expect.objectContaining({ + // Filtered at the API too, so a page full of re-checks cannot hide + // the full scans behind it. + filters: expect.objectContaining({ "filter[is_partial]": "false" }), + fields: { scans: "name,completed_at,provider,is_partial" }, + }), + ); + }); + + it("does not send the Cloud-only partial filter outside Prowler Cloud", async () => { + getCompliancesOverviewMock.mockResolvedValue({ data: [] }); + + await Compliance({ searchParams: Promise.resolve({ scanId: "scan-1" }) }); + + expect(getScansMock).toHaveBeenCalledWith( + expect.objectContaining({ + filters: { "filter[state]": "completed" }, + }), + ); + }); + + it("falls back to the first full scan when the URL names a partial scan", async () => { + // Given - a stale link to a partial scan, absent from the eligible list + getScanMock.mockResolvedValue({ + data: { id: "scan-partial", attributes: { is_partial: true } }, + }); + getCompliancesOverviewMock.mockResolvedValue({ data: [] }); + + // When + const page = await Compliance({ + searchParams: Promise.resolve({ scanId: "scan-partial" }), + }); + render(page as ReactElement); + + // Then - the page selects a scan that has compliance instead + expect(getScanMock).toHaveBeenCalledWith("scan-partial"); + expect(complianceFiltersSpy).toHaveBeenCalledWith( + expect.objectContaining({ selectedScanId: "scan-1" }), + ); + expect(getCompliancesOverviewMock).toHaveBeenCalledWith( + expect.objectContaining({ scanId: "scan-1" }), + ); + }); + + it("falls back to the first full scan when the URL scan cannot be found", async () => { + // Given - a deleted or mistyped id: the lookup returns an error, no data + getScanMock.mockResolvedValue({ error: "Not found", status: 404 }); + getCompliancesOverviewMock.mockResolvedValue({ data: [] }); + + // When + const page = await Compliance({ + searchParams: Promise.resolve({ scanId: "scan-missing" }), + }); + render(page as ReactElement); + + // Then - no compliance request goes out for an id that does not exist + expect(complianceFiltersSpy).toHaveBeenCalledWith( + expect.objectContaining({ selectedScanId: "scan-1" }), + ); + expect(getCompliancesOverviewMock).not.toHaveBeenCalledWith( + expect.objectContaining({ scanId: "scan-missing" }), + ); + }); + + it("keeps trusting a URL scan id that is older than the listed page", async () => { + // Given - a full scan beyond the first page, shaped like an OSS response + // that carries no is_partial field at all + getScanMock.mockResolvedValue({ + data: { id: "scan-old", attributes: { name: "Old scan" } }, + }); + getCompliancesOverviewMock.mockResolvedValue({ data: [] }); + + // When + const page = await Compliance({ + searchParams: Promise.resolve({ scanId: "scan-old" }), + }); + render(page as ReactElement); + + // Then + expect(complianceFiltersSpy).toHaveBeenCalledWith( + expect.objectContaining({ selectedScanId: "scan-old" }), + ); + }); + 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({ diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index a643e70330..6a31432d99 100644 --- a/ui/app/(prowler)/compliance/page.tsx +++ b/ui/app/(prowler)/compliance/page.tsx @@ -7,7 +7,7 @@ import { getCompliancesOverview, } from "@/actions/compliances"; import { getThreatScore } from "@/actions/overview"; -import { getScans, getScansByState } from "@/actions/scans"; +import { getScan, getScans, getScansByState } from "@/actions/scans"; import { ComplianceSkeletonGrid, NoScansAvailable, @@ -41,6 +41,31 @@ import { import type { ComplianceWatchlistContext } from "./_lib/watchlist-context"; import { loadComplianceWatchlistContext } from "./_lib/watchlist-context"; +/** + * A scan id from the URL is trusted when it is listed, or when a single lookup + * returns a scan that is not partial (older full scans keep working). A partial + * scan, reached through a stale link, or an id the API cannot find, has no + * compliance to show, so the caller falls back to the first eligible scan. + */ +async function resolveUrlScanId( + scanIdFromUrl: string | undefined, + eligibleScans: ExpandedScanData[], +): Promise { + if (!scanIdFromUrl) return undefined; + if (eligibleScans.some((scan) => scan.id === scanIdFromUrl)) { + return scanIdFromUrl; + } + + const urlScan = (await getScan(scanIdFromUrl)) as + | { data?: { attributes?: { is_partial?: boolean } } } + | undefined; + const scan = urlScan?.data; + if (!scan) return undefined; + + // OSS scans carry no is_partial at all, so only an explicit true excludes. + return scan.attributes?.is_partial === true ? undefined : scanIdFromUrl; +} + export default async function Compliance({ searchParams, }: { @@ -130,10 +155,14 @@ export default async function Compliance({ getScans({ filters: { "filter[state]": "completed", + // Partial scans compute no compliance. Exclude them at the API so the + // page below never fills up with them; the filter is Cloud-only. + ...(isCloud() ? { "filter[is_partial]": "false" } : {}), }, pageSize: 50, fields: { - scans: "name,completed_at,provider", + // is_partial is Cloud-only; the OSS API ignores unknown sparse fields. + scans: "name,completed_at,provider,is_partial", }, include: "provider", }), @@ -161,7 +190,10 @@ export default async function Compliance({ ); } + // Belt and braces for an API without the filter: partial scans never + // compute compliance, so they have nothing to show or download here. const expandedScansData: ExpandedScanData[] = scansData.data + .filter((scan: ScanProps) => !scan.attributes?.is_partial) .filter((scan: ScanProps) => scan.relationships?.provider?.data?.id) .map((scan: ScanProps) => { const providerId = scan.relationships!.provider!.data!.id; @@ -191,7 +223,9 @@ export default async function Compliance({ ? scanIdParam[0] : scanIdParam; const selectedScanId: string | null = - scanIdFromUrl || expandedScansData[0]?.id || null; + (await resolveUrlScanId(scanIdFromUrl, expandedScansData)) || + expandedScansData[0]?.id || + null; const onboardingAction = selectedScanId ? { flowId: "view-compliance" } : { diff --git a/ui/components/scans/table/scan-jobs-row-actions.test.tsx b/ui/components/scans/table/scan-jobs-row-actions.test.tsx index c9e2ef3944..d9b8fba781 100644 --- a/ui/components/scans/table/scan-jobs-row-actions.test.tsx +++ b/ui/components/scans/table/scan-jobs-row-actions.test.tsx @@ -387,6 +387,36 @@ describe("ScanJobsRowActions", () => { expect(downloadScanZipMock).toHaveBeenCalledWith("scan-1", toastMock); }); + it("offers neither report download nor compliance for a partial scan", async () => { + // A partial scan re-checks a few resources: no report files, no compliance. + const user = userEvent.setup(); + render( + , + ); + + await user.click( + screen.getByRole("button", { name: /open actions menu/i }), + ); + + expect( + screen.queryByRole("menuitem", { name: /download scan reports/i }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("menuitem", { name: /view compliance/i }), + ).not.toBeInTheDocument(); + // The rest of the completed-scan actions stay available. + expect( + screen.getByRole("menuitem", { name: /view findings/i }), + ).toBeInTheDocument(); + }); + it("opens the paid plan upgrade instead of downloading subscription-only reports", async () => { // Given const user = userEvent.setup(); diff --git a/ui/components/scans/table/scan-jobs-row-actions.tsx b/ui/components/scans/table/scan-jobs-row-actions.tsx index ed43777f65..1de2fb6ef3 100644 --- a/ui/components/scans/table/scan-jobs-row-actions.tsx +++ b/ui/components/scans/table/scan-jobs-row-actions.tsx @@ -83,6 +83,9 @@ export function ScanJobsRowActions({ const scanState = scan.attributes.state; const isCompleted = scanState === "completed"; const isFailed = scanState === "failed"; + // Prowler Cloud partial scans re-check a few resources: they compute no + // compliance and write no report files, so neither entry applies. + const isPartial = scan.attributes.is_partial === true; const taskId = scan.relationships.task.data?.id; // The findings page bounds the UTC day range with completed_at; without it the // range collapses to the start day and can miss later findings. @@ -210,18 +213,22 @@ export function ScanJobsRowActions({ onSelect={openFindings} disabled={!isCompleted || !hasCompletedAt} /> - } - label="View Compliance" - onSelect={openCompliance} - /> - } - label="Download Scan Reports" - onSelect={() => - runReportDownload(() => downloadScanZip(scan.id, toast)) - } - /> + {!isPartial && ( + } + label="View Compliance" + onSelect={openCompliance} + /> + )} + {!isPartial && ( + } + label="Download Scan Reports" + onSelect={() => + runReportDownload(() => downloadScanZip(scan.id, toast)) + } + /> + )} )} {isFailed && (