Compare commits

...
Author SHA1 Message Date
Hugo P.Brito b9ba136d43 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
2026-08-10 11:32:51 +01:00
9 changed files with 288 additions and 10 deletions
+8 -1
View File
@@ -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)}`,
);
};
+120
View File
@@ -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");
});
});
});
+29 -2
View File
@@ -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) => {
+52
View File
@@ -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",
});
});
});
+56 -2
View File
@@ -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,
};
}