mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-04-02 08:21:39 +00:00
fix(ui): findings groups improvements — security fixes, code quality, and UX feedback (#10513)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
2
api/poetry.lock
generated
2
api/poetry.lock
generated
@@ -6722,7 +6722,7 @@ uuid6 = "2024.7.10"
|
||||
type = "git"
|
||||
url = "https://github.com/prowler-cloud/prowler.git"
|
||||
reference = "master"
|
||||
resolved_reference = "2ddd5b3091bcdd8c7d44aba73b13c5c6f8f99e35"
|
||||
resolved_reference = "6ac90eb1b58590b6f2f51645dbef17b9231053f4"
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
|
||||
@@ -434,6 +434,7 @@ class ScanFilter(ProviderRelationshipFilterSet):
|
||||
class Meta:
|
||||
model = Scan
|
||||
fields = {
|
||||
"id": ["exact", "in"],
|
||||
"provider": ["exact", "in"],
|
||||
"name": ["exact", "icontains"],
|
||||
"started_at": ["gte", "lte"],
|
||||
|
||||
@@ -3283,6 +3283,29 @@ class TestScanViewSet:
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()["data"]) == 3
|
||||
|
||||
def test_scan_filter_by_id_exact(self, authenticated_client, scans_fixture):
|
||||
scan1, *_ = scans_fixture
|
||||
response = authenticated_client.get(
|
||||
reverse("scan-list"),
|
||||
{"filter[id]": str(scan1.id)},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) == 1
|
||||
assert data[0]["id"] == str(scan1.id)
|
||||
|
||||
def test_scan_filter_by_id_in(self, authenticated_client, scans_fixture):
|
||||
scan1, scan2, *_ = scans_fixture
|
||||
response = authenticated_client.get(
|
||||
reverse("scan-list"),
|
||||
{"filter[id.in]": f"{scan1.id},{scan2.id}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) == 2
|
||||
returned_ids = {item["id"] for item in data}
|
||||
assert returned_ids == {str(scan1.id), str(scan2.id)}
|
||||
|
||||
def test_scans_filter_state_failed(self, authenticated_client, scans_fixture):
|
||||
"""Ensure state filter matches only FAILED scans."""
|
||||
scan1, *_ = scans_fixture
|
||||
|
||||
182
ui/actions/finding-groups/finding-groups.adapter.test.ts
Normal file
182
ui/actions/finding-groups/finding-groups.adapter.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
adaptFindingGroupResourcesResponse,
|
||||
adaptFindingGroupsResponse,
|
||||
} from "./finding-groups.adapter";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 1: adaptFindingGroupsResponse — unknown + type guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("adaptFindingGroupsResponse — malformed input", () => {
|
||||
it("should return [] when apiResponse is null", () => {
|
||||
// Given
|
||||
const input = null;
|
||||
|
||||
// When
|
||||
const result = adaptFindingGroupsResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when apiResponse has no data property", () => {
|
||||
// Given
|
||||
const input = { meta: { total: 0 } };
|
||||
|
||||
// When
|
||||
const result = adaptFindingGroupsResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when data is not an array", () => {
|
||||
// Given
|
||||
const input = { data: "not-an-array" };
|
||||
|
||||
// When
|
||||
const result = adaptFindingGroupsResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when data is null", () => {
|
||||
// Given
|
||||
const input = { data: null };
|
||||
|
||||
// When
|
||||
const result = adaptFindingGroupsResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when apiResponse is undefined", () => {
|
||||
// Given
|
||||
const input = undefined;
|
||||
|
||||
// When
|
||||
const result = adaptFindingGroupsResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return mapped rows for valid data", () => {
|
||||
// Given
|
||||
const input = {
|
||||
data: [
|
||||
{
|
||||
id: "group-1",
|
||||
type: "finding-groups",
|
||||
attributes: {
|
||||
check_id: "s3_bucket_public_access",
|
||||
check_title: "S3 Bucket Public Access",
|
||||
check_description: null,
|
||||
severity: "critical",
|
||||
status: "FAIL",
|
||||
impacted_providers: ["aws"],
|
||||
resources_total: 5,
|
||||
resources_fail: 3,
|
||||
pass_count: 2,
|
||||
fail_count: 3,
|
||||
muted_count: 0,
|
||||
new_count: 1,
|
||||
changed_count: 0,
|
||||
first_seen_at: null,
|
||||
last_seen_at: "2024-01-01T00:00:00Z",
|
||||
failing_since: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// When
|
||||
const result = adaptFindingGroupsResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].checkId).toBe("s3_bucket_public_access");
|
||||
expect(result[0].checkTitle).toBe("S3 Bucket Public Access");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 1: adaptFindingGroupResourcesResponse — unknown + type guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("adaptFindingGroupResourcesResponse — malformed input", () => {
|
||||
it("should return [] when apiResponse is null", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingGroupResourcesResponse(null, "check-1");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when apiResponse has no data property", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingGroupResourcesResponse({ meta: {} }, "check-1");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when data is not an array", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingGroupResourcesResponse({ data: {} }, "check-1");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when apiResponse is undefined", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingGroupResourcesResponse(undefined, "check-1");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return mapped rows for valid data", () => {
|
||||
// Given
|
||||
const input = {
|
||||
data: [
|
||||
{
|
||||
id: "resource-row-1",
|
||||
type: "finding-group-resources",
|
||||
attributes: {
|
||||
resource: {
|
||||
uid: "arn:aws:s3:::my-bucket",
|
||||
name: "my-bucket",
|
||||
service: "s3",
|
||||
region: "us-east-1",
|
||||
type: "Bucket",
|
||||
resource_group: "default",
|
||||
},
|
||||
provider: {
|
||||
type: "aws",
|
||||
uid: "123456789",
|
||||
alias: "production",
|
||||
},
|
||||
status: "FAIL",
|
||||
severity: "critical",
|
||||
first_seen_at: null,
|
||||
last_seen_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// When
|
||||
const result = adaptFindingGroupResourcesResponse(input, "s3_check");
|
||||
|
||||
// Then
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].checkId).toBe("s3_check");
|
||||
expect(result[0].resourceName).toBe("my-bucket");
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import {
|
||||
import type {
|
||||
FindingGroupRow,
|
||||
FindingResourceRow,
|
||||
FINDINGS_ROW_TYPE,
|
||||
FindingStatus,
|
||||
ProviderType,
|
||||
Severity,
|
||||
} from "@/types";
|
||||
import { FINDINGS_ROW_TYPE } from "@/types";
|
||||
|
||||
/**
|
||||
* API response shape for a finding group (JSON:API).
|
||||
@@ -43,13 +43,19 @@ interface FindingGroupApiItem {
|
||||
* Transforms the API response for finding groups into FindingGroupRow[].
|
||||
*/
|
||||
export function adaptFindingGroupsResponse(
|
||||
apiResponse: any,
|
||||
apiResponse: unknown,
|
||||
): FindingGroupRow[] {
|
||||
if (!apiResponse?.data || !Array.isArray(apiResponse.data)) {
|
||||
if (
|
||||
!apiResponse ||
|
||||
typeof apiResponse !== "object" ||
|
||||
!("data" in apiResponse) ||
|
||||
!Array.isArray((apiResponse as { data: unknown }).data)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return apiResponse.data.map((item: FindingGroupApiItem) => ({
|
||||
const data = (apiResponse as { data: FindingGroupApiItem[] }).data;
|
||||
return data.map((item) => ({
|
||||
id: item.id,
|
||||
rowType: FINDINGS_ROW_TYPE.GROUP,
|
||||
checkId: item.attributes.check_id,
|
||||
@@ -109,14 +115,20 @@ interface FindingGroupResourceApiItem {
|
||||
* into FindingResourceRow[].
|
||||
*/
|
||||
export function adaptFindingGroupResourcesResponse(
|
||||
apiResponse: any,
|
||||
apiResponse: unknown,
|
||||
checkId: string,
|
||||
): FindingResourceRow[] {
|
||||
if (!apiResponse?.data || !Array.isArray(apiResponse.data)) {
|
||||
if (
|
||||
!apiResponse ||
|
||||
typeof apiResponse !== "object" ||
|
||||
!("data" in apiResponse) ||
|
||||
!Array.isArray((apiResponse as { data: unknown }).data)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return apiResponse.data.map((item: FindingGroupResourceApiItem) => ({
|
||||
const data = (apiResponse as { data: FindingGroupResourceApiItem[] }).data;
|
||||
return data.map((item) => ({
|
||||
id: item.id,
|
||||
rowType: FINDINGS_ROW_TYPE.RESOURCE,
|
||||
findingId: item.id,
|
||||
|
||||
385
ui/actions/finding-groups/finding-groups.test.ts
Normal file
385
ui/actions/finding-groups/finding-groups.test.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoisted mocks (must be declared before any imports that need them)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted(
|
||||
() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
handleApiResponseMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/provider-filters", () => ({
|
||||
// Simulate real appendSanitizedProviderFilters: appends all non-undefined filters to the URL.
|
||||
appendSanitizedProviderFilters: vi.fn(
|
||||
(url: URL, filters: Record<string, string | string[] | undefined>) => {
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
url.searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Imports (after vi.mock declarations)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import {
|
||||
getFindingGroupResources,
|
||||
getLatestFindingGroupResources,
|
||||
} from "./finding-groups";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Blocker 1 + 2: FAIL-first sort and FAIL-only filter for drill-down resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getFindingGroupResources — SSRF path traversal protection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
handleApiResponseMock.mockResolvedValue({ data: [] });
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
});
|
||||
|
||||
it("should encode a normal checkId without alteration", async () => {
|
||||
// Given
|
||||
const checkId = "s3_bucket_public_access";
|
||||
|
||||
// When
|
||||
await getFindingGroupResources({ checkId });
|
||||
|
||||
// Then — URL path must contain encoded checkId, not raw
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain(
|
||||
`/api/v1/finding-groups/${encodeURIComponent(checkId)}/resources`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should encode a checkId containing a forward slash (path traversal attempt)", async () => {
|
||||
// Given — checkId with embedded slash: attacker attempts path traversal
|
||||
const maliciousCheckId = "../../admin/secret";
|
||||
|
||||
// When
|
||||
await getFindingGroupResources({ checkId: maliciousCheckId });
|
||||
|
||||
// Then — the URL must NOT contain a raw slash from the checkId
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
// The path should NOT end in /resources with traversal segments between
|
||||
expect(url.pathname).not.toContain("/admin/secret/resources");
|
||||
// The encoded checkId must appear in the path
|
||||
expect(url.pathname).toContain(
|
||||
`/finding-groups/${encodeURIComponent(maliciousCheckId)}/resources`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should encode a checkId containing %2F (URL-encoded slash traversal attempt)", async () => {
|
||||
// Given — checkId with %2F: double-encoding traversal attempt
|
||||
const maliciousCheckId = "foo%2Fbar";
|
||||
|
||||
// When
|
||||
await getFindingGroupResources({ checkId: maliciousCheckId });
|
||||
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.pathname).toContain(
|
||||
`/finding-groups/${encodeURIComponent(maliciousCheckId)}/resources`,
|
||||
);
|
||||
expect(url.pathname).not.toContain("/foo/bar/resources");
|
||||
});
|
||||
|
||||
it("should encode a checkId containing special chars like ? and #", async () => {
|
||||
// Given
|
||||
const maliciousCheckId = "check?admin=true#fragment";
|
||||
|
||||
// When
|
||||
await getFindingGroupResources({ checkId: maliciousCheckId });
|
||||
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
expect(calledUrl).not.toContain("?admin=true");
|
||||
expect(calledUrl.split("?")[0]).toContain(
|
||||
`/finding-groups/${encodeURIComponent(maliciousCheckId)}/resources`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLatestFindingGroupResources — SSRF path traversal protection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
handleApiResponseMock.mockResolvedValue({ data: [] });
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
});
|
||||
|
||||
it("should encode a normal checkId without alteration", async () => {
|
||||
// Given
|
||||
const checkId = "iam_user_mfa_enabled";
|
||||
|
||||
// When
|
||||
await getLatestFindingGroupResources({ checkId });
|
||||
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain(
|
||||
`/api/v1/finding-groups/latest/${encodeURIComponent(checkId)}/resources`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should encode a checkId containing a forward slash in the latest endpoint", async () => {
|
||||
// Given
|
||||
const maliciousCheckId = "../other-endpoint";
|
||||
|
||||
// When
|
||||
await getLatestFindingGroupResources({ checkId: maliciousCheckId });
|
||||
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.pathname).not.toContain("/other-endpoint/resources");
|
||||
expect(url.pathname).toContain(
|
||||
`/finding-groups/latest/${encodeURIComponent(maliciousCheckId)}/resources`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Blocker 1: Resources list must show FAIL first (sort=-status)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getFindingGroupResources — Blocker 1: FAIL-first sort", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
handleApiResponseMock.mockResolvedValue({ data: [] });
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
});
|
||||
|
||||
it("should include sort=-status in the API call so FAIL resources appear first", async () => {
|
||||
// Given
|
||||
const checkId = "s3_bucket_public_access";
|
||||
|
||||
// When
|
||||
await getFindingGroupResources({ checkId });
|
||||
|
||||
// Then — the URL must contain sort=-status
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("sort")).toBe("-status");
|
||||
});
|
||||
|
||||
it("should include filter[status]=FAIL in the API call so only impacted resources are shown", async () => {
|
||||
// Given
|
||||
const checkId = "s3_bucket_public_access";
|
||||
|
||||
// When
|
||||
await getFindingGroupResources({ checkId });
|
||||
|
||||
// Then — the URL must contain filter[status]=FAIL
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("filter[status]")).toBe("FAIL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLatestFindingGroupResources — Blocker 1: FAIL-first sort", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
handleApiResponseMock.mockResolvedValue({ data: [] });
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
});
|
||||
|
||||
it("should include sort=-status in the API call so FAIL resources appear first", async () => {
|
||||
// Given
|
||||
const checkId = "iam_user_mfa_enabled";
|
||||
|
||||
// When
|
||||
await getLatestFindingGroupResources({ checkId });
|
||||
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("sort")).toBe("-status");
|
||||
});
|
||||
|
||||
it("should include filter[status]=FAIL in the API call so only impacted resources are shown", async () => {
|
||||
// Given
|
||||
const checkId = "iam_user_mfa_enabled";
|
||||
|
||||
// When
|
||||
await getLatestFindingGroupResources({ checkId });
|
||||
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("filter[status]")).toBe("FAIL");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Triangulation: sort + filter coexist with pagination and caller filters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getFindingGroupResources — triangulation: params coexist", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
handleApiResponseMock.mockResolvedValue({ data: [] });
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
});
|
||||
|
||||
it("should send sort=-status AND filter[status]=FAIL alongside pagination params", async () => {
|
||||
// Given
|
||||
const checkId = "s3_bucket_versioning";
|
||||
|
||||
// When
|
||||
await getFindingGroupResources({ checkId, page: 2, pageSize: 50 });
|
||||
|
||||
// Then — all four params present together
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLatestFindingGroupResources — triangulation: params coexist", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
handleApiResponseMock.mockResolvedValue({ data: [] });
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
});
|
||||
|
||||
it("should send sort=-status AND filter[status]=FAIL alongside pagination params", async () => {
|
||||
// Given
|
||||
const checkId = "iam_root_mfa_enabled";
|
||||
|
||||
// When
|
||||
await getLatestFindingGroupResources({ checkId, page: 3, pageSize: 20 });
|
||||
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Blocker: Duplicate filter[status] — caller-supplied status must be stripped
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getFindingGroupResources — Blocker: caller filter[status] is always overridden to FAIL", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
handleApiResponseMock.mockResolvedValue({ data: [] });
|
||||
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 () => {
|
||||
// Given
|
||||
const checkId = "s3_bucket_public_access";
|
||||
const filters = { "filter[status]": "PASS" };
|
||||
|
||||
// When
|
||||
await getFindingGroupResources({ checkId, filters });
|
||||
|
||||
// Then — no duplicates
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.getAll("filter[status]")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLatestFindingGroupResources — Blocker: caller filter[status] is always overridden to FAIL", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
handleApiResponseMock.mockResolvedValue({ data: [] });
|
||||
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 () => {
|
||||
// Given
|
||||
const checkId = "iam_user_mfa_enabled";
|
||||
const filters = { "filter[status]": "PASS" };
|
||||
|
||||
// When
|
||||
await getLatestFindingGroupResources({ checkId, filters });
|
||||
|
||||
// Then — no duplicates
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.getAll("filter[status]")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -90,13 +90,24 @@ export const getFindingGroupResources = async ({
|
||||
}) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/finding-groups/${checkId}/resources`);
|
||||
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,
|
||||
@@ -123,14 +134,23 @@ export const getLatestFindingGroupResources = async ({
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/finding-groups/latest/${checkId}/resources`,
|
||||
`${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,
|
||||
|
||||
118
ui/actions/findings/findings-by-resource.adapter.test.ts
Normal file
118
ui/actions/findings/findings-by-resource.adapter.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoist mocks BEFORE imports that transitively pull next-auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { createDictMock } = vi.hoisted(() => ({
|
||||
createDictMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
createDict: createDictMock,
|
||||
apiBaseUrl: "https://api.example.com",
|
||||
getAuthHeaders: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import after mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { adaptFindingsByResourceResponse } from "./findings-by-resource.adapter";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 1: adaptFindingsByResourceResponse — unknown + type guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("adaptFindingsByResourceResponse — malformed input", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// createDict returns empty dict by default for most tests
|
||||
createDictMock.mockReturnValue({});
|
||||
});
|
||||
|
||||
it("should return [] when apiResponse is null", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingsByResourceResponse(null);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when apiResponse is undefined", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingsByResourceResponse(undefined);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when apiResponse has no data property", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingsByResourceResponse({ meta: {} });
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when data is not an array", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingsByResourceResponse({ data: "bad" });
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when data is an empty array", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingsByResourceResponse({ data: [], included: [] });
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when data is a number", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingsByResourceResponse({ data: 42 });
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return mapped findings for valid minimal data", () => {
|
||||
// Given — minimal valid JSON:API shape
|
||||
const input = {
|
||||
data: [
|
||||
{
|
||||
id: "finding-1",
|
||||
attributes: {
|
||||
uid: "uid-1",
|
||||
check_id: "s3_check",
|
||||
status: "FAIL",
|
||||
severity: "critical",
|
||||
check_metadata: {
|
||||
checktitle: "S3 Check",
|
||||
},
|
||||
},
|
||||
relationships: {
|
||||
resources: { data: [] },
|
||||
scan: { data: null },
|
||||
},
|
||||
},
|
||||
],
|
||||
included: [],
|
||||
};
|
||||
|
||||
// When
|
||||
const result = adaptFindingsByResourceResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("finding-1");
|
||||
expect(result[0].checkId).toBe("s3_check");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createDict } from "@/lib";
|
||||
import { ProviderType, Severity } from "@/types";
|
||||
import type { ProviderType, Severity } from "@/types";
|
||||
|
||||
export interface RemediationRecommendation {
|
||||
text: string;
|
||||
@@ -119,6 +119,44 @@ function extractComplianceFrameworks(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal shape of a finding item returned by the
|
||||
* `/findings/latest?include=resources,scan.provider` endpoint.
|
||||
*/
|
||||
interface FindingApiAttributes {
|
||||
uid: string;
|
||||
check_id: string;
|
||||
status: string;
|
||||
severity: string;
|
||||
delta?: string | null;
|
||||
muted?: boolean;
|
||||
muted_reason?: string | null;
|
||||
first_seen_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
status_extended?: string;
|
||||
compliance?: Record<string, string[]>;
|
||||
check_metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface FindingApiItem {
|
||||
id: string;
|
||||
attributes: FindingApiAttributes;
|
||||
relationships?: {
|
||||
resources?: { data?: Array<{ id: string }> };
|
||||
scan?: { data?: { id: string } | null };
|
||||
};
|
||||
}
|
||||
|
||||
/** Shape of an included JSON:API resource/scan/provider entry returned by createDict. */
|
||||
interface IncludedItem {
|
||||
id?: string;
|
||||
attributes?: Record<string, unknown>;
|
||||
relationships?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Lookup dict returned by createDict(). */
|
||||
type IncludedDict = Record<string, IncludedItem>;
|
||||
|
||||
/**
|
||||
* Transforms the `/findings/latest?include=resources,scan.provider` response
|
||||
* into a flat ResourceDrawerFinding array.
|
||||
@@ -126,42 +164,82 @@ function extractComplianceFrameworks(
|
||||
* Uses createDict to build lookup maps from the JSON:API `included` array,
|
||||
* then resolves each finding's resource and provider relationships.
|
||||
*/
|
||||
interface JsonApiResponse {
|
||||
data: FindingApiItem[];
|
||||
included?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
function isJsonApiResponse(value: unknown): value is JsonApiResponse {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
"data" in value &&
|
||||
Array.isArray((value as { data: unknown }).data)
|
||||
);
|
||||
}
|
||||
|
||||
export function adaptFindingsByResourceResponse(
|
||||
apiResponse: any,
|
||||
apiResponse: unknown,
|
||||
): ResourceDrawerFinding[] {
|
||||
if (!apiResponse?.data || !Array.isArray(apiResponse.data)) {
|
||||
if (!isJsonApiResponse(apiResponse)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resourcesDict = createDict("resources", apiResponse);
|
||||
const scansDict = createDict("scans", apiResponse);
|
||||
const providersDict = createDict("providers", apiResponse);
|
||||
const resourcesDict = createDict("resources", apiResponse) as IncludedDict;
|
||||
const scansDict = createDict("scans", apiResponse) as IncludedDict;
|
||||
const providersDict = createDict("providers", apiResponse) as IncludedDict;
|
||||
|
||||
return apiResponse.data.map((item: any) => {
|
||||
return apiResponse.data.map((item) => {
|
||||
const attrs = item.attributes;
|
||||
const meta = attrs.check_metadata || {};
|
||||
const remediation = meta.remediation || {
|
||||
const meta = (attrs.check_metadata || {}) as Record<string, unknown>;
|
||||
const remediationRaw = meta.remediation as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const remediation = remediationRaw || {
|
||||
recommendation: { text: "", url: "" },
|
||||
code: { cli: "", other: "", nativeiac: "", terraform: "" },
|
||||
};
|
||||
|
||||
// Resolve resource from included
|
||||
const resourceRel = item.relationships?.resources?.data?.[0];
|
||||
const resource = resourceRel ? resourcesDict[resourceRel.id] : null;
|
||||
const resourceAttrs = resource?.attributes || {};
|
||||
const resource: IncludedItem | null = resourceRel
|
||||
? (resourcesDict[resourceRel.id] ?? null)
|
||||
: null;
|
||||
const resourceAttrs = (resource?.attributes || {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
// Resolve provider via scan → provider (include path: scan.provider)
|
||||
const scanRel = item.relationships?.scan?.data;
|
||||
const scan = scanRel ? scansDict[scanRel.id] : null;
|
||||
const providerRelId = scan?.relationships?.provider?.data?.id ?? null;
|
||||
const provider = providerRelId ? providersDict[providerRelId] : null;
|
||||
const providerAttrs = provider?.attributes || {};
|
||||
const scan: IncludedItem | null = scanRel
|
||||
? (scansDict[scanRel.id] ?? null)
|
||||
: null;
|
||||
const scanRels = scan?.relationships as Record<string, unknown> | undefined;
|
||||
const providerRelId =
|
||||
((
|
||||
(scanRels?.provider as Record<string, unknown> | undefined)?.data as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
)?.id as string | null) ?? null;
|
||||
const provider: IncludedItem | null = providerRelId
|
||||
? (providersDict[providerRelId] ?? null)
|
||||
: null;
|
||||
const providerAttrs = (provider?.attributes || {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const remRec = remediation.recommendation as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const remCode = remediation.code as Record<string, unknown> | undefined;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
uid: attrs.uid,
|
||||
checkId: attrs.check_id,
|
||||
checkTitle: meta.checktitle || attrs.check_id,
|
||||
checkTitle: (meta.checktitle as string | undefined) || attrs.check_id,
|
||||
status: attrs.status,
|
||||
severity: (attrs.severity || "informational") as Severity,
|
||||
delta: attrs.delta || null,
|
||||
@@ -171,52 +249,59 @@ export function adaptFindingsByResourceResponse(
|
||||
updatedAt: attrs.updated_at || null,
|
||||
// Resource
|
||||
resourceId: resourceRel?.id || "",
|
||||
resourceUid: resourceAttrs.uid || "-",
|
||||
resourceName: resourceAttrs.name || "-",
|
||||
resourceService: resourceAttrs.service || "-",
|
||||
resourceRegion: resourceAttrs.region || "-",
|
||||
resourceType: resourceAttrs.type || "-",
|
||||
resourceGroup: meta.resourcegroup || "-",
|
||||
resourceUid: (resourceAttrs.uid as string | undefined) || "-",
|
||||
resourceName: (resourceAttrs.name as string | undefined) || "-",
|
||||
resourceService: (resourceAttrs.service as string | undefined) || "-",
|
||||
resourceRegion: (resourceAttrs.region as string | undefined) || "-",
|
||||
resourceType: (resourceAttrs.type as string | undefined) || "-",
|
||||
resourceGroup: (meta.resourcegroup as string | undefined) || "-",
|
||||
// Provider
|
||||
providerType: (providerAttrs.provider || "aws") as ProviderType,
|
||||
providerAlias: providerAttrs.alias || "",
|
||||
providerUid: providerAttrs.uid || "",
|
||||
providerType: ((providerAttrs.provider as string | undefined) ||
|
||||
"aws") as ProviderType,
|
||||
providerAlias: (providerAttrs.alias as string | undefined) || "",
|
||||
providerUid: (providerAttrs.uid as string | undefined) || "",
|
||||
// Check metadata
|
||||
risk: meta.risk || "",
|
||||
description: meta.description || "",
|
||||
risk: (meta.risk as string | undefined) || "",
|
||||
description: (meta.description as string | undefined) || "",
|
||||
statusExtended: attrs.status_extended || "",
|
||||
complianceFrameworks: extractComplianceFrameworks(
|
||||
meta.compliance ?? meta.Compliance,
|
||||
(meta.compliance ?? meta.Compliance) as unknown,
|
||||
attrs.compliance,
|
||||
),
|
||||
categories: meta.categories || [],
|
||||
categories: (meta.categories as string[] | undefined) || [],
|
||||
remediation: {
|
||||
recommendation: {
|
||||
text: remediation.recommendation?.text || "",
|
||||
url: remediation.recommendation?.url || "",
|
||||
text: (remRec?.text as string | undefined) || "",
|
||||
url: (remRec?.url as string | undefined) || "",
|
||||
},
|
||||
code: {
|
||||
cli: remediation.code?.cli || "",
|
||||
other: remediation.code?.other || "",
|
||||
nativeiac: remediation.code?.nativeiac || "",
|
||||
terraform: remediation.code?.terraform || "",
|
||||
cli: (remCode?.cli as string | undefined) || "",
|
||||
other: (remCode?.other as string | undefined) || "",
|
||||
nativeiac: (remCode?.nativeiac as string | undefined) || "",
|
||||
terraform: (remCode?.terraform as string | undefined) || "",
|
||||
},
|
||||
},
|
||||
additionalUrls: meta.additionalurls || [],
|
||||
additionalUrls: (meta.additionalurls as string[] | undefined) || [],
|
||||
// Scan
|
||||
scan: scan?.attributes
|
||||
? {
|
||||
id: scan.id || "",
|
||||
name: scan.attributes.name || "",
|
||||
trigger: scan.attributes.trigger || "",
|
||||
state: scan.attributes.state || "",
|
||||
uniqueResourceCount: scan.attributes.unique_resource_count || 0,
|
||||
progress: scan.attributes.progress || 0,
|
||||
duration: scan.attributes.duration || 0,
|
||||
startedAt: scan.attributes.started_at || null,
|
||||
completedAt: scan.attributes.completed_at || null,
|
||||
insertedAt: scan.attributes.inserted_at || null,
|
||||
scheduledAt: scan.attributes.scheduled_at || null,
|
||||
id: (scan.id as string | undefined) || "",
|
||||
name: (scan.attributes.name as string | undefined) || "",
|
||||
trigger: (scan.attributes.trigger as string | undefined) || "",
|
||||
state: (scan.attributes.state as string | undefined) || "",
|
||||
uniqueResourceCount:
|
||||
(scan.attributes.unique_resource_count as number | undefined) ||
|
||||
0,
|
||||
progress: (scan.attributes.progress as number | undefined) || 0,
|
||||
duration: (scan.attributes.duration as number | undefined) || 0,
|
||||
startedAt:
|
||||
(scan.attributes.started_at as string | undefined) || null,
|
||||
completedAt:
|
||||
(scan.attributes.completed_at as string | undefined) || null,
|
||||
insertedAt:
|
||||
(scan.attributes.inserted_at as string | undefined) || null,
|
||||
scheduledAt:
|
||||
(scan.attributes.scheduled_at as string | undefined) || null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
@@ -267,3 +267,156 @@ describe("resolveFindingIdsByVisibleGroupResources", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Blocker 3: Muting a group mutes ALL historical findings, not just FAIL ones
|
||||
//
|
||||
// The fix: resolveFindingIds must include filter[status]=FAIL so only active
|
||||
// (failing) findings are resolved for mute, not historical/passing ones.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("resolveFindingIds — Blocker 3: only resolve FAIL findings for mute", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
});
|
||||
|
||||
it("should include filter[status]=FAIL in the findings resolution URL for mute", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: [{ id: "finding-1" }, { id: "finding-2" }],
|
||||
});
|
||||
|
||||
// When
|
||||
await resolveFindingIds({
|
||||
checkId: "check-1",
|
||||
resourceUids: ["resource-1", "resource-2"],
|
||||
});
|
||||
|
||||
// Then — the URL must filter to only FAIL status findings
|
||||
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
||||
expect(calledUrl.searchParams.get("filter[status]")).toBe("FAIL");
|
||||
});
|
||||
|
||||
it("should include filter[status]=FAIL even when date or scan filters are active", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: [{ id: "finding-1" }],
|
||||
});
|
||||
|
||||
// When
|
||||
await resolveFindingIds({
|
||||
checkId: "check-1",
|
||||
resourceUids: ["resource-1"],
|
||||
hasDateOrScanFilter: true,
|
||||
filters: {
|
||||
"filter[inserted_at__gte]": "2026-01-01",
|
||||
},
|
||||
});
|
||||
|
||||
// Then
|
||||
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
||||
expect(calledUrl.pathname).toBe("/api/v1/findings");
|
||||
expect(calledUrl.searchParams.get("filter[status]")).toBe("FAIL");
|
||||
});
|
||||
|
||||
it("should override caller filter[status] with FAIL — no duplicate params", async () => {
|
||||
// Given — caller passes filter[status]=PASS via filters dict
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: [{ id: "finding-1" }],
|
||||
});
|
||||
|
||||
// When
|
||||
await resolveFindingIds({
|
||||
checkId: "check-1",
|
||||
resourceUids: ["resource-1"],
|
||||
filters: {
|
||||
"filter[status]": "PASS",
|
||||
},
|
||||
});
|
||||
|
||||
// Then — hardcoded FAIL must win, exactly 1 value
|
||||
const calledUrl = new URL(fetchMock.mock.calls[0][0] as string);
|
||||
const statusValues = calledUrl.searchParams.getAll("filter[status]");
|
||||
expect(statusValues).toHaveLength(1);
|
||||
expect(statusValues[0]).toBe("FAIL");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 4: Unbounded page[size] cap
|
||||
//
|
||||
// The bug: createResourceFindingResolutionUrl sets page[size]=resourceUids.length
|
||||
// with no upper bound guard. The production fix adds Math.min(resourceUids.length, MAX_PAGE_SIZE)
|
||||
// with MAX_PAGE_SIZE=500 as an explicit defensive cap.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("resolveFindingIds — Fix 4: page[size] explicit cap at MAX_PAGE_SIZE=500", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
});
|
||||
|
||||
it("should use resourceUids.length as page[size] for a small batch (under 500)", async () => {
|
||||
// Given — 3 resources, well under the cap
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: [{ id: "finding-1" }, { id: "finding-2" }, { id: "finding-3" }],
|
||||
});
|
||||
|
||||
// When
|
||||
await resolveFindingIds({
|
||||
checkId: "check-1",
|
||||
resourceUids: ["resource-1", "resource-2", "resource-3"],
|
||||
});
|
||||
|
||||
// Then — page[size] should equal the number of resourceUids (3)
|
||||
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
||||
expect(calledUrl.searchParams.get("page[size]")).toBe("3");
|
||||
});
|
||||
|
||||
it("should cap page[size] at 500 when the chunk has exactly 500 UIDs (boundary value)", async () => {
|
||||
// Given — exactly 500 unique UIDs (at the cap boundary)
|
||||
const resourceUids = Array.from({ length: 500 }, (_, i) => `resource-${i}`);
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
handleApiResponseMock.mockResolvedValue({ data: [] });
|
||||
|
||||
// When
|
||||
await resolveFindingIds({
|
||||
checkId: "check-1",
|
||||
resourceUids,
|
||||
});
|
||||
|
||||
// Then — page[size] must be exactly 500 (not capped lower)
|
||||
const firstUrl = new URL(fetchMock.mock.calls[0][0] as string);
|
||||
expect(firstUrl.searchParams.get("page[size]")).toBe("500");
|
||||
});
|
||||
|
||||
it("should cap page[size] at 500 even when a chunk would exceed 500 — Math.min guard in URL builder", async () => {
|
||||
// Given — 501 UIDs. The chunker splits into [500, 1].
|
||||
// The FIRST chunk has 500 UIDs → page[size] should be 500 (Math.min(500, 500)).
|
||||
// The SECOND chunk has 1 UID → page[size] should be 1 (Math.min(1, 500)).
|
||||
// This proves the Math.min cap fires correctly on every chunk.
|
||||
const resourceUids = Array.from({ length: 501 }, (_, i) => `resource-${i}`);
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
handleApiResponseMock.mockResolvedValue({ data: [] });
|
||||
|
||||
// When
|
||||
await resolveFindingIds({
|
||||
checkId: "check-1",
|
||||
resourceUids,
|
||||
});
|
||||
|
||||
// Then — two fetch calls: one for 500 UIDs, one for 1 UID
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const firstUrl = new URL(fetchMock.mock.calls[0][0] as string);
|
||||
const secondUrl = new URL(fetchMock.mock.calls[1][0] as string);
|
||||
expect(firstUrl.searchParams.get("page[size]")).toBe("500");
|
||||
expect(secondUrl.searchParams.get("page[size]")).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,9 @@ const FINDING_IDS_RESOLUTION_CONCURRENCY = 4;
|
||||
const FINDING_GROUP_RESOURCES_RESOLUTION_PAGE_SIZE = 500;
|
||||
const FINDING_FIELDS = "uid";
|
||||
|
||||
/** Explicit upper bound for page[size] in resource-finding resolution requests. */
|
||||
const MAX_RESOURCE_FINDING_PAGE_SIZE = 500;
|
||||
|
||||
interface ResolveFindingIdsByCheckIdsParams {
|
||||
checkIds: string[];
|
||||
filters?: Record<string, string>;
|
||||
@@ -117,10 +120,17 @@ function createResourceFindingResolutionUrl({
|
||||
url.searchParams.append("filter[check_id]", checkId);
|
||||
url.searchParams.append("filter[resource_uid__in]", resourceUids.join(","));
|
||||
url.searchParams.append("filter[muted]", "false");
|
||||
url.searchParams.append("page[size]", resourceUids.length.toString());
|
||||
url.searchParams.append(
|
||||
"page[size]",
|
||||
Math.min(resourceUids.length, MAX_RESOURCE_FINDING_PAGE_SIZE).toString(),
|
||||
);
|
||||
|
||||
appendSanitizedProviderTypeFilters(url, filters);
|
||||
|
||||
// Hardcoded FAIL filter AFTER appendSanitizedProviderTypeFilters — .set()
|
||||
// guarantees this wins even if the caller passes filter[status] in filters.
|
||||
url.searchParams.set("filter[status]", "FAIL");
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,14 @@ import { CustomSearchInput } from "./custom-search-input";
|
||||
export interface FilterControlsProps {
|
||||
search?: boolean;
|
||||
customFilters?: FilterOption[];
|
||||
/** Element rendered at the start of the filter grid (e.g. an active-filter chip) */
|
||||
prependElement?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FilterControls = ({
|
||||
search = false,
|
||||
customFilters,
|
||||
prependElement,
|
||||
}: FilterControlsProps) => {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
@@ -23,7 +26,10 @@ export const FilterControls = ({
|
||||
</div>
|
||||
{customFilters && customFilters.length > 0 && (
|
||||
<>
|
||||
<DataTableFilterCustom filters={customFilters} />
|
||||
<DataTableFilterCustom
|
||||
filters={customFilters}
|
||||
prependElement={prependElement}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
111
ui/components/findings/floating-mute-button.test.tsx
Normal file
111
ui/components/findings/floating-mute-button.test.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoist mocks to avoid deep dependency chains
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { MuteFindingsModalMock } = vi.hoisted(() => ({
|
||||
MuteFindingsModalMock: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("./mute-findings-modal", () => ({
|
||||
MuteFindingsModal: MuteFindingsModalMock,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import after mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { FloatingMuteButton } from "./floating-mute-button";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 3: onBeforeOpen rejection resets isResolving
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("FloatingMuteButton — onBeforeOpen error handling", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("should reset isResolving (re-enable button) when onBeforeOpen rejects", async () => {
|
||||
// Given — onBeforeOpen always throws
|
||||
const onBeforeOpen = vi.fn().mockRejectedValue(new Error("Network error"));
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<FloatingMuteButton
|
||||
selectedCount={3}
|
||||
selectedFindingIds={[]}
|
||||
onBeforeOpen={onBeforeOpen}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
|
||||
// When — click the button (triggers onBeforeOpen which rejects)
|
||||
await user.click(button);
|
||||
|
||||
// Then — button should NOT be disabled (isResolving reset to false)
|
||||
await waitFor(() => {
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should log the error when onBeforeOpen rejects", async () => {
|
||||
// Given
|
||||
const error = new Error("Fetch failed");
|
||||
const onBeforeOpen = vi.fn().mockRejectedValue(error);
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<FloatingMuteButton
|
||||
selectedCount={2}
|
||||
selectedFindingIds={[]}
|
||||
onBeforeOpen={onBeforeOpen}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
// Then — error was logged
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should open modal when onBeforeOpen resolves successfully", async () => {
|
||||
// Given
|
||||
const onBeforeOpen = vi.fn().mockResolvedValue(["id-1", "id-2"]);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<FloatingMuteButton
|
||||
selectedCount={2}
|
||||
selectedFindingIds={[]}
|
||||
onBeforeOpen={onBeforeOpen}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
// Then — modal opened (MuteFindingsModal called with isOpen=true)
|
||||
await waitFor(() => {
|
||||
const lastCall = (
|
||||
MuteFindingsModalMock.mock.calls as unknown as Array<
|
||||
[{ isOpen: boolean; findingIds: string[] }]
|
||||
>
|
||||
).at(-1);
|
||||
expect(lastCall?.[0]?.isOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -35,11 +35,19 @@ export function FloatingMuteButton({
|
||||
const handleClick = async () => {
|
||||
if (onBeforeOpen) {
|
||||
setIsResolving(true);
|
||||
const ids = await onBeforeOpen();
|
||||
setResolvedIds(ids);
|
||||
setIsResolving(false);
|
||||
if (ids.length > 0) {
|
||||
setIsModalOpen(true);
|
||||
try {
|
||||
const ids = await onBeforeOpen();
|
||||
setResolvedIds(ids);
|
||||
if (ids.length > 0) {
|
||||
setIsModalOpen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"FloatingMuteButton: failed to resolve finding IDs",
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
setIsResolving(false);
|
||||
}
|
||||
} else {
|
||||
setIsModalOpen(true);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/actions/integrations/jira-dispatch";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomBanner } from "@/components/ui/custom/custom-banner";
|
||||
import { Form, FormField, FormMessage } from "@/components/ui/form";
|
||||
@@ -261,8 +262,16 @@ export const SendToJiraModal = ({
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{/* Loading skeleton for project selector */}
|
||||
{isFetchingIntegrations && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-12 w-full rounded-md" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Integration Selection */}
|
||||
{integrations.length > 1 && (
|
||||
{!isFetchingIntegrations && integrations.length > 1 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="integration"
|
||||
@@ -302,41 +311,43 @@ export const SendToJiraModal = ({
|
||||
)}
|
||||
|
||||
{/* Project Selection */}
|
||||
{selectedIntegration && projectEntries.length > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="project"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="jira-project-select"
|
||||
className="text-text-neutral-secondary text-xs font-light tracking-tight"
|
||||
>
|
||||
Project
|
||||
</label>
|
||||
<EnhancedMultiSelect
|
||||
id="jira-project-select"
|
||||
options={projectOptions}
|
||||
onValueChange={(values) => {
|
||||
const selectedValue = values.at(-1) ?? "";
|
||||
field.onChange(selectedValue);
|
||||
// Reset issue type when project changes
|
||||
form.setValue("issueType", "");
|
||||
}}
|
||||
defaultValue={field.value ? [field.value] : []}
|
||||
placeholder="Select a Jira project"
|
||||
searchable={true}
|
||||
emptyIndicator="No projects found."
|
||||
hideSelectAll={true}
|
||||
maxCount={1}
|
||||
closeOnSelect={true}
|
||||
resetOnDefaultValueChange={true}
|
||||
/>
|
||||
<FormMessage className="text-text-error text-xs" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!isFetchingIntegrations &&
|
||||
selectedIntegration &&
|
||||
projectEntries.length > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="project"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="jira-project-select"
|
||||
className="text-text-neutral-secondary text-xs font-light tracking-tight"
|
||||
>
|
||||
Project
|
||||
</label>
|
||||
<EnhancedMultiSelect
|
||||
id="jira-project-select"
|
||||
options={projectOptions}
|
||||
onValueChange={(values) => {
|
||||
const selectedValue = values.at(-1) ?? "";
|
||||
field.onChange(selectedValue);
|
||||
// Reset issue type when project changes
|
||||
form.setValue("issueType", "");
|
||||
}}
|
||||
defaultValue={field.value ? [field.value] : []}
|
||||
placeholder="Select a Jira project"
|
||||
searchable={true}
|
||||
emptyIndicator="No projects found."
|
||||
hideSelectAll={true}
|
||||
maxCount={1}
|
||||
closeOnSelect={true}
|
||||
resetOnDefaultValueChange={true}
|
||||
/>
|
||||
<FormMessage className="text-text-error text-xs" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Issue Type Selection */}
|
||||
{selectedProject && (
|
||||
|
||||
194
ui/components/findings/table/column-finding-groups.test.tsx
Normal file
194
ui/components/findings/table/column-finding-groups.test.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { InputHTMLAttributes, ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoist mocks for dependencies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
useRouter: () => ({ refresh: vi.fn() }),
|
||||
usePathname: () => "/findings",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Checkbox: ({
|
||||
"aria-label": ariaLabel,
|
||||
...props
|
||||
}: InputHTMLAttributes<HTMLInputElement> & {
|
||||
"aria-label"?: string;
|
||||
size?: string;
|
||||
}) => <input type="checkbox" aria-label={ariaLabel} {...props} />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
DataTableColumnHeader: ({
|
||||
title,
|
||||
}: {
|
||||
column: unknown;
|
||||
title: string;
|
||||
param?: string;
|
||||
}) => <span>{title}</span>,
|
||||
SeverityBadge: ({ severity }: { severity: string }) => (
|
||||
<span>{severity}</span>
|
||||
),
|
||||
StatusFindingBadge: ({ status }: { status: string }) => <span>{status}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
cn: (...args: (string | undefined | false | null)[]) =>
|
||||
args.filter(Boolean).join(" "),
|
||||
}));
|
||||
|
||||
vi.mock("./data-table-row-actions", () => ({
|
||||
DataTableRowActions: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./impacted-providers-cell", () => ({
|
||||
ImpactedProvidersCell: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./impacted-resources-cell", () => ({
|
||||
ImpactedResourcesCell: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./notification-indicator", () => ({
|
||||
DeltaValues: { NEW: "new", CHANGED: "changed", NONE: "none" },
|
||||
NotificationIndicator: () => null,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import after mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { FindingGroupRow } from "@/types";
|
||||
|
||||
import { getColumnFindingGroups } from "./column-finding-groups";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeGroup(overrides?: Partial<FindingGroupRow>): FindingGroupRow {
|
||||
return {
|
||||
id: "group-1",
|
||||
rowType: "group" as const,
|
||||
checkId: "s3_check",
|
||||
checkTitle: "S3 Bucket Public Access",
|
||||
severity: "critical",
|
||||
status: "FAIL",
|
||||
resourcesTotal: 5,
|
||||
resourcesFail: 3,
|
||||
newCount: 0,
|
||||
changedCount: 0,
|
||||
mutedCount: 0,
|
||||
providers: ["aws"],
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
} as FindingGroupRow;
|
||||
}
|
||||
|
||||
function renderFindingCell(
|
||||
checkTitle: string,
|
||||
onDrillDown: (checkId: string, group: FindingGroupRow) => void,
|
||||
) {
|
||||
const columns = getColumnFindingGroups({
|
||||
rowSelection: {},
|
||||
selectableRowCount: 1,
|
||||
onDrillDown,
|
||||
});
|
||||
|
||||
// Find the "finding" column (index 2 — the title column)
|
||||
const findingColumn = columns.find(
|
||||
(col) => (col as { accessorKey?: string }).accessorKey === "finding",
|
||||
);
|
||||
if (!findingColumn?.cell) throw new Error("finding column not found");
|
||||
|
||||
const group = makeGroup({ checkTitle });
|
||||
// Render the cell directly with a minimal row mock
|
||||
const CellComponent = findingColumn.cell as (props: {
|
||||
row: { original: FindingGroupRow };
|
||||
}) => ReactNode;
|
||||
|
||||
render(<div>{CellComponent({ row: { original: group } })}</div>);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 5: Accessibility — <p onClick> → <button>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("column-finding-groups — accessibility of check title cell", () => {
|
||||
it("should render the check title as a button element (not a <p>)", () => {
|
||||
// Given
|
||||
const onDrillDown =
|
||||
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
|
||||
|
||||
// When
|
||||
renderFindingCell("S3 Bucket Public Access", onDrillDown);
|
||||
|
||||
// Then — there should be a button with the check title text
|
||||
const button = screen.getByRole("button", {
|
||||
name: "S3 Bucket Public Access",
|
||||
});
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button.tagName.toLowerCase()).toBe("button");
|
||||
});
|
||||
|
||||
it("should NOT render the check title as a <p> element", () => {
|
||||
// Given
|
||||
const onDrillDown =
|
||||
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
|
||||
|
||||
// When
|
||||
renderFindingCell("S3 Bucket Public Access", onDrillDown);
|
||||
|
||||
// Then — <p> should not exist as the interactive element
|
||||
const paragraphs = document.querySelectorAll("p");
|
||||
const clickableParagraph = Array.from(paragraphs).find(
|
||||
(p) => p.textContent === "S3 Bucket Public Access",
|
||||
);
|
||||
expect(clickableParagraph).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should call onDrillDown when the button is clicked", async () => {
|
||||
// Given
|
||||
const onDrillDown =
|
||||
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderFindingCell("S3 Bucket Public Access", onDrillDown);
|
||||
|
||||
// When
|
||||
const button = screen.getByRole("button", {
|
||||
name: "S3 Bucket Public Access",
|
||||
});
|
||||
await user.click(button);
|
||||
|
||||
// Then
|
||||
expect(onDrillDown).toHaveBeenCalledTimes(1);
|
||||
expect(onDrillDown).toHaveBeenCalledWith(
|
||||
"s3_check",
|
||||
expect.objectContaining({ checkId: "s3_check" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should call onDrillDown when Enter key is pressed on the button", async () => {
|
||||
// Given
|
||||
const onDrillDown =
|
||||
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderFindingCell("My Check Title", onDrillDown);
|
||||
|
||||
// When — tab to button and press Enter
|
||||
const button = screen.getByRole("button", { name: "My Check Title" });
|
||||
button.focus();
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
// Then — native button handles Enter natively
|
||||
expect(onDrillDown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -80,22 +80,28 @@ export function getColumnFindingGroups({
|
||||
? DeltaValues.CHANGED
|
||||
: DeltaValues.NONE;
|
||||
|
||||
const canExpand = group.resourcesFail > 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationIndicator delta={delta} isMuted={allMuted} />
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Expand ${group.checkTitle}`}
|
||||
className="hover:bg-bg-neutral-tertiary flex size-4 shrink-0 items-center justify-center rounded-md transition-colors"
|
||||
onClick={() => onDrillDown(group.checkId, group)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"text-text-neutral-secondary size-4 transition-transform duration-200",
|
||||
isExpanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{canExpand ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Expand ${group.checkTitle}`}
|
||||
className="hover:bg-bg-neutral-tertiary flex size-4 shrink-0 items-center justify-center rounded-md transition-colors"
|
||||
onClick={() => onDrillDown(group.checkId, group)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"text-text-neutral-secondary size-4 transition-transform duration-200",
|
||||
isExpanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div className="size-4 shrink-0" />
|
||||
)}
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={
|
||||
@@ -149,15 +155,23 @@ export function getColumnFindingGroups({
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const group = row.original;
|
||||
const canExpand = group.resourcesFail > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p
|
||||
className="text-text-neutral-primary hover:text-button-tertiary cursor-pointer text-left text-sm break-words whitespace-normal hover:underline"
|
||||
onClick={() => onDrillDown(group.checkId, group)}
|
||||
>
|
||||
{group.checkTitle}
|
||||
</p>
|
||||
{canExpand ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-text-neutral-primary hover:text-button-tertiary w-full cursor-pointer border-none bg-transparent p-0 text-left text-sm break-words whitespace-normal hover:underline"
|
||||
onClick={() => onDrillDown(group.checkId, group)}
|
||||
>
|
||||
{group.checkTitle}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-text-neutral-primary w-full text-left text-sm break-words whitespace-normal">
|
||||
{group.checkTitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Container, CornerDownRight, VolumeOff, VolumeX } from "lucide-react";
|
||||
import { useContext, useState } from "react";
|
||||
|
||||
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
|
||||
import { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
|
||||
import { JiraIcon } from "@/components/icons/services/IconServices";
|
||||
import { Checkbox } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
@@ -29,6 +31,7 @@ import { NotificationIndicator } from "./notification-indicator";
|
||||
const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
|
||||
const resource = row.original;
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
|
||||
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
|
||||
const [isResolving, setIsResolving] = useState(false);
|
||||
|
||||
@@ -86,6 +89,12 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
|
||||
onComplete={handleMuteComplete}
|
||||
/>
|
||||
)}
|
||||
<SendToJiraModal
|
||||
isOpen={isJiraModalOpen}
|
||||
onOpenChange={setIsJiraModalOpen}
|
||||
findingId={resource.findingId}
|
||||
findingTitle={resource.checkId}
|
||||
/>
|
||||
<div
|
||||
className="flex items-center justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -105,6 +114,11 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
|
||||
disabled={resource.isMuted || isResolving}
|
||||
onSelect={handleMuteClick}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={<JiraIcon size={20} />}
|
||||
label="Send to Jira"
|
||||
onSelect={() => setIsJiraModalOpen(true)}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -49,6 +49,9 @@ export function FindingsGroupTable({
|
||||
const [expandedGroup, setExpandedGroup] = useState<FindingGroupRow | null>(
|
||||
null,
|
||||
);
|
||||
// Separate display state (updates on keystroke) from committed search (updates on Enter only).
|
||||
// This prevents InlineResourceContainer from remounting on every keystroke.
|
||||
const [resourceSearchInput, setResourceSearchInput] = useState("");
|
||||
const [resourceSearch, setResourceSearch] = useState("");
|
||||
const [resourceSelection, setResourceSelection] = useState<string[]>([]);
|
||||
const inlineRef = useRef<InlineResourceContainerHandle>(null);
|
||||
@@ -133,6 +136,9 @@ export function FindingsGroupTable({
|
||||
};
|
||||
|
||||
const handleDrillDown = (checkId: string, group: FindingGroupRow) => {
|
||||
// No impacted resources → nothing to show, skip drill-down
|
||||
if (group.resourcesFail === 0) return;
|
||||
|
||||
// Toggle: same group = collapse, different = switch
|
||||
if (expandedCheckId === checkId) {
|
||||
handleCollapse();
|
||||
@@ -140,6 +146,7 @@ export function FindingsGroupTable({
|
||||
}
|
||||
setExpandedCheckId(checkId);
|
||||
setExpandedGroup(group);
|
||||
setResourceSearchInput("");
|
||||
setResourceSearch("");
|
||||
setResourceSelection([]);
|
||||
};
|
||||
@@ -147,6 +154,7 @@ export function FindingsGroupTable({
|
||||
const handleCollapse = () => {
|
||||
setExpandedCheckId(null);
|
||||
setExpandedGroup(null);
|
||||
setResourceSearchInput("");
|
||||
setResourceSearch("");
|
||||
setResourceSelection([]);
|
||||
};
|
||||
@@ -197,8 +205,9 @@ export function FindingsGroupTable({
|
||||
searchPlaceholder={
|
||||
expandedCheckId ? "Search resources..." : "Search by name"
|
||||
}
|
||||
controlledSearch={expandedCheckId ? resourceSearch : undefined}
|
||||
onSearchChange={expandedCheckId ? setResourceSearch : undefined}
|
||||
controlledSearch={expandedCheckId ? resourceSearchInput : undefined}
|
||||
onSearchChange={expandedCheckId ? setResourceSearchInput : undefined}
|
||||
onSearchCommit={expandedCheckId ? setResourceSearch : undefined}
|
||||
searchBadge={
|
||||
expandedGroup
|
||||
? { label: expandedGroup.checkTitle, onDismiss: handleCollapse }
|
||||
|
||||
@@ -11,7 +11,6 @@ import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ChevronsDown } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useImperativeHandle, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { resolveFindingIds } from "@/actions/findings/findings-by-resource";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
@@ -133,7 +132,6 @@ export function InlineResourceContainer({
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [resources, setResources] = useState<FindingResourceRow[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Scroll hint: shows "scroll for more" when content overflows
|
||||
const {
|
||||
containerRef: scrollHintContainerRef,
|
||||
@@ -308,7 +306,7 @@ export function InlineResourceContainer({
|
||||
className="max-h-[440px] overflow-y-auto pl-6"
|
||||
>
|
||||
{/* Resource rows or skeleton placeholder */}
|
||||
<table className="mt-[-10] w-full border-separate border-spacing-y-4">
|
||||
<table className="-mt-2.5 w-full border-separate border-spacing-y-4">
|
||||
<tbody>
|
||||
{isLoading && rows.length === 0 ? (
|
||||
Array.from({
|
||||
@@ -323,7 +321,18 @@ export function InlineResourceContainer({
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="cursor-pointer"
|
||||
onClick={() => drawer.openDrawer(row.index)}
|
||||
onClick={(e) => {
|
||||
// Don't open drawer if clicking interactive elements
|
||||
// (links, buttons, checkboxes, dropdown items)
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.closest(
|
||||
"a, button, input, [role=menuitem]",
|
||||
)
|
||||
)
|
||||
return;
|
||||
drawer.openDrawer(row.index);
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
@@ -390,25 +399,22 @@ export function InlineResourceContainer({
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{createPortal(
|
||||
<ResourceDetailDrawer
|
||||
open={drawer.isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) drawer.closeDrawer();
|
||||
}}
|
||||
isLoading={drawer.isLoading}
|
||||
isNavigating={drawer.isNavigating}
|
||||
checkMeta={drawer.checkMeta}
|
||||
currentIndex={drawer.currentIndex}
|
||||
totalResources={drawer.totalResources}
|
||||
currentFinding={drawer.currentFinding}
|
||||
otherFindings={drawer.otherFindings}
|
||||
onNavigatePrev={drawer.navigatePrev}
|
||||
onNavigateNext={drawer.navigateNext}
|
||||
onMuteComplete={handleDrawerMuteComplete}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
<ResourceDetailDrawer
|
||||
open={drawer.isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) drawer.closeDrawer();
|
||||
}}
|
||||
isLoading={drawer.isLoading}
|
||||
isNavigating={drawer.isNavigating}
|
||||
checkMeta={drawer.checkMeta}
|
||||
currentIndex={drawer.currentIndex}
|
||||
totalResources={drawer.totalResources}
|
||||
currentFinding={drawer.currentFinding}
|
||||
otherFindings={drawer.otherFindings}
|
||||
onNavigatePrev={drawer.navigatePrev}
|
||||
onNavigateNext={drawer.navigateNext}
|
||||
onMuteComplete={handleDrawerMuteComplete}
|
||||
/>
|
||||
</FindingsSelectionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { MutedIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/shadcn/popover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -29,40 +37,11 @@ export const NotificationIndicator = ({
|
||||
isMuted = false,
|
||||
mutedReason,
|
||||
}: NotificationIndicatorProps) => {
|
||||
// Muted takes precedence over delta
|
||||
// Muted takes precedence over delta.
|
||||
// Uses Popover (not Tooltip) because the content has an interactive link.
|
||||
// Radix Tooltip does not support interactive content — clicks fall through.
|
||||
if (isMuted) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="flex w-2 shrink-0 cursor-pointer items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MutedIcon className="text-bg-data-muted size-2" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent onClick={(e) => e.stopPropagation()}>
|
||||
<Link
|
||||
href="/mutelist"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-button-tertiary hover:text-button-tertiary-hover flex items-center gap-1 text-xs underline-offset-4"
|
||||
>
|
||||
{/* TODO: always show rule name once the API returns muted_reason in finding-group-resources */}
|
||||
{mutedReason ? (
|
||||
<>
|
||||
<span className="text-text-neutral-primary">Mute rule:</span>
|
||||
<span className="max-w-[150px] truncate">{mutedReason}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-text-neutral-primary">Mute rule:</span>
|
||||
<span>view rules</span>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
return <MutedIndicator mutedReason={mutedReason} />;
|
||||
}
|
||||
|
||||
// Show dot with tooltip for new or changed findings
|
||||
@@ -115,3 +94,49 @@ export const NotificationIndicator = ({
|
||||
// No indicator - return minimal width placeholder
|
||||
return <div className="w-2 shrink-0" />;
|
||||
};
|
||||
|
||||
/** Muted indicator with hover-triggered Popover for interactive link. */
|
||||
function MutedIndicator({ mutedReason }: { mutedReason?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-4 shrink-0 cursor-pointer items-center justify-center bg-transparent p-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<MutedIcon className="text-bg-data-muted size-2" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="border-border-neutral-tertiary bg-bg-neutral-tertiary w-auto rounded-lg px-2 py-1.5 shadow-lg"
|
||||
sideOffset={4}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Link
|
||||
href="/mutelist"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-button-tertiary hover:text-button-tertiary-hover flex items-center gap-1 text-xs underline-offset-4"
|
||||
>
|
||||
{mutedReason ? (
|
||||
<>
|
||||
<span className="text-text-neutral-primary">Mute rule:</span>
|
||||
<span className="max-w-[150px] truncate">{mutedReason}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-text-neutral-primary">Mute rule:</span>
|
||||
<span>view rules</span>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,669 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoist mocks for components that pull in next-auth transitively
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
mockGetComplianceIcon,
|
||||
mockGetCompliancesOverview,
|
||||
mockRouterPush,
|
||||
mockSearchParamsState,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetComplianceIcon: vi.fn((_: string) => null as string | null),
|
||||
mockGetCompliancesOverview: vi.fn(),
|
||||
mockRouterPush: vi.fn(),
|
||||
mockSearchParamsState: { value: "" },
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockRouterPush, refresh: vi.fn() }),
|
||||
usePathname: () => "/findings",
|
||||
useSearchParams: () => new URLSearchParams(mockSearchParamsState.value),
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/image", () => ({
|
||||
default: ({ alt }: { alt: string }) => <span role="img" aria-label={alt} />,
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href }: { children: ReactNode; href: string }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock the entire shadcn barrel to avoid auth import chain
|
||||
vi.mock("@/components/shadcn", () => {
|
||||
const Passthrough = ({ children }: { children?: ReactNode }) => (
|
||||
<>{children}</>
|
||||
);
|
||||
return {
|
||||
Badge: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) => <span className={className}>{children}</span>,
|
||||
Button: ({
|
||||
children,
|
||||
variant: _variant,
|
||||
size: _size,
|
||||
asChild: _asChild,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: string;
|
||||
size?: string;
|
||||
asChild?: boolean;
|
||||
}) => <button {...props}>{children}</button>,
|
||||
InfoField: ({
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
variant?: string;
|
||||
}) => (
|
||||
<div>
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Tabs: Passthrough,
|
||||
TabsContent: ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
value: string;
|
||||
}) => <div data-value={value}>{children}</div>,
|
||||
TabsList: Passthrough,
|
||||
TabsTrigger: ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
value: string;
|
||||
}) => <button data-value={value}>{children}</button>,
|
||||
Tooltip: Passthrough,
|
||||
TooltipContent: Passthrough,
|
||||
TooltipTrigger: Passthrough,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/shadcn/card/card", () => ({
|
||||
Card: ({ children, variant }: { children: ReactNode; variant?: string }) => (
|
||||
<div data-slot="card" data-variant={variant}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
ActionDropdown: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
ActionDropdownItem: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/skeleton/skeleton", () => ({
|
||||
Skeleton: () => <div />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/spinner/spinner", () => ({
|
||||
Spinner: () => <div data-testid="spinner" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/mute-findings-modal", () => ({
|
||||
MuteFindingsModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/send-to-jira-modal", () => ({
|
||||
SendToJiraModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/markdown-container", () => ({
|
||||
MarkdownContainer: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/compliances", () => ({
|
||||
getCompliancesOverview: mockGetCompliancesOverview,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons", () => ({
|
||||
getComplianceIcon: mockGetComplianceIcon,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons/services/IconServices", () => ({
|
||||
JiraIcon: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/code-snippet/code-snippet", () => ({
|
||||
CodeSnippet: ({ value }: { value: string }) => <span>{value}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/custom/custom-link", () => ({
|
||||
CustomLink: ({ children, href }: { children: ReactNode; href: string }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities/date-with-time", () => ({
|
||||
DateWithTime: ({ dateTime }: { dateTime: string }) => <span>{dateTime}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities/entity-info", () => ({
|
||||
EntityInfo: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
Table: ({ children }: { children: ReactNode }) => <table>{children}</table>,
|
||||
TableBody: ({ children }: { children: ReactNode }) => (
|
||||
<tbody>{children}</tbody>
|
||||
),
|
||||
TableCell: ({ children }: { children: ReactNode }) => <td>{children}</td>,
|
||||
TableHead: ({ children }: { children: ReactNode }) => <th>{children}</th>,
|
||||
TableHeader: ({ children }: { children: ReactNode }) => (
|
||||
<thead>{children}</thead>
|
||||
),
|
||||
TableRow: ({ children, ...props }: HTMLAttributes<HTMLTableRowElement>) => (
|
||||
<tr {...props}>{children}</tr>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table/severity-badge", () => ({
|
||||
SeverityBadge: ({ severity }: { severity: string }) => (
|
||||
<span>{severity}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table/status-finding-badge", () => ({
|
||||
FindingStatus: {},
|
||||
StatusFindingBadge: ({ status }: { status: string }) => <span>{status}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shared/events-timeline/events-timeline", () => ({
|
||||
EventsTimeline: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/region-flags", () => ({
|
||||
getRegionFlag: vi.fn(() => "🇺🇸"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/date-utils", () => ({
|
||||
getFailingForLabel: vi.fn(() => "2 days"),
|
||||
formatDuration: vi.fn(() => "5m"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils", () => ({
|
||||
cn: (...args: (string | undefined | false | null)[]) =>
|
||||
args.filter(Boolean).join(" "),
|
||||
}));
|
||||
|
||||
vi.mock("../delta-indicator", () => ({
|
||||
DeltaIndicator: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../notification-indicator", () => ({
|
||||
NotificationIndicator: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./resource-detail-skeleton", () => ({
|
||||
ResourceDetailSkeleton: () => <div data-testid="skeleton" />,
|
||||
}));
|
||||
|
||||
vi.mock("../../muted", () => ({
|
||||
Muted: () => null,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import after mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { ResourceDrawerFinding } from "@/actions/findings";
|
||||
|
||||
import { ResourceDetailDrawerContent } from "./resource-detail-drawer-content";
|
||||
import type { CheckMeta } from "./use-resource-detail-drawer";
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSearchParamsState.value = "";
|
||||
mockGetComplianceIcon.mockImplementation(
|
||||
(_: string) => null as string | null,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockCheckMeta: CheckMeta = {
|
||||
checkId: "s3_check",
|
||||
checkTitle: "S3 Check",
|
||||
risk: "High",
|
||||
description: "S3 description",
|
||||
complianceFrameworks: ["CIS-1.4", "PCI-DSS"],
|
||||
categories: ["security"],
|
||||
remediation: {
|
||||
recommendation: { text: "Fix it", url: "https://example.com" },
|
||||
code: { cli: "", other: "", nativeiac: "", terraform: "" },
|
||||
},
|
||||
additionalUrls: [],
|
||||
};
|
||||
|
||||
const mockFinding: ResourceDrawerFinding = {
|
||||
id: "finding-1",
|
||||
uid: "uid-1",
|
||||
checkId: "s3_check",
|
||||
checkTitle: "S3 Check",
|
||||
status: "FAIL",
|
||||
severity: "critical",
|
||||
delta: null,
|
||||
isMuted: false,
|
||||
mutedReason: null,
|
||||
firstSeenAt: null,
|
||||
updatedAt: null,
|
||||
resourceId: "res-1",
|
||||
resourceUid: "arn:aws:s3:::bucket",
|
||||
resourceName: "my-bucket",
|
||||
resourceService: "s3",
|
||||
resourceRegion: "us-east-1",
|
||||
resourceType: "Bucket",
|
||||
resourceGroup: "default",
|
||||
providerType: "aws",
|
||||
providerAlias: "prod",
|
||||
providerUid: "123456789",
|
||||
risk: "High",
|
||||
description: "Description",
|
||||
statusExtended: "Status extended",
|
||||
complianceFrameworks: [],
|
||||
categories: [],
|
||||
remediation: {
|
||||
recommendation: { text: "Fix", url: "" },
|
||||
code: { cli: "", other: "", nativeiac: "", terraform: "" },
|
||||
},
|
||||
additionalUrls: [],
|
||||
scan: null,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 1: Lighthouse AI button text change
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ResourceDetailDrawerContent — Fix 1: Lighthouse AI button text", () => {
|
||||
it("should say 'Analyze this finding with Lighthouse AI' instead of 'View This Finding'", () => {
|
||||
// Given
|
||||
const { container } = render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When — look for the lighthouse link
|
||||
const allText = container.textContent ?? "";
|
||||
|
||||
// Then — correct text must be present, old text must be absent
|
||||
expect(allText.toLowerCase()).toContain("analyze this finding");
|
||||
expect(allText.toLowerCase()).not.toContain("view this finding");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 2: Remediation heading labels — remove "Command" suffix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ResourceDetailDrawerContent — Fix 2: Remediation heading labels", () => {
|
||||
const checkMetaWithCommands: CheckMeta = {
|
||||
...mockCheckMeta,
|
||||
remediation: {
|
||||
recommendation: { text: "Fix it", url: "https://example.com" },
|
||||
code: {
|
||||
cli: "aws s3 ...",
|
||||
terraform: "resource aws_s3_bucket {}",
|
||||
nativeiac: "AWSTemplateFormatVersion: ...",
|
||||
other: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("should render 'Terraform' heading without 'Command' suffix", () => {
|
||||
// Given
|
||||
const { container } = render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={checkMetaWithCommands}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const allText = container.textContent ?? "";
|
||||
|
||||
// Then — "Terraform" present, "Terraform Command" absent
|
||||
expect(allText).toContain("Terraform");
|
||||
expect(allText).not.toContain("Terraform Command");
|
||||
});
|
||||
|
||||
it("should render 'CloudFormation' heading without 'Command' suffix", () => {
|
||||
// Given
|
||||
const { container } = render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={checkMetaWithCommands}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const allText = container.textContent ?? "";
|
||||
|
||||
// Then — "CloudFormation" present, "CloudFormation Command" absent
|
||||
expect(allText).toContain("CloudFormation");
|
||||
expect(allText).not.toContain("CloudFormation Command");
|
||||
});
|
||||
|
||||
it("should still render 'CLI Command' label for CLI section", () => {
|
||||
// Given
|
||||
const { container } = render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={checkMetaWithCommands}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const allText = container.textContent ?? "";
|
||||
|
||||
// Then — CLI Command label must remain
|
||||
expect(allText).toContain("CLI Command");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 5 & 6: Risk section has danger styling, sections have separators and bigger headings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ResourceDetailDrawerContent — Fix 5 & 6: Risk section styling", () => {
|
||||
it("should wrap the Risk section in a Card component (data-slot='card')", () => {
|
||||
// Given
|
||||
const { container } = render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When — find a Card with variant="danger" that contains the Risk label
|
||||
const dangerCards = Array.from(
|
||||
container.querySelectorAll('[data-variant="danger"]'),
|
||||
);
|
||||
const riskCard = dangerCards.find((el) =>
|
||||
el.textContent?.includes("Risk:"),
|
||||
);
|
||||
|
||||
// Then — Risk section must be wrapped in a Card variant="danger"
|
||||
expect(riskCard).toBeDefined();
|
||||
});
|
||||
|
||||
it("should use larger heading size for section labels (text-sm → text-base or larger)", () => {
|
||||
// Given
|
||||
const { container } = render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When — look for section heading span with "Risk:"
|
||||
const headingSpans = Array.from(container.querySelectorAll("span")).filter(
|
||||
(el) => el.textContent?.trim() === "Risk:",
|
||||
);
|
||||
|
||||
// Then — heading must not be tiny text-xs; should be text-sm or larger with font-semibold/font-medium
|
||||
expect(headingSpans.length).toBeGreaterThan(0);
|
||||
const riskHeading = headingSpans[0];
|
||||
expect(riskHeading.className).not.toContain("text-xs");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 4: Compliance icon styling should match master
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ResourceDetailDrawerContent — compliance icon styling", () => {
|
||||
it("should render framework icons inside the same white chip used in master", () => {
|
||||
// Given
|
||||
mockGetComplianceIcon.mockImplementation((framework: string) =>
|
||||
framework === "CIS-1.4" ? "/cis.svg" : null,
|
||||
);
|
||||
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const icon = screen.getByRole("img", { name: "CIS-1.4" });
|
||||
const chip = icon.closest("div");
|
||||
|
||||
// Then
|
||||
expect(chip).toHaveClass("bg-white");
|
||||
expect(chip).toHaveClass("border-gray-300");
|
||||
});
|
||||
|
||||
it("should render framework fallback pills with the same master styling", () => {
|
||||
// Given
|
||||
mockGetComplianceIcon.mockReturnValue(null);
|
||||
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const chip = screen.getByText("PCI-DSS");
|
||||
|
||||
// Then
|
||||
expect(chip).toHaveClass("bg-white");
|
||||
expect(chip).toHaveClass("border-gray-300");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceDetailDrawerContent — compliance navigation", () => {
|
||||
it("should resolve the clicked framework against the selected scan and navigate to compliance detail", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
mockSearchParamsState.value =
|
||||
"filter[scan__in]=scan-selected&filter[region__in]=eu-west-1";
|
||||
mockGetCompliancesOverview.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "compliance-1",
|
||||
type: "compliance-overviews",
|
||||
attributes: {
|
||||
framework: "PCI-DSS",
|
||||
version: "4.0",
|
||||
requirements_passed: 10,
|
||||
requirements_failed: 2,
|
||||
requirements_manual: 0,
|
||||
total_requirements: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Open PCI-DSS compliance details",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(mockGetCompliancesOverview).toHaveBeenCalledWith({
|
||||
scanId: "scan-selected",
|
||||
});
|
||||
expect(mockRouterPush).toHaveBeenCalledWith(
|
||||
"/compliance/PCI-DSS?complianceId=compliance-1&version=4.0&scanId=scan-selected&filter%5Bregion__in%5D=eu-west-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("should use the current finding scan when no scan filter is active", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
mockGetCompliancesOverview.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "compliance-2",
|
||||
type: "compliance-overviews",
|
||||
attributes: {
|
||||
framework: "PCI-DSS",
|
||||
version: "4.0",
|
||||
requirements_passed: 10,
|
||||
requirements_failed: 2,
|
||||
requirements_manual: 0,
|
||||
total_requirements: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
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}
|
||||
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 PCI-DSS compliance details",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(mockGetCompliancesOverview).toHaveBeenCalledWith({
|
||||
scanId: "scan-from-finding",
|
||||
});
|
||||
expect(mockRouterPush).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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { getCompliancesOverview } from "@/actions/compliances";
|
||||
import type { ResourceDrawerFinding } from "@/actions/findings";
|
||||
import { MarkdownContainer } from "@/components/findings/markdown-container";
|
||||
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
|
||||
@@ -62,6 +64,7 @@ import {
|
||||
import { getFailingForLabel } from "@/lib/date-utils";
|
||||
import { formatDuration } from "@/lib/date-utils";
|
||||
import { getRegionFlag } from "@/lib/region-flags";
|
||||
import type { ComplianceOverviewData } from "@/types/compliance";
|
||||
|
||||
import { Muted } from "../../muted";
|
||||
import { DeltaIndicator } from "../delta-indicator";
|
||||
@@ -69,6 +72,111 @@ import { NotificationIndicator } from "../notification-indicator";
|
||||
import { ResourceDetailSkeleton } from "./resource-detail-skeleton";
|
||||
import type { CheckMeta } from "./use-resource-detail-drawer";
|
||||
|
||||
/** Strip markdown code fences (```lang ... ```) so CodeSnippet shows clean code. */
|
||||
function stripCodeFences(code: string): string {
|
||||
return code
|
||||
.replace(/^```\w*\n?/, "")
|
||||
.replace(/\n?```\s*$/, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeComplianceFrameworkName(framework: string): string {
|
||||
return framework
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, "-");
|
||||
}
|
||||
|
||||
function parseSelectedScanIds(scanFilterValue: string | null): string[] {
|
||||
if (!scanFilterValue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return scanFilterValue
|
||||
.split(",")
|
||||
.map((scanId) => scanId.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveComplianceMatch(
|
||||
compliances: ComplianceOverviewData[] | undefined,
|
||||
framework: string,
|
||||
): {
|
||||
complianceId: string;
|
||||
framework: string;
|
||||
version: string;
|
||||
} | null {
|
||||
if (!compliances?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedFramework = normalizeComplianceFrameworkName(framework);
|
||||
const match = compliances.find(
|
||||
(compliance) =>
|
||||
normalizeComplianceFrameworkName(compliance.attributes.framework) ===
|
||||
normalizedFramework,
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
complianceId: match.id,
|
||||
framework: match.attributes.framework,
|
||||
version: match.attributes.version,
|
||||
};
|
||||
}
|
||||
|
||||
function buildComplianceDetailHref({
|
||||
complianceId,
|
||||
framework,
|
||||
version,
|
||||
scanId,
|
||||
regionFilter,
|
||||
currentFinding,
|
||||
includeScanData,
|
||||
}: {
|
||||
complianceId: string;
|
||||
framework: string;
|
||||
version: string;
|
||||
scanId: string;
|
||||
regionFilter: string | null;
|
||||
currentFinding: ResourceDrawerFinding | null;
|
||||
includeScanData: boolean;
|
||||
}): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set("complianceId", complianceId);
|
||||
if (version) {
|
||||
params.set("version", version);
|
||||
}
|
||||
params.set("scanId", scanId);
|
||||
|
||||
if (regionFilter) {
|
||||
params.set("filter[region__in]", regionFilter);
|
||||
}
|
||||
|
||||
if (includeScanData && currentFinding?.scan?.completedAt) {
|
||||
params.set(
|
||||
"scanData",
|
||||
JSON.stringify({
|
||||
id: currentFinding.scan.id,
|
||||
providerInfo: {
|
||||
provider: currentFinding.providerType,
|
||||
alias: currentFinding.providerAlias,
|
||||
uid: currentFinding.providerUid,
|
||||
},
|
||||
attributes: {
|
||||
name: currentFinding.scan.name,
|
||||
completed_at: currentFinding.scan.completedAt,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return `/compliance/${encodeURIComponent(framework)}?${params.toString()}`;
|
||||
}
|
||||
|
||||
interface ResourceDetailDrawerContentProps {
|
||||
isLoading: boolean;
|
||||
isNavigating: boolean;
|
||||
@@ -94,8 +202,13 @@ 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,
|
||||
);
|
||||
|
||||
// Initial load — no check metadata yet
|
||||
if (!checkMeta && isLoading) {
|
||||
@@ -140,6 +253,54 @@ export function ResourceDetailDrawerContent({
|
||||
const f = currentFinding;
|
||||
const hasPrev = currentIndex > 0;
|
||||
const hasNext = currentIndex < totalResources - 1;
|
||||
const selectedScanIds = parseSelectedScanIds(
|
||||
searchParams.get("filter[scan__in]"),
|
||||
);
|
||||
const complianceScanId =
|
||||
selectedScanIds.length === 1
|
||||
? selectedScanIds[0]
|
||||
: selectedScanIds.length === 0
|
||||
? (f?.scan?.id ?? null)
|
||||
: null;
|
||||
const regionFilter = searchParams.get("filter[region__in]");
|
||||
|
||||
const handleOpenCompliance = async (framework: string) => {
|
||||
if (!complianceScanId || resolvingFramework) {
|
||||
return;
|
||||
}
|
||||
|
||||
setResolvingFramework(framework);
|
||||
|
||||
try {
|
||||
const compliancesOverview = await getCompliancesOverview({
|
||||
scanId: complianceScanId,
|
||||
});
|
||||
const complianceMatch = resolveComplianceMatch(
|
||||
compliancesOverview?.data,
|
||||
framework,
|
||||
);
|
||||
|
||||
if (!complianceMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(
|
||||
buildComplianceDetailHref({
|
||||
complianceId: complianceMatch.complianceId,
|
||||
framework: complianceMatch.framework,
|
||||
version: complianceMatch.version,
|
||||
scanId: complianceScanId,
|
||||
regionFilter,
|
||||
currentFinding: f,
|
||||
includeScanData: f?.scan?.id === complianceScanId,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error resolving compliance detail:", error);
|
||||
} finally {
|
||||
setResolvingFramework(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
|
||||
@@ -197,27 +358,66 @@ export function ResourceDetailDrawerContent({
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{checkMeta.complianceFrameworks.map((framework) => {
|
||||
const icon = getComplianceIcon(framework);
|
||||
const isNavigable = Boolean(complianceScanId);
|
||||
const isResolving = resolvingFramework === framework;
|
||||
|
||||
return icon ? (
|
||||
<Tooltip key={framework}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-md border border-gray-300 bg-white p-0.5">
|
||||
<Image
|
||||
src={icon}
|
||||
alt={framework}
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
</div>
|
||||
{isNavigable ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Open ${framework} compliance details`}
|
||||
onClick={() => void handleOpenCompliance(framework)}
|
||||
disabled={Boolean(resolvingFramework)}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md border border-gray-300 bg-white p-0.5 transition-shadow hover:shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-wait disabled:opacity-70"
|
||||
>
|
||||
<Image
|
||||
src={icon}
|
||||
alt={framework}
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
{isResolving && (
|
||||
<span className="sr-only">Opening compliance</span>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-md border border-gray-300 bg-white p-0.5">
|
||||
<Image
|
||||
src={icon}
|
||||
alt={framework}
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{framework}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip key={framework}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-text-neutral-secondary inline-flex h-7 shrink-0 items-center rounded-md border border-gray-300 bg-white px-1.5 text-xs">
|
||||
{framework}
|
||||
</span>
|
||||
{isNavigable ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Open ${framework} compliance details`}
|
||||
onClick={() => void handleOpenCompliance(framework)}
|
||||
disabled={Boolean(resolvingFramework)}
|
||||
className="text-text-neutral-secondary inline-flex h-7 shrink-0 items-center rounded-md border border-gray-300 bg-white px-1.5 text-xs transition-shadow hover:shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-wait disabled:opacity-70"
|
||||
>
|
||||
{framework}
|
||||
{isResolving && (
|
||||
<span className="sr-only">Opening compliance</span>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-text-neutral-secondary inline-flex h-7 shrink-0 items-center rounded-md border border-gray-300 bg-white px-1.5 text-xs">
|
||||
{framework}
|
||||
</span>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{framework}</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -382,16 +582,16 @@ export function ResourceDetailDrawerContent({
|
||||
{(checkMeta.risk || checkMeta.description || f?.statusExtended) && (
|
||||
<Card variant="inner">
|
||||
{checkMeta.risk && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
<Card variant="danger">
|
||||
<span className="text-text-neutral-secondary text-sm font-semibold">
|
||||
Risk:
|
||||
</span>
|
||||
<MarkdownContainer>{checkMeta.risk}</MarkdownContainer>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
{checkMeta.description && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
<div className="border-default-200 flex flex-col gap-1 border-b pb-4">
|
||||
<span className="text-text-neutral-secondary text-sm font-semibold">
|
||||
Description:
|
||||
</span>
|
||||
<MarkdownContainer>
|
||||
@@ -401,7 +601,7 @@ export function ResourceDetailDrawerContent({
|
||||
)}
|
||||
{f?.statusExtended && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
<span className="text-text-neutral-secondary text-sm font-semibold">
|
||||
Status Extended:
|
||||
</span>
|
||||
<p className="text-text-neutral-primary text-sm">
|
||||
@@ -448,7 +648,7 @@ export function ResourceDetailDrawerContent({
|
||||
CLI Command:
|
||||
</span>
|
||||
<CodeSnippet
|
||||
value={`$ ${checkMeta.remediation.code.cli}`}
|
||||
value={`$ ${stripCodeFences(checkMeta.remediation.code.cli)}`}
|
||||
multiline
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
@@ -459,10 +659,12 @@ export function ResourceDetailDrawerContent({
|
||||
{checkMeta.remediation.code.terraform && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
Terraform Command:
|
||||
Terraform:
|
||||
</span>
|
||||
<CodeSnippet
|
||||
value={`$ ${checkMeta.remediation.code.terraform}`}
|
||||
value={stripCodeFences(
|
||||
checkMeta.remediation.code.terraform,
|
||||
)}
|
||||
multiline
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
@@ -473,10 +675,12 @@ export function ResourceDetailDrawerContent({
|
||||
{checkMeta.remediation.code.nativeiac && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
CloudFormation Command:
|
||||
CloudFormation:
|
||||
</span>
|
||||
<CodeSnippet
|
||||
value={`$ ${checkMeta.remediation.code.nativeiac}`}
|
||||
value={stripCodeFences(
|
||||
checkMeta.remediation.code.nativeiac,
|
||||
)}
|
||||
multiline
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
@@ -526,9 +730,17 @@ export function ResourceDetailDrawerContent({
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
Categories:
|
||||
</span>
|
||||
<p className="text-text-neutral-primary text-sm">
|
||||
{checkMeta.categories.join(", ")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{checkMeta.categories.map((category) => (
|
||||
<Badge
|
||||
key={category}
|
||||
variant="outline"
|
||||
className="text-xs capitalize"
|
||||
>
|
||||
{category}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
@@ -611,7 +823,7 @@ export function ResourceDetailDrawerContent({
|
||||
</p>
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<Link
|
||||
href={`/scans?id=${f.scan.id}`}
|
||||
href={`/scans?filter[id__in]=${f.scan.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@@ -684,13 +896,13 @@ export function ResourceDetailDrawerContent({
|
||||
{/* Lighthouse AI button */}
|
||||
<a
|
||||
href={`/lighthouse?${new URLSearchParams({ prompt: `Analyze this security finding and provide remediation guidance:\n\n- **Finding**: ${checkMeta.checkTitle}\n- **Check ID**: ${checkMeta.checkId}\n- **Severity**: ${f?.severity ?? "unknown"}\n- **Status**: ${f?.status ?? "unknown"}${f?.statusExtended ? `\n- **Detail**: ${f.statusExtended}` : ""}${checkMeta.risk ? `\n- **Risk**: ${checkMeta.risk}` : ""}` }).toString()}`}
|
||||
className="flex items-center gap-1.5 rounded-lg px-4 py-3 text-sm font-bold text-slate-950 transition-opacity hover:opacity-90"
|
||||
className="flex items-center gap-1.5 rounded-lg px-4 py-3 text-sm font-bold text-slate-900 transition-opacity hover:opacity-90"
|
||||
style={{
|
||||
background: "var(--gradient-lighthouse)",
|
||||
}}
|
||||
>
|
||||
<CircleArrowRight className="size-5" />
|
||||
View This Finding With Lighthouse AI
|
||||
Analyze This Finding With Lighthouse AI
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoist mocks before imports that chain to next-auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
getLatestFindingsByResourceUidMock,
|
||||
adaptFindingsByResourceResponseMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getLatestFindingsByResourceUidMock: vi.fn(),
|
||||
adaptFindingsByResourceResponseMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/findings", () => ({
|
||||
getLatestFindingsByResourceUid: getLatestFindingsByResourceUidMock,
|
||||
adaptFindingsByResourceResponse: adaptFindingsByResourceResponseMock,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import after mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
|
||||
import { useResourceDetailDrawer } from "./use-resource-detail-drawer";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeResource(
|
||||
overrides?: Partial<FindingResourceRow>,
|
||||
): FindingResourceRow {
|
||||
return {
|
||||
id: "row-1",
|
||||
rowType: "resource" as const,
|
||||
findingId: "finding-1",
|
||||
checkId: "s3_check",
|
||||
providerType: "aws",
|
||||
providerAlias: "prod",
|
||||
providerUid: "123",
|
||||
resourceName: "my-bucket",
|
||||
resourceGroup: "default",
|
||||
resourceUid: "arn:aws:s3:::my-bucket",
|
||||
service: "s3",
|
||||
region: "us-east-1",
|
||||
severity: "critical",
|
||||
status: "FAIL",
|
||||
isMuted: false,
|
||||
mutedReason: undefined,
|
||||
firstSeenAt: null,
|
||||
lastSeenAt: null,
|
||||
...overrides,
|
||||
} as FindingResourceRow;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 2: AbortController cleanup on unmount
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useResourceDetailDrawer — unmount cleanup", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should abort the in-flight fetch controller when the hook unmounts", async () => {
|
||||
// Given — spy on AbortController.prototype.abort to detect abort calls
|
||||
const abortSpy = vi.spyOn(AbortController.prototype, "abort");
|
||||
|
||||
// never-resolving fetch to simulate in-flight request
|
||||
getLatestFindingsByResourceUidMock.mockImplementation(
|
||||
() => new Promise(() => {}),
|
||||
);
|
||||
adaptFindingsByResourceResponseMock.mockReturnValue([]);
|
||||
|
||||
const resources = [makeResource()];
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId: "s3_check",
|
||||
}),
|
||||
);
|
||||
|
||||
// When — trigger a fetch by opening the drawer
|
||||
act(() => {
|
||||
result.current.openDrawer(0);
|
||||
});
|
||||
|
||||
// Verify a fetch was started
|
||||
expect(getLatestFindingsByResourceUidMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Reset spy count to detect only the unmount abort
|
||||
abortSpy.mockClear();
|
||||
|
||||
// Then — unmount while fetch is in flight
|
||||
unmount();
|
||||
|
||||
// The abort should have been called on unmount
|
||||
expect(abortSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not abort when no fetch has been started yet", () => {
|
||||
// Given — spy on abort
|
||||
const abortSpy = vi.spyOn(AbortController.prototype, "abort");
|
||||
|
||||
const resources = [makeResource()];
|
||||
|
||||
// When — render without opening drawer (no fetch started)
|
||||
const { unmount } = renderHook(() =>
|
||||
useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId: "s3_check",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then — unmount without any fetch
|
||||
unmount();
|
||||
|
||||
// abort should NOT have been called (fetchControllerRef.current is null)
|
||||
expect(abortSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
adaptFindingsByResourceResponse,
|
||||
@@ -84,6 +84,14 @@ export function useResourceDetailDrawer({
|
||||
const checkMetaRef = useRef<CheckMeta | null>(null);
|
||||
const fetchControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Abort any in-flight request on unmount to prevent state updates
|
||||
// on an already-unmounted component.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
fetchControllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchFindings = async (resourceUid: string) => {
|
||||
// Abort any in-flight request to prevent stale data from out-of-order responses
|
||||
fetchControllerRef.current?.abort();
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
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";
|
||||
|
||||
@@ -14,6 +18,11 @@ export const ScansFilters = ({
|
||||
providerUIDs,
|
||||
providerDetails,
|
||||
}: ScansFiltersProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const idFilter = searchParams.get("filter[id__in]");
|
||||
|
||||
const { availableProviderUIDs } = useRelatedFilters({
|
||||
providerUIDs,
|
||||
providerDetails,
|
||||
@@ -21,6 +30,32 @@ export const ScansFilters = ({
|
||||
providerFilterType: FilterType.PROVIDER_UID,
|
||||
});
|
||||
|
||||
const handleDismissIdFilter = () => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete("filter[id__in]");
|
||||
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>
|
||||
<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 (
|
||||
<FilterControls
|
||||
customFilters={[
|
||||
@@ -33,6 +68,7 @@ export const ScansFilters = ({
|
||||
index: 1,
|
||||
},
|
||||
]}
|
||||
prependElement={scanIdChip}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,6 +19,8 @@ const cardVariants = cva("flex flex-col gap-6 rounded-xl border", {
|
||||
base: "border-border-neutral-secondary bg-bg-neutral-secondary px-[18px] pt-3 pb-4",
|
||||
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",
|
||||
},
|
||||
padding: {
|
||||
default: "",
|
||||
@@ -34,6 +36,11 @@ const cardVariants = cva("flex flex-col gap-6 rounded-xl border", {
|
||||
padding: "default",
|
||||
className: "px-4 py-3", // md padding by default for inner
|
||||
},
|
||||
{
|
||||
variant: "danger",
|
||||
padding: "default",
|
||||
className: "px-4 py-3", // md padding by default for danger
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { EllipsisVertical } from "lucide-react";
|
||||
import { ComponentProps, ReactNode } from "react";
|
||||
import { ComponentProps, ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -43,8 +43,18 @@ export function ActionDropdown({
|
||||
ariaLabel = "Open actions menu",
|
||||
children,
|
||||
}: ActionDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Close dropdown when any ancestor scrolls (capture phase catches all scroll events)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleScroll = () => setOpen(false);
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
return () => window.removeEventListener("scroll", handleScroll, true);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{trigger ?? (
|
||||
<button
|
||||
|
||||
@@ -27,6 +27,13 @@ interface DataTableSearchProps {
|
||||
*/
|
||||
controlledValue?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
/**
|
||||
* Called when the user commits a search (pressing Enter).
|
||||
* When provided, the search is only "committed" on Enter, while
|
||||
* onSearchChange still fires on every keystroke for responsive display.
|
||||
* Use this to avoid remounting child components on every keystroke.
|
||||
*/
|
||||
onSearchCommit?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
/** Badge shown inside the search input (e.g., active drill-down group title) */
|
||||
badge?: { label: string; onDismiss: () => void };
|
||||
@@ -36,6 +43,7 @@ export const DataTableSearch = ({
|
||||
paramPrefix = "",
|
||||
controlledValue,
|
||||
onSearchChange,
|
||||
onSearchCommit,
|
||||
placeholder = "Search...",
|
||||
badge,
|
||||
}: DataTableSearchProps) => {
|
||||
@@ -47,7 +55,6 @@ export const DataTableSearch = ({
|
||||
// In controlled mode, track display value separately for immediate feedback
|
||||
const [displayValue, setDisplayValue] = useState(controlledValue ?? "");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const id = useId();
|
||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -72,31 +79,30 @@ export const DataTableSearch = ({
|
||||
const searchParam = paramPrefix ? `${paramPrefix}Search` : "filter[search]";
|
||||
const pageParam = paramPrefix ? `${paramPrefix}Page` : "page";
|
||||
|
||||
// Keep expanded if there's a value or input is focused or badge is present
|
||||
const shouldStayExpanded = value.length > 0 || isFocused || hasBadge;
|
||||
|
||||
// Sync with URL on mount (only for uncontrolled mode)
|
||||
useEffect(() => {
|
||||
if (isControlled) return;
|
||||
const searchFromUrl = searchParams.get(searchParam) || "";
|
||||
setInternalValue(searchFromUrl);
|
||||
// If there's a search value, start expanded
|
||||
if (searchFromUrl) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [searchParams, searchParam, isControlled]);
|
||||
|
||||
// Handle input change with debounce
|
||||
const handleChange = (newValue: string) => {
|
||||
// For controlled mode, update display immediately, debounce the callback
|
||||
if (isControlled) {
|
||||
// Update display value immediately for responsive typing
|
||||
setDisplayValue(newValue);
|
||||
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (onSearchCommit) {
|
||||
// Enter-to-commit mode: sync parent immediately (no debounce, no loading).
|
||||
// The actual search commit happens on Enter via onSearchCommit.
|
||||
onSearchChange(newValue);
|
||||
return;
|
||||
}
|
||||
|
||||
// Standard controlled mode: debounce the callback
|
||||
setIsLoading(true);
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
onSearchChange(newValue);
|
||||
@@ -105,39 +111,9 @@ export const DataTableSearch = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Uncontrolled mode: only update display value on keystroke.
|
||||
// The actual URL update happens on Enter (see onKeyDown handler).
|
||||
setInternalValue(newValue);
|
||||
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
// If using prefix, handle URL updates directly instead of useUrlFilters
|
||||
if (paramPrefix) {
|
||||
setIsLoading(true);
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (newValue) {
|
||||
params.set(searchParam, newValue);
|
||||
} else {
|
||||
params.delete(searchParam);
|
||||
}
|
||||
params.set(pageParam, "1"); // Reset to first page
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
setIsLoading(false);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
} else {
|
||||
// Original behavior for non-prefixed search
|
||||
if (newValue) {
|
||||
setIsLoading(true);
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
updateFilter("search", newValue);
|
||||
setIsLoading(false);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
updateFilter("search", null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
@@ -149,67 +125,22 @@ export const DataTableSearch = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsExpanded(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (!shouldStayExpanded) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsFocused(true);
|
||||
setIsExpanded(true);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
if (!value && !hasBadge) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIconClick = () => {
|
||||
setIsExpanded(true);
|
||||
// Focus input after expansion animation starts
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const effectiveExpanded = isExpanded || hasBadge;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center transition-all duration-300 ease-in-out",
|
||||
effectiveExpanded ? (hasBadge ? "w-[28rem]" : "w-64") : "w-10",
|
||||
"relative flex items-center",
|
||||
hasBadge ? "w-[28rem]" : "w-64",
|
||||
)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* Collapsed state - just icon button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleIconClick}
|
||||
className={cn(
|
||||
"border-border-neutral-tertiary bg-bg-neutral-tertiary absolute left-0 flex size-10 items-center justify-center rounded-md border transition-opacity duration-200",
|
||||
effectiveExpanded ? "pointer-events-none opacity-0" : "opacity-100",
|
||||
)}
|
||||
aria-label="Open search"
|
||||
>
|
||||
<SearchIcon className="text-text-neutral-tertiary size-4" />
|
||||
</button>
|
||||
|
||||
{/* Expanded state - full input with optional badge */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full transition-opacity duration-200",
|
||||
effectiveExpanded ? "opacity-100" : "pointer-events-none opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="relative w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"border-border-neutral-tertiary bg-bg-neutral-tertiary hover:bg-bg-neutral-secondary flex items-center gap-1.5 rounded-md border transition-colors",
|
||||
@@ -252,6 +183,40 @@ export const DataTableSearch = ({
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
|
||||
// Cancel any pending debounce — Enter commits immediately
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
debounceTimeoutRef.current = null;
|
||||
}
|
||||
setIsLoading(false);
|
||||
|
||||
// Controlled mode with explicit commit callback
|
||||
if (isControlled && onSearchCommit) {
|
||||
onSearchCommit(value);
|
||||
return;
|
||||
}
|
||||
|
||||
// Uncontrolled mode: immediate URL update (shortcut for debounce)
|
||||
if (!isControlled) {
|
||||
if (paramPrefix) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(searchParam, value);
|
||||
} else {
|
||||
params.delete(searchParam);
|
||||
}
|
||||
params.set(pageParam, "1");
|
||||
router.push(`${pathname}?${params.toString()}`, {
|
||||
scroll: false,
|
||||
});
|
||||
} else {
|
||||
updateFilter("search", value || null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className="h-9 min-w-0 flex-1 border-0 bg-transparent pr-9 shadow-none hover:bg-transparent focus:border-0 focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none"
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import type { ReactNode } from "react";
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
@@ -90,6 +91,11 @@ interface DataTableProviderProps<TData, TValue> {
|
||||
*/
|
||||
controlledSearch?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
/**
|
||||
* Called when the user commits a search by pressing Enter.
|
||||
* Use this alongside onSearchChange to implement "search on Enter" behavior.
|
||||
*/
|
||||
onSearchCommit?: (value: string) => void;
|
||||
controlledPage?: number;
|
||||
controlledPageSize?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
@@ -99,7 +105,7 @@ interface DataTableProviderProps<TData, TValue> {
|
||||
/** Custom placeholder text for the search input */
|
||||
searchPlaceholder?: string;
|
||||
/** Render additional content after each row (e.g., inline expansion) */
|
||||
renderAfterRow?: (row: Row<TData>) => React.ReactNode;
|
||||
renderAfterRow?: (row: Row<TData>) => ReactNode;
|
||||
/** Badge shown inside the search input (e.g., active drill-down group) */
|
||||
searchBadge?: { label: string; onDismiss: () => void };
|
||||
}
|
||||
@@ -122,6 +128,7 @@ export function DataTable<TData, TValue>({
|
||||
paramPrefix = "",
|
||||
controlledSearch,
|
||||
onSearchChange,
|
||||
onSearchCommit,
|
||||
controlledPage,
|
||||
controlledPageSize,
|
||||
onPageChange,
|
||||
@@ -222,6 +229,7 @@ export function DataTable<TData, TValue>({
|
||||
paramPrefix={paramPrefix}
|
||||
controlledValue={controlledSearch}
|
||||
onSearchChange={onSearchChange}
|
||||
onSearchCommit={onSearchCommit}
|
||||
placeholder={searchPlaceholder}
|
||||
badge={searchBadge}
|
||||
/>
|
||||
|
||||
@@ -206,7 +206,9 @@ describe("useInfiniteResources", () => {
|
||||
// Then — only page 1 was fetched, never page 2
|
||||
const calls =
|
||||
findingGroupActionsMock.getLatestFindingGroupResources.mock.calls;
|
||||
const pageNumbers = calls.map((c: { page: number }[]) => c[0].page);
|
||||
const pageNumbers = calls.map(
|
||||
(c: unknown[]) => (c[0] as { page: number }).page,
|
||||
);
|
||||
expect(pageNumbers.every((p: number) => p === 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -381,4 +383,157 @@ describe("useInfiniteResources", () => {
|
||||
).toHaveBeenCalledWith(expect.objectContaining({ filters }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("when refresh() fires while loadNextPage is in-flight (race condition — Fix 5)", () => {
|
||||
it("should discard in-flight page 2 and fetch page 1 when refresh fires during loadNextPage", async () => {
|
||||
// Given — page 1 has 2 pages total, page 2 hangs indefinitely
|
||||
const page1Response = makeApiResponse(
|
||||
Array.from({ length: 10 }, (_, i) => ({ id: `r${i}` })),
|
||||
{ pages: 2 },
|
||||
);
|
||||
const page1Adapted = Array.from({ length: 10 }, (_, i) =>
|
||||
fakeResource(`r${i}`),
|
||||
);
|
||||
|
||||
const page2Response = makeApiResponse(
|
||||
Array.from({ length: 5 }, (_, i) => ({ id: `r${10 + i}` })),
|
||||
{ pages: 2 },
|
||||
);
|
||||
|
||||
const refreshPage1Response = makeApiResponse([{ id: "r-fresh-1" }], {
|
||||
pages: 1,
|
||||
});
|
||||
const refreshPage1Adapted = [fakeResource("r-fresh-1")];
|
||||
|
||||
// page 2 hangs until we explicitly resolve it
|
||||
let resolveNextPage: (v: unknown) => void = () => {};
|
||||
const hangingPage2 = new Promise((r) => {
|
||||
resolveNextPage = r;
|
||||
});
|
||||
|
||||
let callCount = 0;
|
||||
findingGroupActionsMock.getLatestFindingGroupResources.mockImplementation(
|
||||
(args: { page: number }) => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return Promise.resolve(page1Response);
|
||||
}
|
||||
if (args.page === 2) {
|
||||
return hangingPage2;
|
||||
}
|
||||
return Promise.resolve(refreshPage1Response);
|
||||
},
|
||||
);
|
||||
|
||||
findingGroupActionsMock.adaptFindingGroupResourcesResponse
|
||||
.mockReturnValueOnce(page1Adapted)
|
||||
.mockReturnValue(refreshPage1Adapted);
|
||||
|
||||
const onSetResources = vi.fn();
|
||||
const onAppendResources = vi.fn();
|
||||
|
||||
// When — mount and wait for page 1
|
||||
const { result } = renderHook(() =>
|
||||
useInfiniteResources(
|
||||
defaultOptions({ onSetResources, onAppendResources }),
|
||||
),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
expect(onSetResources).toHaveBeenCalledWith(page1Adapted, true);
|
||||
|
||||
// Trigger loadNextPage (increments pageRef to 2 in buggy code)
|
||||
const sentinel = document.createElement("div");
|
||||
act(() => {
|
||||
result.current.sentinelRef(sentinel);
|
||||
});
|
||||
act(() => {
|
||||
triggerIntersection();
|
||||
});
|
||||
// Do NOT flush — page 2 is hanging in-flight
|
||||
|
||||
// Refresh fires while page 2 is in-flight
|
||||
act(() => {
|
||||
result.current.refresh();
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
// Resolve hanging page 2 after refresh (simulates late stale response)
|
||||
await act(async () => {
|
||||
resolveNextPage(page2Response);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
// Then — the aborted page 2 must NOT deliver resources (signal.aborted check)
|
||||
expect(onAppendResources).not.toHaveBeenCalled();
|
||||
|
||||
// The refresh must have fetched page 1 and delivered fresh resources
|
||||
expect(onSetResources).toHaveBeenCalledWith(refreshPage1Adapted, false);
|
||||
|
||||
// The refresh call must request page=1 (not page=3 due to stale pageRef)
|
||||
// Exact call sequence: [0]=initial page 1, [1]=loadNextPage page 2, [2]=refresh page 1
|
||||
const calls =
|
||||
findingGroupActionsMock.getLatestFindingGroupResources.mock.calls;
|
||||
expect((calls[0][0] as { page: number }).page).toBe(1); // initial fetch
|
||||
expect((calls[1][0] as { page: number }).page).toBe(2); // loadNextPage
|
||||
expect((calls[2][0] as { page: number }).page).toBe(1); // refresh
|
||||
});
|
||||
|
||||
it("should fetch sequential pages without skipping when loadNextPage is used normally", async () => {
|
||||
// Given — page 1 has 3 pages; pages load sequentially
|
||||
const makePageResponse = (startIdx: number, total: number) =>
|
||||
makeApiResponse(
|
||||
Array.from({ length: 5 }, (_, i) => ({ id: `r${startIdx + i}` })),
|
||||
{ pages: total },
|
||||
);
|
||||
|
||||
findingGroupActionsMock.getLatestFindingGroupResources
|
||||
.mockResolvedValueOnce(makePageResponse(0, 3)) // page 1
|
||||
.mockResolvedValueOnce(makePageResponse(5, 3)) // page 2
|
||||
.mockResolvedValueOnce(makePageResponse(10, 3)); // page 3
|
||||
|
||||
findingGroupActionsMock.adaptFindingGroupResourcesResponse
|
||||
.mockReturnValueOnce(
|
||||
Array.from({ length: 5 }, (_, i) => fakeResource(`r${i}`)),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
Array.from({ length: 5 }, (_, i) => fakeResource(`r${5 + i}`)),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
Array.from({ length: 5 }, (_, i) => fakeResource(`r${10 + i}`)),
|
||||
);
|
||||
|
||||
const onAppendResources = vi.fn();
|
||||
|
||||
// When — mount and wait for page 1
|
||||
const { result } = renderHook(() =>
|
||||
useInfiniteResources(defaultOptions({ onAppendResources })),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
// Attach sentinel
|
||||
const sentinel = document.createElement("div");
|
||||
act(() => {
|
||||
result.current.sentinelRef(sentinel);
|
||||
});
|
||||
|
||||
// Load page 2
|
||||
act(() => {
|
||||
triggerIntersection();
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
// Load page 3
|
||||
act(() => {
|
||||
triggerIntersection();
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
// Then — pages were fetched in order: 2, 3 (not 2, 4 due to double-increment)
|
||||
const calls =
|
||||
findingGroupActionsMock.getLatestFindingGroupResources.mock.calls;
|
||||
expect(calls[1][0].page).toBe(2);
|
||||
expect(calls[2][0].page).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,6 +111,11 @@ export function useInfiniteResources({
|
||||
const totalPages = response?.meta?.pagination?.pages ?? 1;
|
||||
const hasMore = page < totalPages;
|
||||
|
||||
// Commit the page number only after a successful (non-aborted) fetch.
|
||||
// This prevents a premature pageRef increment from loadNextPage being
|
||||
// permanently committed if a concurrent abort fires before fetchPage
|
||||
// starts executing.
|
||||
pageRef.current = page;
|
||||
hasMoreRef.current = hasMore;
|
||||
|
||||
if (append) {
|
||||
@@ -160,9 +165,11 @@ export function useInfiniteResources({
|
||||
)
|
||||
return;
|
||||
|
||||
const nextPage = pageRef.current + 1;
|
||||
pageRef.current = nextPage;
|
||||
fetchPage(nextPage, true, currentCheckIdRef.current, signal);
|
||||
// Pass the next page number as an argument without pre-committing
|
||||
// pageRef.current. The fetchPage function commits pageRef.current = page
|
||||
// only after a successful (non-aborted) response, eliminating the race
|
||||
// where a concurrent abort would leave pageRef permanently incremented.
|
||||
fetchPage(pageRef.current + 1, true, currentCheckIdRef.current, signal);
|
||||
}
|
||||
|
||||
// IntersectionObserver callback
|
||||
|
||||
71
ui/lib/region-flags.test.ts
Normal file
71
ui/lib/region-flags.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getRegionFlag } from "./region-flags";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 6: Taiwan (asia-east1) mapped correctly vs Hong Kong (asia-east2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getRegionFlag — Taiwan vs Hong Kong disambiguation", () => {
|
||||
it("should return 🇹🇼 for GCP asia-east1 (Taiwan)", () => {
|
||||
// Given/When
|
||||
const result = getRegionFlag("asia-east1");
|
||||
|
||||
// Then
|
||||
expect(result).toBe("🇹🇼");
|
||||
});
|
||||
|
||||
it("should return 🇭🇰 for GCP asia-east2 (Hong Kong)", () => {
|
||||
// Given/When
|
||||
const result = getRegionFlag("asia-east2");
|
||||
|
||||
// Then
|
||||
expect(result).toBe("🇭🇰");
|
||||
});
|
||||
|
||||
it("should return 🇭🇰 for regions containing 'hongkong'", () => {
|
||||
// Given/When
|
||||
const result = getRegionFlag("hongkong");
|
||||
|
||||
// Then
|
||||
expect(result).toBe("🇭🇰");
|
||||
});
|
||||
|
||||
it("should NOT return 🇭🇰 for asia-east1", () => {
|
||||
// Given/When
|
||||
const result = getRegionFlag("asia-east1");
|
||||
|
||||
// Then — confirm it's not Hong Kong flag
|
||||
expect(result).not.toBe("🇭🇰");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRegionFlag — existing regions not broken", () => {
|
||||
it("should return 🇺🇸 for us-east-1 (AWS)", () => {
|
||||
expect(getRegionFlag("us-east-1")).toBe("🇺🇸");
|
||||
});
|
||||
|
||||
it("should return 🇪🇺 for eu-west-1 (AWS)", () => {
|
||||
expect(getRegionFlag("eu-west-1")).toBe("🇪🇺");
|
||||
});
|
||||
|
||||
it("should return 🇯🇵 for ap-northeast-1 (Japan)", () => {
|
||||
expect(getRegionFlag("ap-northeast-1")).toBe("🇯🇵");
|
||||
});
|
||||
|
||||
it("should return 🇦🇺 for ap-southeast-2 (Australia)", () => {
|
||||
expect(getRegionFlag("ap-southeast-2")).toBe("🇦🇺");
|
||||
});
|
||||
|
||||
it("should return 🇸🇬 for ap-southeast-1 (Singapore)", () => {
|
||||
expect(getRegionFlag("ap-southeast-1")).toBe("🇸🇬");
|
||||
});
|
||||
|
||||
it("should return empty string for '-' (unknown/no region)", () => {
|
||||
expect(getRegionFlag("-")).toBe("");
|
||||
});
|
||||
|
||||
it("should return empty string for empty string", () => {
|
||||
expect(getRegionFlag("")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -56,8 +56,10 @@ const REGION_FLAG_RULES: [RegExp, string][] = [
|
||||
/\bap[-_]?south[-_]?1|india|centralindia|southindia|westindia|mumbai|hyderabad/i,
|
||||
"🇮🇳",
|
||||
],
|
||||
// Hong Kong
|
||||
[/\bap[-_]?east[-_]?1|hongkong/i, "🇭🇰"],
|
||||
// Taiwan — GCP asia-east1 (must come BEFORE Hong Kong rule)
|
||||
[/\basia[-_]east[-_]?1\b/i, "🇹🇼"],
|
||||
// Hong Kong — GCP asia-east2 (ap-east-1 is AWS HK)
|
||||
[/\bap[-_]?east[-_]?1|\basia[-_]east[-_]?2\b|hongkong/i, "🇭🇰"],
|
||||
// Indonesia
|
||||
[/\bap[-_]?southeast[-_]?3|indonesia|jakarta/i, "🇮🇩"],
|
||||
// China
|
||||
|
||||
Reference in New Issue
Block a user