mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-06 08:47:18 +00:00
refactor: unify filtering and sorting for finding (#10803)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
@@ -12,9 +12,31 @@ const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted(
|
||||
}),
|
||||
);
|
||||
|
||||
// Real helpers/constants pulled from submodules that don't import server-only
|
||||
// code, so the mock factory stays free of top-level variable hoisting issues
|
||||
// and the vitest runtime doesn't choke on next-auth's `next/server` import.
|
||||
import {
|
||||
includesMutedFindings,
|
||||
splitCsvFilterValues,
|
||||
} from "@/lib/findings-filters";
|
||||
import {
|
||||
composeSort,
|
||||
FG_FAIL_FIRST,
|
||||
FG_RECENT_LAST_SEEN,
|
||||
FG_SEVERITY_HIGH_FIRST,
|
||||
FINDING_GROUP_RESOURCES_DEFAULT_SORT,
|
||||
} from "@/lib/findings-sort";
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
composeSort,
|
||||
FG_FAIL_FIRST,
|
||||
FG_RECENT_LAST_SEEN,
|
||||
FG_SEVERITY_HIGH_FIRST,
|
||||
FINDING_GROUP_RESOURCES_DEFAULT_SORT,
|
||||
includesMutedFindings,
|
||||
splitCsvFilterValues,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/provider-filters", () => ({
|
||||
|
||||
@@ -2,7 +2,17 @@
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import {
|
||||
apiBaseUrl,
|
||||
composeSort,
|
||||
FG_FAIL_FIRST,
|
||||
FG_RECENT_LAST_SEEN,
|
||||
FG_SEVERITY_HIGH_FIRST,
|
||||
FINDING_GROUP_RESOURCES_DEFAULT_SORT,
|
||||
getAuthHeaders,
|
||||
includesMutedFindings,
|
||||
splitCsvFilterValues,
|
||||
} from "@/lib";
|
||||
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { FilterParam } from "@/types/filters";
|
||||
@@ -24,24 +34,6 @@ function mapSearchFilter(
|
||||
return mapped;
|
||||
}
|
||||
|
||||
function splitCsvFilterValues(value: string | string[] | undefined): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.flatMap((item) => item.split(","))
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters that belong to finding-groups but are NOT valid for the
|
||||
* finding-group resources sub-endpoint. These must be stripped before
|
||||
@@ -82,14 +74,34 @@ function normalizeFindingGroupResourceFilters(
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const DEFAULT_FINDING_GROUPS_SORT =
|
||||
"-status,-severity,-new_fail_count,-changed_fail_count,-fail_count,-last_seen_at";
|
||||
// Composite sorts for finding-groups (Family B in lib/findings-sort.ts).
|
||||
// The `-status,-severity,...,-last_seen_at` shape is required by the API:
|
||||
// these endpoints map status/severity to weighted integer columns where
|
||||
// DESC = FAIL/critical first. The intermediate `*_count` tokens are
|
||||
// finding-group-specific impact tiebreakers and have no Family A analogue.
|
||||
const DEFAULT_FINDING_GROUPS_SORT = composeSort(
|
||||
FG_FAIL_FIRST,
|
||||
FG_SEVERITY_HIGH_FIRST,
|
||||
"-new_fail_count",
|
||||
"-changed_fail_count",
|
||||
"-fail_count",
|
||||
FG_RECENT_LAST_SEEN,
|
||||
);
|
||||
|
||||
const DEFAULT_FINDING_GROUPS_SORT_WITH_MUTED =
|
||||
"-status,-severity,-new_fail_count,-changed_fail_count,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at";
|
||||
const DEFAULT_FINDING_GROUPS_SORT_WITH_MUTED = composeSort(
|
||||
FG_FAIL_FIRST,
|
||||
FG_SEVERITY_HIGH_FIRST,
|
||||
"-new_fail_count",
|
||||
"-changed_fail_count",
|
||||
"-new_fail_muted_count",
|
||||
"-changed_fail_muted_count",
|
||||
"-fail_count",
|
||||
"-fail_muted_count",
|
||||
FG_RECENT_LAST_SEEN,
|
||||
);
|
||||
|
||||
const DEFAULT_FINDING_GROUP_RESOURCES_SORT =
|
||||
"-status,-severity,-delta,-last_seen_at";
|
||||
FINDING_GROUP_RESOURCES_DEFAULT_SORT;
|
||||
|
||||
interface FetchFindingGroupsParams {
|
||||
page?: number;
|
||||
@@ -98,18 +110,6 @@ interface FetchFindingGroupsParams {
|
||||
filters?: Record<string, string | string[] | undefined>;
|
||||
}
|
||||
|
||||
function includesMutedFindings(
|
||||
filters: Record<string, string | string[] | undefined>,
|
||||
): boolean {
|
||||
const mutedFilter = filters["filter[muted]"];
|
||||
|
||||
if (Array.isArray(mutedFilter)) {
|
||||
return mutedFilter.includes("include");
|
||||
}
|
||||
|
||||
return mutedFilter === "include";
|
||||
}
|
||||
|
||||
function getDefaultFindingGroupsSort(
|
||||
filters: Record<string, string | string[] | undefined>,
|
||||
): string {
|
||||
|
||||
@@ -24,9 +24,15 @@ const {
|
||||
getLatestFindingGroupResourcesMock: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import the real sort constant directly from its submodule. Going via the
|
||||
// `@/lib` barrel would pull in server-only code (next-auth) that does not
|
||||
// resolve in the vitest runtime.
|
||||
import { RESOURCE_DRAWER_OTHER_FINDINGS_SORT } from "@/lib/findings-sort";
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
RESOURCE_DRAWER_OTHER_FINDINGS_SORT,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/provider-filters", () => ({
|
||||
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
getFindingGroupResources,
|
||||
getLatestFindingGroupResources,
|
||||
} from "@/actions/finding-groups";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import {
|
||||
apiBaseUrl,
|
||||
getAuthHeaders,
|
||||
RESOURCE_DRAWER_OTHER_FINDINGS_SORT,
|
||||
} from "@/lib";
|
||||
import { runWithConcurrencyLimit } from "@/lib/concurrency";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
@@ -266,7 +270,7 @@ export const getLatestFindingsByResourceUid = async ({
|
||||
url.searchParams.append("filter[resource_uid]", resourceUid);
|
||||
url.searchParams.append("filter[status]", "FAIL");
|
||||
url.searchParams.append("filter[muted]", includeMuted ? "include" : "false");
|
||||
url.searchParams.append("sort", "severity,-updated_at");
|
||||
url.searchParams.append("sort", RESOURCE_DRAWER_OTHER_FINDINGS_SORT);
|
||||
if (page) url.searchParams.append("page[number]", page.toString());
|
||||
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
|
||||
|
||||
|
||||
@@ -8,9 +8,35 @@ const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted(
|
||||
}),
|
||||
);
|
||||
|
||||
// Pull every constant transitively required by the modules under test
|
||||
// (resources.ts → findings action → finding-groups action) so the `@/lib`
|
||||
// mock is a complete surface. Going via the barrel would drag in next-auth.
|
||||
import {
|
||||
includesMutedFindings,
|
||||
splitCsvFilterValues,
|
||||
} from "@/lib/findings-filters";
|
||||
import {
|
||||
composeSort,
|
||||
FG_FAIL_FIRST,
|
||||
FG_RECENT_LAST_SEEN,
|
||||
FG_SEVERITY_HIGH_FIRST,
|
||||
FINDING_GROUP_RESOURCES_DEFAULT_SORT,
|
||||
FINDINGS_FILTERED_SORT,
|
||||
RESOURCE_DRAWER_OTHER_FINDINGS_SORT,
|
||||
} from "@/lib/findings-sort";
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
composeSort,
|
||||
FG_FAIL_FIRST,
|
||||
FG_RECENT_LAST_SEEN,
|
||||
FG_SEVERITY_HIGH_FIRST,
|
||||
FINDING_GROUP_RESOURCES_DEFAULT_SORT,
|
||||
FINDINGS_FILTERED_SORT,
|
||||
RESOURCE_DRAWER_OTHER_FINDINGS_SORT,
|
||||
includesMutedFindings,
|
||||
splitCsvFilterValues,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { redirect } from "next/navigation";
|
||||
|
||||
import { getLatestFindings } from "@/actions/findings";
|
||||
import { listOrganizationsSafe } from "@/actions/organizations/organizations";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl, FINDINGS_FILTERED_SORT, getAuthHeaders } from "@/lib";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { OrganizationResource } from "@/types/organizations";
|
||||
@@ -285,7 +285,7 @@ export const getResourceDrawerData = async ({
|
||||
page,
|
||||
pageSize,
|
||||
query,
|
||||
sort: "severity,-inserted_at",
|
||||
sort: FINDINGS_FILTERED_SORT,
|
||||
filters: {
|
||||
"filter[resource_uid]": resourceUid,
|
||||
"filter[status]": "FAIL",
|
||||
|
||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
||||
|
||||
import { AttackSurfaceItem } from "@/actions/overview";
|
||||
import { Card, CardContent } from "@/components/shadcn";
|
||||
import { applyFailNonMutedFilters } from "@/lib";
|
||||
|
||||
interface AttackSurfaceCardItemProps {
|
||||
item: AttackSurfaceItem;
|
||||
@@ -18,8 +19,7 @@ export function AttackSurfaceCardItem({
|
||||
|
||||
// Add attack surface category filter
|
||||
params.set("filter[category__in]", item.id);
|
||||
params.set("filter[status__in]", "FAIL");
|
||||
params.set("filter[muted]", "false");
|
||||
applyFailNonMutedFilters(params);
|
||||
|
||||
// Add current page filters (provider, account, etc.)
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { LinkToFindings } from "@/components/overview";
|
||||
import { ColumnLatestFindings } from "@/components/overview/new-findings-table/table";
|
||||
import { CardTitle } from "@/components/shadcn";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { FINDINGS_FILTERED_SORT } from "@/lib";
|
||||
import { createDict } from "@/lib/helper";
|
||||
import { FindingProps, SearchParamsProps } from "@/types";
|
||||
|
||||
@@ -17,7 +18,7 @@ interface FindingsViewSSRProps {
|
||||
|
||||
export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) {
|
||||
const page = 1;
|
||||
const sort = "severity,-inserted_at";
|
||||
const sort = FINDINGS_FILTERED_SORT;
|
||||
|
||||
const defaultFilters = {
|
||||
"filter[status]": "FAIL",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
|
||||
import { ScatterPlot } from "@/components/graphs/scatter-plot";
|
||||
import { AlertPill } from "@/components/graphs/shared/alert-pill";
|
||||
import type { BarDataPoint } from "@/components/graphs/types";
|
||||
import { applyFailNonMutedFilters } from "@/lib";
|
||||
import { SEVERITY_FILTER_MAP } from "@/types/severities";
|
||||
|
||||
// Score color thresholds (0-100 scale, higher = better)
|
||||
@@ -50,11 +51,7 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
|
||||
// Add provider filter for the selected point
|
||||
params.set("filter[provider_id__in]", selectedPoint.providerId);
|
||||
|
||||
// Add exclude muted findings filter
|
||||
params.set("filter[muted]", "false");
|
||||
|
||||
// Filter by FAIL findings
|
||||
params.set("filter[status__in]", "FAIL");
|
||||
applyFailNonMutedFilters(params);
|
||||
|
||||
// Navigate to findings page
|
||||
router.push(`/findings?${params.toString()}`);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
|
||||
import { RadarChart } from "@/components/graphs/radar-chart";
|
||||
import type { BarDataPoint, RadarDataPoint } from "@/components/graphs/types";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { applyFailNonMutedFilters } from "@/lib";
|
||||
import { SEVERITY_FILTER_MAP } from "@/types/severities";
|
||||
|
||||
import { CategorySelector } from "./category-selector";
|
||||
@@ -50,11 +51,7 @@ export function RiskRadarViewClient({ data }: RiskRadarViewClientProps) {
|
||||
// Add category filter for the selected point
|
||||
params.set("filter[category__in]", selectedPoint.categoryId);
|
||||
|
||||
// Add exclude muted findings filter
|
||||
params.set("filter[muted]", "false");
|
||||
|
||||
// Filter by FAIL findings
|
||||
params.set("filter[status__in]", "FAIL");
|
||||
applyFailNonMutedFilters(params);
|
||||
|
||||
// Navigate to findings page
|
||||
router.push(`/findings?${params.toString()}`);
|
||||
|
||||
+3
-5
@@ -6,6 +6,7 @@ import { useState } from "react";
|
||||
import { getSeverityTrendsByTimeRange } from "@/actions/overview/severity-trends";
|
||||
import { LineChart } from "@/components/graphs/line-chart";
|
||||
import { LineConfig, LineDataPoint } from "@/components/graphs/types";
|
||||
import { applyFailNonMutedFilters } from "@/lib";
|
||||
import {
|
||||
SEVERITY_LEVELS,
|
||||
SEVERITY_LINE_CONFIGS,
|
||||
@@ -57,11 +58,8 @@ export const FindingSeverityOverTime = ({
|
||||
}) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Always filter by FAIL status since this chart shows failed findings
|
||||
params.set("filter[status__in]", "FAIL");
|
||||
|
||||
// Exclude muted findings
|
||||
params.set("filter[muted]", "false");
|
||||
// Show active failing findings only for this chart's drill-down.
|
||||
applyFailNonMutedFilters(params);
|
||||
|
||||
// Add scan_ids filter
|
||||
if (
|
||||
|
||||
@@ -33,6 +33,10 @@ describe("findings page", () => {
|
||||
expect(source).toContain("getLatestFindingGroups");
|
||||
});
|
||||
|
||||
it("defaults filter[muted]=false through the shared muted filter helper", () => {
|
||||
expect(source).toContain("applyDefaultMutedFilter(filtersWithScanDates)");
|
||||
});
|
||||
|
||||
it("guards errors array access with a length check", () => {
|
||||
expect(source).toContain("errors?.length > 0");
|
||||
});
|
||||
|
||||
@@ -40,25 +40,22 @@ export default async function Findings({
|
||||
getScans({ pageSize: 50 }),
|
||||
]);
|
||||
|
||||
const filtersWithScanDates = applyDefaultMutedFilter(
|
||||
await resolveFindingScanDateFilters({
|
||||
filters,
|
||||
scans: scansData?.data || [],
|
||||
loadScan: async (scanId: string) => {
|
||||
const response = await getScan(scanId);
|
||||
return response?.data;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const filtersWithScanDates = await resolveFindingScanDateFilters({
|
||||
filters,
|
||||
scans: scansData?.data || [],
|
||||
loadScan: async (scanId: string) => {
|
||||
const response = await getScan(scanId);
|
||||
return response?.data;
|
||||
},
|
||||
});
|
||||
const resolvedFilters = applyDefaultMutedFilter(filtersWithScanDates);
|
||||
const hasHistoricalData = hasDateOrScanFilter(filtersWithScanDates);
|
||||
|
||||
const metadataInfoData = await (
|
||||
hasHistoricalData ? getMetadataInfo : getLatestMetadataInfo
|
||||
)({
|
||||
query,
|
||||
sort: encodedSort,
|
||||
filters: filtersWithScanDates,
|
||||
filters: resolvedFilters,
|
||||
});
|
||||
|
||||
// Extract unique regions, services, categories, groups from the new endpoint
|
||||
@@ -102,7 +99,7 @@ export default async function Findings({
|
||||
<Suspense fallback={<SkeletonTableFindings />}>
|
||||
<SSRDataTable
|
||||
searchParams={resolvedSearchParams}
|
||||
filters={filtersWithScanDates}
|
||||
filters={resolvedFilters}
|
||||
/>
|
||||
</Suspense>
|
||||
</FilterTransitionWrapper>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "@/components/findings/table";
|
||||
import { Accordion } from "@/components/ui/accordion/Accordion";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { createDict } from "@/lib";
|
||||
import { createDict, FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib";
|
||||
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
|
||||
import { Requirement } from "@/types/compliance";
|
||||
import { FindingProps, FindingsResponse } from "@/types/components";
|
||||
@@ -34,12 +34,16 @@ export const ClientAccordionContent = ({
|
||||
const pageNumber = searchParams.get("page") || "1";
|
||||
const complianceId = searchParams.get("complianceId");
|
||||
const openFindingId = searchParams.get("id");
|
||||
const defaultSort = "severity,status,-inserted_at";
|
||||
const sort = searchParams.get("sort") || defaultSort;
|
||||
const sort = searchParams.get("sort") || FINDINGS_DEFAULT_SORT;
|
||||
const loadedPageRef = useRef<string | null>(null);
|
||||
const loadedSortRef = useRef<string | null>(null);
|
||||
const loadedMutedRef = useRef<string | null>(null);
|
||||
const isExpandedRef = useRef(false);
|
||||
const region = searchParams.get("filter[region__in]") || "";
|
||||
// Respect the user's muted preference from the URL; default to EXCLUDE
|
||||
// so the requirement view stays consistent with every other findings
|
||||
// surface in the app (findings page, resource drawer, overview widgets).
|
||||
const mutedFilter = searchParams.get("filter[muted]") || MUTED_FILTER.EXCLUDE;
|
||||
|
||||
useEffect(() => {
|
||||
async function loadFindings() {
|
||||
@@ -49,10 +53,12 @@ export const ClientAccordionContent = ({
|
||||
requirement.status !== "No findings" &&
|
||||
(loadedPageRef.current !== pageNumber ||
|
||||
loadedSortRef.current !== sort ||
|
||||
loadedMutedRef.current !== mutedFilter ||
|
||||
!isExpandedRef.current)
|
||||
) {
|
||||
loadedPageRef.current = pageNumber;
|
||||
loadedSortRef.current = sort;
|
||||
loadedMutedRef.current = mutedFilter;
|
||||
isExpandedRef.current = true;
|
||||
|
||||
try {
|
||||
@@ -62,7 +68,7 @@ export const ClientAccordionContent = ({
|
||||
filters: {
|
||||
"filter[check_id__in]": checkIds.join(","),
|
||||
"filter[scan]": scanId,
|
||||
"filter[muted]": "false",
|
||||
"filter[muted]": mutedFilter,
|
||||
...(region && { "filter[region__in]": region }),
|
||||
},
|
||||
page: parseInt(pageNumber, 10),
|
||||
@@ -101,7 +107,15 @@ export const ClientAccordionContent = ({
|
||||
}
|
||||
|
||||
loadFindings();
|
||||
}, [requirement, scanId, pageNumber, sort, region, disableFindings]);
|
||||
}, [
|
||||
requirement,
|
||||
scanId,
|
||||
pageNumber,
|
||||
sort,
|
||||
region,
|
||||
mutedFilter,
|
||||
disableFindings,
|
||||
]);
|
||||
|
||||
const renderDetails = () => {
|
||||
if (!complianceId) {
|
||||
|
||||
@@ -4,12 +4,7 @@ import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { Checkbox } from "@/components/shadcn";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
|
||||
// Constants for muted filter URL values
|
||||
const MUTED_FILTER_VALUES = {
|
||||
EXCLUDE: "false",
|
||||
INCLUDE: "include",
|
||||
} as const;
|
||||
import { MUTED_FILTER } from "@/lib";
|
||||
|
||||
/** Batch mode: caller controls both the checked state and the notification callback (all-or-nothing). */
|
||||
interface CustomCheckboxMutedFindingsBatchProps {
|
||||
@@ -53,7 +48,7 @@ export const CustomCheckboxMutedFindings = ({
|
||||
const includeMuted =
|
||||
checkedProp !== undefined
|
||||
? checkedProp
|
||||
: mutedFilterValue === MUTED_FILTER_VALUES.INCLUDE;
|
||||
: mutedFilterValue === MUTED_FILTER.INCLUDE;
|
||||
|
||||
const handleMutedChange = (checked: boolean | "indeterminate") => {
|
||||
const isChecked = checked === true;
|
||||
@@ -62,7 +57,7 @@ export const CustomCheckboxMutedFindings = ({
|
||||
// Batch mode: notify caller instead of navigating
|
||||
onBatchChange(
|
||||
"muted",
|
||||
isChecked ? MUTED_FILTER_VALUES.INCLUDE : MUTED_FILTER_VALUES.EXCLUDE,
|
||||
isChecked ? MUTED_FILTER.INCLUDE : MUTED_FILTER.EXCLUDE,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -71,10 +66,10 @@ export const CustomCheckboxMutedFindings = ({
|
||||
navigateWithParams((params) => {
|
||||
if (isChecked) {
|
||||
// Include muted: set special value (API will ignore invalid value and show all)
|
||||
params.set("filter[muted]", MUTED_FILTER_VALUES.INCLUDE);
|
||||
params.set("filter[muted]", MUTED_FILTER.INCLUDE);
|
||||
} else {
|
||||
// Exclude muted: apply filter to show only non-muted
|
||||
params.set("filter[muted]", MUTED_FILTER_VALUES.EXCLUDE);
|
||||
params.set("filter[muted]", MUTED_FILTER.EXCLUDE);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,48 +1,26 @@
|
||||
import {
|
||||
FAIL_FILTER_VALUE,
|
||||
includesMutedFindings,
|
||||
splitCsvFilterValues,
|
||||
} from "@/lib/findings-filters";
|
||||
import { FindingGroupRow } from "@/types";
|
||||
|
||||
function parseStatusFilterValue(statusFilterValue?: string): string[] {
|
||||
if (!statusFilterValue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return statusFilterValue
|
||||
.split(",")
|
||||
.map((status) => status.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function isFailOnlyStatusFilter(
|
||||
filters: Record<string, string | string[] | undefined>,
|
||||
): boolean {
|
||||
const directStatusValues = parseStatusFilterValue(
|
||||
typeof filters["filter[status]"] === "string"
|
||||
? filters["filter[status]"]
|
||||
: undefined,
|
||||
// Normalise both `filter[status]` and `filter[status__in]` CSV forms
|
||||
// and uppercase so "fail", "Fail" etc. still match the wire value.
|
||||
const direct = splitCsvFilterValues(filters["filter[status]"]).map((s) =>
|
||||
s.toUpperCase(),
|
||||
);
|
||||
|
||||
if (directStatusValues.length > 0) {
|
||||
return directStatusValues.length === 1 && directStatusValues[0] === "FAIL";
|
||||
if (direct.length > 0) {
|
||||
return direct.length === 1 && direct[0] === FAIL_FILTER_VALUE;
|
||||
}
|
||||
|
||||
const multiStatusValues = parseStatusFilterValue(
|
||||
typeof filters["filter[status__in]"] === "string"
|
||||
? filters["filter[status__in]"]
|
||||
: undefined,
|
||||
const multi = splitCsvFilterValues(filters["filter[status__in]"]).map((s) =>
|
||||
s.toUpperCase(),
|
||||
);
|
||||
|
||||
return multiStatusValues.length === 1 && multiStatusValues[0] === "FAIL";
|
||||
}
|
||||
|
||||
function includesMutedFindings(
|
||||
filters: Record<string, string | string[] | undefined>,
|
||||
): boolean {
|
||||
const mutedFilter = filters["filter[muted]"];
|
||||
|
||||
if (Array.isArray(mutedFilter)) {
|
||||
return mutedFilter.includes("include");
|
||||
}
|
||||
|
||||
return mutedFilter === "include";
|
||||
return multi.length === 1 && multi[0] === FAIL_FILTER_VALUE;
|
||||
}
|
||||
|
||||
export function getFilteredFindingGroupResourceCount(
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
|
||||
import { Rectangle, ResponsiveContainer, Sankey, Tooltip } from "recharts";
|
||||
|
||||
import { PROVIDER_BADGE_BY_NAME } from "@/components/icons/providers-badge";
|
||||
import { applyFailNonMutedFilters } from "@/lib";
|
||||
import { initializeChartColors } from "@/lib/charts/colors";
|
||||
import { PROVIDER_DISPLAY_NAMES } from "@/types/providers";
|
||||
import { SEVERITY_FILTER_MAP } from "@/types/severities";
|
||||
@@ -463,8 +464,7 @@ export function SankeyChart({
|
||||
if (severityFilter) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("filter[severity__in]", severityFilter);
|
||||
params.set("filter[status__in]", "FAIL");
|
||||
params.set("filter[muted]", "false");
|
||||
applyFailNonMutedFilters(params);
|
||||
router.push(`/findings?${params.toString()}`);
|
||||
}
|
||||
};
|
||||
@@ -484,8 +484,7 @@ export function SankeyChart({
|
||||
}
|
||||
|
||||
params.set("filter[severity__in]", severityFilter);
|
||||
params.set("filter[status__in]", "FAIL");
|
||||
params.set("filter[muted]", "false");
|
||||
applyFailNonMutedFilters(params);
|
||||
router.push(`/findings?${params.toString()}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
FAIL_FILTER_VALUE,
|
||||
NEW_DELTA_FILTER_VALUE,
|
||||
} from "@/lib/findings-filters";
|
||||
import { FINDING_GROUPS_FILTERED_SORT } from "@/lib/findings-sort";
|
||||
|
||||
const FINDINGS_LINK_HREF = `/findings?sort=${FINDING_GROUPS_FILTERED_SORT}&filter[status__in]=${FAIL_FILTER_VALUE}&filter[delta]=${NEW_DELTA_FILTER_VALUE}`;
|
||||
|
||||
export const LinkToFindings = () => {
|
||||
return (
|
||||
<Link
|
||||
href="/findings?sort=-severity,-last_seen_at&filter[status__in]=FAIL&filter[delta]=new"
|
||||
href={FINDINGS_LINK_HREF}
|
||||
aria-label="Go to Findings page"
|
||||
className="text-button-tertiary hover:text-button-tertiary-hover text-sm font-medium transition-colors"
|
||||
>
|
||||
|
||||
@@ -9,6 +9,10 @@ describe("useFindingGroupResourceState", () => {
|
||||
const filePath = path.join(currentDir, "use-finding-group-resource-state.ts");
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
|
||||
it("defaults drill-down resource loading through the shared muted filter helper", () => {
|
||||
expect(source).toContain("applyDefaultMutedFilter(filters)");
|
||||
});
|
||||
|
||||
it("enables muted findings only for the finding-group resource drawer", () => {
|
||||
expect(source).toContain("includeMutedInOtherFindings: true");
|
||||
});
|
||||
|
||||
+115
-37
@@ -1,42 +1,120 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { applyDefaultMutedFilter, MUTED_FILTER } from "./findings-filters";
|
||||
import {
|
||||
applyDefaultMutedFilter,
|
||||
applyFailNonMutedFilters,
|
||||
FAIL_FILTER_VALUE,
|
||||
includesMutedFindings,
|
||||
MUTED_FILTER,
|
||||
NEW_DELTA_FILTER_VALUE,
|
||||
splitCsvFilterValues,
|
||||
} from "./findings-filters";
|
||||
|
||||
describe("applyDefaultMutedFilter", () => {
|
||||
it("injects filter[muted]=false when the caller has not set it", () => {
|
||||
const input: Record<string, string> = { "filter[status__in]": "FAIL" };
|
||||
const result = applyDefaultMutedFilter(input);
|
||||
|
||||
expect(result["filter[muted]"]).toBe(MUTED_FILTER.EXCLUDE);
|
||||
expect(result["filter[status__in]"]).toBe("FAIL");
|
||||
});
|
||||
|
||||
it("preserves an explicit filter[muted]=include opt-in from the checkbox", () => {
|
||||
const result = applyDefaultMutedFilter({
|
||||
"filter[muted]": MUTED_FILTER.INCLUDE,
|
||||
});
|
||||
|
||||
expect(result["filter[muted]"]).toBe(MUTED_FILTER.INCLUDE);
|
||||
});
|
||||
|
||||
it("preserves an explicit filter[muted]=false (no silent overwrite)", () => {
|
||||
const result = applyDefaultMutedFilter({
|
||||
"filter[muted]": MUTED_FILTER.EXCLUDE,
|
||||
});
|
||||
|
||||
expect(result["filter[muted]"]).toBe(MUTED_FILTER.EXCLUDE);
|
||||
});
|
||||
|
||||
it("does not mutate the input object", () => {
|
||||
const input = { "filter[status__in]": "FAIL" };
|
||||
applyDefaultMutedFilter(input);
|
||||
|
||||
expect(input).not.toHaveProperty("filter[muted]");
|
||||
});
|
||||
|
||||
it("returns a default-filled object when called with no caller filters", () => {
|
||||
const result = applyDefaultMutedFilter({} as Record<string, string>);
|
||||
|
||||
expect(result["filter[muted]"]).toBe(MUTED_FILTER.EXCLUDE);
|
||||
describe("filter value constants", () => {
|
||||
it("exposes wire-format values exactly as the API expects", () => {
|
||||
expect(FAIL_FILTER_VALUE).toBe("FAIL");
|
||||
expect(NEW_DELTA_FILTER_VALUE).toBe("new");
|
||||
expect(MUTED_FILTER.EXCLUDE).toBe("false");
|
||||
expect(MUTED_FILTER.INCLUDE).toBe("include");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyFailNonMutedFilters", () => {
|
||||
it("sets filter[status__in]=FAIL and filter[muted]=false", () => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
applyFailNonMutedFilters(params);
|
||||
|
||||
expect(params.get("filter[status__in]")).toBe("FAIL");
|
||||
expect(params.get("filter[muted]")).toBe("false");
|
||||
});
|
||||
|
||||
it("overrides pre-existing values so the drill-down is idempotent", () => {
|
||||
const params = new URLSearchParams(
|
||||
"filter[status__in]=PASS&filter[muted]=include",
|
||||
);
|
||||
|
||||
applyFailNonMutedFilters(params);
|
||||
|
||||
expect(params.get("filter[status__in]")).toBe("FAIL");
|
||||
expect(params.get("filter[muted]")).toBe("false");
|
||||
});
|
||||
|
||||
it("preserves unrelated params", () => {
|
||||
const params = new URLSearchParams(
|
||||
"filter[provider_id__in]=abc&sort=-severity",
|
||||
);
|
||||
|
||||
applyFailNonMutedFilters(params);
|
||||
|
||||
expect(params.get("filter[provider_id__in]")).toBe("abc");
|
||||
expect(params.get("sort")).toBe("-severity");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDefaultMutedFilter", () => {
|
||||
it("adds filter[muted]=false when the filter is absent", () => {
|
||||
expect(applyDefaultMutedFilter({ "filter[status__in]": "FAIL" })).toEqual({
|
||||
"filter[muted]": "false",
|
||||
"filter[status__in]": "FAIL",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an explicit include value from the caller", () => {
|
||||
expect(
|
||||
applyDefaultMutedFilter({
|
||||
"filter[muted]": "include",
|
||||
"filter[status__in]": "FAIL",
|
||||
}),
|
||||
).toEqual({
|
||||
"filter[muted]": "include",
|
||||
"filter[status__in]": "FAIL",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitCsvFilterValues", () => {
|
||||
it("returns an empty array when the value is undefined", () => {
|
||||
expect(splitCsvFilterValues(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("splits a CSV string and trims whitespace", () => {
|
||||
expect(splitCsvFilterValues("FAIL, PASS ,MANUAL")).toEqual([
|
||||
"FAIL",
|
||||
"PASS",
|
||||
"MANUAL",
|
||||
]);
|
||||
});
|
||||
|
||||
it("flattens repeated array values (Next.js can surface them this way)", () => {
|
||||
expect(splitCsvFilterValues(["FAIL", "PASS,MANUAL"])).toEqual([
|
||||
"FAIL",
|
||||
"PASS",
|
||||
"MANUAL",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops empty tokens produced by stray commas", () => {
|
||||
expect(splitCsvFilterValues("FAIL,,PASS,")).toEqual(["FAIL", "PASS"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("includesMutedFindings", () => {
|
||||
it("returns false when filter[muted] is absent", () => {
|
||||
expect(includesMutedFindings({})).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for the literal 'include' sentinel", () => {
|
||||
expect(includesMutedFindings({ "filter[muted]": "include" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for 'false' (the exclude value)", () => {
|
||||
expect(includesMutedFindings({ "filter[muted]": "false" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when 'include' appears anywhere in an array value", () => {
|
||||
expect(
|
||||
includesMutedFindings({ "filter[muted]": ["false", "include"] }),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+104
-13
@@ -1,30 +1,67 @@
|
||||
/**
|
||||
* Shared helpers for findings filter handling.
|
||||
* Shared filter constants and helpers for findings-shaped endpoints.
|
||||
*
|
||||
* The `/findings` SSR page and the finding-group resource drill-down both
|
||||
* need to hide muted findings by default — unless the user has opted in via
|
||||
* the "include muted findings" checkbox. Keeping that default in one place
|
||||
* prevents surfaces from drifting.
|
||||
* Pairs with `lib/findings-sort.ts` (sort tokens). This module covers the
|
||||
* filter side of the same query language.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The "FAIL" status value as it crosses the wire to the API. Used in both
|
||||
* `filter[status]` (single) and `filter[status__in]` (CSV) form.
|
||||
*
|
||||
* NOTE: this is a bare value, not a full enum. The broader Status/Delta
|
||||
* enum migration is intentionally out of scope here — see PR follow-up.
|
||||
*/
|
||||
export const FAIL_FILTER_VALUE = "FAIL";
|
||||
|
||||
/**
|
||||
* The "new" delta value. Used in `filter[delta]` and `filter[delta__in]`.
|
||||
*/
|
||||
export const NEW_DELTA_FILTER_VALUE = "new";
|
||||
|
||||
/**
|
||||
* Values accepted by `filter[muted]`.
|
||||
*
|
||||
* - `EXCLUDE` ("false"): the API hides muted findings (default UI behaviour).
|
||||
* - `INCLUDE` ("include"): a sentinel that the API treats as "show all
|
||||
* regardless of muted state". This is NOT the literal string "true" — the
|
||||
* server route ignores invalid values which conveniently bypasses the
|
||||
* filter.
|
||||
*/
|
||||
export const MUTED_FILTER = {
|
||||
/** Wire value sent to the API to exclude muted findings. */
|
||||
EXCLUDE: "false",
|
||||
/**
|
||||
* Sentinel value that tells the API to return both muted and non-muted
|
||||
* findings. The checkbox writes this to the URL when the user opts in.
|
||||
*/
|
||||
INCLUDE: "include",
|
||||
} as const;
|
||||
|
||||
export type MutedFilterValue = (typeof MUTED_FILTER)[keyof typeof MUTED_FILTER];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns a new filter object with the default muted behaviour applied:
|
||||
* Drill-down preset: "FAIL findings, hide muted". Mutates `params` in place.
|
||||
*
|
||||
* Repeated 6+ times across overview widgets that link to /findings
|
||||
* (attack-surface card, sankey, severity-over-time, risk-radar, risk-plot,
|
||||
* etc). Centralising avoids drift if product later adds, say, `delta=new`
|
||||
* to all drill-downs.
|
||||
*/
|
||||
export function applyFailNonMutedFilters(params: URLSearchParams): void {
|
||||
params.set("filter[status__in]", FAIL_FILTER_VALUE);
|
||||
params.set("filter[muted]", MUTED_FILTER.EXCLUDE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new filter object with the default findings behaviour applied:
|
||||
* hide muted findings unless the caller already set `filter[muted]`.
|
||||
*
|
||||
* The default is spread BEFORE the caller filters so any explicit value
|
||||
* (including `"false"` or the `"include"` opt-in) wins.
|
||||
* Used by both the grouped findings SSR path and the resource drill-down so
|
||||
* they stay aligned with the checkbox default on `/findings`.
|
||||
*/
|
||||
export function applyDefaultMutedFilter<
|
||||
T extends Record<string, string | string[] | undefined>,
|
||||
@@ -34,3 +71,57 @@ export function applyDefaultMutedFilter<
|
||||
...filters,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Splits a JSON:API CSV filter value into clean string tokens.
|
||||
*
|
||||
* Accepts both string and string[] inputs because Next.js `searchParams`
|
||||
* surface either form depending on whether the key appears once or multiple
|
||||
* times in the URL. Returns trimmed, non-empty tokens in input order.
|
||||
*
|
||||
* Previously duplicated in three call sites
|
||||
* (actions/finding-groups, components/findings/table/inline-resource-container,
|
||||
* implicitly inside lib/findings-groups). Single source now.
|
||||
*/
|
||||
export function splitCsvFilterValues(
|
||||
value: string | string[] | undefined,
|
||||
): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.flatMap((item) => item.split(","))
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the caller has opted into seeing muted findings via either the
|
||||
* `filter[muted]=include` shorthand or a multi-value variant.
|
||||
*
|
||||
* Previously duplicated in actions/finding-groups and
|
||||
* components/findings/table/inline-resource-container.
|
||||
*/
|
||||
export function includesMutedFindings(
|
||||
filters: Record<string, string | string[] | undefined>,
|
||||
): boolean {
|
||||
const mutedFilter = filters["filter[muted]"];
|
||||
|
||||
if (Array.isArray(mutedFilter)) {
|
||||
return mutedFilter.includes(MUTED_FILTER.INCLUDE);
|
||||
}
|
||||
|
||||
return mutedFilter === MUTED_FILTER.INCLUDE;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
composeSort,
|
||||
FG_DELTA_NEW_FIRST,
|
||||
FG_FAIL_FIRST,
|
||||
FG_RECENT_LAST_SEEN,
|
||||
FG_SEVERITY_HIGH_FIRST,
|
||||
FINDING_GROUP_RESOURCES_DEFAULT_SORT,
|
||||
FINDING_GROUPS_DEFAULT_SORT,
|
||||
FINDING_GROUPS_FILTERED_SORT,
|
||||
FINDINGS_DEFAULT_SORT,
|
||||
FINDINGS_FAIL_FIRST,
|
||||
FINDINGS_FILTERED_SORT,
|
||||
FINDINGS_RECENT_INSERT,
|
||||
FINDINGS_RECENT_UPDATE,
|
||||
FINDINGS_SEVERITY_HIGH_FIRST,
|
||||
RESOURCE_DRAWER_OTHER_FINDINGS_SORT,
|
||||
} from "./findings-sort";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Family A — plain findings (Postgres ENUM, ASC = critical/FAIL first)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plain findings tokens (Family A)", () => {
|
||||
it("uses bare keys so ASC = declaration order = FAIL/critical first", () => {
|
||||
// Postgres ENUM contract for the Finding model:
|
||||
// severity declared as: critical, high, medium, low, informational
|
||||
// status declared as: FAIL, PASS, MANUAL
|
||||
expect(FINDINGS_FAIL_FIRST).toBe("status");
|
||||
expect(FINDINGS_SEVERITY_HIGH_FIRST).toBe("severity");
|
||||
});
|
||||
|
||||
it("never prefixes status or severity with a minus", () => {
|
||||
expect(FINDINGS_FAIL_FIRST.startsWith("-")).toBe(false);
|
||||
expect(FINDINGS_SEVERITY_HIGH_FIRST.startsWith("-")).toBe(false);
|
||||
});
|
||||
|
||||
it("flips inserted_at and updated_at to DESC for recency", () => {
|
||||
expect(FINDINGS_RECENT_INSERT).toBe("-inserted_at");
|
||||
expect(FINDINGS_RECENT_UPDATE).toBe("-updated_at");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Family B — finding-groups (computed integer, DESC = FAIL/critical/new first)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("finding-groups tokens (Family B)", () => {
|
||||
it("uses minus prefixes because the API maps the keys to integer-weighted columns", () => {
|
||||
// _FINDING_GROUP_SORT_MAP / _RESOURCE_SORT_MAP remap:
|
||||
// status -> status_order (3=FAIL, 2=PASS, 1=MANUAL)
|
||||
// severity -> severity_order (5=critical … 1=informational)
|
||||
// delta -> delta_order (2=new, 1=changed, 0=otherwise)
|
||||
// Higher integer = more important, so DESC puts FAIL/critical/new first.
|
||||
expect(FG_FAIL_FIRST).toBe("-status");
|
||||
expect(FG_SEVERITY_HIGH_FIRST).toBe("-severity");
|
||||
expect(FG_DELTA_NEW_FIRST).toBe("-delta");
|
||||
});
|
||||
|
||||
it("uses -last_seen_at for recency on aggregated rows", () => {
|
||||
expect(FG_RECENT_LAST_SEEN).toBe("-last_seen_at");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("composeSort", () => {
|
||||
it("joins tokens with commas in the given order", () => {
|
||||
expect(
|
||||
composeSort(
|
||||
FINDINGS_FAIL_FIRST,
|
||||
FINDINGS_SEVERITY_HIGH_FIRST,
|
||||
FINDINGS_RECENT_INSERT,
|
||||
),
|
||||
).toBe("status,severity,-inserted_at");
|
||||
});
|
||||
|
||||
it("returns an empty string when no tokens are passed", () => {
|
||||
expect(composeSort()).toBe("");
|
||||
});
|
||||
|
||||
it("preserves token order so left-most has highest precedence (JSON:API rule)", () => {
|
||||
expect(composeSort(FG_FAIL_FIRST, FG_SEVERITY_HIGH_FIRST)).toBe(
|
||||
"-status,-severity",
|
||||
);
|
||||
expect(composeSort(FG_SEVERITY_HIGH_FIRST, FG_FAIL_FIRST)).toBe(
|
||||
"-severity,-status",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Presets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("findings presets (Family A)", () => {
|
||||
it("FINDINGS_DEFAULT_SORT puts FAIL first, then severity, then recency — no delta (unsupported)", () => {
|
||||
expect(FINDINGS_DEFAULT_SORT).toBe("status,severity,-inserted_at");
|
||||
expect(FINDINGS_DEFAULT_SORT).not.toMatch(/\bdelta\b/);
|
||||
});
|
||||
|
||||
it("FINDINGS_FILTERED_SORT omits status because the API call already applies filter[status]", () => {
|
||||
expect(FINDINGS_FILTERED_SORT).toBe("severity,-inserted_at");
|
||||
});
|
||||
|
||||
it("RESOURCE_DRAWER_OTHER_FINDINGS_SORT uses updated_at since /findings/latest exposes it", () => {
|
||||
expect(RESOURCE_DRAWER_OTHER_FINDINGS_SORT).toBe("severity,-updated_at");
|
||||
});
|
||||
});
|
||||
|
||||
describe("finding-groups presets (Family B)", () => {
|
||||
it("FINDING_GROUPS_DEFAULT_SORT puts FAIL → critical → new → recent", () => {
|
||||
expect(FINDING_GROUPS_DEFAULT_SORT).toBe(
|
||||
"-status,-severity,-delta,-last_seen_at",
|
||||
);
|
||||
});
|
||||
|
||||
it("FINDING_GROUP_RESOURCES_DEFAULT_SORT uses the same shape as the groups list", () => {
|
||||
expect(FINDING_GROUP_RESOURCES_DEFAULT_SORT).toBe(
|
||||
"-status,-severity,-delta,-last_seen_at",
|
||||
);
|
||||
});
|
||||
|
||||
it("FINDING_GROUPS_FILTERED_SORT omits status/delta and uses last_seen_at (NOT inserted_at, which is invalid here)", () => {
|
||||
expect(FINDING_GROUPS_FILTERED_SORT).toBe("-severity,-last_seen_at");
|
||||
// Regression guard for the latent /findings link bug:
|
||||
// _FINDING_GROUP_SORT_MAP does not expose `inserted_at`, so the API
|
||||
// returns "invalid sort parameter: inserted_at" if we send it.
|
||||
expect(FINDING_GROUPS_FILTERED_SORT).not.toMatch(/inserted_at/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-family invariants — these would have prevented the original bug
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("cross-family invariants", () => {
|
||||
it("Family A presets never minus-prefix status or severity", () => {
|
||||
const familyA = [
|
||||
FINDINGS_DEFAULT_SORT,
|
||||
FINDINGS_FILTERED_SORT,
|
||||
RESOURCE_DRAWER_OTHER_FINDINGS_SORT,
|
||||
];
|
||||
|
||||
for (const preset of familyA) {
|
||||
expect(preset).not.toMatch(/-severity\b/);
|
||||
expect(preset).not.toMatch(/-status\b/);
|
||||
}
|
||||
});
|
||||
|
||||
it("Family B presets always minus-prefix status, severity and delta", () => {
|
||||
const familyB = [
|
||||
FINDING_GROUPS_DEFAULT_SORT,
|
||||
FINDING_GROUP_RESOURCES_DEFAULT_SORT,
|
||||
];
|
||||
|
||||
for (const preset of familyB) {
|
||||
expect(preset).toMatch(/-status\b/);
|
||||
expect(preset).toMatch(/-severity\b/);
|
||||
// status must precede severity (FAIL-first dominates severity-high-first)
|
||||
expect(preset.indexOf("-status")).toBeLessThan(
|
||||
preset.indexOf("-severity"),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Sort presets for findings-shaped endpoints.
|
||||
*
|
||||
* The Prowler API exposes two families of findings endpoints with INVERTED
|
||||
* sort semantics for the same human intent. Reading them wrong inverts the
|
||||
* triage order silently — a bug that has shipped more than once.
|
||||
*
|
||||
* ─── Family A: plain findings ─────────────────────────────────────────────
|
||||
* `/findings`, `/findings/latest`
|
||||
* `FindingViewSet.ordering_fields` (api/v1/views.py) maps `status` and
|
||||
* `severity` straight to the Postgres ENUM columns. Postgres sorts ENUMs
|
||||
* by DECLARATION order:
|
||||
* severity: critical, high, medium, low, informational → ASC = critical first
|
||||
* status: FAIL, PASS, MANUAL → ASC = FAIL first
|
||||
* Use the bare token. NO minus prefix on `status` or `severity`.
|
||||
* `delta` is NOT in `ordering_fields` — sorting by delta is unsupported.
|
||||
*
|
||||
* ─── Family B: finding-groups ─────────────────────────────────────────────
|
||||
* `/finding-groups`, `/finding-groups/latest`, `/finding-groups/{id}/resources`
|
||||
* `_FINDING_GROUP_SORT_MAP` and `_RESOURCE_SORT_MAP` (api/v1/views.py)
|
||||
* REMAP the public sort keys to computed integer columns:
|
||||
* severity → severity_order (5=critical … 1=informational)
|
||||
* status → status_order (3=FAIL, 2=PASS, 1=MANUAL)
|
||||
* delta → delta_order (2=new, 1=changed, 0=otherwise)
|
||||
* Higher integer = more important. PREFIX with `-` to put FAIL/critical/new first.
|
||||
*
|
||||
* The two families look identical from the outside (`sort=...`) but require
|
||||
* opposite tokens. Always import from this file. Never hard-code.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Family A: plain findings (Postgres ENUM — no minus on status/severity)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FINDINGS_FAIL_FIRST = "status";
|
||||
export const FINDINGS_SEVERITY_HIGH_FIRST = "severity";
|
||||
export const FINDINGS_RECENT_INSERT = "-inserted_at";
|
||||
export const FINDINGS_RECENT_UPDATE = "-updated_at";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Family B: finding-groups (computed integer — minus on status/severity/delta)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FG_FAIL_FIRST = "-status";
|
||||
export const FG_SEVERITY_HIGH_FIRST = "-severity";
|
||||
export const FG_DELTA_NEW_FIRST = "-delta";
|
||||
export const FG_RECENT_LAST_SEEN = "-last_seen_at";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const composeSort = (...tokens: string[]): string => tokens.join(",");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Presets — Family A
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Default for plain-findings tables WITHOUT a server-side `filter[status]`.
|
||||
* FAIL rows first, then critical→informational, then most recent.
|
||||
* Delta is intentionally omitted — `/findings` does not accept `delta` as a
|
||||
* sort field (see FindingViewSet.ordering_fields).
|
||||
*/
|
||||
export const FINDINGS_DEFAULT_SORT = composeSort(
|
||||
FINDINGS_FAIL_FIRST,
|
||||
FINDINGS_SEVERITY_HIGH_FIRST,
|
||||
FINDINGS_RECENT_INSERT,
|
||||
);
|
||||
|
||||
/**
|
||||
* Default for plain-findings tables that ALREADY apply `filter[status]=FAIL`
|
||||
* (or equivalent) server-side. Status sort would be redundant.
|
||||
*/
|
||||
export const FINDINGS_FILTERED_SORT = composeSort(
|
||||
FINDINGS_SEVERITY_HIGH_FIRST,
|
||||
FINDINGS_RECENT_INSERT,
|
||||
);
|
||||
|
||||
/**
|
||||
* Resource-detail drawer "other findings" tab. Pairs with a server-side
|
||||
* `filter[status]=FAIL`, so status is omitted. Uses `-updated_at` because
|
||||
* `/findings/latest` exposes `updated_at`, not `inserted_at`.
|
||||
*/
|
||||
export const RESOURCE_DRAWER_OTHER_FINDINGS_SORT = composeSort(
|
||||
FINDINGS_SEVERITY_HIGH_FIRST,
|
||||
FINDINGS_RECENT_UPDATE,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Presets — Family B
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Default for finding-groups list endpoints. FAIL groups first, then by
|
||||
* severity, then by `new` deltas (deltas matter on group endpoints since
|
||||
* `delta_order` is a real ordering column).
|
||||
*/
|
||||
export const FINDING_GROUPS_DEFAULT_SORT = composeSort(
|
||||
FG_FAIL_FIRST,
|
||||
FG_SEVERITY_HIGH_FIRST,
|
||||
FG_DELTA_NEW_FIRST,
|
||||
FG_RECENT_LAST_SEEN,
|
||||
);
|
||||
|
||||
/**
|
||||
* Default for the per-group resources sub-endpoint
|
||||
* (`/finding-groups/{id}/resources`). Same shape as the groups list because
|
||||
* `_RESOURCE_SORT_MAP` exposes the same computed columns.
|
||||
*/
|
||||
export const FINDING_GROUP_RESOURCES_DEFAULT_SORT = composeSort(
|
||||
FG_FAIL_FIRST,
|
||||
FG_SEVERITY_HIGH_FIRST,
|
||||
FG_DELTA_NEW_FIRST,
|
||||
FG_RECENT_LAST_SEEN,
|
||||
);
|
||||
|
||||
/**
|
||||
* Default for the `/findings` PAGE (which renders finding-groups, NOT plain
|
||||
* findings) when the URL already constrains `filter[status__in]` and/or
|
||||
* `filter[delta__in]`. Status and delta sort would be redundant.
|
||||
*
|
||||
* IMPORTANT: do NOT pass `inserted_at` here — `_FINDING_GROUP_SORT_MAP`
|
||||
* does not expose it; valid recency keys are `last_seen_at`, `first_seen_at`,
|
||||
* and `failing_since`.
|
||||
*/
|
||||
export const FINDING_GROUPS_FILTERED_SORT = composeSort(
|
||||
FG_SEVERITY_HIGH_FIRST,
|
||||
FG_RECENT_LAST_SEEN,
|
||||
);
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from "./error-mappings";
|
||||
export * from "./external-urls";
|
||||
export * from "./findings-filters";
|
||||
export * from "./findings-sort";
|
||||
export * from "./helper";
|
||||
export * from "./helper-filters";
|
||||
export * from "./menu-list";
|
||||
|
||||
Reference in New Issue
Block a user