mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 21:11:53 +00:00
fix(ui): expose finding group filter
This commit is contained in:
@@ -44,4 +44,10 @@ describe("findings page", () => {
|
||||
it("applies the shared default muted filter so muted findings are hidden unless the caller opts in", () => {
|
||||
expect(source).toContain("applyDefaultMutedFilter");
|
||||
});
|
||||
|
||||
it("loads finding groups as selectable Finding Group filter options", () => {
|
||||
expect(source).toContain("fetchFindingGroupFilterOptions");
|
||||
expect(source).toContain("pageSize: 100");
|
||||
expect(source).toContain("checkOptions={checkOptions}");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,19 @@ export default async function Findings({
|
||||
metadataInfoData?.data?.attributes?.resource_types || [];
|
||||
const uniqueCategories = metadataInfoData?.data?.attributes?.categories || [];
|
||||
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
|
||||
const fetchFindingGroupFilterOptions = hasHistoricalData
|
||||
? getFindingGroups
|
||||
: getLatestFindingGroups;
|
||||
const findingGroupFilterOptionsData = await fetchFindingGroupFilterOptions({
|
||||
filters: resolvedFilters,
|
||||
pageSize: 100,
|
||||
});
|
||||
const checkOptions = adaptFindingGroupsResponse(
|
||||
findingGroupFilterOptionsData,
|
||||
).map((group) => ({
|
||||
checkId: group.checkId,
|
||||
checkTitle: group.checkTitle,
|
||||
}));
|
||||
|
||||
const completedScans = scansData?.data?.filter(
|
||||
(scan: ScanProps) =>
|
||||
@@ -110,6 +123,7 @@ export default async function Findings({
|
||||
uniqueResourceTypes={uniqueResourceTypes}
|
||||
uniqueCategories={uniqueCategories}
|
||||
uniqueGroups={uniqueGroups}
|
||||
checkOptions={checkOptions}
|
||||
trailingControls={
|
||||
<SeedFromFindingsButton
|
||||
filterBag={filters}
|
||||
|
||||
@@ -26,7 +26,9 @@ import { DATA_TABLE_FILTER_MODE } from "@/types/filters";
|
||||
import { ProviderProps } from "@/types/providers";
|
||||
|
||||
import {
|
||||
buildFindingGroupFilterOption,
|
||||
buildFindingsFilterChips,
|
||||
type FindingCheckFilterOption,
|
||||
getFindingsFilterDisplayValue,
|
||||
} from "./findings-filters.utils";
|
||||
|
||||
@@ -47,11 +49,6 @@ interface FindingsFiltersProps {
|
||||
variant?: "default" | "alerts-edit";
|
||||
}
|
||||
|
||||
interface FindingCheckFilterOption {
|
||||
checkId: string;
|
||||
checkTitle?: string;
|
||||
}
|
||||
|
||||
interface FindingsFilterBatchControlsProps extends FindingsFiltersProps {
|
||||
appliedFilters: Record<string, string[]>;
|
||||
pendingFilters: Record<string, string[]>;
|
||||
@@ -78,10 +75,6 @@ const FILTER_CONTROL_COLUMN_CLASS =
|
||||
"min-w-0 flex-none basis-full sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)] xl:basis-[calc((100%_-_2.25rem)/4)] 2xl:basis-[calc((100%_-_3rem)/5)]";
|
||||
const FILTER_GRID_ITEM_CLASS = "min-w-0";
|
||||
|
||||
function uniqueNonEmptyValues(values: string[]): string[] {
|
||||
return Array.from(new Set(values.filter(Boolean)));
|
||||
}
|
||||
|
||||
export const FindingsFilterBatchControls = ({
|
||||
providers,
|
||||
// Undefined = caller opted out (the alert editor shares this component but
|
||||
@@ -119,11 +112,12 @@ export const FindingsFilterBatchControls = ({
|
||||
checkTitle || checkId,
|
||||
]),
|
||||
);
|
||||
const checkFilterValues = uniqueNonEmptyValues([
|
||||
...checkOptions.map((option) => option.checkId),
|
||||
...getFilterValue("filter[check_id]"),
|
||||
...getFilterValue("filter[check_id__in]"),
|
||||
]);
|
||||
const findingGroupFilterOption = buildFindingGroupFilterOption({
|
||||
checkOptions,
|
||||
selectedCheckIds: getFilterValue("filter[check_id]"),
|
||||
selectedCheckIdsIn: getFilterValue("filter[check_id__in]"),
|
||||
checkTitles,
|
||||
});
|
||||
|
||||
const customFilters = [
|
||||
...filterFindings
|
||||
@@ -137,20 +131,7 @@ export const FindingsFilterBatchControls = ({
|
||||
checkTitles,
|
||||
}),
|
||||
})),
|
||||
...(checkFilterValues.length > 0
|
||||
? [
|
||||
{
|
||||
key: "check_id",
|
||||
labelCheckboxGroup: "Finding Group",
|
||||
values: checkFilterValues,
|
||||
labelFormatter: (value: string) =>
|
||||
getFindingsFilterDisplayValue("filter[check_id]", value, {
|
||||
checkTitles,
|
||||
}),
|
||||
index: 3,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(findingGroupFilterOption ? [findingGroupFilterOption] : []),
|
||||
{
|
||||
key: FILTER_FIELD.REGION,
|
||||
labelCheckboxGroup: "Regions",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ProviderProps } from "@/types/providers";
|
||||
import { ScanEntity } from "@/types/scans";
|
||||
|
||||
import {
|
||||
buildFindingGroupFilterOption,
|
||||
buildFindingsFilterChips,
|
||||
getFindingsFilterDisplayValue,
|
||||
} from "./findings-filters.utils";
|
||||
@@ -387,3 +388,47 @@ describe("buildFindingsFilterChips", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFindingGroupFilterOption", () => {
|
||||
it("builds a selectable Finding Group filter from fetched options and URL-backed values", () => {
|
||||
// Given
|
||||
const filter = buildFindingGroupFilterOption({
|
||||
checkOptions: [
|
||||
{
|
||||
checkId: "teams_external_users_can_join",
|
||||
checkTitle: "External Teams users can join meetings",
|
||||
},
|
||||
],
|
||||
selectedCheckIds: ["s3_bucket_public_access"],
|
||||
selectedCheckIdsIn: ["teams_external_users_can_join"],
|
||||
checkTitles: {
|
||||
teams_external_users_can_join: "External Teams users can join meetings",
|
||||
},
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(filter).toMatchObject({
|
||||
key: "check_id",
|
||||
labelCheckboxGroup: "Finding Group",
|
||||
values: ["teams_external_users_can_join", "s3_bucket_public_access"],
|
||||
index: 3,
|
||||
});
|
||||
expect(filter?.labelFormatter?.("teams_external_users_can_join")).toBe(
|
||||
"External Teams users can join meetings",
|
||||
);
|
||||
expect(filter?.labelFormatter?.("s3_bucket_public_access")).toBe(
|
||||
"s3_bucket_public_access",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the Finding Group filter when there are no selectable or URL-backed values", () => {
|
||||
expect(
|
||||
buildFindingGroupFilterOption({
|
||||
checkOptions: [],
|
||||
selectedCheckIds: [],
|
||||
selectedCheckIdsIn: [],
|
||||
checkTitles: {},
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,10 +7,16 @@ import {
|
||||
} from "@/lib/helper-filters";
|
||||
import { FINDING_STATUS_DISPLAY_NAMES } from "@/types";
|
||||
import { ProviderGroup } from "@/types/components";
|
||||
import type { FilterOption } from "@/types/filters";
|
||||
import { getProviderDisplayName, ProviderProps } from "@/types/providers";
|
||||
import { ScanEntity } from "@/types/scans";
|
||||
import { SEVERITY_DISPLAY_NAMES } from "@/types/severities";
|
||||
|
||||
export interface FindingCheckFilterOption {
|
||||
checkId: string;
|
||||
checkTitle?: string;
|
||||
}
|
||||
|
||||
interface GetFindingsFilterDisplayValueOptions {
|
||||
providers?: ProviderProps[];
|
||||
scans?: Array<{ [scanId: string]: ScanEntity }>;
|
||||
@@ -107,6 +113,43 @@ export function getFindingsFilterDisplayValue(
|
||||
return formatLabel(value);
|
||||
}
|
||||
|
||||
function uniqueNonEmptyValues(values: string[]): string[] {
|
||||
return Array.from(new Set(values.filter(Boolean)));
|
||||
}
|
||||
|
||||
export function buildFindingGroupFilterOption({
|
||||
checkOptions,
|
||||
selectedCheckIds,
|
||||
selectedCheckIdsIn,
|
||||
checkTitles,
|
||||
}: {
|
||||
checkOptions: FindingCheckFilterOption[];
|
||||
selectedCheckIds: string[];
|
||||
selectedCheckIdsIn: string[];
|
||||
checkTitles: Record<string, string>;
|
||||
}): FilterOption | null {
|
||||
const values = uniqueNonEmptyValues([
|
||||
...checkOptions.map((option) => option.checkId),
|
||||
...selectedCheckIds,
|
||||
...selectedCheckIdsIn,
|
||||
]);
|
||||
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
key: "check_id",
|
||||
labelCheckboxGroup: "Finding Group",
|
||||
values,
|
||||
labelFormatter: (value: string) =>
|
||||
getFindingsFilterDisplayValue("filter[check_id]", value, {
|
||||
checkTitles,
|
||||
}),
|
||||
index: 3,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps raw filter param keys (e.g. "filter[severity__in]") to human-readable labels.
|
||||
* Used to render chips in the FilterSummaryStrip.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildJiraDispatchChoiceCopy } from "./send-to-jira-modal-copy";
|
||||
import {
|
||||
buildJiraDispatchChoiceCopy,
|
||||
JIRA_SELECTION_KIND,
|
||||
} from "./send-to-jira-modal-copy";
|
||||
|
||||
describe("buildJiraDispatchChoiceCopy", () => {
|
||||
it("uses Finding Group copy for selected Findings grouped Jira choice", () => {
|
||||
@@ -44,7 +47,7 @@ describe("buildJiraDispatchChoiceCopy", () => {
|
||||
buildJiraDispatchChoiceCopy({
|
||||
selectedCount: 2,
|
||||
isSelectedFindingGroupFlow: false,
|
||||
selectionKind: "findings",
|
||||
selectionKind: JIRA_SELECTION_KIND.FINDINGS,
|
||||
}),
|
||||
).toEqual({
|
||||
description: "Create Jira issue(s) for 2 selected Findings.",
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
export const JIRA_SELECTION_KIND = {
|
||||
FINDINGS: "findings",
|
||||
RESOURCES: "resources",
|
||||
} as const;
|
||||
|
||||
type JiraSelectionKind =
|
||||
(typeof JIRA_SELECTION_KIND)[keyof typeof JIRA_SELECTION_KIND];
|
||||
|
||||
interface JiraDispatchChoiceCopyParams {
|
||||
selectedCount: number;
|
||||
isSelectedFindingGroupFlow: boolean;
|
||||
selectionKind?: "findings" | "resources";
|
||||
selectionKind?: JiraSelectionKind;
|
||||
}
|
||||
|
||||
interface JiraDispatchChoiceCopy {
|
||||
@@ -14,7 +22,7 @@ interface JiraDispatchChoiceCopy {
|
||||
export const buildJiraDispatchChoiceCopy = ({
|
||||
selectedCount,
|
||||
isSelectedFindingGroupFlow,
|
||||
selectionKind = "resources",
|
||||
selectionKind = JIRA_SELECTION_KIND.RESOURCES,
|
||||
}: JiraDispatchChoiceCopyParams): JiraDispatchChoiceCopy => {
|
||||
if (isSelectedFindingGroupFlow) {
|
||||
return {
|
||||
@@ -28,7 +36,7 @@ export const buildJiraDispatchChoiceCopy = ({
|
||||
};
|
||||
}
|
||||
|
||||
if (selectionKind === "findings") {
|
||||
if (selectionKind === JIRA_SELECTION_KIND.FINDINGS) {
|
||||
return {
|
||||
description: `Create Jira issue(s) for ${selectedCount} selected Findings.`,
|
||||
groupedTitle: "Create one Jira issue for all selected Findings",
|
||||
|
||||
@@ -125,10 +125,13 @@ describe("SendToJiraModal", () => {
|
||||
|
||||
expect(screen.getByText("Jira issue creation mode")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
screen.getByText("Create one Jira issue for all selected Findings"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"Create one Jira issue for all selected Findings in this Finding Group",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Create Jira issues for 1 Group and 2 Findings."),
|
||||
).toBeInTheDocument();
|
||||
@@ -600,12 +603,72 @@ describe("SendToJiraModal", () => {
|
||||
await waitFor(() =>
|
||||
expect(pollJiraDispatchTaskMock).toHaveBeenCalledWith("group-task"),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to launch Finding batch.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports polling and launch failures together for mixed dispatches", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
sendJiraDispatchMock
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
taskId: "group-task",
|
||||
message: "Group started",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: "Failed to launch Finding batch.",
|
||||
});
|
||||
pollJiraDispatchTaskMock.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: "Jira dispatch completed with 1 failed issue.",
|
||||
});
|
||||
render(
|
||||
<SendToJiraModal
|
||||
isOpen
|
||||
onOpenChange={onOpenChange}
|
||||
findingId="check-a"
|
||||
findingTitle="Check A"
|
||||
targetBatches={[
|
||||
{
|
||||
targetIds: ["check-a"],
|
||||
targetType: "check_id",
|
||||
dispatchMode: "grouped",
|
||||
},
|
||||
{
|
||||
targetIds: ["finding-1", "finding-2"],
|
||||
targetType: "finding_id",
|
||||
},
|
||||
]}
|
||||
defaultDispatchMode="grouped"
|
||||
selectedResourceCount={1}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Select a Jira project" }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Select an issue type" }),
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description:
|
||||
"Some Jira dispatches started, but Failed to launch Finding batch.",
|
||||
"Jira dispatch completed with 1 failed issue. Failed to launch Finding batch.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -30,7 +30,10 @@ import {
|
||||
type JiraDispatchMode,
|
||||
} from "@/types/integrations";
|
||||
|
||||
import { buildJiraDispatchChoiceCopy } from "./send-to-jira-modal-copy";
|
||||
import {
|
||||
buildJiraDispatchChoiceCopy,
|
||||
JIRA_SELECTION_KIND,
|
||||
} from "./send-to-jira-modal-copy";
|
||||
|
||||
const JIRA_DISPATCH_TARGET = {
|
||||
CHECK_ID: "check_id",
|
||||
@@ -142,16 +145,26 @@ export const SendToJiraModal = ({
|
||||
const shouldShowDispatchChoice =
|
||||
(canChooseGroupedDispatch || hasMultipleFindingTargets) &&
|
||||
(multiFindingTargetCount > 1 || jiraSelectedResourceCount > 1);
|
||||
const checkIdBatches = jiraTargetBatches.filter(
|
||||
(batch) => batch.targetType === JIRA_DISPATCH_TARGET.CHECK_ID,
|
||||
);
|
||||
const hasOnlySingleFindingGroupBatch =
|
||||
jiraTargetBatches.length === 1 &&
|
||||
checkIdBatches.length === 1 &&
|
||||
checkIdBatches[0].targetIds.length === 1;
|
||||
const isSelectedFindingGroupFlow =
|
||||
shouldShowDispatchChoice &&
|
||||
(targetType === JIRA_DISPATCH_TARGET.CHECK_ID || isFindingGroupSelection);
|
||||
(isFindingGroupSelection || hasOnlySingleFindingGroupBatch);
|
||||
const jiraDispatchChoiceCopy = buildJiraDispatchChoiceCopy({
|
||||
selectedCount:
|
||||
multiFindingTargetCount > 1
|
||||
? multiFindingTargetCount
|
||||
: jiraSelectedResourceCount,
|
||||
isSelectedFindingGroupFlow,
|
||||
selectionKind: multiFindingTargetCount > 1 ? "findings" : "resources",
|
||||
selectionKind:
|
||||
multiFindingTargetCount > 1
|
||||
? JIRA_SELECTION_KIND.FINDINGS
|
||||
: JIRA_SELECTION_KIND.RESOURCES,
|
||||
});
|
||||
|
||||
const selectedIntegration = form.watch("integration");
|
||||
@@ -258,18 +271,17 @@ export const SendToJiraModal = ({
|
||||
const taskResults = await Promise.all(
|
||||
taskIds.map((taskId) => pollJiraDispatchTaskUntilDone(taskId)),
|
||||
);
|
||||
const failedTask = taskResults.find(
|
||||
(taskResult) => !taskResult.success,
|
||||
);
|
||||
const errors = [
|
||||
...taskResults
|
||||
.filter((taskResult) => !taskResult.success)
|
||||
.map(
|
||||
(taskResult) => taskResult.error || "Failed to create Jira issue",
|
||||
),
|
||||
...launchErrors,
|
||||
];
|
||||
|
||||
if (failedTask && !failedTask.success) {
|
||||
throw new Error(failedTask.error || "Failed to create Jira issue");
|
||||
}
|
||||
|
||||
if (launchErrors.length > 0) {
|
||||
throw new Error(
|
||||
`Some Jira dispatches started, but ${launchErrors.join(" ")}`,
|
||||
);
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join(" "));
|
||||
}
|
||||
|
||||
toast({
|
||||
|
||||
Reference in New Issue
Block a user