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 && (