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:
Pablo F.G
2026-06-23 15:00:33 +02:00
parent 0a2abbe4a9
commit 542a6e458b
5 changed files with 155 additions and 24 deletions
@@ -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,
+1 -3
View File
@@ -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) {