diff --git a/ui/actions/compliances/compliances.test.ts b/ui/actions/compliances/compliances.test.ts new file mode 100644 index 0000000000..207280a4c2 --- /dev/null +++ b/ui/actions/compliances/compliances.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted( + () => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiResponseMock: vi.fn(), + }), +); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiResponse: handleApiResponseMock, +})); + +import { + getComplianceOverviewMetadataInfo, + getComplianceRequirements, + getCompliancesOverview, +} from "./compliances"; + +const calledUrl = () => new URL(fetchMock.mock.calls[0][0] as string); + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + fetchMock.mockResolvedValue(new Response(null, { status: 200 })); + handleApiResponseMock.mockResolvedValue({ data: [] }); +}); + +describe("getCompliancesOverview", () => { + it("sends scan_id and region in scan mode", async () => { + await getCompliancesOverview({ scanId: "scan-1", region: "eu-west-1" }); + + const url = calledUrl(); + expect(url.searchParams.get("filter[scan_id]")).toBe("scan-1"); + expect(url.searchParams.get("filter[region__in]")).toBe("eu-west-1"); + }); + + it("forwards provider filters and omits scan_id in aggregated mode", async () => { + await getCompliancesOverview({ + scanId: "scan-1", + filters: { "filter[provider_type__in]": "aws,gcp" }, + }); + + const url = calledUrl(); + expect(url.searchParams.get("filter[provider_type__in]")).toBe("aws,gcp"); + // XOR: provider filters present -> never send scan_id (avoids backend 400) + expect(url.searchParams.get("filter[scan_id]")).toBeNull(); + }); +}); + +describe("getComplianceOverviewMetadataInfo", () => { + it("forwards provider filters", async () => { + await getComplianceOverviewMetadataInfo({ + filters: { "filter[provider_groups__in]": "g1,g2" }, + }); + + expect(calledUrl().searchParams.get("filter[provider_groups__in]")).toBe( + "g1,g2", + ); + }); +}); + +describe("getComplianceRequirements", () => { + it("appends compliance_id and scan_id in scan mode", async () => { + await getComplianceRequirements({ + complianceId: "cis_2.0_aws", + scanId: "scan-1", + }); + + const url = calledUrl(); + expect(url.searchParams.get("filter[compliance_id]")).toBe("cis_2.0_aws"); + expect(url.searchParams.get("filter[scan_id]")).toBe("scan-1"); + }); + + it("forwards provider filters and omits scan_id in aggregated mode", async () => { + await getComplianceRequirements({ + complianceId: "cis_2.0_aws", + scanId: "scan-1", + filters: { "filter[provider_id__in]": "p1,p2" }, + }); + + const url = calledUrl(); + expect(url.searchParams.get("filter[compliance_id]")).toBe("cis_2.0_aws"); + expect(url.searchParams.get("filter[provider_id__in]")).toBe("p1,p2"); + expect(url.searchParams.get("filter[scan_id]")).toBeNull(); + }); + + it("omits scan_id when no scan is provided", async () => { + await getComplianceRequirements({ complianceId: "cis_2.0_aws" }); + + expect(calledUrl().searchParams.get("filter[scan_id]")).toBeNull(); + }); +}); diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts index e06b06d29d..63c49ac647 100644 --- a/ui/actions/compliances/compliances.ts +++ b/ui/actions/compliances/compliances.ts @@ -1,6 +1,7 @@ "use server"; import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { hasComplianceProviderFilters } from "@/lib/compliance/compliance-provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; export const getCompliancesOverview = async ({ @@ -27,7 +28,10 @@ export const getCompliancesOverview = async ({ Object.entries(filters).forEach(([key, value]) => setParam(key, value)); - setParam("filter[scan_id]", scanId); + // XOR: the backend rejects filter[scan_id] together with provider filters. + if (!hasComplianceProviderFilters(filters)) { + setParam("filter[scan_id]", scanId); + } setParam("filter[region__in]", region); try { const response = await fetch(url.toString(), { @@ -111,22 +115,33 @@ export const getComplianceRequirements = async ({ complianceId, scanId, region, + filters = {}, }: { complianceId: string; - scanId: string; + scanId?: string; region?: string | string[]; + filters?: Record; }) => { const headers = await getAuthHeaders({ contentType: false }); try { const url = new URL(`${apiBaseUrl}/compliance-overviews/requirements`); url.searchParams.append("filter[compliance_id]", complianceId); - url.searchParams.append("filter[scan_id]", scanId); + + // Forward provider-scope filters (aggregated mode); XOR with scan_id. + Object.entries(filters).forEach(([key, value]) => { + if (value === undefined) return; + const serialized = Array.isArray(value) ? value.join(",") : value; + if (serialized.trim() !== "") url.searchParams.append(key, serialized); + }); + + if (scanId && !hasComplianceProviderFilters(filters)) { + url.searchParams.append("filter[scan_id]", scanId); + } if (region) { const regionValue = Array.isArray(region) ? region.join(",") : region; url.searchParams.append("filter[region__in]", regionValue); - //remove page param } url.searchParams.delete("page");