mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
fix(ui): scope aggregated compliance findings to the latest scan
Aggregated mode sent provider filters with no scan/date to /findings, which the API rejects with 400, so requirement findings always rendered empty. Use /findings/latest in aggregated mode (needs neither; scopes to the latest scan per matching provider) and keep /findings for single-scan mode. Also tidies the provider-filter helpers (review nits): - Derive the filter keys/type from a FILTER_FIELD subset const - Accept ReadonlyURLSearchParams so call sites drop the search-params wrap Adds a regression test covering both aggregated and single-scan modes.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Requirement } from "@/types/compliance";
|
||||
|
||||
import { ClientAccordionContent } from "./client-accordion-content";
|
||||
|
||||
const { getFindingsMock, getLatestFindingsMock } = vi.hoisted(() => ({
|
||||
getFindingsMock: vi.fn(),
|
||||
getLatestFindingsMock: vi.fn(),
|
||||
}));
|
||||
|
||||
let currentSearchParams = new URLSearchParams();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => currentSearchParams,
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/findings/findings", () => ({
|
||||
getFindings: getFindingsMock,
|
||||
getLatestFindings: getLatestFindingsMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/table", () => ({
|
||||
getStandaloneFindingColumns: () => [],
|
||||
SkeletonTableFindings: () => <div data-testid="skeleton" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/accordion/Accordion", () => ({
|
||||
Accordion: () => <div data-testid="accordion" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
DataTable: () => <div data-testid="data-table" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/compliance/compliance-mapper", () => ({
|
||||
getComplianceMapper: () => ({ getDetailsComponent: () => null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
createDict: () => ({}),
|
||||
FINDINGS_DEFAULT_SORT: "severity",
|
||||
MUTED_FILTER: { EXCLUDE: "false" },
|
||||
}));
|
||||
|
||||
const requirement = {
|
||||
check_ids: ["check-1"],
|
||||
status: "FAIL",
|
||||
} as unknown as Requirement;
|
||||
|
||||
describe("ClientAccordionContent findings drill-down", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getFindingsMock.mockResolvedValue({ data: [], meta: {} });
|
||||
getLatestFindingsMock.mockResolvedValue({ data: [], meta: {} });
|
||||
});
|
||||
|
||||
describe("when provider filters drive aggregated mode", () => {
|
||||
it("loads findings from the latest endpoint, not the scan-scoped one", async () => {
|
||||
// Given - the URL carries a provider-scope filter and no scanId
|
||||
currentSearchParams = new URLSearchParams({
|
||||
complianceId: "cis_2.0_aws",
|
||||
"filter[provider_type__in]": "aws",
|
||||
});
|
||||
|
||||
// When
|
||||
render(
|
||||
<ClientAccordionContent
|
||||
requirement={requirement}
|
||||
framework="cis_aws"
|
||||
scanId=""
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then - /findings 400s without a scan or date filter, so aggregated mode
|
||||
// must use /findings/latest, forwarding the provider filters and no scan
|
||||
await waitFor(() =>
|
||||
expect(getLatestFindingsMock).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(getFindingsMock).not.toHaveBeenCalled();
|
||||
const { filters } = getLatestFindingsMock.mock.calls[0][0];
|
||||
expect(filters).toMatchObject({ "filter[provider_type__in]": "aws" });
|
||||
expect(filters).not.toHaveProperty("filter[scan]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when a single scan drives the scope", () => {
|
||||
it("loads findings from the scan-scoped endpoint", async () => {
|
||||
// Given - no provider filters, a concrete scanId
|
||||
currentSearchParams = new URLSearchParams({
|
||||
complianceId: "cis_2.0_aws",
|
||||
});
|
||||
|
||||
// When
|
||||
render(
|
||||
<ClientAccordionContent
|
||||
requirement={requirement}
|
||||
framework="cis_aws"
|
||||
scanId="scan-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(getFindingsMock).toHaveBeenCalledTimes(1));
|
||||
expect(getLatestFindingsMock).not.toHaveBeenCalled();
|
||||
const { filters } = getFindingsMock.mock.calls[0][0];
|
||||
expect(filters).toMatchObject({ "filter[scan]": "scan-1" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { getFindings } from "@/actions/findings/findings";
|
||||
import { getFindings, getLatestFindings } from "@/actions/findings/findings";
|
||||
import {
|
||||
getStandaloneFindingColumns,
|
||||
SkeletonTableFindings,
|
||||
@@ -51,9 +51,7 @@ export const ClientAccordionContent = ({
|
||||
// so scope this requirement's findings by those providers rather than one scan.
|
||||
// Stable string key keeps the effect deps free of a per-render object.
|
||||
const providerScopeKey = new URLSearchParams(
|
||||
extractComplianceProviderFilters(
|
||||
new URLSearchParams(searchParams.toString()),
|
||||
),
|
||||
extractComplianceProviderFilters(searchParams),
|
||||
).toString();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -77,10 +75,15 @@ export const ClientAccordionContent = ({
|
||||
try {
|
||||
const checkIds = requirement.check_ids;
|
||||
const encodedSort = sort.replace(/^\+/, "");
|
||||
const scopeFilters = providerScopeKey
|
||||
// Aggregated mode carries provider filters but no scan/date, which the
|
||||
// /findings endpoint rejects (400). Use /findings/latest there — it
|
||||
// needs neither and scopes to the latest scan per matching provider.
|
||||
const isAggregated = providerScopeKey.length > 0;
|
||||
const scopeFilters = isAggregated
|
||||
? Object.fromEntries(new URLSearchParams(providerScopeKey))
|
||||
: { "filter[scan]": scanId };
|
||||
const findingsData = await getFindings({
|
||||
const loadFindings = isAggregated ? getLatestFindings : getFindings;
|
||||
const findingsData = await loadFindings({
|
||||
filters: {
|
||||
"filter[check_id__in]": checkIds.join(","),
|
||||
...scopeFilters,
|
||||
|
||||
@@ -58,9 +58,7 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
|
||||
// Aggregated mode: provider filters replace the single-scan scope, so per-scan
|
||||
// affordances (CIS PDF) are hidden and the drill-down carries provider filters.
|
||||
const providerFilters = extractComplianceProviderFilters(
|
||||
new URLSearchParams(searchParams.toString()),
|
||||
);
|
||||
const providerFilters = extractComplianceProviderFilters(searchParams);
|
||||
const isAggregated = Object.keys(providerFilters).length > 0;
|
||||
|
||||
const formatTitle = (title: string) => {
|
||||
|
||||
@@ -45,9 +45,7 @@ export const ComplianceOverviewGrid = ({
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// Aggregated mode: provider filters in the URL replace the single-scan scope.
|
||||
const providerFilters = extractComplianceProviderFilters(
|
||||
new URLSearchParams(searchParams.toString()),
|
||||
);
|
||||
const providerFilters = extractComplianceProviderFilters(searchParams);
|
||||
const isAggregated = Object.keys(providerFilters).length > 0;
|
||||
|
||||
const filteredFrameworks = frameworks.filter((compliance) =>
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import type { ReadonlyURLSearchParams } from "next/navigation";
|
||||
|
||||
import type { SearchParamsProps } from "@/types/components";
|
||||
import { FILTER_FIELD, FilterParam } from "@/types/filters";
|
||||
|
||||
/**
|
||||
* Provider-scope filter param keys the compliance UI sets — the same three the
|
||||
* overview dashboard uses, derived from the shared `FILTER_FIELD` source of
|
||||
* truth. The backend (`ComplianceOverviewViewSet`) treats these as an
|
||||
* alternative to `filter[scan_id]` (XOR) and aggregates compliance across the
|
||||
* latest completed scan of each matching provider.
|
||||
* Provider-scope filter fields the compliance UI sets — a subset of the shared
|
||||
* `FILTER_FIELD` source of truth, the same three the overview dashboard uses.
|
||||
*/
|
||||
export type ComplianceProviderFilterParam = FilterParam<
|
||||
(typeof FILTER_FIELD)["PROVIDER_TYPE" | "PROVIDER_ID" | "PROVIDER_GROUPS"]
|
||||
>;
|
||||
const COMPLIANCE_PROVIDER_FILTER_FIELD = {
|
||||
PROVIDER_TYPE: FILTER_FIELD.PROVIDER_TYPE,
|
||||
PROVIDER_ID: FILTER_FIELD.PROVIDER_ID,
|
||||
PROVIDER_GROUPS: FILTER_FIELD.PROVIDER_GROUPS,
|
||||
} as const;
|
||||
|
||||
type ComplianceProviderFilterField =
|
||||
(typeof COMPLIANCE_PROVIDER_FILTER_FIELD)[keyof typeof COMPLIANCE_PROVIDER_FILTER_FIELD];
|
||||
|
||||
/**
|
||||
* Provider-scope filter param keys (e.g. `filter[provider_type__in]`). The
|
||||
* backend (`ComplianceOverviewViewSet`) treats these as an alternative to
|
||||
* `filter[scan_id]` (XOR) and aggregates compliance across the latest completed
|
||||
* scan of each matching provider.
|
||||
*/
|
||||
export type ComplianceProviderFilterParam =
|
||||
FilterParam<ComplianceProviderFilterField>;
|
||||
|
||||
/** Present, CSV-joined provider-scope filters (aggregated mode). */
|
||||
export type ComplianceProviderFilters = Partial<
|
||||
@@ -26,12 +39,20 @@ export type ComplianceFilters = Partial<
|
||||
>;
|
||||
|
||||
export const COMPLIANCE_PROVIDER_FILTER_KEYS = [
|
||||
`filter[${FILTER_FIELD.PROVIDER_TYPE}]`,
|
||||
`filter[${FILTER_FIELD.PROVIDER_ID}]`,
|
||||
`filter[${FILTER_FIELD.PROVIDER_GROUPS}]`,
|
||||
`filter[${COMPLIANCE_PROVIDER_FILTER_FIELD.PROVIDER_TYPE}]`,
|
||||
`filter[${COMPLIANCE_PROVIDER_FILTER_FIELD.PROVIDER_ID}]`,
|
||||
`filter[${COMPLIANCE_PROVIDER_FILTER_FIELD.PROVIDER_GROUPS}]`,
|
||||
] as const satisfies ReadonlyArray<ComplianceProviderFilterParam>;
|
||||
|
||||
type SearchParamsLike = SearchParamsProps | URLSearchParams;
|
||||
/**
|
||||
* Accepts either an SSR plain search-params object or the client
|
||||
* `useSearchParams()` result (`ReadonlyURLSearchParams`), so callers don't need
|
||||
* to wrap the latter in a fresh `URLSearchParams`.
|
||||
*/
|
||||
type SearchParamsLike =
|
||||
| SearchParamsProps
|
||||
| URLSearchParams
|
||||
| ReadonlyURLSearchParams;
|
||||
|
||||
const readParam = (params: SearchParamsLike, key: string): string => {
|
||||
if (params instanceof URLSearchParams) {
|
||||
|
||||
Reference in New Issue
Block a user