mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
fix(ui): align scan findings dates with UTC
- Preserve local dates in findings filters while querying by UTC completion day - Invalidate scan-date provenance when relevant filters change - Add regression coverage for conversion and marker cleanup
This commit is contained in:
@@ -25,7 +25,11 @@ import {
|
||||
hasDateOrScanFilter,
|
||||
} from "@/lib";
|
||||
import { getFindingGroupFilterOptions } from "@/lib/finding-group-filter-options";
|
||||
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
|
||||
import {
|
||||
FINDING_SCAN_DATE_SOURCE_PARAM,
|
||||
parseFindingScanDateSource,
|
||||
resolveFindingScanDateFilters,
|
||||
} from "@/lib/findings-scan-filters";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { ScanEntity, ScanProps } from "@/types";
|
||||
import { SearchParamsProps } from "@/types/components";
|
||||
@@ -52,6 +56,9 @@ export default async function Findings({
|
||||
const response = await getScan(scanId);
|
||||
return response?.data;
|
||||
},
|
||||
dateSource: parseFindingScanDateSource(
|
||||
resolvedSearchParams[FINDING_SCAN_DATE_SOURCE_PARAM],
|
||||
),
|
||||
});
|
||||
const resolvedFilters = applyDefaultMutedFilter(filtersWithScanDates);
|
||||
const hasHistoricalData = hasDateOrScanFilter(filtersWithScanDates);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Scan `View Findings` navigation now preserves the user's local date while querying findings with the scan's UTC completion date
|
||||
@@ -20,6 +20,10 @@ import { ExpandableSection } from "@/components/shadcn/expandable-section";
|
||||
import { DataTableFilterCustom } from "@/components/shadcn/table/data-table-filter-custom";
|
||||
import { useFilterBatch } from "@/hooks/use-filter-batch";
|
||||
import { getCategoryLabel, getGroupLabel } from "@/lib/categories";
|
||||
import {
|
||||
FINDING_SCAN_DATE_PROVENANCE_FILTER_KEYS,
|
||||
FINDING_SCAN_DATE_SOURCE_PARAM,
|
||||
} from "@/lib/findings-scan-filters";
|
||||
import { FILTER_FIELD, ScanEntity } from "@/types";
|
||||
import { ProviderGroup } from "@/types/components";
|
||||
import { DATA_TABLE_FILTER_MODE } from "@/types/filters";
|
||||
@@ -369,6 +373,12 @@ export const FindingsFilters = (props: FindingsFiltersProps) => {
|
||||
} = useFilterBatch({
|
||||
defaultParams: { "filter[muted]": "false" },
|
||||
exclusiveFilterGroups: [FINDING_GROUP_FILTER_KEYS],
|
||||
urlParamInvalidationRules: [
|
||||
{
|
||||
param: FINDING_SCAN_DATE_SOURCE_PARAM,
|
||||
filterKeys: FINDING_SCAN_DATE_PROVENANCE_FILTER_KEYS,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -314,7 +314,7 @@ describe("ScanJobsRowActions", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links completed scans to filtered findings", async () => {
|
||||
it("links completed scans to findings with a local display date", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
@@ -334,9 +334,11 @@ describe("ScanJobsRowActions", () => {
|
||||
await user.click(screen.getByRole("menuitem", { name: /view findings/i }));
|
||||
|
||||
// Then
|
||||
expect(pushMock).toHaveBeenCalledWith(
|
||||
"/findings?filter[scan__in]=scan-1&filter[inserted_at]=2026-01-01&filter[status__in]=FAIL",
|
||||
);
|
||||
const findingsHref = pushMock.mock.calls[0]?.[0] as string;
|
||||
expect(findingsHref).toContain("filter[scan__in]=scan-1");
|
||||
expect(findingsHref).toContain("filter[inserted_at]=2026-01-01");
|
||||
expect(findingsHref).toContain("filter[status__in]=FAIL");
|
||||
expect(findingsHref).toContain("scanDateSource=scan-action:2026-01-01");
|
||||
});
|
||||
|
||||
it("triggers downloadScanZip with the scan id when downloading reports", async () => {
|
||||
|
||||
@@ -31,6 +31,10 @@ import {
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { buildPerScanComplianceHref } from "@/lib/compliance/compliance-tab-url";
|
||||
import { toLocalDateString } from "@/lib/date-utils";
|
||||
import {
|
||||
buildFindingScanDateSource,
|
||||
FINDING_SCAN_DATE_SOURCE_PARAM,
|
||||
} from "@/lib/findings-scan-filters";
|
||||
import { downloadScanZip } from "@/lib/helper";
|
||||
import { getScanScheduleCapability } from "@/lib/schedules";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
@@ -93,8 +97,9 @@ export function ScanJobsRowActions({
|
||||
|
||||
const openFindings = () => {
|
||||
if (!isCompleted || !scanDate) return;
|
||||
|
||||
router.push(
|
||||
`/findings?filter[scan__in]=${scan.id}&filter[inserted_at]=${scanDate}&filter[status__in]=FAIL`,
|
||||
`/findings?filter[scan__in]=${scan.id}&filter[inserted_at]=${scanDate}&filter[status__in]=FAIL&${FINDING_SCAN_DATE_SOURCE_PARAM}=${buildFindingScanDateSource(scanDate)}`,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
buildFindingScanDateSource,
|
||||
FINDING_SCAN_DATE_PROVENANCE_FILTER_KEYS,
|
||||
FINDING_SCAN_DATE_SOURCE_PARAM,
|
||||
} from "@/lib/findings-scan-filters";
|
||||
|
||||
// --- Mock next/navigation ---
|
||||
const mockPush = vi.fn();
|
||||
let mockSearchParamsValue = new URLSearchParams();
|
||||
@@ -432,6 +438,84 @@ describe("useFilterBatch", () => {
|
||||
expect(calledUrl).toContain("filter%5Bsearch%5D=my-search");
|
||||
expect(calledUrl).toContain("filter%5Bmuted%5D=false");
|
||||
});
|
||||
|
||||
it("should clear scan-date provenance when the displayed date changes", () => {
|
||||
// Given
|
||||
setSearchParams({
|
||||
"filter[scan__in]": "scan-1",
|
||||
"filter[inserted_at]": "2026-04-06",
|
||||
[FINDING_SCAN_DATE_SOURCE_PARAM]:
|
||||
buildFindingScanDateSource("2026-04-06"),
|
||||
expandedCheckId: "check-1",
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
useFilterBatch({
|
||||
urlParamInvalidationRules: [
|
||||
{
|
||||
param: FINDING_SCAN_DATE_SOURCE_PARAM,
|
||||
filterKeys: FINDING_SCAN_DATE_PROVENANCE_FILTER_KEYS,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setPending("filter[inserted_at]", ["2026-04-05"]);
|
||||
});
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.applyAll();
|
||||
});
|
||||
|
||||
// Then
|
||||
const calledUrl = new URL(
|
||||
mockPush.mock.calls[0][0],
|
||||
"https://example.com",
|
||||
);
|
||||
expect(calledUrl.searchParams.has(FINDING_SCAN_DATE_SOURCE_PARAM)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(calledUrl.searchParams.get("expandedCheckId")).toBe("check-1");
|
||||
});
|
||||
|
||||
it("should preserve scan-date provenance for unrelated filter changes", () => {
|
||||
// Given
|
||||
const dateSource = buildFindingScanDateSource("2026-04-06");
|
||||
setSearchParams({
|
||||
"filter[scan__in]": "scan-1",
|
||||
"filter[inserted_at]": "2026-04-06",
|
||||
[FINDING_SCAN_DATE_SOURCE_PARAM]: dateSource,
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
useFilterBatch({
|
||||
urlParamInvalidationRules: [
|
||||
{
|
||||
param: FINDING_SCAN_DATE_SOURCE_PARAM,
|
||||
filterKeys: FINDING_SCAN_DATE_PROVENANCE_FILTER_KEYS,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setPending("filter[severity__in]", ["critical"]);
|
||||
});
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.applyAll();
|
||||
});
|
||||
|
||||
// Then
|
||||
const calledUrl = new URL(
|
||||
mockPush.mock.calls[0][0],
|
||||
"https://example.com",
|
||||
);
|
||||
expect(calledUrl.searchParams.get(FINDING_SCAN_DATE_SOURCE_PARAM)).toBe(
|
||||
dateSource,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── discardAll ─────────────────────────────────────────────────────────────
|
||||
@@ -635,5 +719,41 @@ describe("useFilterBatch", () => {
|
||||
const calledUrl: string = mockPush.mock.calls[0][0];
|
||||
expect(calledUrl).toContain("page=1");
|
||||
});
|
||||
|
||||
it("should clear scan-date provenance without deleting unrelated URL params", () => {
|
||||
// Given
|
||||
setSearchParams({
|
||||
"filter[scan__in]": "scan-1",
|
||||
"filter[inserted_at]": "2026-04-06",
|
||||
[FINDING_SCAN_DATE_SOURCE_PARAM]:
|
||||
buildFindingScanDateSource("2026-04-06"),
|
||||
expandedCheckId: "check-1",
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
useFilterBatch({
|
||||
urlParamInvalidationRules: [
|
||||
{
|
||||
param: FINDING_SCAN_DATE_SOURCE_PARAM,
|
||||
filterKeys: FINDING_SCAN_DATE_PROVENANCE_FILTER_KEYS,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.clearAndApply();
|
||||
});
|
||||
|
||||
// Then
|
||||
const calledUrl = new URL(
|
||||
mockPush.mock.calls[0][0],
|
||||
"https://example.com",
|
||||
);
|
||||
expect(calledUrl.searchParams.has(FINDING_SCAN_DATE_SOURCE_PARAM)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(calledUrl.searchParams.get("expandedCheckId")).toBe("check-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,6 +157,16 @@ export interface UseFilterBatchOptions {
|
||||
* applied together.
|
||||
*/
|
||||
exclusiveFilterGroups?: string[][];
|
||||
/**
|
||||
* Non-filter URL params whose provenance becomes invalid when specific
|
||||
* URL-backed filters change.
|
||||
*/
|
||||
urlParamInvalidationRules?: UrlParamInvalidationRule[];
|
||||
}
|
||||
|
||||
export interface UrlParamInvalidationRule {
|
||||
param: string;
|
||||
filterKeys: readonly string[];
|
||||
}
|
||||
|
||||
function normalizeFilterKey(key: string): string {
|
||||
@@ -233,10 +243,27 @@ export const useFilterBatch = (
|
||||
};
|
||||
|
||||
/** Private helper — builds URLSearchParams from a pending state and pushes. */
|
||||
const buildAndPush = (nextPending: PendingFilters) => {
|
||||
const buildAndPush = (
|
||||
nextPending: PendingFilters,
|
||||
clearInvalidatedParams = false,
|
||||
) => {
|
||||
setAppliedFilters(nextPending);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
|
||||
options?.urlParamInvalidationRules?.forEach(({ param, filterKeys }) => {
|
||||
const isInvalidated =
|
||||
clearInvalidatedParams ||
|
||||
filterKeys.some((filterKey) => {
|
||||
const currentValue = searchParams.get(filterKey) || "";
|
||||
const nextValue = (nextPending[filterKey] ?? [])
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
return currentValue !== nextValue;
|
||||
});
|
||||
|
||||
if (isInvalidated) params.delete(param);
|
||||
});
|
||||
|
||||
// Remove all batch-managed filter params
|
||||
Array.from(params.keys()).forEach((key) => {
|
||||
if (key.startsWith("filter[") && !EXCLUDED_FROM_BATCH.includes(key)) {
|
||||
@@ -290,7 +317,7 @@ export const useFilterBatch = (
|
||||
*/
|
||||
const clearAndApply = () => {
|
||||
setPendingFilters({});
|
||||
buildAndPush({});
|
||||
buildAndPush({}, true);
|
||||
};
|
||||
|
||||
const removeAppliedAndApply = (key: string, value?: string) => {
|
||||
|
||||
@@ -109,4 +109,56 @@ describe("resolveFindingScanDateFilters", () => {
|
||||
"filter[inserted_at__gte]": "2026-04-01",
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces a scan-action local date with the scan UTC completion date", async () => {
|
||||
const result = await resolveFindingScanDateFilters({
|
||||
filters: {
|
||||
"filter[muted]": "false",
|
||||
"filter[scan__in]": "scan-1",
|
||||
"filter[inserted_at]": "2026-04-06",
|
||||
"filter[status__in]": "FAIL",
|
||||
},
|
||||
scans: [
|
||||
{
|
||||
id: "scan-1",
|
||||
attributes: {
|
||||
completed_at: "2026-04-07T00:30:00Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
loadScan: vi.fn(),
|
||||
dateSource: "scan-action:2026-04-06",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
"filter[muted]": "false",
|
||||
"filter[scan__in]": "scan-1",
|
||||
"filter[inserted_at]": "2026-04-07",
|
||||
"filter[status__in]": "FAIL",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a manually changed date when the scan-action marker is stale", async () => {
|
||||
const result = await resolveFindingScanDateFilters({
|
||||
filters: {
|
||||
"filter[scan__in]": "scan-1",
|
||||
"filter[inserted_at]": "2026-04-05",
|
||||
},
|
||||
scans: [
|
||||
{
|
||||
id: "scan-1",
|
||||
attributes: {
|
||||
completed_at: "2026-04-07T00:30:00Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
loadScan: vi.fn(),
|
||||
dateSource: "scan-action:2026-04-06",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
"filter[scan__in]": "scan-1",
|
||||
"filter[inserted_at]": "2026-04-05",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,36 @@ interface ResolveFindingScanDateFiltersOptions {
|
||||
filters: Record<string, string>;
|
||||
scans: ScanDateSource[];
|
||||
loadScan: (scanId: string) => Promise<ScanDateSource | null | undefined>;
|
||||
dateSource?: FindingScanDateSource;
|
||||
}
|
||||
|
||||
export const FINDING_SCAN_DATE_SOURCE_PARAM = "scanDateSource";
|
||||
|
||||
export const FINDING_SCAN_DATE_SOURCE = {
|
||||
SCAN_ACTION: "scan-action",
|
||||
} as const;
|
||||
|
||||
type FindingScanDateSource =
|
||||
`${typeof FINDING_SCAN_DATE_SOURCE.SCAN_ACTION}:${string}`;
|
||||
|
||||
const SCAN_ACTION_DATE_PREFIX =
|
||||
`${FINDING_SCAN_DATE_SOURCE.SCAN_ACTION}:` as const;
|
||||
|
||||
export function buildFindingScanDateSource(
|
||||
displayDate: string,
|
||||
): FindingScanDateSource {
|
||||
return `${SCAN_ACTION_DATE_PREFIX}${displayDate}`;
|
||||
}
|
||||
|
||||
export function parseFindingScanDateSource(
|
||||
value: string | string[] | undefined,
|
||||
): FindingScanDateSource | undefined {
|
||||
const candidate = Array.isArray(value) ? value[0] : value;
|
||||
|
||||
return candidate?.startsWith(SCAN_ACTION_DATE_PREFIX) &&
|
||||
candidate.length > SCAN_ACTION_DATE_PREFIX.length
|
||||
? (candidate as FindingScanDateSource)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const INSERTED_AT_FILTER_KEYS = [
|
||||
@@ -22,6 +52,12 @@ const INSERTED_AT_FILTER_KEYS = [
|
||||
"filter[inserted_at__lte]",
|
||||
] as const;
|
||||
|
||||
export const FINDING_SCAN_DATE_PROVENANCE_FILTER_KEYS = [
|
||||
"filter[scan__in]",
|
||||
"filter[scan]",
|
||||
...INSERTED_AT_FILTER_KEYS,
|
||||
] as const;
|
||||
|
||||
function getScanFilterIds(filters: Record<string, string>): string[] {
|
||||
const scanIds = filters["filter[scan__in]"] || filters["filter[scan]"] || "";
|
||||
return Array.from(new Set(scanIds.split(",").filter(Boolean)));
|
||||
@@ -64,10 +100,20 @@ export async function resolveFindingScanDateFilters({
|
||||
filters,
|
||||
scans,
|
||||
loadScan,
|
||||
dateSource,
|
||||
}: ResolveFindingScanDateFiltersOptions): Promise<Record<string, string>> {
|
||||
const scanIds = getScanFilterIds(filters);
|
||||
const scanActionDisplayDate = dateSource?.slice(
|
||||
SCAN_ACTION_DATE_PREFIX.length,
|
||||
);
|
||||
const isScanActionDate =
|
||||
Boolean(scanActionDisplayDate) &&
|
||||
filters["filter[inserted_at]"] === scanActionDisplayDate;
|
||||
|
||||
if (scanIds.length === 0 || hasInsertedAtFilter(filters)) {
|
||||
if (
|
||||
scanIds.length === 0 ||
|
||||
(hasInsertedAtFilter(filters) && !isScanActionDate)
|
||||
) {
|
||||
return filters;
|
||||
}
|
||||
|
||||
@@ -96,8 +142,16 @@ export async function resolveFindingScanDateFilters({
|
||||
return filters;
|
||||
}
|
||||
|
||||
const apiFilters = isScanActionDate
|
||||
? Object.fromEntries(
|
||||
Object.entries(filters).filter(([key]) =>
|
||||
INSERTED_AT_FILTER_KEYS.every((dateKey) => dateKey !== key),
|
||||
),
|
||||
)
|
||||
: filters;
|
||||
|
||||
return {
|
||||
...filters,
|
||||
...apiFilters,
|
||||
...dateFilters,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user