refactor: unify filtering and sorting for finding (#10803)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Pepe Fagoaga
2026-04-22 13:11:50 +02:00
committed by GitHub
parent 1093f6c99b
commit 94ee24071a
24 changed files with 697 additions and 178 deletions
@@ -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", () => ({
+36 -36
View File
@@ -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", () => ({
+6 -2
View File
@@ -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());
+26
View File
@@ -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", () => ({
+2 -2
View File
@@ -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()}`);
@@ -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 (
+4
View File
@@ -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");
});
+11 -14
View File
@@ -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(
+3 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+169
View File
@@ -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"),
);
}
});
});
+130
View File
@@ -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
View File
@@ -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";