mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
fix(ui): stop offering reports and compliance for partial scans (#12880)
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user