Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🚀 Added

- Controlled `402` and `403` Server Action error messages for alert seed and mutation flows [(#11629)](https://github.com/prowler-cloud/prowler/pull/11629)
- Filter the Overview, Findings, Resources, Scans, and Providers views by provider group [(#11659)](https://github.com/prowler-cloud/prowler/pull/11659)
- Filter the Compliance overview and detail pages by provider [(#11668)](https://github.com/prowler-cloud/prowler/pull/11668)

### 🐞 Fixed

Expand Down
100 changes: 100 additions & 0 deletions ui/actions/compliances/compliances.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
29 changes: 23 additions & 6 deletions ui/actions/compliances/compliances.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
"use server";

import { apiBaseUrl, getAuthHeaders } from "@/lib";
import {
type ComplianceFilters,
type ComplianceProviderFilters,
hasComplianceProviderFilters,
} from "@/lib/compliance/compliance-provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";

export const getCompliancesOverview = async ({
Expand All @@ -10,7 +15,7 @@ export const getCompliancesOverview = async ({
}: {
scanId?: string;
region?: string | string[];
filters?: Record<string, string | string[] | undefined>;
filters?: ComplianceProviderFilters;
} = {}) => {
const headers = await getAuthHeaders({ contentType: false });

Expand All @@ -27,7 +32,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(), {
Expand All @@ -46,7 +54,7 @@ export const getComplianceOverviewMetadataInfo = async ({
filters = {},
}: {
sort?: string;
filters?: Record<string, string | string[] | undefined>;
filters?: ComplianceFilters;
} = {}) => {
const headers = await getAuthHeaders({ contentType: false });

Expand Down Expand Up @@ -111,22 +119,31 @@ export const getComplianceRequirements = async ({
complianceId,
scanId,
region,
filters = {},
}: {
complianceId: string;
scanId: string;
scanId?: string;
region?: string | string[];
filters?: ComplianceProviderFilters;
}) => {
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 && value.trim() !== "") url.searchParams.append(key, value);
});

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");

Expand Down
6 changes: 3 additions & 3 deletions ui/actions/finding-groups/finding-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { redirect } from "next/navigation";

import type { FindingsFilterParam } from "@/actions/findings/findings-filters";
import {
apiBaseUrl,
composeSort,
Expand All @@ -15,7 +16,6 @@ import {
} from "@/lib";
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { FilterParam } from "@/types/filters";

/**
* Maps filter[search] to filter[check_title__icontains] for finding-groups.
Expand All @@ -39,7 +39,7 @@ function mapSearchFilter(
* finding-group resources sub-endpoint. These must be stripped before
* calling the resources API to avoid empty results.
*/
const FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS: FilterParam[] = [
const FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS: FindingsFilterParam[] = [
"filter[service__in]",
"filter[scan__in]",
"filter[scan_id]",
Expand All @@ -53,7 +53,7 @@ function normalizeFindingGroupResourceFilters(
Object.entries(filters).filter(
([key]) =>
!FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS.includes(
key as FilterParam,
key as FindingsFilterParam,
),
),
);
Expand Down
32 changes: 32 additions & 0 deletions ui/actions/findings/findings-filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { FILTER_FIELD, FilterParam } from "@/types/filters";

/**
* URL filter param keys the findings view supports, e.g. `filter[severity__in]`.
* Composed from the shared fields it uses plus a few findings-only extras
* (alternate scan/date/delta forms not used by other views).
*/
export type FindingsFilterParam = FilterParam<
// findings uses provider_id, not provider_uid
| (typeof FILTER_FIELD)[
| "PROVIDER_TYPE"
| "PROVIDER_ID"
| "PROVIDER_GROUPS"
| "REGION"
| "SERVICE"
| "SEVERITY"
| "STATUS"
| "DELTA"
| "RESOURCE_TYPE"
| "CATEGORY"
| "RESOURCE_GROUPS"
| "SCAN"]
// findings-only extras
| "delta__in"
| "scan"
| "scan_id"
| "scan_id__in"
| "inserted_at"
| "inserted_at__gte"
| "inserted_at__lte"
| "muted"
>;
138 changes: 138 additions & 0 deletions ui/actions/manage-groups/manage-groups.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const {
fetchMock,
getAuthHeadersMock,
handleApiErrorMock,
handleApiResponseMock,
} = vi.hoisted(() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiErrorMock: vi.fn(),
handleApiResponseMock: vi.fn(),
}));

vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}));

vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));

vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
getErrorMessage: vi.fn(),
}));

vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: handleApiErrorMock,
handleApiResponse: handleApiResponseMock,
}));

import { getAllProviderGroups } from "./manage-groups";

const makeGroup = (id: string, name: string) => ({
type: "provider-groups" as const,
id,
attributes: { name, inserted_at: "", updated_at: "" },
relationships: {
providers: { meta: { count: 0 }, data: [] },
roles: { meta: { count: 0 }, data: [] },
},
links: { self: "" },
});

const makePage = (
data: ReturnType<typeof makeGroup>[],
page: number,
pages: number,
) => ({
links: { first: "", last: "", next: null, prev: null },
data,
meta: { pagination: { page, pages, count: data.length } },
});

describe("getAllProviderGroups", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
});

it("merges every page into a single response with collapsed pagination", async () => {
handleApiResponseMock
.mockResolvedValueOnce(
makePage(
[makeGroup("g1", "Group 1"), makeGroup("g2", "Group 2")],
1,
2,
),
)
.mockResolvedValueOnce(makePage([makeGroup("g3", "Group 3")], 2, 2));

const result = await getAllProviderGroups();

expect(fetchMock).toHaveBeenCalledTimes(2);
expect(result?.data.map((group) => group.id)).toEqual(["g1", "g2", "g3"]);
expect(result?.meta.pagination).toMatchObject({
page: 1,
pages: 1,
count: 3,
});
});

it("stops after the first page when there is only one page", async () => {
handleApiResponseMock.mockResolvedValueOnce(
makePage([makeGroup("g1", "Group 1")], 1, 1),
);

const result = await getAllProviderGroups();

expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result?.data).toHaveLength(1);
});

it("returns undefined when the first page has no data", async () => {
handleApiResponseMock.mockResolvedValueOnce(makePage([], 1, 1));

const result = await getAllProviderGroups();

expect(result).toBeUndefined();
});

it("returns undefined when the request throws", async () => {
fetchMock.mockRejectedValueOnce(new Error("network down"));

const result = await getAllProviderGroups();

expect(result).toBeUndefined();
});

it("returns undefined when a later page resolves to an error payload", async () => {
handleApiResponseMock
.mockResolvedValueOnce(makePage([makeGroup("g1", "Group 1")], 1, 2))
.mockResolvedValueOnce({ error: "Forbidden", status: 403 });

const result = await getAllProviderGroups();

expect(result).toBeUndefined();
});

it("returns undefined instead of a truncated list when the max-page cap is hit", async () => {
// Given an API that always reports more pages than the 50-page safety cap
handleApiResponseMock.mockImplementation((response: Response) => {
void response;
return Promise.resolve(makePage([makeGroup("g", "Group")], 1, 9999));
});

// When fetching every page
const result = await getAllProviderGroups();

// Then it must not return a partial/truncated list; bail out instead
expect(result).toBeUndefined();
expect(fetchMock).toHaveBeenCalledTimes(50);
});
});
Loading