diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.test.tsx b/ui/components/compliance/compliance-accordion/client-accordion-content.test.tsx new file mode 100644 index 0000000000..bb1bfde5c7 --- /dev/null +++ b/ui/components/compliance/compliance-accordion/client-accordion-content.test.tsx @@ -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: () =>
, +})); + +vi.mock("@/components/ui/accordion/Accordion", () => ({ + Accordion: () =>
, +})); + +vi.mock("@/components/ui/table", () => ({ + DataTable: () =>
, +})); + +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( + , + ); + + // 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( + , + ); + + // 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" }); + }); + }); +}); diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx index 8e9d3ad0b2..43051ebef0 100644 --- a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx +++ b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx @@ -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, diff --git a/ui/components/compliance/compliance-card.tsx b/ui/components/compliance/compliance-card.tsx index 4546d9f13c..7dd53eadb5 100644 --- a/ui/components/compliance/compliance-card.tsx +++ b/ui/components/compliance/compliance-card.tsx @@ -58,9 +58,7 @@ export const ComplianceCard: React.FC = ({ // 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) => { diff --git a/ui/components/compliance/compliance-overview-grid.tsx b/ui/components/compliance/compliance-overview-grid.tsx index f3e3ac5ebb..24d187320c 100644 --- a/ui/components/compliance/compliance-overview-grid.tsx +++ b/ui/components/compliance/compliance-overview-grid.tsx @@ -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) => diff --git a/ui/lib/compliance/compliance-provider-filters.ts b/ui/lib/compliance/compliance-provider-filters.ts index bb2ace6784..be0e28ef72 100644 --- a/ui/lib/compliance/compliance-provider-filters.ts +++ b/ui/lib/compliance/compliance-provider-filters.ts @@ -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; /** 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; -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) {