feat(ui): improvements over findings groups feature (#10590)

This commit is contained in:
Alejandro Bailo
2026-04-09 09:39:52 +02:00
committed by GitHub
parent 379df7800d
commit b9270df3e6
36 changed files with 2061 additions and 327 deletions
@@ -163,6 +163,7 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
alias: "production",
},
status: "FAIL",
delta: "new",
severity: "critical",
first_seen_at: null,
last_seen_at: "2024-01-01T00:00:00Z",
@@ -178,5 +179,6 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
expect(result).toHaveLength(1);
expect(result[0].checkId).toBe("s3_check");
expect(result[0].resourceName).toBe("my-bucket");
expect(result[0].delta).toBe("new");
});
});
@@ -98,6 +98,7 @@ interface FindingGroupResourceAttributes {
resource: ResourceInfo;
provider: ProviderInfo;
status: string;
delta?: string | null;
severity: string;
first_seen_at: string | null;
last_seen_at: string | null;
@@ -137,14 +138,15 @@ export function adaptFindingGroupResourcesResponse(
providerAlias: item.attributes.provider?.alias || "",
providerUid: item.attributes.provider?.uid || "",
resourceName: item.attributes.resource?.name || "-",
resourceType: item.attributes.resource?.type || "-",
resourceGroup: item.attributes.resource?.resource_group || "-",
resourceUid: item.attributes.resource?.uid || "-",
service: item.attributes.resource?.service || "-",
region: item.attributes.resource?.region || "-",
severity: (item.attributes.severity || "informational") as Severity,
status: item.attributes.status,
delta: item.attributes.delta || null,
isMuted: item.attributes.status === "MUTED",
// TODO: remove fallback once the API returns muted_reason in finding-group-resources
mutedReason: item.attributes.muted_reason || undefined,
firstSeenAt: item.attributes.first_seen_at,
lastSeenAt: item.attributes.last_seen_at,
+112 -62
View File
@@ -47,10 +47,6 @@ import {
getLatestFindingGroupResources,
} from "./finding-groups";
// ---------------------------------------------------------------------------
// Blocker 1 + 2: FAIL-first sort and FAIL-only filter for drill-down resources
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -169,7 +165,7 @@ describe("getLatestFindingGroupResources — SSRF path traversal protection", ()
});
// ---------------------------------------------------------------------------
// Blocker 1: Resources list must show FAIL first (sort=-status)
// Resources list keeps FAIL-first sort but no longer forces FAIL-only filtering
// ---------------------------------------------------------------------------
describe("getFindingGroupResources — Blocker 1: FAIL-first sort", () => {
@@ -181,30 +177,30 @@ describe("getFindingGroupResources — Blocker 1: FAIL-first sort", () => {
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
});
it("should include sort=-status in the API call so FAIL resources appear first", async () => {
it("should include the composite sort so FAIL resources appear first, then severity", async () => {
// Given
const checkId = "s3_bucket_public_access";
// When
await getFindingGroupResources({ checkId });
// Then — the URL must contain sort=-status
// Then — the URL must contain the composite sort
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe("-status");
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
});
it("should include filter[status]=FAIL in the API call so only impacted resources are shown", async () => {
it("should not force filter[status]=FAIL so PASS resources can also be shown", async () => {
// Given
const checkId = "s3_bucket_public_access";
// When
await getFindingGroupResources({ checkId });
// Then — the URL must contain filter[status]=FAIL
// Then — the URL should not add a hardcoded status filter
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("filter[status]")).toBe("FAIL");
expect(url.searchParams.get("filter[status]")).toBeNull();
});
});
@@ -217,7 +213,7 @@ describe("getLatestFindingGroupResources — Blocker 1: FAIL-first sort", () =>
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
});
it("should include sort=-status in the API call so FAIL resources appear first", async () => {
it("should include the composite sort so FAIL resources appear first, then severity", async () => {
// Given
const checkId = "iam_user_mfa_enabled";
@@ -227,10 +223,10 @@ describe("getLatestFindingGroupResources — Blocker 1: FAIL-first sort", () =>
// Then
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe("-status");
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
});
it("should include filter[status]=FAIL in the API call so only impacted resources are shown", async () => {
it("should not force filter[status]=FAIL so PASS resources can also be shown", async () => {
// Given
const checkId = "iam_user_mfa_enabled";
@@ -240,7 +236,7 @@ describe("getLatestFindingGroupResources — Blocker 1: FAIL-first sort", () =>
// Then
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("filter[status]")).toBe("FAIL");
expect(url.searchParams.get("filter[status]")).toBeNull();
});
});
@@ -257,7 +253,7 @@ describe("getFindingGroupResources — triangulation: params coexist", () => {
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
});
it("should send sort=-status AND filter[status]=FAIL alongside pagination params", async () => {
it("should send the composite sort alongside pagination params without forcing filter[status]", async () => {
// Given
const checkId = "s3_bucket_versioning";
@@ -269,8 +265,8 @@ describe("getFindingGroupResources — triangulation: params coexist", () => {
const url = new URL(calledUrl);
expect(url.searchParams.get("page[number]")).toBe("2");
expect(url.searchParams.get("page[size]")).toBe("50");
expect(url.searchParams.get("sort")).toBe("-status");
expect(url.searchParams.get("filter[status]")).toBe("FAIL");
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
expect(url.searchParams.get("filter[status]")).toBeNull();
});
});
@@ -283,7 +279,7 @@ describe("getLatestFindingGroupResources — triangulation: params coexist", ()
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
});
it("should send sort=-status AND filter[status]=FAIL alongside pagination params", async () => {
it("should send the composite sort alongside pagination params without forcing filter[status]", async () => {
// Given
const checkId = "iam_root_mfa_enabled";
@@ -295,16 +291,16 @@ describe("getLatestFindingGroupResources — triangulation: params coexist", ()
const url = new URL(calledUrl);
expect(url.searchParams.get("page[number]")).toBe("3");
expect(url.searchParams.get("page[size]")).toBe("20");
expect(url.searchParams.get("sort")).toBe("-status");
expect(url.searchParams.get("filter[status]")).toBe("FAIL");
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
expect(url.searchParams.get("filter[status]")).toBeNull();
});
});
// ---------------------------------------------------------------------------
// Blocker: Duplicate filter[status] — caller-supplied status must be stripped
// Caller filters should propagate unchanged to the drill-down resources endpoint
// ---------------------------------------------------------------------------
describe("getFindingGroupResources — Blocker: caller filter[status] is always overridden to FAIL", () => {
describe("getFindingGroupResources — caller filters are preserved", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
@@ -313,23 +309,7 @@ describe("getFindingGroupResources — Blocker: caller filter[status] is always
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
});
it("should use filter[status]=FAIL even when caller passes filter[status]=PASS", async () => {
// Given — caller explicitly passes PASS, which must be ignored
const checkId = "s3_bucket_public_access";
const filters = { "filter[status]": "PASS" };
// When
await getFindingGroupResources({ checkId, filters });
// Then — the final URL must have exactly one filter[status]=FAIL, not PASS
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
const allStatusValues = url.searchParams.getAll("filter[status]");
expect(allStatusValues).toHaveLength(1);
expect(allStatusValues[0]).toBe("FAIL");
});
it("should not have duplicate filter[status] params when caller passes filter[status]", async () => {
it("should preserve caller filter[status] when explicitly provided", async () => {
// Given
const checkId = "s3_bucket_public_access";
const filters = { "filter[status]": "PASS" };
@@ -337,14 +317,56 @@ describe("getFindingGroupResources — Blocker: caller filter[status] is always
// When
await getFindingGroupResources({ checkId, filters });
// Then — no duplicates
// Then
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.getAll("filter[status]")).toHaveLength(1);
const allStatusValues = url.searchParams.getAll("filter[status]");
expect(allStatusValues).toHaveLength(1);
expect(allStatusValues[0]).toBe("PASS");
});
it("should translate a single group status__in filter into filter[status] for resources", async () => {
// Given
const checkId = "s3_bucket_public_access";
const filters = {
"filter[status__in]": "PASS",
"filter[severity__in]": "medium",
"filter[provider_type__in]": "aws",
};
// When
await getFindingGroupResources({ checkId, filters });
// Then
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("filter[status]")).toBe("PASS");
expect(url.searchParams.get("filter[status__in]")).toBeNull();
expect(url.searchParams.get("filter[severity__in]")).toBe("medium");
expect(url.searchParams.get("filter[provider_type__in]")).toBe("aws");
});
it("should keep the composite sort when the resource search filter is applied", async () => {
// Given
const checkId = "s3_bucket_public_access";
const filters = {
"filter[name__icontains]": "bucket-prod",
"filter[severity__in]": "high",
};
// When
await getFindingGroupResources({ checkId, filters });
// Then
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
expect(url.searchParams.get("filter[name__icontains]")).toBe("bucket-prod");
expect(url.searchParams.get("filter[severity__in]")).toBe("high");
});
});
describe("getLatestFindingGroupResources — Blocker: caller filter[status] is always overridden to FAIL", () => {
describe("getLatestFindingGroupResources — caller filters are preserved", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
@@ -353,23 +375,7 @@ describe("getLatestFindingGroupResources — Blocker: caller filter[status] is a
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
});
it("should use filter[status]=FAIL even when caller passes filter[status]=PASS", async () => {
// Given — caller explicitly passes PASS, which must be ignored
const checkId = "iam_user_mfa_enabled";
const filters = { "filter[status]": "PASS" };
// When
await getLatestFindingGroupResources({ checkId, filters });
// Then — the final URL must have exactly one filter[status]=FAIL, not PASS
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
const allStatusValues = url.searchParams.getAll("filter[status]");
expect(allStatusValues).toHaveLength(1);
expect(allStatusValues[0]).toBe("FAIL");
});
it("should not have duplicate filter[status] params when caller passes filter[status]", async () => {
it("should preserve caller filter[status] when explicitly provided", async () => {
// Given
const checkId = "iam_user_mfa_enabled";
const filters = { "filter[status]": "PASS" };
@@ -377,9 +383,53 @@ describe("getLatestFindingGroupResources — Blocker: caller filter[status] is a
// When
await getLatestFindingGroupResources({ checkId, filters });
// Then — no duplicates
// Then
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.getAll("filter[status]")).toHaveLength(1);
const allStatusValues = url.searchParams.getAll("filter[status]");
expect(allStatusValues).toHaveLength(1);
expect(allStatusValues[0]).toBe("PASS");
});
it("should translate a single group status__in filter into filter[status] for latest resources", async () => {
// Given
const checkId = "iam_user_mfa_enabled";
const filters = {
"filter[status__in]": "PASS",
"filter[severity__in]": "low",
"filter[provider_type__in]": "aws",
};
// When
await getLatestFindingGroupResources({ checkId, filters });
// Then
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("filter[status]")).toBe("PASS");
expect(url.searchParams.get("filter[status__in]")).toBeNull();
expect(url.searchParams.get("filter[severity__in]")).toBe("low");
expect(url.searchParams.get("filter[provider_type__in]")).toBe("aws");
});
it("should keep the composite sort when the resource search filter is applied", async () => {
// Given
const checkId = "iam_user_mfa_enabled";
const filters = {
"filter[name__icontains]": "instance-prod",
"filter[status__in]": "PASS,FAIL",
};
// When
await getLatestFindingGroupResources({ checkId, filters });
// Then
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
expect(url.searchParams.get("filter[name__icontains]")).toBe(
"instance-prod",
);
expect(url.searchParams.get("filter[status__in]")).toBe("PASS,FAIL");
});
});
+97 -106
View File
@@ -23,17 +23,68 @@ function mapSearchFilter(
return mapped;
}
export const getFindingGroups = async ({
page = 1,
pageSize = 10,
sort = "",
filters = {},
}) => {
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 [];
}
function normalizeFindingGroupResourceFilters(
filters: Record<string, string | string[] | undefined>,
): Record<string, string | string[] | undefined> {
const normalized = { ...filters };
const exactStatusFilter = normalized["filter[status]"];
if (exactStatusFilter !== undefined) {
delete normalized["filter[status__in]"];
return normalized;
}
const statusValues = splitCsvFilterValues(normalized["filter[status__in]"]);
if (statusValues.length === 1) {
normalized["filter[status]"] = statusValues[0];
delete normalized["filter[status__in]"];
}
return normalized;
}
const DEFAULT_FINDING_GROUPS_SORT =
"-severity,-delta,-fail_count,-last_seen_at";
interface FetchFindingGroupsParams {
page?: number;
pageSize?: number;
sort?: string;
filters?: Record<string, string | string[] | undefined>;
}
async function fetchFindingGroupsEndpoint(
endpoint: string,
{
page = 1,
pageSize = 10,
sort = DEFAULT_FINDING_GROUPS_SORT,
filters = {},
}: FetchFindingGroupsParams,
) {
const headers = await getAuthHeaders({ contentType: false });
if (isNaN(Number(page)) || page < 1) redirect("/findings");
const url = new URL(`${apiBaseUrl}/finding-groups`);
const url = new URL(`${apiBaseUrl}/${endpoint}`);
if (page) url.searchParams.append("page[number]", page.toString());
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
@@ -45,120 +96,60 @@ export const getFindingGroups = async ({
const response = await fetch(url.toString(), { headers });
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching finding groups:", error);
console.error(`Error fetching ${endpoint}:`, error);
return undefined;
}
};
}
export const getLatestFindingGroups = async ({
page = 1,
pageSize = 10,
sort = "",
filters = {},
}) => {
export const getFindingGroups = async (params: FetchFindingGroupsParams = {}) =>
fetchFindingGroupsEndpoint("finding-groups", params);
export const getLatestFindingGroups = async (
params: FetchFindingGroupsParams = {},
) => fetchFindingGroupsEndpoint("finding-groups/latest", params);
interface FetchFindingGroupResourcesParams {
checkId: string;
page?: number;
pageSize?: number;
filters?: Record<string, string | string[] | undefined>;
}
async function fetchFindingGroupResourcesEndpoint(
endpointPrefix: string,
{
checkId,
page = 1,
pageSize = 20,
filters = {},
}: FetchFindingGroupResourcesParams,
) {
const headers = await getAuthHeaders({ contentType: false });
const normalizedFilters = normalizeFindingGroupResourceFilters(filters);
if (isNaN(Number(page)) || page < 1) redirect("/findings");
const url = new URL(`${apiBaseUrl}/finding-groups/latest`);
const url = new URL(
`${apiBaseUrl}/${endpointPrefix}/${encodeURIComponent(checkId)}/resources`,
);
if (page) url.searchParams.append("page[number]", page.toString());
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
if (sort) url.searchParams.append("sort", sort);
url.searchParams.append("sort", "-severity,-delta,-last_seen_at");
appendSanitizedProviderFilters(url, mapSearchFilter(filters));
appendSanitizedProviderFilters(url, normalizedFilters);
try {
const response = await fetch(url.toString(), { headers });
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching latest finding groups:", error);
console.error(`Error fetching ${endpointPrefix} resources:`, error);
return undefined;
}
};
}
export const getFindingGroupResources = async ({
checkId,
page = 1,
pageSize = 20,
filters = {},
}: {
checkId: string;
page?: number;
pageSize?: number;
filters?: Record<string, string | string[] | undefined>;
}) => {
const headers = await getAuthHeaders({ contentType: false });
export const getFindingGroupResources = async (
params: FetchFindingGroupResourcesParams,
) => fetchFindingGroupResourcesEndpoint("finding-groups", params);
const url = new URL(
`${apiBaseUrl}/finding-groups/${encodeURIComponent(checkId)}/resources`,
);
if (page) url.searchParams.append("page[number]", page.toString());
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
// sort=-status is kept for future-proofing: if the filter[status]=FAIL
// constraint is ever relaxed to allow multiple statuses, the sort ensures
// FAIL resources still appear first in the result set.
url.searchParams.append("sort", "-status");
appendSanitizedProviderFilters(url, filters);
// Use .set() AFTER appendSanitizedProviderFilters so our hardcoded FAIL
// always wins, even if the caller passed a different filter[status] value.
// Using .set() instead of .append() prevents duplicate filter[status] params.
url.searchParams.set("filter[status]", "FAIL");
try {
const response = await fetch(url.toString(), {
headers,
});
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching finding group resources:", error);
return undefined;
}
};
export const getLatestFindingGroupResources = async ({
checkId,
page = 1,
pageSize = 20,
filters = {},
}: {
checkId: string;
page?: number;
pageSize?: number;
filters?: Record<string, string | string[] | undefined>;
}) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(
`${apiBaseUrl}/finding-groups/latest/${encodeURIComponent(checkId)}/resources`,
);
if (page) url.searchParams.append("page[number]", page.toString());
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
// sort=-status is kept for future-proofing: if the filter[status]=FAIL
// constraint is ever relaxed to allow multiple statuses, the sort ensures
// FAIL resources still appear first in the result set.
url.searchParams.append("sort", "-status");
appendSanitizedProviderFilters(url, filters);
// Use .set() AFTER appendSanitizedProviderFilters so our hardcoded FAIL
// always wins, even if the caller passed a different filter[status] value.
// Using .set() instead of .append() prevents duplicate filter[status] params.
url.searchParams.set("filter[status]", "FAIL");
try {
const response = await fetch(url.toString(), {
headers,
});
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching latest finding group resources:", error);
return undefined;
}
};
export const getLatestFindingGroupResources = async (
params: FetchFindingGroupResourcesParams,
) => fetchFindingGroupResourcesEndpoint("finding-groups/latest", params);
@@ -379,6 +379,9 @@ export const getLatestFindingsByResourceUid = async ({
);
url.searchParams.append("filter[resource_uid]", resourceUid);
url.searchParams.append("filter[status]", "FAIL");
url.searchParams.append("filter[muted]", "include");
url.searchParams.append("sort", "-severity,status,-updated_at");
if (page) url.searchParams.append("page[number]", page.toString());
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
+37
View File
@@ -0,0 +1,37 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
/**
* Source-level assertions for the findings page.
*
* Directly importing page.tsx triggers deep transitive imports
* (next-auth → next/server) that vitest cannot resolve without the
* full Next.js build pipeline. These tests verify key architectural
* invariants via source analysis instead.
*/
describe("findings page", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const pagePath = path.join(currentDir, "page.tsx");
const source = readFileSync(pagePath, "utf8");
it("only passes sort to fetchFindingGroups when the user has an explicit sort param", () => {
expect(source).toContain("...(encodedSort && { sort: encodedSort })");
});
it("normalizes scan filters with the required inserted_at params before fetching historical finding groups", () => {
expect(source).toContain("resolveFindingScanDateFilters");
});
it("uses getLatestFindingGroups for non-date/scan queries and getFindingGroups for historical", () => {
expect(source).toContain("hasDateOrScan");
expect(source).toContain("getFindingGroups");
expect(source).toContain("getLatestFindingGroups");
});
it("guards errors array access with a length check", () => {
expect(source).toContain("errors?.length > 0");
});
});
+29 -17
View File
@@ -7,7 +7,7 @@ import {
} from "@/actions/finding-groups";
import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings";
import { getProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { getScan, getScans } from "@/actions/scans";
import { FindingsFilters } from "@/components/findings/findings-filters";
import {
FindingsGroupTable,
@@ -21,6 +21,7 @@ import {
extractSortAndKey,
hasDateOrScanFilter,
} from "@/lib";
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
import { ScanEntity, ScanProps } from "@/types";
import { SearchParamsProps } from "@/types/components";
@@ -39,16 +40,28 @@ export default async function Findings({
// TODO: Re-implement deep link support (/findings?id=<uuid>) using the grouped view's resource detail drawer
// once the legacy FindingDetailsSheet is fully deprecated (still used by /resources and overview dashboard).
const [metadataInfoData, providersData, scansData] = await Promise.all([
(hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({
query,
sort: encodedSort,
filters,
}),
const [providersData, scansData] = await Promise.all([
getProviders({ pageSize: 50 }),
getScans({ pageSize: 50 }),
]);
const filtersWithScanDates = await resolveFindingScanDateFilters({
filters,
scans: scansData?.data || [],
loadScan: async (scanId: string) => {
const response = await getScan(scanId);
return response?.data;
},
});
const metadataInfoData = await (
hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo
)({
query,
sort: encodedSort,
filters: filtersWithScanDates,
});
// Extract unique regions, services, categories, groups from the new endpoint
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
const uniqueServices = metadataInfoData?.data?.attributes?.services || [];
@@ -88,7 +101,10 @@ export default async function Findings({
/>
</div>
<Suspense fallback={<SkeletonTableFindings />}>
<SSRDataTable searchParams={resolvedSearchParams} />
<SSRDataTable
searchParams={resolvedSearchParams}
filters={filtersWithScanDates}
/>
</Suspense>
</FilterTransitionWrapper>
</ContentLayout>
@@ -97,19 +113,15 @@ export default async function Findings({
const SSRDataTable = async ({
searchParams,
filters,
}: {
searchParams: SearchParamsProps;
filters: Record<string, string>;
}) => {
const page = parseInt(searchParams.page?.toString() || "1", 10);
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
const defaultSort = "-severity,-fail_count,-last_seen_at";
const { encodedSort } = extractSortAndKey({
...searchParams,
sort: searchParams.sort ?? defaultSort,
});
const { filters } = extractFiltersAndQuery(searchParams);
const { encodedSort } = extractSortAndKey(searchParams);
// Check if the searchParams contain any date or scan filter
const hasDateOrScan = hasDateOrScanFilter(searchParams);
@@ -119,7 +131,7 @@ const SSRDataTable = async ({
const findingGroupsData = await fetchFindingGroups({
page,
sort: encodedSort,
...(encodedSort && { sort: encodedSort }),
filters,
pageSize,
});
@@ -131,7 +143,7 @@ const SSRDataTable = async ({
return (
<>
{findingGroupsData?.errors && (
{findingGroupsData?.errors?.length > 0 && (
<div className="text-small mb-4 flex rounded-lg border border-red-500 bg-red-100 p-2 text-red-700">
<p className="mr-2 font-semibold">Error:</p>
<p>{findingGroupsData.errors[0].detail}</p>
+35 -2
View File
@@ -18,7 +18,12 @@ import {
createProviderDetailsMapping,
extractProviderUIDs,
} from "@/lib/provider-helpers";
import { ProviderProps, ScanProps, SearchParamsProps } from "@/types";
import {
ExpandedScanData,
ProviderProps,
ScanProps,
SearchParamsProps,
} from "@/types";
export default async function Scans({
searchParams,
@@ -30,7 +35,34 @@ export default async function Scans({
const filteredParams = { ...resolvedSearchParams };
delete filteredParams.scanId;
const providersData = await getAllProviders();
const [providersData, completedScansData] = await Promise.all([
getAllProviders(),
getScans({
filters: { "filter[state]": "completed" },
pageSize: 50,
fields: { scans: "name,completed_at,provider" },
include: "provider",
}),
]);
const completedScans: ExpandedScanData[] = (completedScansData?.data ?? [])
.map((scan: ScanProps) => {
const providerId = scan.relationships?.provider?.data?.id;
const providerData = completedScansData?.included?.find(
(item: { type: string; id: string }) =>
item.type === "providers" && item.id === providerId,
);
if (!providerData) return null;
return {
...scan,
providerInfo: {
provider: providerData.attributes.provider,
uid: providerData.attributes.uid,
alias: providerData.attributes.alias,
},
};
})
.filter(Boolean) as ExpandedScanData[];
const providerInfo =
providersData?.data
@@ -90,6 +122,7 @@ export default async function Scans({
<ScansFilters
providerUIDs={providerUIDs}
providerDetails={providerDetails}
completedScans={completedScans}
/>
<div className="flex items-center justify-end">
<MutedFindingsConfigButton />
+9 -48
View File
@@ -18,11 +18,12 @@ import { Button } from "@/components/shadcn";
import { ExpandableSection } from "@/components/ui/expandable-section";
import { DataTableFilterCustom } from "@/components/ui/table";
import { useFilterBatch } from "@/hooks/use-filter-batch";
import { formatLabel, getCategoryLabel, getGroupLabel } from "@/lib/categories";
import { FilterType, FINDING_STATUS_DISPLAY_NAMES, ScanEntity } from "@/types";
import { getCategoryLabel, getGroupLabel } from "@/lib/categories";
import { FilterType, ScanEntity } from "@/types";
import { DATA_TABLE_FILTER_MODE, FilterParam } from "@/types/filters";
import { getProviderDisplayName, ProviderProps } from "@/types/providers";
import { SEVERITY_DISPLAY_NAMES } from "@/types/severities";
import { ProviderProps } from "@/types/providers";
import { getFindingsFilterDisplayValue } from "./findings-filters.utils";
interface FindingsFiltersProps {
/** Provider data for ProviderTypeSelector and AccountsSelector */
@@ -58,49 +59,6 @@ const FILTER_KEY_LABELS: Record<FilterParam, string> = {
"filter[muted]": "Muted",
};
/**
* Formats a raw filter value into a human-readable display string.
* - Provider types: uses shared getProviderDisplayName utility
* - Severities: uses shared SEVERITY_DISPLAY_NAMES (e.g. "critical" → "Critical")
* - Status: uses shared FINDING_STATUS_DISPLAY_NAMES (e.g. "FAIL" → "Fail")
* - Categories: uses getCategoryLabel (handles IAM, EC2, IMDSv1, etc.)
* - Resource groups: uses getGroupLabel (underscore-delimited)
* - Date (filter[inserted_at]): returns the ISO date string as-is (YYYY-MM-DD)
* - Other values: uses formatLabel as a generic fallback (avoids naive capitalisation)
*/
const formatFilterValue = (filterKey: string, value: string): string => {
if (!value) return value;
if (filterKey === "filter[provider_type__in]") {
return getProviderDisplayName(value);
}
if (filterKey === "filter[severity__in]") {
return (
SEVERITY_DISPLAY_NAMES[
value.toLowerCase() as keyof typeof SEVERITY_DISPLAY_NAMES
] ?? formatLabel(value)
);
}
if (filterKey === "filter[status__in]") {
return (
FINDING_STATUS_DISPLAY_NAMES[
value as keyof typeof FINDING_STATUS_DISPLAY_NAMES
] ?? formatLabel(value)
);
}
if (filterKey === "filter[category__in]") {
return getCategoryLabel(value);
}
if (filterKey === "filter[resource_groups__in]") {
return getGroupLabel(value);
}
// Date filter: preserve ISO date string (YYYY-MM-DD) — do not run through formatLabel
if (filterKey === "filter[inserted_at]") {
return value;
}
// Generic fallback: handles hyphen/underscore-delimited IDs with smart capitalisation
return formatLabel(value);
};
export const FindingsFilters = ({
providers,
completedScanIds,
@@ -185,7 +143,10 @@ export const FindingsFilters = ({
key,
label,
value,
displayValue: formatFilterValue(key, value),
displayValue: getFindingsFilterDisplayValue(key, value, {
providers,
scans: scanDetails,
}),
});
});
});
@@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
import { ProviderProps } from "@/types/providers";
import { ScanEntity } from "@/types/scans";
import { getFindingsFilterDisplayValue } from "./findings-filters.utils";
function makeProvider(
overrides: Partial<ProviderProps> & { id: string },
): ProviderProps {
return {
type: "providers",
attributes: {
provider: "aws",
uid: "123456789012",
alias: "Production Account",
status: "completed",
resources: 10,
connection: { connected: true, last_checked_at: "2026-04-07T10:00:00Z" },
scanner_args: {
only_logs: false,
excluded_checks: [],
aws_retries_max_attempts: 3,
},
inserted_at: "2026-04-07T10:00:00Z",
updated_at: "2026-04-07T10:00:00Z",
created_by: { object: "user", id: "user-1" },
},
relationships: {
secret: { data: null },
provider_groups: { meta: { count: 0 }, data: [] },
},
...overrides,
} as ProviderProps;
}
function makeScanMap(
scanId: string,
overrides?: Partial<ScanEntity>,
): { [scanId: string]: ScanEntity } {
return {
[scanId]: {
id: scanId,
providerInfo: {
provider: "aws",
alias: "Scan Account",
uid: "123456789012",
},
attributes: {
name: "Nightly scan",
completed_at: "2026-04-07T10:00:00Z",
},
...overrides,
},
};
}
const providers = [makeProvider({ id: "provider-1" })];
const scans = [makeScanMap("scan-1")];
describe("getFindingsFilterDisplayValue", () => {
it("shows the account alias for provider_id filters instead of the raw provider id", () => {
expect(
getFindingsFilterDisplayValue("filter[provider_id__in]", "provider-1", {
providers,
}),
).toBe("Production Account");
});
it("falls back to the provider uid when the alias is empty", () => {
expect(
getFindingsFilterDisplayValue("filter[provider_id__in]", "provider-2", {
providers: [
...providers,
makeProvider({
id: "provider-2",
attributes: {
...providers[0].attributes,
alias: "",
uid: "210987654321",
},
}),
],
}),
).toBe("210987654321");
});
it("keeps the raw value when the provider cannot be resolved", () => {
expect(
getFindingsFilterDisplayValue(
"filter[provider_id__in]",
"missing-provider",
{ providers },
),
).toBe("missing-provider");
});
it("shows the resolved scan badge label for scan filters instead of formatting the raw scan id", () => {
expect(
getFindingsFilterDisplayValue("filter[scan__in]", "scan-1", { scans }),
).toBe("Scan Account");
});
it("falls back to the scan provider uid when the alias is missing", () => {
expect(
getFindingsFilterDisplayValue("filter[scan__in]", "scan-2", {
scans: [
...scans,
makeScanMap("scan-2", {
providerInfo: { provider: "aws", uid: "210987654321" },
attributes: {
name: "Weekly scan",
completed_at: "2026-04-08T10:00:00Z",
},
}),
],
}),
).toBe("210987654321");
});
it("keeps the raw scan value when the scan cannot be resolved", () => {
expect(
getFindingsFilterDisplayValue("filter[scan__in]", "missing-scan", {
scans,
}),
).toBe("missing-scan");
});
it("passes through date values for inserted_at__gte filters", () => {
expect(
getFindingsFilterDisplayValue(
"filter[inserted_at__gte]",
"2026-04-03",
{},
),
).toBe("2026-04-03");
});
it("passes through date values for inserted_at__lte filters", () => {
expect(
getFindingsFilterDisplayValue(
"filter[inserted_at__lte]",
"2026-04-07",
{},
),
).toBe("2026-04-07");
});
});
@@ -0,0 +1,80 @@
import { formatLabel, getCategoryLabel, getGroupLabel } from "@/lib/categories";
import { FINDING_STATUS_DISPLAY_NAMES } from "@/types";
import { getProviderDisplayName, ProviderProps } from "@/types/providers";
import { ScanEntity } from "@/types/scans";
import { SEVERITY_DISPLAY_NAMES } from "@/types/severities";
interface GetFindingsFilterDisplayValueOptions {
providers?: ProviderProps[];
scans?: Array<{ [scanId: string]: ScanEntity }>;
}
function getProviderAccountDisplayValue(
providerId: string,
providers: ProviderProps[],
): string {
const provider = providers.find((item) => item.id === providerId);
if (!provider) {
return providerId;
}
return provider.attributes.alias || provider.attributes.uid || providerId;
}
function getScanDisplayValue(
scanId: string,
scans: Array<{ [scanId: string]: ScanEntity }>,
): string {
const scan = scans.find((item) => item[scanId])?.[scanId];
if (!scan) {
return scanId;
}
return scan.providerInfo.alias || scan.providerInfo.uid || scanId;
}
export function getFindingsFilterDisplayValue(
filterKey: string,
value: string,
options: GetFindingsFilterDisplayValueOptions = {},
): string {
if (!value) return value;
if (filterKey === "filter[provider_type__in]") {
return getProviderDisplayName(value);
}
if (filterKey === "filter[provider_id__in]") {
return getProviderAccountDisplayValue(value, options.providers || []);
}
if (filterKey === "filter[scan__in]") {
return getScanDisplayValue(value, options.scans || []);
}
if (filterKey === "filter[severity__in]") {
return (
SEVERITY_DISPLAY_NAMES[
value.toLowerCase() as keyof typeof SEVERITY_DISPLAY_NAMES
] ?? formatLabel(value)
);
}
if (filterKey === "filter[status__in]") {
return (
FINDING_STATUS_DISPLAY_NAMES[
value as keyof typeof FINDING_STATUS_DISPLAY_NAMES
] ?? formatLabel(value)
);
}
if (filterKey === "filter[category__in]") {
return getCategoryLabel(value);
}
if (filterKey === "filter[resource_groups__in]") {
return getGroupLabel(value);
}
if (
filterKey === "filter[inserted_at]" ||
filterKey === "filter[inserted_at__gte]" ||
filterKey === "filter[inserted_at__lte]"
) {
return value;
}
return formatLabel(value);
}
@@ -17,11 +17,20 @@ vi.mock("next/navigation", () => ({
vi.mock("@/components/shadcn", () => ({
Checkbox: ({
"aria-label": ariaLabel,
onCheckedChange,
...props
}: InputHTMLAttributes<HTMLInputElement> & {
"aria-label"?: string;
size?: string;
}) => <input type="checkbox" aria-label={ariaLabel} {...props} />,
onCheckedChange?: (checked: boolean) => void;
}) => (
<input
type="checkbox"
aria-label={ariaLabel}
onChange={(event) => onCheckedChange?.(event.target.checked)}
{...props}
/>
),
}));
vi.mock("@/components/ui/table", () => ({
@@ -52,7 +61,13 @@ vi.mock("./impacted-providers-cell", () => ({
}));
vi.mock("./impacted-resources-cell", () => ({
ImpactedResourcesCell: () => null,
ImpactedResourcesCell: ({
impacted,
total,
}: {
impacted: number;
total: number;
}) => <span>{`${impacted}/${total}`}</span>,
}));
vi.mock("./notification-indicator", () => ({
@@ -94,6 +109,7 @@ function makeGroup(overrides?: Partial<FindingGroupRow>): FindingGroupRow {
function renderFindingCell(
checkTitle: string,
onDrillDown: (checkId: string, group: FindingGroupRow) => void,
overrides?: Partial<FindingGroupRow>,
) {
const columns = getColumnFindingGroups({
rowSelection: {},
@@ -107,7 +123,7 @@ function renderFindingCell(
);
if (!findingColumn?.cell) throw new Error("finding column not found");
const group = makeGroup({ checkTitle });
const group = makeGroup({ checkTitle, ...overrides });
// Render the cell directly with a minimal row mock
const CellComponent = findingColumn.cell as (props: {
row: { original: FindingGroupRow };
@@ -116,6 +132,67 @@ function renderFindingCell(
render(<div>{CellComponent({ row: { original: group } })}</div>);
}
function renderImpactedResourcesCell(overrides?: Partial<FindingGroupRow>) {
const columns = getColumnFindingGroups({
rowSelection: {},
selectableRowCount: 1,
onDrillDown: vi.fn(),
});
const impactedResourcesColumn = columns.find(
(col) => (col as { id?: string }).id === "impactedResources",
);
if (!impactedResourcesColumn?.cell) {
throw new Error("impactedResources column not found");
}
const group = makeGroup(overrides);
const CellComponent = impactedResourcesColumn.cell as (props: {
row: { original: FindingGroupRow };
}) => ReactNode;
render(<div>{CellComponent({ row: { original: group } })}</div>);
}
function renderSelectCell(overrides?: Partial<FindingGroupRow>) {
const toggleSelected = vi.fn();
const columns = getColumnFindingGroups({
rowSelection: {},
selectableRowCount: 1,
onDrillDown: vi.fn(),
});
const selectColumn = columns.find(
(col) => (col as { id?: string }).id === "select",
);
if (!selectColumn?.cell) {
throw new Error("select column not found");
}
const group = makeGroup(overrides);
const CellComponent = selectColumn.cell as (props: {
row: {
id: string;
original: FindingGroupRow;
toggleSelected: (selected: boolean) => void;
};
}) => ReactNode;
render(
<div>
{CellComponent({
row: {
id: "0",
original: group,
toggleSelected,
},
})}
</div>,
);
return { toggleSelected };
}
// ---------------------------------------------------------------------------
// Fix 5: Accessibility — <p onClick> → <button>
// ---------------------------------------------------------------------------
@@ -191,4 +268,60 @@ describe("column-finding-groups — accessibility of check title cell", () => {
// Then — native button handles Enter natively
expect(onDrillDown).toHaveBeenCalledTimes(1);
});
it("should allow expanding a group that only has PASS resources", async () => {
// Given
const user = userEvent.setup();
const onDrillDown =
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
renderFindingCell("My Passing Check", onDrillDown, {
resourcesTotal: 2,
resourcesFail: 0,
status: "PASS",
});
// When
await user.click(
screen.getByRole("button", {
name: "My Passing Check",
}),
);
// Then
expect(onDrillDown).toHaveBeenCalledTimes(1);
expect(onDrillDown).toHaveBeenCalledWith(
"s3_check",
expect.objectContaining({
resourcesTotal: 2,
resourcesFail: 0,
status: "PASS",
}),
);
});
});
describe("column-finding-groups — impacted resources count", () => {
it("should keep impacted resources based on failing resources only", () => {
// Given/When
renderImpactedResourcesCell({
resourcesTotal: 5,
resourcesFail: 3,
});
// Then
expect(screen.getByText("3/5")).toBeInTheDocument();
});
});
describe("column-finding-groups — group selection", () => {
it("should disable the row checkbox when the group has zero impacted resources", () => {
renderSelectCell({
resourcesTotal: 2,
resourcesFail: 0,
status: "PASS",
});
expect(screen.getByRole("checkbox", { name: "Select row" })).toBeDisabled();
});
});
@@ -13,6 +13,7 @@ import { cn } from "@/lib";
import { FindingGroupRow, ProviderType } from "@/types";
import { DataTableRowActions } from "./data-table-row-actions";
import { canMuteFindingGroup } from "./finding-group-selection";
import { ImpactedProvidersCell } from "./impacted-providers-cell";
import { ImpactedResourcesCell } from "./impacted-resources-cell";
import { DeltaValues, NotificationIndicator } from "./notification-indicator";
@@ -26,6 +27,9 @@ interface GetColumnFindingGroupsOptions {
hasResourceSelection?: boolean;
}
const VISIBLE_DISABLED_CHECKBOX_CLASS =
"disabled:opacity-100 disabled:bg-bg-input-primary/60 disabled:border-border-input-primary/70";
export function getColumnFindingGroups({
rowSelection,
selectableRowCount,
@@ -56,6 +60,7 @@ export function getColumnFindingGroups({
<div className="w-4" />
<Checkbox
size="sm"
className={VISIBLE_DISABLED_CHECKBOX_CLASS}
checked={headerChecked}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(checked === true)
@@ -80,7 +85,12 @@ export function getColumnFindingGroups({
? DeltaValues.CHANGED
: DeltaValues.NONE;
const canExpand = group.resourcesFail > 0;
const canExpand = group.resourcesTotal > 0;
const canSelect = canMuteFindingGroup({
resourcesFail: group.resourcesFail,
resourcesTotal: group.resourcesTotal,
mutedCount: group.mutedCount,
});
return (
<div className="flex items-center gap-2">
@@ -104,11 +114,13 @@ export function getColumnFindingGroups({
)}
<Checkbox
size="sm"
className={VISIBLE_DISABLED_CHECKBOX_CLASS}
checked={
rowSelection[row.id] && isExpanded && hasResourceSelection
? "indeterminate"
: !!rowSelection[row.id]
}
disabled={!canSelect}
onCheckedChange={(checked) => {
// When indeterminate (resources selected), clicking deselects the group
if (
@@ -155,7 +167,7 @@ export function getColumnFindingGroups({
),
cell: ({ row }) => {
const group = row.original;
const canExpand = group.resourcesFail > 0;
const canExpand = group.resourcesTotal > 0;
return (
<div>
@@ -0,0 +1,203 @@
import { render, screen } from "@testing-library/react";
import type { InputHTMLAttributes, ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/components/shadcn", () => ({
Checkbox: ({
"aria-label": ariaLabel,
onCheckedChange,
...props
}: InputHTMLAttributes<HTMLInputElement> & {
"aria-label"?: string;
size?: string;
onCheckedChange?: (checked: boolean) => void;
}) => (
<input
type="checkbox"
aria-label={ariaLabel}
onChange={(event) => onCheckedChange?.(event.target.checked)}
{...props}
/>
),
}));
vi.mock("@/components/findings/mute-findings-modal", () => ({
MuteFindingsModal: () => null,
}));
vi.mock("@/components/findings/send-to-jira-modal", () => ({
SendToJiraModal: () => null,
}));
vi.mock("@/components/icons/services/IconServices", () => ({
JiraIcon: () => null,
}));
vi.mock("@/components/shadcn/dropdown", () => ({
ActionDropdown: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
ActionDropdownItem: ({ label }: { label: string }) => (
<button>{label}</button>
),
}));
vi.mock("@/components/shadcn/info-field/info-field", () => ({
InfoField: () => null,
}));
vi.mock("@/components/shadcn/spinner/spinner", () => ({
Spinner: () => null,
}));
vi.mock("@/components/ui/entities", () => ({
DateWithTime: () => null,
}));
vi.mock("@/components/ui/entities/entity-info", () => ({
EntityInfo: ({
entityAlias,
entityId,
}: {
entityAlias?: string;
entityId?: string;
}) => (
<div>
<span>{entityAlias}</span>
<span>{entityId}</span>
</div>
),
}));
vi.mock("@/components/ui/table", () => ({
SeverityBadge: ({ severity }: { severity: string }) => (
<span>{severity}</span>
),
}));
vi.mock("@/components/ui/table/data-table-column-header", () => ({
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
}));
vi.mock("@/components/ui/table/status-finding-badge", () => ({
StatusFindingBadge: ({ status }: { status: string }) => <span>{status}</span>,
}));
vi.mock("@/lib/date-utils", () => ({
getFailingForLabel: () => "2d",
}));
const notificationIndicatorMock = vi.fn((_props: unknown) => null);
vi.mock("./notification-indicator", () => ({
NotificationIndicator: (props: unknown) => {
notificationIndicatorMock(props);
return null;
},
}));
import type { FindingResourceRow } from "@/types";
import { getColumnFindingResources } from "./column-finding-resources";
function makeResource(
overrides?: Partial<FindingResourceRow>,
): FindingResourceRow {
return {
id: "resource-row-1",
rowType: "resource",
findingId: "finding-1",
checkId: "s3_check",
providerType: "aws",
providerAlias: "production",
providerUid: "123456789",
resourceName: "my-bucket",
resourceType: "bucket",
resourceGroup: "default",
resourceUid: "arn:aws:s3:::my-bucket",
service: "s3",
region: "us-east-1",
severity: "critical",
status: "FAIL",
delta: "new",
isMuted: false,
firstSeenAt: null,
lastSeenAt: "2024-01-01T00:00:00Z",
...overrides,
};
}
describe("column-finding-resources", () => {
it("should pass delta to NotificationIndicator for resource rows", () => {
const columns = getColumnFindingResources({
rowSelection: {},
selectableRowCount: 1,
});
const selectColumn = columns.find(
(col) => (col as { id?: string }).id === "select",
);
if (!selectColumn?.cell) {
throw new Error("select column not found");
}
const CellComponent = selectColumn.cell as (props: {
row: {
id: string;
original: FindingResourceRow;
toggleSelected: (selected: boolean) => void;
};
}) => ReactNode;
render(
<div>
{CellComponent({
row: {
id: "0",
original: makeResource(),
toggleSelected: vi.fn(),
},
})}
</div>,
);
expect(screen.getByLabelText("Select resource")).toBeInTheDocument();
expect(notificationIndicatorMock).toHaveBeenCalledWith(
expect.objectContaining({
delta: "new",
isMuted: false,
}),
);
});
it("should render the resource EntityInfo with resourceName as alias", () => {
const columns = getColumnFindingResources({
rowSelection: {},
selectableRowCount: 1,
});
const resourceColumn = columns.find(
(col) => (col as { id?: string }).id === "resource",
);
if (!resourceColumn?.cell) {
throw new Error("resource column not found");
}
const CellComponent = resourceColumn.cell as (props: {
row: { original: FindingResourceRow };
}) => ReactNode;
render(
<div>
{CellComponent({
row: {
original: makeResource(),
},
})}
</div>,
);
expect(screen.getByText("my-bucket")).toBeInTheDocument();
expect(screen.getByText("arn:aws:s3:::my-bucket")).toBeInTheDocument();
});
});
@@ -25,11 +25,16 @@ import {
import { getFailingForLabel } from "@/lib/date-utils";
import { FindingResourceRow } from "@/types";
import { canMuteFindingResource } from "./finding-resource-selection";
import { FindingsSelectionContext } from "./findings-selection-context";
import { NotificationIndicator } from "./notification-indicator";
import {
type DeltaType,
NotificationIndicator,
} from "./notification-indicator";
const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
const resource = row.original;
const canMute = canMuteFindingResource(resource);
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
@@ -81,7 +86,7 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
return (
<>
{!resource.isMuted && (
{canMute && (
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={setIsMuteModalOpen}
@@ -111,7 +116,7 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
)
}
label={isResolving ? "Resolving..." : getMuteLabel()}
disabled={resource.isMuted || isResolving}
disabled={!canMute || isResolving}
onSelect={handleMuteClick}
/>
<ActionDropdownItem
@@ -171,6 +176,7 @@ export function getColumnFindingResources({
cell: ({ row }) => (
<div className="flex items-center gap-2">
<NotificationIndicator
delta={row.original.delta as DeltaType | undefined}
isMuted={row.original.isMuted}
mutedReason={row.original.mutedReason}
/>
@@ -178,7 +184,7 @@ export function getColumnFindingResources({
<Checkbox
size="sm"
checked={!!rowSelection[row.id]}
disabled={row.original.isMuted}
disabled={!canMuteFindingResource(row.original)}
onCheckedChange={(checked) => row.toggleSelected(checked === true)}
onClick={(e) => e.stopPropagation()}
aria-label="Select resource"
@@ -198,7 +204,7 @@ export function getColumnFindingResources({
<div className="max-w-[240px]">
<EntityInfo
nameIcon={<Container className="size-4" />}
entityAlias={row.original.resourceGroup}
entityAlias={row.original.resourceName}
entityId={row.original.resourceUid}
/>
</div>
@@ -213,8 +219,12 @@ export function getColumnFindingResources({
),
cell: ({ row }) => {
const rawStatus = row.original.status;
const status =
rawStatus === "MUTED" ? "FAIL" : (rawStatus as FindingStatus);
const status: FindingStatus =
rawStatus === "MUTED" || rawStatus === "FAIL"
? "FAIL"
: rawStatus === "PASS"
? "PASS"
: "FAIL";
return <StatusFindingBadge status={status} />;
},
enableSorting: false,
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { canMuteFindingGroup } from "./finding-group-selection";
describe("canMuteFindingGroup", () => {
it("returns false when impacted resources is zero", () => {
expect(
canMuteFindingGroup({
resourcesFail: 0,
resourcesTotal: 2,
mutedCount: 0,
}),
).toBe(false);
});
it("returns false when all resources are already muted", () => {
expect(
canMuteFindingGroup({
resourcesFail: 3,
resourcesTotal: 3,
mutedCount: 3,
}),
).toBe(false);
});
it("returns false when all failing resources are muted even if PASS resources exist", () => {
expect(
canMuteFindingGroup({
resourcesFail: 2,
resourcesTotal: 5,
mutedCount: 2,
}),
).toBe(false);
});
it("returns true when the group still has failing resources to mute", () => {
expect(
canMuteFindingGroup({
resourcesFail: 2,
resourcesTotal: 5,
mutedCount: 1,
}),
).toBe(true);
});
});
@@ -0,0 +1,13 @@
interface FindingGroupSelectionState {
resourcesFail: number;
resourcesTotal: number;
mutedCount: number;
}
export function canMuteFindingGroup({
resourcesFail,
mutedCount,
}: FindingGroupSelectionState): boolean {
const allMuted = mutedCount > 0 && mutedCount === resourcesFail;
return resourcesFail > 0 && !allMuted;
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import type { FindingResourceRow } from "@/types";
import { canMuteFindingResource } from "./finding-resource-selection";
function makeResource(
overrides?: Partial<FindingResourceRow>,
): FindingResourceRow {
return {
id: "finding-1",
rowType: "resource",
findingId: "finding-1",
checkId: "check-1",
providerType: "aws",
providerAlias: "prod",
providerUid: "123456789012",
resourceName: "bucket-a",
resourceType: "Bucket",
resourceGroup: "bucket-a",
resourceUid: "arn:aws:s3:::bucket-a",
service: "s3",
region: "us-east-1",
severity: "high",
status: "FAIL",
isMuted: false,
firstSeenAt: null,
lastSeenAt: null,
...overrides,
};
}
describe("canMuteFindingResource", () => {
it("should allow muting FAIL resources that are not muted", () => {
expect(canMuteFindingResource(makeResource())).toBe(true);
});
it("should disable muting for PASS resources", () => {
expect(canMuteFindingResource(makeResource({ status: "PASS" }))).toBe(
false,
);
});
it("should disable muting for already muted resources", () => {
expect(canMuteFindingResource(makeResource({ isMuted: true }))).toBe(false);
});
});
@@ -0,0 +1,5 @@
import { FindingResourceRow } from "@/types";
export function canMuteFindingResource(resource: FindingResourceRow): boolean {
return resource.status === "FAIL" && !resource.isMuted;
}
@@ -28,6 +28,7 @@ import { FindingGroupRow, FindingResourceRow } from "@/types";
import { FloatingMuteButton } from "../floating-mute-button";
import { getColumnFindingResources } from "./column-finding-resources";
import { canMuteFindingResource } from "./finding-resource-selection";
import { FindingsSelectionContext } from "./findings-selection-context";
import { ImpactedResourcesCell } from "./impacted-resources-cell";
import { DeltaValues, NotificationIndicator } from "./notification-indicator";
@@ -82,7 +83,7 @@ export function FindingsGroupDrillDown({
setIsLoading(loading);
};
const { sentinelRef, refresh, loadMore } = useInfiniteResources({
const { sentinelRef, refresh, loadMore, totalCount } = useInfiniteResources({
checkId: group.checkId,
hasDateOrScanFilter: hasDateOrScan,
filters,
@@ -95,7 +96,7 @@ export function FindingsGroupDrillDown({
const drawer = useResourceDetailDrawer({
resources,
checkId: group.checkId,
totalResourceCount: group.resourcesTotal,
totalResourceCount: totalCount ?? group.resourcesTotal,
onRequestMoreResources: loadMore,
});
@@ -108,7 +109,7 @@ export function FindingsGroupDrillDown({
const selectedFindingIds = Object.keys(rowSelection)
.filter((key) => rowSelection[key])
.map((idx) => resources[parseInt(idx)]?.findingId)
.filter(Boolean);
.filter((id): id is string => id !== null && id !== undefined && id !== "");
/** Converts resource_ids (display) → resourceUids → finding UUIDs via API. */
const resolveResourceIds = async (ids: string[]) => {
@@ -124,10 +125,10 @@ export function FindingsGroupDrillDown({
});
};
const selectableRowCount = resources.filter((r) => !r.isMuted).length;
const selectableRowCount = resources.filter(canMuteFindingResource).length;
const getRowCanSelect = (row: Row<FindingResourceRow>): boolean => {
return !row.original.isMuted;
return canMuteFindingResource(row.original);
};
const clearSelection = () => {
@@ -14,6 +14,7 @@ import { FindingGroupRow, MetaDataProps } from "@/types";
import { FloatingMuteButton } from "../floating-mute-button";
import { getColumnFindingGroups } from "./column-finding-groups";
import { canMuteFindingGroup } from "./finding-group-selection";
import { FindingsSelectionContext } from "./findings-selection-context";
import {
InlineResourceContainer,
@@ -88,13 +89,21 @@ export function FindingsGroupTable({
.filter(Boolean);
// Count of selectable rows (groups where not ALL findings are muted)
const selectableRowCount = safeData.filter(
(g) => !(g.mutedCount > 0 && g.mutedCount === g.resourcesTotal),
const selectableRowCount = safeData.filter((g) =>
canMuteFindingGroup({
resourcesFail: g.resourcesFail,
resourcesTotal: g.resourcesTotal,
mutedCount: g.mutedCount,
}),
).length;
const getRowCanSelect = (row: Row<FindingGroupRow>): boolean => {
const group = row.original;
return !(group.mutedCount > 0 && group.mutedCount === group.resourcesTotal);
return canMuteFindingGroup({
resourcesFail: group.resourcesFail,
resourcesTotal: group.resourcesTotal,
mutedCount: group.mutedCount,
});
};
const clearSelection = () => {
@@ -136,8 +145,8 @@ export function FindingsGroupTable({
};
const handleDrillDown = (checkId: string, group: FindingGroupRow) => {
// No impacted resources → nothing to show, skip drill-down
if (group.resourcesFail === 0) return;
// No resources in the group → nothing to show, skip drill-down
if (group.resourcesTotal === 0) return;
// Toggle: same group = collapse, different = switch
if (expandedCheckId === checkId) {
@@ -22,6 +22,7 @@ import { hasDateOrScanFilter } from "@/lib";
import { FindingGroupRow, FindingResourceRow } from "@/types";
import { getColumnFindingResources } from "./column-finding-resources";
import { canMuteFindingResource } from "./finding-resource-selection";
import { FindingsSelectionContext } from "./findings-selection-context";
import {
ResourceDetailDrawer,
@@ -180,7 +181,7 @@ export function InlineResourceContainer({
setIsLoading(loading);
};
const { sentinelRef, refresh, loadMore } = useInfiniteResources({
const { sentinelRef, refresh, loadMore, totalCount } = useInfiniteResources({
checkId: group.checkId,
hasDateOrScanFilter: hasDateOrScan,
filters,
@@ -194,7 +195,7 @@ export function InlineResourceContainer({
const drawer = useResourceDetailDrawer({
resources,
checkId: group.checkId,
totalResourceCount: group.resourcesTotal,
totalResourceCount: totalCount ?? group.resourcesTotal,
onRequestMoreResources: loadMore,
});
@@ -222,10 +223,10 @@ export function InlineResourceContainer({
});
};
const selectableRowCount = resources.filter((r) => !r.isMuted).length;
const selectableRowCount = resources.filter(canMuteFindingResource).length;
const getRowCanSelect = (row: Row<FindingResourceRow>): boolean => {
return !row.original.isMuted;
return canMuteFindingResource(row.original);
};
const clearSelection = () => {
@@ -1,6 +1,7 @@
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { createPortal } from "react-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
// ---------------------------------------------------------------------------
@@ -10,17 +11,17 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const {
mockGetComplianceIcon,
mockGetCompliancesOverview,
mockRouterPush,
mockWindowOpen,
mockSearchParamsState,
} = vi.hoisted(() => ({
mockGetComplianceIcon: vi.fn((_: string) => null as string | null),
mockGetCompliancesOverview: vi.fn(),
mockRouterPush: vi.fn(),
mockWindowOpen: vi.fn(),
mockSearchParamsState: { value: "" },
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockRouterPush, refresh: vi.fn() }),
useRouter: () => ({ refresh: vi.fn() }),
usePathname: () => "/findings",
useSearchParams: () => new URLSearchParams(mockSearchParamsState.value),
redirect: vi.fn(),
@@ -104,10 +105,30 @@ vi.mock("@/components/shadcn/card/card", () => ({
}));
vi.mock("@/components/shadcn/dropdown", () => ({
ActionDropdown: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
ActionDropdown: ({
children,
ariaLabel,
}: {
children: ReactNode;
ariaLabel?: string;
}) => (
<div role="menu" aria-label={ariaLabel}>
{children}
</div>
),
ActionDropdownItem: ({
label,
disabled,
onSelect,
}: {
label: string;
disabled?: boolean;
onSelect?: () => void;
}) => (
<button type="button" disabled={disabled} onClick={onSelect}>
{label}
</button>
),
ActionDropdownItem: () => null,
}));
vi.mock("@/components/shadcn/skeleton/skeleton", () => ({
@@ -125,7 +146,25 @@ vi.mock("@/components/shadcn/tooltip", () => ({
}));
vi.mock("@/components/findings/mute-findings-modal", () => ({
MuteFindingsModal: () => null,
MuteFindingsModal: ({
isOpen,
findingIds,
onComplete,
}: {
isOpen: boolean;
findingIds: string[];
onComplete?: () => void;
}) =>
isOpen
? globalThis.document?.body &&
// Render into body to mirror the real modal portal behavior.
createPortal(
<button type="button" onClick={onComplete}>
{`Confirm mute ${findingIds.join(",")}`}
</button>,
globalThis.document.body,
)
: null,
}));
vi.mock("@/components/findings/send-to-jira-modal", () => ({
@@ -547,9 +586,14 @@ describe("ResourceDetailDrawerContent — compliance icon styling", () => {
});
describe("ResourceDetailDrawerContent — compliance navigation", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("should resolve the clicked framework against the selected scan and navigate to compliance detail", async () => {
// Given
const user = userEvent.setup();
vi.stubGlobal("open", mockWindowOpen);
mockSearchParamsState.value =
"filter[scan__in]=scan-selected&filter[region__in]=eu-west-1";
mockGetCompliancesOverview.mockResolvedValue({
@@ -595,14 +639,17 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => {
expect(mockGetCompliancesOverview).toHaveBeenCalledWith({
scanId: "scan-selected",
});
expect(mockRouterPush).toHaveBeenCalledWith(
expect(mockWindowOpen).toHaveBeenCalledWith(
"/compliance/PCI-DSS?complianceId=compliance-1&version=4.0&scanId=scan-selected&filter%5Bregion__in%5D=eu-west-1",
"_blank",
"noopener,noreferrer",
);
});
it("should use the current finding scan when no scan filter is active", async () => {
// Given
const user = userEvent.setup();
vi.stubGlobal("open", mockWindowOpen);
mockGetCompliancesOverview.mockResolvedValue({
data: [
{
@@ -662,8 +709,134 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => {
expect(mockGetCompliancesOverview).toHaveBeenCalledWith({
scanId: "scan-from-finding",
});
expect(mockRouterPush).toHaveBeenCalledWith(
expect(mockWindowOpen).toHaveBeenCalledWith(
"/compliance/PCI-DSS?complianceId=compliance-2&version=4.0&scanId=scan-from-finding&scanData=%7B%22id%22%3A%22scan-from-finding%22%2C%22providerInfo%22%3A%7B%22provider%22%3A%22aws%22%2C%22alias%22%3A%22prod%22%2C%22uid%22%3A%22123456789%22%7D%2C%22attributes%22%3A%7B%22name%22%3A%22Nightly+scan%22%2C%22completed_at%22%3A%222026-03-30T10%3A05%3A00Z%22%7D%7D",
"_blank",
"noopener,noreferrer",
);
});
it("should navigate when the finding framework is a short alias of the compliance overview framework", async () => {
// Given
const user = userEvent.setup();
vi.stubGlobal("open", mockWindowOpen);
mockGetComplianceIcon.mockImplementation((framework: string) =>
framework.toLowerCase().includes("kisa") ? "/kisa.svg" : null,
);
mockGetCompliancesOverview.mockResolvedValue({
data: [
{
id: "compliance-kisa",
type: "compliance-overviews",
attributes: {
framework: "KISA-ISMS-P",
version: "1.0",
requirements_passed: 5,
requirements_failed: 1,
requirements_manual: 0,
total_requirements: 6,
},
},
],
});
const findingWithScan = {
...mockFinding,
scan: {
id: "scan-from-finding",
name: "Nightly scan",
trigger: "manual",
state: "completed",
uniqueResourceCount: 25,
progress: 100,
duration: 300,
startedAt: "2026-03-30T10:00:00Z",
completedAt: "2026-03-30T10:05:00Z",
insertedAt: "2026-03-30T09:59:00Z",
scheduledAt: null,
},
};
render(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={{
...mockCheckMeta,
complianceFrameworks: ["KISA"],
}}
currentIndex={0}
totalResources={1}
currentFinding={findingWithScan}
otherFindings={[]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>,
);
// When
await user.click(
screen.getByRole("button", {
name: "Open KISA compliance details",
}),
);
// Then
expect(mockGetCompliancesOverview).toHaveBeenCalledWith({
scanId: "scan-from-finding",
});
expect(mockWindowOpen).toHaveBeenCalledWith(
"/compliance/KISA-ISMS-P?complianceId=compliance-kisa&version=1.0&scanId=scan-from-finding&scanData=%7B%22id%22%3A%22scan-from-finding%22%2C%22providerInfo%22%3A%7B%22provider%22%3A%22aws%22%2C%22alias%22%3A%22prod%22%2C%22uid%22%3A%22123456789%22%7D%2C%22attributes%22%3A%7B%22name%22%3A%22Nightly+scan%22%2C%22completed_at%22%3A%222026-03-30T10%3A05%3A00Z%22%7D%7D",
"_blank",
"noopener,noreferrer",
);
});
});
describe("ResourceDetailDrawerContent — other findings mute refresh", () => {
it("should update only the muted other-finding row without refreshing the current finding group", async () => {
// Given
const user = userEvent.setup();
const onMuteComplete = vi.fn();
const otherFinding: ResourceDrawerFinding = {
...mockFinding,
id: "finding-2",
uid: "uid-2",
checkId: "ec2_check",
checkTitle: "EC2 Check",
updatedAt: "2026-03-30T10:05:00Z",
};
render(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={mockCheckMeta}
currentIndex={0}
totalResources={1}
currentFinding={mockFinding}
otherFindings={[otherFinding]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={onMuteComplete}
/>,
);
// When
const row = screen.getByText("EC2 Check").closest("tr");
expect(row).not.toBeNull();
await user.click(
within(row as HTMLElement).getByRole("button", { name: "Mute" }),
);
await user.click(
screen.getByRole("button", { name: "Confirm mute finding-2" }),
);
// Then
expect(
within(row as HTMLElement).getByRole("button", { name: "Muted" }),
).toBeDisabled();
expect(onMuteComplete).not.toHaveBeenCalled();
});
});
@@ -12,7 +12,7 @@ import {
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { getCompliancesOverview } from "@/actions/compliances";
@@ -84,7 +84,90 @@ function normalizeComplianceFrameworkName(framework: string): string {
return framework
.trim()
.toLowerCase()
.replace(/[\s_]+/g, "-");
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-");
}
function stripComplianceVersionSuffix(framework: string): string {
return framework.replace(/-\d+(?:\.\d+)*$/g, "");
}
function canonicalComplianceKey(framework: string): string {
return stripComplianceVersionSuffix(
normalizeComplianceFrameworkName(framework),
)
.replace(/[^a-z0-9]+/g, "")
.trim();
}
function complianceTokens(framework: string): string[] {
return stripComplianceVersionSuffix(
normalizeComplianceFrameworkName(framework),
)
.split("-")
.map((token) => token.trim())
.filter(Boolean)
.filter((token) => !/^\d+(?:\.\d+)*$/.test(token));
}
function complianceMatchScore(
sourceFramework: string,
targetFramework: string,
): number {
const normalizedSource = normalizeComplianceFrameworkName(sourceFramework);
const normalizedTarget = normalizeComplianceFrameworkName(targetFramework);
if (normalizedSource === normalizedTarget) {
return 5;
}
const canonicalSource = canonicalComplianceKey(sourceFramework);
const canonicalTarget = canonicalComplianceKey(targetFramework);
if (canonicalSource === canonicalTarget) {
return 4;
}
if (canonicalSource && canonicalTarget) {
const sourceTokens = canonicalSource.split("-");
const targetTokens = canonicalTarget.split("-");
if (
sourceTokens.length !== targetTokens.length &&
(sourceTokens.every((t) => targetTokens.includes(t)) ||
targetTokens.every((t) => sourceTokens.includes(t)))
) {
return 3;
}
}
const sourceTokens = complianceTokens(sourceFramework);
const targetTokens = complianceTokens(targetFramework);
if (!sourceTokens.length || !targetTokens.length) {
return 0;
}
const sourceMatchesTarget = sourceTokens.every((token) =>
targetTokens.includes(token),
);
const targetMatchesSource = targetTokens.every((token) =>
sourceTokens.includes(token),
);
if (sourceMatchesTarget || targetMatchesSource) {
return 2;
}
if (
sourceTokens.some((token) => targetTokens.includes(token)) &&
canonicalSource &&
canonicalTarget &&
(canonicalTarget.includes(canonicalSource) ||
canonicalSource.includes(canonicalTarget))
) {
return 1;
}
return 0;
}
function parseSelectedScanIds(scanFilterValue: string | null): string[] {
@@ -110,12 +193,13 @@ function resolveComplianceMatch(
return null;
}
const normalizedFramework = normalizeComplianceFrameworkName(framework);
const match = compliances.find(
(compliance) =>
normalizeComplianceFrameworkName(compliance.attributes.framework) ===
normalizedFramework,
);
const match = compliances
.map((compliance) => ({
compliance,
score: complianceMatchScore(framework, compliance.attributes.framework),
}))
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)[0]?.compliance;
if (!match) {
return null;
@@ -202,13 +286,15 @@ export function ResourceDetailDrawerContent({
onNavigateNext,
onMuteComplete,
}: ResourceDetailDrawerContentProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
const [resolvingFramework, setResolvingFramework] = useState<string | null>(
null,
);
const [optimisticallyMutedIds, setOptimisticallyMutedIds] = useState<
Set<string>
>(new Set());
// Initial load — no check metadata yet
if (!checkMeta && isLoading) {
@@ -284,7 +370,7 @@ export function ResourceDetailDrawerContent({
return;
}
router.push(
window.open(
buildComplianceDetailHref({
complianceId: complianceMatch.complianceId,
framework: complianceMatch.framework,
@@ -294,6 +380,8 @@ export function ResourceDetailDrawerContent({
currentFinding: f,
includeScanData: f?.scan?.id === complianceScanId,
}),
"_blank",
"noopener,noreferrer",
);
} catch (error) {
console.error("Error resolving compliance detail:", error);
@@ -428,10 +516,10 @@ export function ResourceDetailDrawerContent({
)}
</div>
{/* Navigation: "Impacted Resource (X of N)" */}
{/* Navigation: "Resource (X of N)" */}
<div className="flex items-center justify-between">
<Badge variant="tag" className="rounded text-sm">
Impacted Resource
Resource
<span className="font-bold">{currentIndex + 1}</span>
<span className="font-normal">of</span>
<span className="font-bold">{totalResources}</span>
@@ -477,7 +565,7 @@ export function ResourceDetailDrawerContent({
/>
<EntityInfo
nameIcon={<Container className="size-4" />}
entityAlias={f.resourceGroup}
entityAlias={f.resourceName}
entityId={f.resourceUid}
idLabel="UID"
/>
@@ -505,7 +593,9 @@ export function ResourceDetailDrawerContent({
<InfoField label="Failing for" variant="compact">
{getFailingForLabel(f.firstSeenAt) || "-"}
</InfoField>
<div className="hidden md:block" />
<InfoField label="Group" variant="compact">
{f.resourceGroup || "-"}
</InfoField>
{/* Row 3: IDs */}
<InfoField label="Check ID" variant="compact">
@@ -529,6 +619,11 @@ export function ResourceDetailDrawerContent({
className="max-w-full text-sm"
/>
</InfoField>
{/* Row 4: Resource metadata */}
<InfoField label="Resource type" variant="compact">
{f.resourceType || "-"}
</InfoField>
</div>
{/* Actions button — fixed size, aligned with row 1 */}
@@ -757,10 +852,7 @@ export function ResourceDetailDrawerContent({
</div>
) : (
<>
<div className="flex items-center justify-between">
<h4 className="text-text-neutral-primary text-sm font-medium">
Failed Findings For This Resource
</h4>
<div className="flex items-center justify-end">
<span className="text-text-neutral-tertiary text-sm">
{otherFindings.length} Total Entries
</span>
@@ -796,7 +888,18 @@ export function ResourceDetailDrawerContent({
<TableBody>
{otherFindings.length > 0 ? (
otherFindings.map((finding) => (
<OtherFindingRow key={finding.id} finding={finding} />
<OtherFindingRow
key={finding.id}
finding={finding}
isOptimisticallyMuted={optimisticallyMutedIds.has(
finding.id,
)}
onMuted={() =>
setOptimisticallyMutedIds((prev) =>
new Set(prev).add(finding.id),
)
}
/>
))
) : (
<TableRow>
@@ -908,19 +1011,32 @@ export function ResourceDetailDrawerContent({
);
}
function OtherFindingRow({ finding }: { finding: ResourceDrawerFinding }) {
function OtherFindingRow({
finding,
isOptimisticallyMuted,
onMuted,
}: {
finding: ResourceDrawerFinding;
isOptimisticallyMuted: boolean;
onMuted: () => void;
}) {
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
const isMuted = finding.isMuted || isOptimisticallyMuted;
const findingUrl = `/findings?filter%5Bcheck_id__in%5D=${encodeURIComponent(finding.checkId)}&filter%5Bmuted%5D=include`;
return (
<>
{!finding.isMuted && (
{!isMuted && (
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={setIsMuteModalOpen}
findingIds={[finding.id]}
onComplete={() => {
setIsMuteModalOpen(false);
onMuted();
}}
/>
)}
<SendToJiraModal
@@ -934,7 +1050,7 @@ function OtherFindingRow({ finding }: { finding: ResourceDrawerFinding }) {
onClick={() => window.open(findingUrl, "_blank", "noopener,noreferrer")}
>
<TableCell className="w-10">
<NotificationIndicator isMuted={finding.isMuted} />
<NotificationIndicator isMuted={isMuted} />
</TableCell>
<TableCell>
<StatusFindingBadge status={finding.status as FindingStatus} />
@@ -955,14 +1071,14 @@ function OtherFindingRow({ finding }: { finding: ResourceDrawerFinding }) {
<ActionDropdown ariaLabel="Finding actions">
<ActionDropdownItem
icon={
finding.isMuted ? (
isMuted ? (
<VolumeOff className="size-5" />
) : (
<VolumeX className="size-5" />
)
}
label={finding.isMuted ? "Muted" : "Mute"}
disabled={finding.isMuted}
label={isMuted ? "Muted" : "Mute"}
disabled={isMuted}
onSelect={() => setIsMuteModalOpen(true)}
/>
<ActionDropdownItem
@@ -0,0 +1,26 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/components/shadcn/skeleton/skeleton", () => ({
Skeleton: ({ className }: { className?: string }) => (
<div data-testid="skeleton-block" data-class={className ?? ""} />
),
}));
import { ResourceDetailSkeleton } from "./resource-detail-skeleton";
describe("ResourceDetailSkeleton", () => {
it("should include placeholders for group and resource type fields", () => {
render(<ResourceDetailSkeleton />);
const blocks = screen.getAllByTestId("skeleton-block");
const classes = blocks.map(
(block) => block.getAttribute("data-class") ?? "",
);
expect(classes).toContain("h-3.5 w-10 rounded");
expect(classes).toContain("h-5 w-18 rounded");
expect(classes).toContain("h-3.5 w-20 rounded");
expect(classes).toContain("h-5 w-28 rounded");
});
});
@@ -2,8 +2,8 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
/**
* Skeleton placeholder for the resource info grid in the detail drawer.
* Mirrors the 4-column layout: EntityInfo × 2, InfoField × 2 per row,
* plus the actions button.
* Mirrors the drawer layout so added metadata fields don't leave visual gaps
* while the next resource is loading.
*/
export function ResourceDetailSkeleton() {
return (
@@ -15,16 +15,19 @@ export function ResourceDetailSkeleton() {
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-20" />
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-24" />
{/* Row 2: Last detected, First seen, Failing for */}
{/* Row 2: Last detected, First seen, Failing for, Group */}
<InfoFieldSkeleton labelWidth="w-20" valueWidth="w-32" />
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-32" />
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-16" />
<div className="hidden md:block" />
<InfoFieldSkeleton labelWidth="w-10" valueWidth="w-18" />
{/* Row 3: Check ID, Finding ID, Finding UID */}
<InfoFieldSkeleton labelWidth="w-14" valueWidth="w-36" />
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-36" />
<InfoFieldSkeleton labelWidth="w-20" valueWidth="w-36" />
{/* Row 4: Resource type */}
<InfoFieldSkeleton labelWidth="w-20" valueWidth="w-28" />
</div>
{/* Actions button */}
@@ -26,6 +26,7 @@ vi.mock("next/navigation", () => ({
// Import after mocks
// ---------------------------------------------------------------------------
import type { ResourceDrawerFinding } from "@/actions/findings";
import type { FindingResourceRow } from "@/types";
import { useResourceDetailDrawer } from "./use-resource-detail-drawer";
@@ -60,6 +61,46 @@ function makeResource(
} as FindingResourceRow;
}
function makeDrawerFinding(
overrides?: Partial<ResourceDrawerFinding>,
): ResourceDrawerFinding {
return {
id: "finding-1",
uid: "uid-1",
checkId: "s3_check",
checkTitle: "S3 Check",
status: "FAIL",
severity: "high",
delta: null,
isMuted: false,
mutedReason: null,
firstSeenAt: null,
updatedAt: null,
resourceId: "resource-1",
resourceUid: "arn:aws:s3:::my-bucket",
resourceName: "my-bucket",
resourceService: "s3",
resourceRegion: "us-east-1",
resourceType: "bucket",
resourceGroup: "default",
providerType: "aws",
providerAlias: "prod",
providerUid: "123",
risk: "high",
description: "desc",
statusExtended: "status",
complianceFrameworks: [],
categories: [],
remediation: {
recommendation: { text: "", url: "" },
code: { cli: "", other: "", nativeiac: "", terraform: "" },
},
additionalUrls: [],
scan: null,
...overrides,
};
}
// ---------------------------------------------------------------------------
// Fix 2: AbortController cleanup on unmount
// ---------------------------------------------------------------------------
@@ -128,3 +169,212 @@ describe("useResourceDetailDrawer — unmount cleanup", () => {
expect(abortSpy).not.toHaveBeenCalled();
});
});
describe("useResourceDetailDrawer — other findings filtering", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should exclude the current finding from otherFindings and preserve API order", async () => {
const resources = [makeResource()];
getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] });
adaptFindingsByResourceResponseMock.mockReturnValue([
makeDrawerFinding({
id: "current",
checkId: "s3_check",
checkTitle: "Current",
status: "FAIL",
severity: "critical",
}),
makeDrawerFinding({
id: "other-1",
checkId: "check-other-1",
checkTitle: "Other 1",
status: "PASS",
severity: "critical",
}),
makeDrawerFinding({
id: "other-2",
checkId: "check-other-2",
checkTitle: "Other 2",
status: "FAIL",
severity: "medium",
}),
]);
const { result } = renderHook(() =>
useResourceDetailDrawer({
resources,
checkId: "s3_check",
}),
);
await act(async () => {
result.current.openDrawer(0);
await Promise.resolve();
});
expect(result.current.otherFindings.map((finding) => finding.id)).toEqual([
"other-1",
"other-2",
]);
});
it("should keep isNavigating true for a cached resource long enough to render skeletons", async () => {
vi.useFakeTimers();
const resources = [
makeResource({
id: "row-1",
findingId: "finding-1",
resourceUid: "arn:aws:s3:::first-bucket",
resourceName: "first-bucket",
}),
makeResource({
id: "row-2",
findingId: "finding-2",
resourceUid: "arn:aws:s3:::second-bucket",
resourceName: "second-bucket",
}),
];
getLatestFindingsByResourceUidMock.mockImplementation(
async ({ resourceUid }: { resourceUid: string }) => ({
data: [resourceUid],
}),
);
adaptFindingsByResourceResponseMock.mockImplementation(
(response: { data: string[] }) => [
makeDrawerFinding({
id: response.data[0].includes("first") ? "finding-1" : "finding-2",
resourceUid: response.data[0],
resourceName: response.data[0].includes("first")
? "first-bucket"
: "second-bucket",
}),
],
);
const { result } = renderHook(() =>
useResourceDetailDrawer({
resources,
checkId: "s3_check",
}),
);
await act(async () => {
result.current.openDrawer(0);
await Promise.resolve();
});
await act(async () => {
result.current.navigateNext();
await Promise.resolve();
});
expect(result.current.currentIndex).toBe(1);
expect(result.current.currentFinding?.id).toBe("finding-2");
act(() => {
result.current.navigatePrev();
});
expect(result.current.currentIndex).toBe(0);
expect(result.current.isNavigating).toBe(true);
await act(async () => {
vi.runAllTimers();
await Promise.resolve();
});
expect(result.current.isNavigating).toBe(false);
expect(result.current.currentFinding?.id).toBe("finding-1");
vi.useRealTimers();
});
it("should keep isNavigating true for a fast uncached navigation long enough to avoid flicker", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-08T15:00:00.000Z"));
const resources = [
makeResource({
id: "row-1",
findingId: "finding-1",
resourceUid: "arn:aws:s3:::first-bucket",
resourceName: "first-bucket",
}),
makeResource({
id: "row-2",
findingId: "finding-2",
resourceUid: "arn:aws:s3:::second-bucket",
resourceName: "second-bucket",
}),
];
getLatestFindingsByResourceUidMock.mockImplementation(
async ({ resourceUid }: { resourceUid: string }) => ({
data: [resourceUid],
}),
);
adaptFindingsByResourceResponseMock.mockImplementation(
(response: { data: string[] }) => [
makeDrawerFinding({
id: response.data[0].includes("first") ? "finding-1" : "finding-2",
resourceUid: response.data[0],
resourceName: response.data[0].includes("first")
? "first-bucket"
: "second-bucket",
}),
],
);
const { result } = renderHook(() =>
useResourceDetailDrawer({
resources,
checkId: "s3_check",
}),
);
await act(async () => {
result.current.openDrawer(0);
await Promise.resolve();
});
act(() => {
result.current.navigateNext();
});
expect(result.current.currentIndex).toBe(1);
expect(result.current.isNavigating).toBe(true);
await act(async () => {
await Promise.resolve();
});
expect(result.current.currentFinding?.id).toBe("finding-2");
expect(result.current.isNavigating).toBe(true);
await act(async () => {
vi.advanceTimersByTime(119);
await Promise.resolve();
});
expect(result.current.isNavigating).toBe(true);
await act(async () => {
vi.advanceTimersByTime(1);
await Promise.resolve();
});
await act(async () => {
vi.runOnlyPendingTimers();
await Promise.resolve();
});
expect(result.current.isNavigating).toBe(false);
vi.useRealTimers();
});
});
@@ -9,6 +9,10 @@ import {
} from "@/actions/findings";
import { FindingResourceRow } from "@/types";
// Keep fast carousel navigations in a loading state for one short beat so
// React doesn't batch away the skeleton frame when switching resources.
const MIN_NAVIGATION_SKELETON_MS = 300;
/**
* Check-level metadata that is identical across all resources for a given check.
* Extracted once on first successful fetch and kept stable during navigation.
@@ -83,18 +87,65 @@ export function useResourceDetailDrawer({
const cacheRef = useRef<Map<string, ResourceDrawerFinding[]>>(new Map());
const checkMetaRef = useRef<CheckMeta | null>(null);
const fetchControllerRef = useRef<AbortController | null>(null);
const navigationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const navigationStartedAtRef = useRef<number | null>(null);
const clearNavigationTimeout = () => {
if (navigationTimeoutRef.current !== null) {
clearTimeout(navigationTimeoutRef.current);
navigationTimeoutRef.current = null;
}
};
const finishNavigation = () => {
clearNavigationTimeout();
setIsLoading(false);
const navigationStartedAt = navigationStartedAtRef.current;
if (navigationStartedAt === null) {
navigationStartedAtRef.current = null;
setIsNavigating(false);
return;
}
const elapsed = Date.now() - navigationStartedAt;
const remaining = Math.max(0, MIN_NAVIGATION_SKELETON_MS - elapsed);
if (remaining === 0) {
navigationStartedAtRef.current = null;
setIsNavigating(false);
return;
}
navigationTimeoutRef.current = setTimeout(() => {
setIsNavigating(false);
navigationStartedAtRef.current = null;
navigationTimeoutRef.current = null;
}, remaining);
};
const startNavigation = () => {
clearNavigationTimeout();
navigationStartedAtRef.current = Date.now();
setIsNavigating(true);
};
// Abort any in-flight request on unmount to prevent state updates
// on an already-unmounted component.
useEffect(() => {
return () => {
fetchControllerRef.current?.abort();
clearNavigationTimeout();
navigationStartedAtRef.current = null;
};
}, []);
const fetchFindings = async (resourceUid: string) => {
// Abort any in-flight request to prevent stale data from out-of-order responses
fetchControllerRef.current?.abort();
clearNavigationTimeout();
const controller = new AbortController();
fetchControllerRef.current = controller;
@@ -106,8 +157,7 @@ export function useResourceDetailDrawer({
if (main) checkMetaRef.current = extractCheckMeta(main);
}
setFindings(cached);
setIsLoading(false);
setIsNavigating(false);
finishNavigation();
return;
}
@@ -135,8 +185,7 @@ export function useResourceDetailDrawer({
}
} finally {
if (!controller.signal.aborted) {
setIsLoading(false);
setIsNavigating(false);
finishNavigation();
}
}
};
@@ -145,8 +194,11 @@ export function useResourceDetailDrawer({
const resource = resources[index];
if (!resource) return;
clearNavigationTimeout();
navigationStartedAtRef.current = null;
setCurrentIndex(index);
setIsOpen(true);
setIsNavigating(false);
setFindings([]);
fetchFindings(resource.resourceUid);
};
@@ -159,7 +211,7 @@ export function useResourceDetailDrawer({
const resource = resources[currentIndex];
if (!resource) return;
cacheRef.current.delete(resource.resourceUid);
setIsNavigating(true);
startNavigation();
fetchFindings(resource.resourceUid);
};
@@ -168,7 +220,7 @@ export function useResourceDetailDrawer({
if (!resource) return;
setCurrentIndex(index);
setIsNavigating(true);
startNavigation();
fetchFindings(resource.resourceUid);
};
+43 -14
View File
@@ -3,20 +3,23 @@
import { X } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { ScanSelector } from "@/components/compliance/compliance-header";
import { filterScans } from "@/components/filters/data-filters";
import { FilterControls } from "@/components/filters/filter-controls";
import { Badge } from "@/components/shadcn/badge/badge";
import { useRelatedFilters } from "@/hooks";
import { FilterEntity, FilterType } from "@/types";
import { ExpandedScanData, FilterEntity, FilterType } from "@/types";
interface ScansFiltersProps {
providerUIDs: string[];
providerDetails: { [uid: string]: FilterEntity }[];
completedScans?: ExpandedScanData[];
}
export const ScansFilters = ({
providerUIDs,
providerDetails,
completedScans = [],
}: ScansFiltersProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -36,24 +39,50 @@ export const ScansFilters = ({
router.push(`${pathname}?${params.toString()}`);
};
const scanIdChip = idFilter ? (
<div className="flex items-center">
<Badge
variant="tag"
className="max-w-[300px] shrink-0 cursor-default gap-1 truncate"
>
<span className="text-text-neutral-secondary mr-1 text-xs">Scan:</span>
<span className="truncate">{idFilter}</span>
const handleScanChange = (selectedScanId: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("filter[id__in]", selectedScanId);
router.push(`${pathname}?${params.toString()}`);
};
const scanIdElement = idFilter ? (
completedScans.length > 0 ? (
<div className="flex items-center gap-2">
<ScanSelector
scans={completedScans}
selectedScanId={idFilter}
onSelectionChange={handleScanChange}
/>
<button
type="button"
aria-label="Clear scan filter"
className="hover:text-text-neutral-primary ml-0.5 shrink-0"
className="text-text-neutral-secondary hover:text-text-neutral-primary shrink-0"
onClick={handleDismissIdFilter}
>
<X className="size-3" />
<X className="size-4" />
</button>
</Badge>
</div>
</div>
) : (
<div className="flex items-center">
<Badge
variant="tag"
className="max-w-[300px] shrink-0 cursor-default gap-1 truncate"
>
<span className="text-text-neutral-secondary mr-1 text-xs">
Scan:
</span>
<span className="truncate">{idFilter}</span>
<button
type="button"
aria-label="Clear scan filter"
className="hover:text-text-neutral-primary ml-0.5 shrink-0"
onClick={handleDismissIdFilter}
>
<X className="size-3" />
</button>
</Badge>
</div>
)
) : null;
return (
@@ -68,7 +97,7 @@ export const ScansFilters = ({
index: 1,
},
]}
prependElement={scanIdChip}
prependElement={scanIdElement}
/>
);
};
+1 -1
View File
@@ -20,7 +20,7 @@ const cardVariants = cva("flex flex-col gap-6 rounded-xl border", {
inner:
"rounded-[12px] backdrop-blur-[46px] border-border-neutral-tertiary bg-bg-neutral-tertiary",
danger:
"gap-1 rounded-[12px] border-border-error-primary bg-bg-fail-secondary",
"gap-1 rounded-[12px] border-[rgba(67,34,50,0.5)] bg-[rgba(67,34,50,0.2)] dark:border-[rgba(67,34,50,0.7)] dark:bg-[rgba(67,34,50,0.3)]",
},
padding: {
default: "",
+36 -4
View File
@@ -1,5 +1,10 @@
import { format, parseISO } from "date-fns";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { cn } from "@/lib/utils";
interface DateWithTimeProps {
@@ -33,25 +38,52 @@ export const DateWithTime = ({
?.substring(0, 3)
.toUpperCase() || "";
return (
const fullText = showTime
? `${formattedDate} ${formattedTime} ${timezone}`
: formattedDate;
const content = (
<div
className={cn(
"gap-1",
inline
? "inline-flex flex-row flex-wrap items-center"
? "inline-flex flex-row items-center overflow-hidden"
: "flex flex-col",
)}
>
<span className="text-text-neutral-primary text-sm whitespace-nowrap">
<span
className={cn(
"text-text-neutral-primary text-sm whitespace-nowrap",
inline && "truncate",
)}
>
{formattedDate}
</span>
{showTime && (
<span className="text-text-neutral-tertiary text-xs font-medium whitespace-nowrap">
<span
className={cn(
"text-text-neutral-tertiary text-xs font-medium whitespace-nowrap",
inline && "truncate",
)}
>
{formattedTime} {timezone}
</span>
)}
</div>
);
if (inline) {
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="min-w-0 overflow-hidden">{content}</div>
</TooltipTrigger>
<TooltipContent>{fullText}</TooltipContent>
</Tooltip>
);
}
return content;
} catch {
return <span>-</span>;
}
+32
View File
@@ -163,6 +163,38 @@ describe("useInfiniteResources", () => {
findingGroupActionsMock.getLatestFindingGroupResources,
).not.toHaveBeenCalled();
});
it("should forward the active finding-group filters to the resources endpoint", async () => {
// Given
const apiResponse = makeApiResponse([], { pages: 1 });
const filters = {
"filter[status__in]": "PASS",
"filter[severity__in]": "medium",
"filter[provider_type__in]": "aws",
};
findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue(
apiResponse,
);
findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
[],
);
// When
renderHook(() => useInfiniteResources(defaultOptions({ filters })));
await flushAsync();
// Then
expect(
findingGroupActionsMock.getLatestFindingGroupResources,
).toHaveBeenCalledWith(
expect.objectContaining({
checkId: "check_1",
page: 1,
pageSize: 10,
filters,
}),
);
});
});
describe("when all resources fit in one page", () => {
+11 -1
View File
@@ -32,6 +32,8 @@ interface UseInfiniteResourcesReturn {
refresh: () => void;
/** Imperatively load the next page (e.g. from drawer navigation). */
loadMore: () => void;
/** Total number of resources matching current filters (from API pagination). */
totalCount: number | null;
}
/**
@@ -60,6 +62,7 @@ export function useInfiniteResources({
const currentCheckIdRef = useRef(checkId);
const controllerRef = useRef<AbortController | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const totalCountRef = useRef<number | null>(null);
// Store latest values in refs so the fetch function always reads current values
// without being recreated on every render
@@ -70,6 +73,7 @@ export function useInfiniteResources({
const onSetLoadingRef = useRef(onSetLoading);
// Keep refs in sync with latest props
currentCheckIdRef.current = checkId;
hasDateOrScanRef.current = hasDateOrScanFilter;
filtersRef.current = filters;
onSetResourcesRef.current = onSetResources;
@@ -110,6 +114,7 @@ export function useInfiniteResources({
);
const totalPages = response?.meta?.pagination?.pages ?? 1;
const hasMore = page < totalPages;
totalCountRef.current = response?.meta?.pagination?.count ?? null;
// Commit the page number only after a successful (non-aborted) fetch.
// This prevents a premature pageRef increment from loadNextPage being
@@ -209,5 +214,10 @@ export function useInfiniteResources({
fetchPage(1, false, currentCheckIdRef.current, controller.signal);
}
return { sentinelRef, refresh, loadMore: loadNextPage };
return {
sentinelRef,
refresh,
loadMore: loadNextPage,
totalCount: totalCountRef.current,
};
}
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it, vi } from "vitest";
import {
buildFindingScanDateFilters,
resolveFindingScanDateFilters,
} from "./findings-scan-filters";
describe("buildFindingScanDateFilters", () => {
it("uses an exact inserted_at filter when all selected scans belong to the same day", () => {
expect(
buildFindingScanDateFilters([
"2026-04-07T10:00:00Z",
"2026-04-07T18:30:00Z",
]),
).toEqual({
"filter[inserted_at]": "2026-04-07",
});
});
it("ignores whitespace-only date strings", () => {
expect(buildFindingScanDateFilters([" ", "2026-04-07T10:00:00Z"])).toEqual(
{
"filter[inserted_at]": "2026-04-07",
},
);
});
it("uses a date range when selected scans span multiple days", () => {
expect(
buildFindingScanDateFilters([
"2026-04-03T10:00:00Z",
"2026-04-07T18:30:00Z",
"2026-04-05T12:00:00Z",
]),
).toEqual({
"filter[inserted_at__gte]": "2026-04-03",
"filter[inserted_at__lte]": "2026-04-07",
});
});
});
describe("resolveFindingScanDateFilters", () => {
it("adds the required inserted_at filter for a selected scan when the URL only contains scan__in", async () => {
const result = await resolveFindingScanDateFilters({
filters: {
"filter[muted]": "false",
"filter[scan__in]": "scan-1",
},
scans: [
{
id: "scan-1",
attributes: {
inserted_at: "2026-04-07T10:00:00Z",
},
},
],
loadScan: vi.fn(),
});
expect(result).toEqual({
"filter[muted]": "false",
"filter[scan__in]": "scan-1",
"filter[inserted_at]": "2026-04-07",
});
});
it("fetches missing scan details when the selected scan is not present in the prefetched scans list", async () => {
const loadScan = vi.fn().mockResolvedValue({
id: "scan-2",
attributes: {
inserted_at: "2026-04-05T08:00:00Z",
},
});
const result = await resolveFindingScanDateFilters({
filters: {
"filter[scan__in]": "scan-2",
},
scans: [],
loadScan,
});
expect(loadScan).toHaveBeenCalledWith("scan-2");
expect(result).toEqual({
"filter[scan__in]": "scan-2",
"filter[inserted_at]": "2026-04-05",
});
});
it("does not override an explicit inserted_at filter already chosen in the frontend", async () => {
const result = await resolveFindingScanDateFilters({
filters: {
"filter[scan__in]": "scan-1",
"filter[inserted_at__gte]": "2026-04-01",
},
scans: [
{
id: "scan-1",
attributes: {
inserted_at: "2026-04-07T10:00:00Z",
},
},
],
loadScan: vi.fn(),
});
expect(result).toEqual({
"filter[scan__in]": "scan-1",
"filter[inserted_at__gte]": "2026-04-01",
});
});
});
+99
View File
@@ -0,0 +1,99 @@
interface ScanDateSource {
id: string;
attributes?: {
inserted_at?: string;
};
}
interface ResolveFindingScanDateFiltersOptions {
filters: Record<string, string>;
scans: ScanDateSource[];
loadScan: (scanId: string) => Promise<ScanDateSource | null | undefined>;
}
const INSERTED_AT_FILTER_KEYS = [
"filter[inserted_at]",
"filter[inserted_at__date]",
"filter[inserted_at__gte]",
"filter[inserted_at__lte]",
] 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)));
}
function formatScanDate(dateTime?: string): string | null {
if (!dateTime) return null;
const [date] = dateTime.split("T");
return date?.trim() || null;
}
function hasInsertedAtFilter(filters: Record<string, string>): boolean {
return INSERTED_AT_FILTER_KEYS.some((key) => Boolean(filters[key]));
}
export function buildFindingScanDateFilters(
scanInsertedAtValues: string[],
): Record<string, string> {
const dates = Array.from(
new Set(scanInsertedAtValues.map(formatScanDate).filter(Boolean)),
).sort() as string[];
if (dates.length === 0) {
return {};
}
if (dates.length === 1) {
return {
"filter[inserted_at]": dates[0],
};
}
return {
"filter[inserted_at__gte]": dates[0],
"filter[inserted_at__lte]": dates[dates.length - 1],
};
}
export async function resolveFindingScanDateFilters({
filters,
scans,
loadScan,
}: ResolveFindingScanDateFiltersOptions): Promise<Record<string, string>> {
const scanIds = getScanFilterIds(filters);
if (scanIds.length === 0 || hasInsertedAtFilter(filters)) {
return filters;
}
const scansById = new Map(scans.map((scan) => [scan.id, scan]));
const missingScanIds = scanIds.filter((scanId) => !scansById.has(scanId));
if (missingScanIds.length > 0) {
const missingScans = await Promise.all(
missingScanIds.map((scanId) => loadScan(scanId)),
);
missingScans.forEach((scan) => {
if (scan) {
scansById.set(scan.id, scan);
}
});
}
const scanInsertedAtValues = scanIds
.map((scanId) => scansById.get(scanId)?.attributes?.inserted_at)
.filter((insertedAt): insertedAt is string => Boolean(insertedAt));
const dateFilters = buildFindingScanDateFilters(scanInsertedAtValues);
if (Object.keys(dateFilters).length === 0) {
return filters;
}
return {
...filters,
...dateFilters,
};
}
+2
View File
@@ -34,12 +34,14 @@ export interface FindingResourceRow {
providerAlias: string;
providerUid: string;
resourceName: string;
resourceType: string;
resourceGroup: string;
resourceUid: string;
service: string;
region: string;
severity: Severity;
status: string;
delta?: string | null;
isMuted: boolean;
mutedReason?: string;
firstSeenAt: string | null;