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"; import Compliance from "./page";
const { const {
complianceFiltersSpy,
complianceOverviewGridSpy, complianceOverviewGridSpy,
getComplianceOverviewMetadataInfoMock, getComplianceOverviewMetadataInfoMock,
getCompliancesOverviewMock, getCompliancesOverviewMock,
getScanMock,
getScansMock, getScansMock,
getThreatScoreMock, getThreatScoreMock,
isCloudMock,
loadComplianceWatchlistContextMock, loadComplianceWatchlistContextMock,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
complianceFiltersSpy: vi.fn(),
complianceOverviewGridSpy: vi.fn(), complianceOverviewGridSpy: vi.fn(),
getComplianceOverviewMetadataInfoMock: vi.fn(), getComplianceOverviewMetadataInfoMock: vi.fn(),
getCompliancesOverviewMock: vi.fn(), getCompliancesOverviewMock: vi.fn(),
getScanMock: vi.fn(),
getScansMock: vi.fn(), getScansMock: vi.fn(),
getThreatScoreMock: vi.fn(), getThreatScoreMock: vi.fn(),
isCloudMock: vi.fn(() => false),
loadComplianceWatchlistContextMock: vi.fn(), loadComplianceWatchlistContextMock: vi.fn(),
})); }));
@@ -41,12 +47,13 @@ vi.mock("@/actions/overview", () => ({
})); }));
vi.mock("@/actions/scans", () => ({ vi.mock("@/actions/scans", () => ({
getScan: getScanMock,
getScans: getScansMock, getScans: getScansMock,
getScansByState: vi.fn(), getScansByState: vi.fn(),
})); }));
vi.mock("@/lib/shared/env", () => ({ vi.mock("@/lib/shared/env", () => ({
isCloud: () => false, isCloud: isCloudMock,
})); }));
vi.mock("./_lib/watchlist-context", () => ({ vi.mock("./_lib/watchlist-context", () => ({
@@ -83,7 +90,10 @@ vi.mock("@/components/compliance", () => ({
})); }));
vi.mock("@/components/compliance/compliance-header/compliance-filters", () => ({ 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", () => ({ vi.mock("@/components/compliance/compliance-overview-grid", () => ({
@@ -148,6 +158,8 @@ describe("Compliance overview page", () => {
describe("Compliance overview task response", () => { describe("Compliance overview task response", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
isCloudMock.mockReturnValue(false);
getScanMock.mockResolvedValue(undefined);
getScansMock.mockResolvedValue({ getScansMock.mockResolvedValue({
data: [ 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 () => { it("shows the invalid scan message for a JSON:API error response", async () => {
// Given - handleApiResponse converted a client error to its error result // Given - handleApiResponse converted a client error to its error result
getCompliancesOverviewMock.mockResolvedValue({ getCompliancesOverviewMock.mockResolvedValue({
+37 -3
View File
@@ -7,7 +7,7 @@ import {
getCompliancesOverview, getCompliancesOverview,
} from "@/actions/compliances"; } from "@/actions/compliances";
import { getThreatScore } from "@/actions/overview"; import { getThreatScore } from "@/actions/overview";
import { getScans, getScansByState } from "@/actions/scans"; import { getScan, getScans, getScansByState } from "@/actions/scans";
import { import {
ComplianceSkeletonGrid, ComplianceSkeletonGrid,
NoScansAvailable, NoScansAvailable,
@@ -41,6 +41,31 @@ import {
import type { ComplianceWatchlistContext } from "./_lib/watchlist-context"; import type { ComplianceWatchlistContext } from "./_lib/watchlist-context";
import { loadComplianceWatchlistContext } 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({ export default async function Compliance({
searchParams, searchParams,
}: { }: {
@@ -130,10 +155,14 @@ export default async function Compliance({
getScans({ getScans({
filters: { filters: {
"filter[state]": "completed", "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, pageSize: 50,
fields: { 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", 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 const expandedScansData: ExpandedScanData[] = scansData.data
.filter((scan: ScanProps) => !scan.attributes?.is_partial)
.filter((scan: ScanProps) => scan.relationships?.provider?.data?.id) .filter((scan: ScanProps) => scan.relationships?.provider?.data?.id)
.map((scan: ScanProps) => { .map((scan: ScanProps) => {
const providerId = scan.relationships!.provider!.data!.id; const providerId = scan.relationships!.provider!.data!.id;
@@ -191,7 +223,9 @@ export default async function Compliance({
? scanIdParam[0] ? scanIdParam[0]
: scanIdParam; : scanIdParam;
const selectedScanId: string | null = const selectedScanId: string | null =
scanIdFromUrl || expandedScansData[0]?.id || null; (await resolveUrlScanId(scanIdFromUrl, expandedScansData)) ||
expandedScansData[0]?.id ||
null;
const onboardingAction = selectedScanId const onboardingAction = selectedScanId
? { flowId: "view-compliance" } ? { flowId: "view-compliance" }
: { : {
@@ -387,6 +387,36 @@ describe("ScanJobsRowActions", () => {
expect(downloadScanZipMock).toHaveBeenCalledWith("scan-1", toastMock); 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 () => { it("opens the paid plan upgrade instead of downloading subscription-only reports", async () => {
// Given // Given
const user = userEvent.setup(); const user = userEvent.setup();
@@ -83,6 +83,9 @@ export function ScanJobsRowActions({
const scanState = scan.attributes.state; const scanState = scan.attributes.state;
const isCompleted = scanState === "completed"; const isCompleted = scanState === "completed";
const isFailed = scanState === "failed"; 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; const taskId = scan.relationships.task.data?.id;
// The findings page bounds the UTC day range with completed_at; without it the // 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. // range collapses to the start day and can miss later findings.
@@ -210,18 +213,22 @@ export function ScanJobsRowActions({
onSelect={openFindings} onSelect={openFindings}
disabled={!isCompleted || !hasCompletedAt} disabled={!isCompleted || !hasCompletedAt}
/> />
<ActionDropdownItem {!isPartial && (
icon={<ShieldCheck />} <ActionDropdownItem
label="View Compliance" icon={<ShieldCheck />}
onSelect={openCompliance} label="View Compliance"
/> onSelect={openCompliance}
<ActionDropdownItem />
icon={<Download />} )}
label="Download Scan Reports" {!isPartial && (
onSelect={() => <ActionDropdownItem
runReportDownload(() => downloadScanZip(scan.id, toast)) icon={<Download />}
} label="Download Scan Reports"
/> onSelect={() =>
runReportDownload(() => downloadScanZip(scan.id, toast))
}
/>
)}
</> </>
)} )}
{isFailed && ( {isFailed && (