fix(ui): stop offering reports and compliance for partial scans (#12880)

This commit is contained in:
Alejandro Bailo
2026-09-25 10:09:33 +02:00
committed by GitHub
parent e0fa23b9ee
commit ee59e35bc2
4 changed files with 232 additions and 17 deletions
+146 -2
View File
@@ -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: () => <div>Compliance filters</div>,
ComplianceFilters: (props: { scans: Array<{ id: string }> }) => {
complianceFiltersSpy(props);
return <div>Compliance filters</div>;
},
}));
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({
+37 -3
View File
@@ -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<string | undefined> {
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" }
: {
@@ -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(
<ScanJobsRowActions
scan={makeScan({
state: "completed",
completed_at: "2026-01-01T10:05:00Z",
is_partial: true,
})}
tab="completed"
/>,
);
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();
@@ -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}
/>
<ActionDropdownItem
icon={<ShieldCheck />}
label="View Compliance"
onSelect={openCompliance}
/>
<ActionDropdownItem
icon={<Download />}
label="Download Scan Reports"
onSelect={() =>
runReportDownload(() => downloadScanZip(scan.id, toast))
}
/>
{!isPartial && (
<ActionDropdownItem
icon={<ShieldCheck />}
label="View Compliance"
onSelect={openCompliance}
/>
)}
{!isPartial && (
<ActionDropdownItem
icon={<Download />}
label="Download Scan Reports"
onSelect={() =>
runReportDownload(() => downloadScanZip(scan.id, toast))
}
/>
)}
</>
)}
{isFailed && (