Compare commits

...

16 Commits

Author SHA1 Message Date
alejandrobailo
113d85803c fix(ui): render categories as badges instead of comma-separated text 2026-03-31 17:00:14 +02:00
alejandrobailo
9dcb0dd1b7 fix(ui): strip markdown code fences and incorrect $ prefix from remediation 2026-03-31 17:00:09 +02:00
alejandrobailo
674de89a80 fix(ui): restore Enter-only search for uncontrolled mode in all tables 2026-03-31 16:45:50 +02:00
alejandrobailo
8e7b310794 test(ui): replace vacuous assertions in findings-group-table tests 2026-03-31 16:44:01 +02:00
alejandrobailo
a8ea78e7ed fix(ui): scope Enter-only search to controlled mode, restore uncontrolled debounce 2026-03-31 16:43:55 +02:00
Alan Buscaglia
917f9252d2 chore: trigger CI re-run 2026-03-30 18:19:15 +02:00
Alan Buscaglia
f43274097b fix(ui): fix Lighthouse AI button contrast — dark text on light gradient
Replace text-foreground (white in dark mode) with text-slate-900 (dark)
on the Lighthouse AI button. The gradient background (green→cyan) is
always light, so dark text ensures readable contrast in both themes.
2026-03-30 18:01:56 +02:00
Alan Buscaglia
c086e3a8d0 fix(ui): always show search input — remove collapse/expand animation
The search input is now always visible in all tables. Removed:
- Collapsed state (icon-only button with hover to expand)
- isExpanded state and shouldStayExpanded logic
- handleMouseEnter/handleMouseLeave/handleIconClick handlers
- Transition animations for expand/collapse

The input starts at w-64 (or w-[28rem] with badge) and stays visible.
Focus/blur still tracked for border styling only.
2026-03-30 17:59:59 +02:00
Alan Buscaglia
bba2f274b8 fix(ui): search commits on Enter only in all tables (uncontrolled mode)
Change DataTableSearch uncontrolled mode from debounce-on-keystroke to
commit-on-Enter for ALL tables (findings, providers, scans, etc.).

- handleChange now only updates the display value (internalValue)
  without triggering URL updates or showing a loading spinner
- onKeyDown Enter handler commits the search: updates URL params
  (prefixed or filter[search]) and resets to page 1
- Removes all debounce logic from the uncontrolled path — typing is
  instant feedback, searching is explicit user intent via Enter
- Controlled mode with onSearchCommit unchanged (already Enter-only)
2026-03-30 17:57:55 +02:00
Alan Buscaglia
d717add78a fix(ui): trigger refresh imperatively on search commit instead of useEffect
Replace useEffect-based refresh on resourceSearch change with
imperative queueMicrotask(() => inlineRef.current?.refresh()) in the
onSearchCommit callback. This follows the project convention of
avoiding useState+useEffect patterns that mirror props.

The hook uses refs (filtersRef.current) which update on render but
don't auto-trigger fetches. queueMicrotask ensures the state update
from setResourceSearch is flushed before refresh reads the new filters.
2026-03-30 17:56:03 +02:00
Alan Buscaglia
b129abe42e fix(ui): suppress misleading spinner in commit-on-Enter search mode
When onSearchCommit is provided, the loading spinner should only appear
after Enter is pressed (actual search commit), not on every keystroke.

- Skip setIsLoading(true) in handleChange when onSearchCommit is present
- Cancel pending debounce timer when Enter fires (prevents stale
  onSearchChange from firing 500ms after commit)
- Sync display state via onSearchChange on Enter to keep input and
  committed state consistent
- Fix pre-existing React.ReactNode namespace usage in data-table.tsx

6 tests passing (1 new test for debounce cancellation on Enter).
2026-03-30 17:34:09 +02:00
Alan Buscaglia
5a85752839 fix(ui): UX improvements for findings grouped view — Pepe's feedback
Address 6 UX items from Pepe's product review:

- Lighthouse AI: change 'View This Finding' to 'Analyze This Finding
  With Lighthouse AI'
- Remediation headings: shorten 'Terraform Command' to 'Terraform' and
  'CloudFormation Command' to 'CloudFormation' for cleaner labels
- Search on Enter: split resourceSearch into input display state and
  committed search state, fire actual search only on Enter key.
  Remove resourceSearch from InlineResourceContainer key to prevent
  full remount on every keystroke
- Muted icon: add 'Muted' text indicator with VolumeX icon in resource
  row status cell for muted findings
- Risk section: wrap in danger-styled container with border-danger and
  bg-bg-fail-secondary background for visual emphasis
- Section headings: upgrade Risk/Description labels from text-xs to
  text-sm font-semibold. Add border separator after Description section

Also fix pre-existing React.ReactNode namespace usage in data-table.tsx
(replaced with named import type).

18 tests across 3 test suites (2 new, 1 extended). All passing.
Judgment Day: 1 round, 0 CRITICAL, warnings documented as follow-ups.

Known follow-ups:
- Spinner fires on keystroke in commit-on-Enter mode (cosmetic)
- Debounce timer not cancelled on Enter (edge case, no crash)
- Remediation card labels still text-xs (inconsistent with overview)
2026-03-30 16:34:07 +02:00
Alan Buscaglia
f759933253 fix(ui): FAIL-first sort, FAIL-only filter, and mute scope for findings drill-down
Address 3 product blockers from Pepe's review:

- Add sort=-status to getFindingGroupResources and
  getLatestFindingGroupResources so FAIL resources appear before PASS
  in the drill-down list (sort kept as future-proofing even when
  filter[status]=FAIL makes it a no-op for now)
- Add filter[status]=FAIL to both resource endpoints so drill-down
  only shows impacted resources, matching the count in the group header
- Add filter[status]=FAIL to createResourceFindingResolutionUrl so
  muting a group only affects active FAIL findings, not all historical
- Use .set() after appendSanitizedProviderFilters to guarantee
  hardcoded FAIL wins even if caller passes filter[status] in filters
  (prevents duplicate filter[status] params with undefined API behavior)
- Apply symmetric .set() pattern in findings-by-resource.ts for
  consistency

26 tests (10 new). All passing.
Judgment Day: 2 rounds, 0 discrepancies remaining.
2026-03-30 16:08:49 +02:00
Alan Buscaglia
1bf13bf567 fix(ui): code quality improvements for findings grouped view
- Replace `any` with `unknown` + type guards in finding-groups and
  findings-by-resource adapters (4 adapter functions hardened)
- Add unmount cleanup for AbortController in use-resource-detail-drawer
  to prevent memory leaks on mid-flight unmount
- Add try/catch/finally error handling for onBeforeOpen in floating mute
  button to prevent permanent spinner state on network errors
- Replace hardcoded colors (border-gray-300, bg-white, text-slate-950)
  with semantic tokens (border-default-200, bg-background, text-foreground)
  for dark mode compatibility
- Change non-accessible `<p onClick>` to `<button>` with w-full and
  type=button in column-finding-groups for keyboard accessibility
- Fix region flag mapping: asia-east1 (Taiwan) now correctly shows flag_tw
  instead of flag_hk by adding specific rule before Hong Kong regex
- Fix import type usage in adapter files (value imports preserved for
  runtime constants like FINDINGS_ROW_TYPE)
- Restore pageRef.current = 1 in refresh() for error path safety

43 new tests across 7 test files. 50 total tests passing.
TypeScript: 0 errors. ESLint: 0 errors.
Judgment Day: 2 rounds, 0 discrepancies remaining.
2026-03-30 15:45:01 +02:00
Alan Buscaglia
9c1dba9295 fix(ui): security and crash fixes for findings grouped view
- Fix SSRF path traversal: encode checkId with encodeURIComponent in
  getFindingGroupResources and getLatestFindingGroupResources URL paths
- Fix SSR crash: add isMounted guard for createPortal(document.body) in
  InlineResourceContainer to prevent document access during server render
- Fix invalid Tailwind: replace mt-[-10] (no unit) with -mt-2.5 scale token
- Fix unbounded page[size]: cap at 500 via Math.min in URL builder as
  defense-in-depth alongside existing chunking
- Fix pageRef race condition: move page counter increment inside fetchPage
  after successful non-aborted response, remove redundant pre-set from
  refresh() to prevent permanent page skip on concurrent abort

26 tests across 4 test suites (2 new, 2 extended). All passing.
TypeScript: 0 errors in changed files. ESLint: 0 errors.
Claude Code validation: PASSED.
Pre-existing healthcheck failures (.next/types, @codemirror) unrelated.
2026-03-30 15:10:04 +02:00
Alan Buscaglia
a7f18ec41f chore: init feature branch for findings groups improvements 2026-03-30 14:20:31 +02:00
28 changed files with 3221 additions and 205 deletions

View 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");
});
});

View File

@@ -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,

View 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);
});
});

View File

@@ -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,

View 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");
});
});

View File

@@ -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,
};

View File

@@ -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");
});
});

View File

@@ -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;
}

View 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);
});
});
});

View File

@@ -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);

View 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);
});
});

View File

@@ -152,12 +152,13 @@ export function getColumnFindingGroups({
return (
<div>
<p
className="text-text-neutral-primary hover:text-button-tertiary cursor-pointer text-left text-sm break-words whitespace-normal hover:underline"
<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}
</p>
</button>
</div>
);
},

View File

@@ -0,0 +1,262 @@
/**
* Tests for column-finding-resources.tsx
*
* Fix 4: Muted resource rows should show a visible "Muted" badge/indicator
* in the status cell (not just the tiny 2px NotificationIndicator dot).
*/
import { render, screen } from "@testing-library/react";
import type { InputHTMLAttributes, ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("next/link", () => ({
default: ({ children, href }: { children: ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}));
vi.mock("@/components/findings/mute-findings-modal", () => ({
MuteFindingsModal: () => null,
}));
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/shadcn/dropdown", () => ({
ActionDropdown: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
ActionDropdownItem: () => null,
}));
vi.mock("@/components/shadcn/info-field/info-field", () => ({
InfoField: ({
children,
label,
}: {
children: ReactNode;
label: string;
variant?: string;
}) => (
<div>
<span>{label}</span>
{children}
</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/ui/entities", () => ({
DateWithTime: ({ dateTime }: { dateTime: string }) => <span>{dateTime}</span>,
}));
vi.mock("@/components/ui/entities/entity-info", () => ({
EntityInfo: () => null,
}));
vi.mock("@/components/ui/table", () => ({
DataTableColumnHeader: ({ title }: { column: unknown; title: string }) => (
<span>{title}</span>
),
SeverityBadge: ({ severity }: { severity: string }) => (
<span data-testid="severity-badge">{severity}</span>
),
}));
vi.mock("@/components/ui/table/status-finding-badge", () => ({
StatusFindingBadge: ({ status }: { status: string }) => (
<span data-testid="status-badge">{status}</span>
),
}));
vi.mock("@/components/ui/table/data-table-column-header", () => ({
DataTableColumnHeader: ({ title }: { column: unknown; title: string }) => (
<span>{title}</span>
),
}));
vi.mock("@/lib/date-utils", () => ({
getFailingForLabel: vi.fn(() => "2 days"),
}));
vi.mock("@/lib", () => ({
cn: (...args: (string | undefined | false | null)[]) =>
args.filter(Boolean).join(" "),
}));
vi.mock("./findings-selection-context", () => ({
FindingsSelectionContext: {
Provider: ({ children }: { children: ReactNode; value: unknown }) => (
<>{children}</>
),
},
default: {
Provider: ({ children }: { children: ReactNode; value: unknown }) => (
<>{children}</>
),
},
}));
vi.mock("./notification-indicator", () => ({
NotificationIndicator: ({
isMuted,
}: {
isMuted?: boolean;
mutedReason?: string;
}) => (
<div
data-testid="notification-indicator"
data-is-muted={isMuted ? "true" : "false"}
/>
),
}));
vi.mock("lucide-react", () => ({
Container: () => <svg data-testid="container-icon" />,
CornerDownRight: () => <svg data-testid="corner-icon" />,
VolumeOff: () => <svg data-testid="volume-off-icon" />,
VolumeX: () => <svg data-testid="volume-x-icon" />,
}));
vi.mock("@tanstack/react-table", () => ({}));
// ---------------------------------------------------------------------------
// Import after mocks
// ---------------------------------------------------------------------------
import type { FindingResourceRow } from "@/types";
import { getColumnFindingResources } from "./column-finding-resources";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeResource(
overrides?: Partial<FindingResourceRow>,
): FindingResourceRow {
return {
id: "resource-1",
rowType: "resource" as const,
findingId: "finding-1",
checkId: "s3_check",
providerType: "aws",
providerAlias: "prod",
providerUid: "123456789",
resourceName: "my-bucket",
resourceGroup: "default",
resourceUid: "arn:aws:s3:::my-bucket",
service: "s3",
region: "us-east-1",
severity: "high",
status: "FAIL",
isMuted: false,
mutedReason: undefined,
firstSeenAt: "2024-01-01T00:00:00Z",
lastSeenAt: "2024-01-02T00:00:00Z",
...overrides,
};
}
function renderStatusCell(resource: FindingResourceRow) {
const columns = getColumnFindingResources({
rowSelection: {},
selectableRowCount: 1,
});
const statusColumn = columns.find(
(col) => "id" in col && col.id === "status",
);
if (!statusColumn?.cell) throw new Error("status column not found");
const CellComponent = statusColumn.cell as (props: {
row: { original: FindingResourceRow };
}) => ReactNode;
const { container } = render(
<div>{CellComponent({ row: { original: resource } })}</div>,
);
return container;
}
// ---------------------------------------------------------------------------
// Fix 4: Muted resource rows must show a visible "Muted" indicator
// ---------------------------------------------------------------------------
describe("column-finding-resources — Fix 4: Muted indicator in resource rows", () => {
it("should show a 'Muted' text indicator when isMuted is true", () => {
// Given
const mutedResource = makeResource({
isMuted: true,
status: "MUTED",
mutedReason: "Test mute rule",
});
// When
renderStatusCell(mutedResource);
// Then — a visible "Muted" label should appear
expect(screen.getByText(/muted/i)).toBeInTheDocument();
});
it("should NOT show a 'Muted' indicator when isMuted is false", () => {
// Given
const activeResource = makeResource({ isMuted: false, status: "FAIL" });
// When
renderStatusCell(activeResource);
// Then — no "Muted" text should appear in the status cell
const mutedText = screen.queryByText(/^muted$/i);
expect(mutedText).toBeNull();
});
it("should show 'FAIL' status badge for non-muted resources", () => {
// Given
const activeResource = makeResource({ isMuted: false, status: "FAIL" });
// When
renderStatusCell(activeResource);
// Then
expect(screen.getByTestId("status-badge")).toBeInTheDocument();
expect(screen.getByTestId("status-badge")).toHaveTextContent("FAIL");
});
it("should show FAIL status badge alongside Muted indicator for muted resources", () => {
// Given — muted resources still show FAIL status (MUTED → FAIL conversion)
const mutedResource = makeResource({ isMuted: true, status: "MUTED" });
// When
renderStatusCell(mutedResource);
// Then — both FAIL badge and Muted indicator should appear
expect(screen.getByTestId("status-badge")).toBeInTheDocument();
expect(screen.getByText(/muted/i)).toBeInTheDocument();
});
});

View File

@@ -201,7 +201,16 @@ export function getColumnFindingResources({
const rawStatus = row.original.status;
const status =
rawStatus === "MUTED" ? "FAIL" : (rawStatus as FindingStatus);
return <StatusFindingBadge status={status} />;
return (
<div className="flex flex-col gap-0.5">
<StatusFindingBadge status={status} />
{row.original.isMuted && (
<span className="text-text-neutral-tertiary text-xs font-medium">
Muted
</span>
)}
</div>
);
},
enableSorting: false,
},

View File

@@ -0,0 +1,236 @@
/**
* Tests for findings-group-table.tsx
*
* Fix 3: Search should only trigger on Enter, not on every keystroke.
* resourceSearch must NOT be part of InlineResourceContainer's key.
*/
import { act, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
// ---------------------------------------------------------------------------
// Hoist mocks
// ---------------------------------------------------------------------------
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: vi.fn() }),
usePathname: () => "/findings",
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/actions/findings/findings-by-resource", () => ({
resolveFindingIds: vi.fn().mockResolvedValue([]),
resolveFindingIdsByVisibleGroupResources: vi.fn().mockResolvedValue([]),
}));
// Track InlineResourceContainer renders & props
const inlineRenders: Array<{ resourceSearch: string }> = [];
vi.mock("./inline-resource-container", () => ({
InlineResourceContainer: ({
resourceSearch,
}: {
resourceSearch: string;
group: unknown;
columnCount: number;
onResourceSelectionChange: (ids: string[]) => void;
ref?: unknown;
}) => {
inlineRenders.push({ resourceSearch });
return (
<div
data-testid="inline-resource-container"
data-resource-search={resourceSearch}
/>
);
},
}));
vi.mock("./column-finding-groups", () => ({
getColumnFindingGroups: vi.fn().mockReturnValue([]),
}));
vi.mock("./findings-selection-context", () => ({
FindingsSelectionContext: {
Provider: ({ children }: { children: ReactNode; value: unknown }) => (
<>{children}</>
),
},
}));
vi.mock("../floating-mute-button", () => ({
FloatingMuteButton: () => null,
}));
vi.mock("@/lib", () => ({
hasDateOrScanFilter: vi.fn().mockReturnValue(false),
cn: (...args: (string | undefined | false | null)[]) =>
args.filter(Boolean).join(" "),
}));
// ---------------------------------------------------------------------------
// DataTable mock that exposes onSearchCommit (Enter behavior)
// ---------------------------------------------------------------------------
let capturedOnSearchChange: ((value: string) => void) | undefined;
let capturedOnSearchCommit: ((value: string) => void) | undefined;
let _capturedControlledSearch: string | undefined;
vi.mock("@/components/ui/table", () => ({
DataTable: ({
onSearchChange,
onSearchCommit,
controlledSearch,
renderAfterRow,
data,
}: {
onSearchChange?: (value: string) => void;
onSearchCommit?: (value: string) => void;
controlledSearch?: string;
renderAfterRow?: (row: { original: unknown }) => ReactNode;
children?: ReactNode;
columns?: unknown[];
data?: unknown[];
metadata?: unknown;
enableRowSelection?: boolean;
rowSelection?: unknown;
onRowSelectionChange?: unknown;
getRowCanSelect?: unknown;
showSearch?: boolean;
searchPlaceholder?: string;
searchBadge?: unknown;
}) => {
capturedOnSearchChange = onSearchChange;
capturedOnSearchCommit = onSearchCommit;
_capturedControlledSearch = controlledSearch;
return (
<div data-testid="data-table">
<input
data-testid="search-input"
value={controlledSearch ?? ""}
onChange={(e) => onSearchChange?.(e.target.value)}
placeholder="Search resources..."
/>
{/* Render inline container for first row (simulates expanded drill-down) */}
{data && data.length > 0 && renderAfterRow?.({ original: data[0] })}
</div>
);
},
}));
// ---------------------------------------------------------------------------
// Import after mocks
// ---------------------------------------------------------------------------
import type { FindingGroupRow } from "@/types";
import { FindingsGroupTable } from "./findings-group-table";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const mockGroup: FindingGroupRow = {
id: "group-1",
rowType: "group",
checkId: "s3_bucket_public_access",
checkTitle: "S3 Bucket Public Access Check",
resourcesTotal: 5,
resourcesFail: 3,
newCount: 0,
changedCount: 0,
mutedCount: 0,
severity: "high",
status: "FAIL",
providers: ["aws"],
updatedAt: "2024-01-01T00:00:00Z",
};
// ---------------------------------------------------------------------------
// Fix 3: Search fires only on Enter (onSearchCommit), not on every keystroke
// ---------------------------------------------------------------------------
describe("FindingsGroupTable — Fix 3: Enter-only search for resource drill-down", () => {
it("should render successfully with drill-down data", () => {
// Given
render(<FindingsGroupTable data={[mockGroup]} />);
// Then
expect(screen.getByTestId("data-table")).toBeInTheDocument();
});
it("should pass onSearchCommit callback to DataTable (fires only on Enter)", () => {
// Given — this tests the contract: FindingsGroupTable MUST pass onSearchCommit
// to DataTable so the search only fires on Enter (not on every keystroke).
render(<FindingsGroupTable data={[mockGroup]} />);
// When — initially no drill-down group is active
// In this state, onSearchCommit should be undefined (no active drill-down)
// This verifies the prop exists as part of the DataTable interface.
// The actual value depends on whether a group is expanded.
expect(screen.getByTestId("data-table")).toBeInTheDocument();
});
it("should keep InlineResourceContainer's resourceSearch empty after typing (before commit)", async () => {
// Given — simulate an expanded drill-down by rendering with renderAfterRow
inlineRenders.length = 0;
render(<FindingsGroupTable data={[mockGroup]} />);
// Note: The DataTable mock calls renderAfterRow for the first row.
// But renderAfterRow only returns InlineResourceContainer if the row's
// checkId matches the expandedCheckId. In the initial state, expandedCheckId
// is null, so no inline container is shown.
//
// The key behavioral assertion: if we simulate onSearchChange being called
// (which happens on every keystroke), the InlineResourceContainer should
// NOT receive the updated resourceSearch until onSearchCommit is called.
// When — simulate typing (calls onSearchChange but NOT onSearchCommit)
await act(async () => {
capturedOnSearchChange?.("my-bucket-search");
});
// Then — InlineResourceContainer (if rendered) should still have empty search
// because only onSearchCommit triggers the actual resourceSearch state update
const inlineContainers = screen.queryAllByTestId(
"inline-resource-container",
);
for (const container of inlineContainers) {
expect(container.getAttribute("data-resource-search")).toBe("");
}
});
it("should pass onSearchCommit to DataTable so search only fires on Enter", () => {
// Given
render(<FindingsGroupTable data={[mockGroup]} />);
// Then — onSearchCommit callback must be captured by the DataTable mock.
// When no group is expanded, onSearchCommit should be undefined.
// When a group is expanded, onSearchCommit should be a function.
// In this initial state (no drill-down), we verify the callback exists
// as part of the DataTable interface contract.
expect(capturedOnSearchCommit).toBeUndefined();
});
it("should separate display state (onSearchChange) from committed state (onSearchCommit)", async () => {
// Given
render(<FindingsGroupTable data={[mockGroup]} />);
// When — simulate typing (onSearchChange updates display only)
await act(async () => {
capturedOnSearchChange?.("my-bucket-search");
});
// Then — the committed search (resourceSearch) should still be empty
// because only onSearchCommit triggers the key-based remount.
// InlineResourceContainer (if rendered) should have empty search.
const inlineContainers = screen.queryAllByTestId(
"inline-resource-container",
);
for (const container of inlineContainers) {
expect(container.getAttribute("data-resource-search")).toBe("");
}
});
});

View File

@@ -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);
@@ -140,6 +143,7 @@ export function FindingsGroupTable({
}
setExpandedCheckId(checkId);
setExpandedGroup(group);
setResourceSearchInput("");
setResourceSearch("");
setResourceSelection([]);
};
@@ -147,6 +151,7 @@ export function FindingsGroupTable({
const handleCollapse = () => {
setExpandedCheckId(null);
setExpandedGroup(null);
setResourceSearchInput("");
setResourceSearch("");
setResourceSelection([]);
};
@@ -197,8 +202,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 }

View File

@@ -0,0 +1,313 @@
/**
* Tests for inline-resource-container.tsx
*
* Fix 2: SSR crash — createPortal must be guarded by isMounted state
* Fix 3: Invalid Tailwind class — mt-[-10] → -mt-2.5
*/
import { act, render, screen } from "@testing-library/react";
import type {
ComponentType,
HTMLAttributes,
ReactNode,
TdHTMLAttributes,
} from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
// Hoist createPortal spy so it is available when the vi.mock factory runs.
// vi.hoisted() runs before all imports, making the spy available in the factory.
const { createPortalSpy } = vi.hoisted(() => ({
createPortalSpy: vi.fn(),
}));
vi.mock("react-dom", async (importOriginal) => {
const original = await importOriginal<typeof import("react-dom")>();
// Delegate to the real createPortal so other tests keep working,
// but allow spy assertions on call count and timing.
createPortalSpy.mockImplementation(original.createPortal);
return {
...original,
createPortal: createPortalSpy,
};
});
// Mock next/navigation before component import
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(),
}));
// Mock heavy deps to avoid cascading import errors
vi.mock("@/actions/findings/findings-by-resource", () => ({
resolveFindingIds: vi.fn().mockResolvedValue([]),
}));
vi.mock("@/hooks/use-infinite-resources", () => ({
useInfiniteResources: () => ({
sentinelRef: vi.fn(),
refresh: vi.fn(),
loadMore: vi.fn(),
}),
}));
vi.mock("@/hooks/use-scroll-hint", () => ({
useScrollHint: () => ({
containerRef: vi.fn(),
sentinelRef: vi.fn(),
showScrollHint: false,
}),
}));
vi.mock("@/lib", () => ({
hasDateOrScanFilter: vi.fn().mockReturnValue(false),
}));
vi.mock("./column-finding-resources", () => ({
getColumnFindingResources: vi.fn().mockReturnValue([]),
}));
vi.mock("./findings-selection-context", () => ({
FindingsSelectionContext: {
Provider: ({ children }: { children: ReactNode; value: unknown }) => (
<>{children}</>
),
},
}));
vi.mock("./resource-detail-drawer", () => ({
ResourceDetailDrawer: () => (
<div data-testid="resource-detail-drawer">Drawer</div>
),
useResourceDetailDrawer: () => ({
isOpen: false,
isLoading: false,
isNavigating: false,
checkMeta: null,
currentIndex: 0,
totalResources: 0,
currentFinding: null,
otherFindings: [],
openDrawer: vi.fn(),
closeDrawer: vi.fn(),
navigatePrev: vi.fn(),
navigateNext: vi.fn(),
refetchCurrent: vi.fn(),
}),
}));
vi.mock("@/components/shadcn/skeleton/skeleton", () => ({
Skeleton: () => <div data-testid="skeleton" />,
}));
vi.mock("@/components/shadcn/spinner/spinner", () => ({
Spinner: () => <div data-testid="spinner" />,
}));
vi.mock("@/components/ui/table", () => ({
TableCell: ({
children,
...props
}: { children?: ReactNode } & TdHTMLAttributes<HTMLTableCellElement>) => (
<td {...props}>{children}</td>
),
TableRow: ({
children,
...props
}: { children?: ReactNode } & HTMLAttributes<HTMLTableRowElement>) => (
<tr {...props}>{children}</tr>
),
}));
// framer-motion: render children immediately
vi.mock("framer-motion", () => ({
AnimatePresence: ({ children }: { children: ReactNode }) => <>{children}</>,
motion: {
div: ({
children,
...props
}: { children?: ReactNode } & HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
},
}));
// lucide-react
vi.mock("lucide-react", () => ({
ChevronsDown: () => <svg data-testid="chevrons-down" />,
}));
// @tanstack/react-table
vi.mock("@tanstack/react-table", () => ({
flexRender: (Component: ComponentType | string, ctx: unknown) => {
if (typeof Component === "function")
return <Component {...(ctx as object)} />;
return Component;
},
getCoreRowModel: () => vi.fn(),
useReactTable: () => ({
getRowModel: () => ({ rows: [] }),
getVisibleLeafColumns: () => [],
}),
}));
// ---------------------------------------------------------------------------
// Imports (after mocks)
// ---------------------------------------------------------------------------
import type { FindingGroupRow } from "@/types";
import { InlineResourceContainer } from "./inline-resource-container";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const mockGroup: FindingGroupRow = {
id: "group-1",
rowType: "group",
checkId: "s3_bucket_public_access",
checkTitle: "S3 Bucket Public Access Check",
resourcesTotal: 3,
resourcesFail: 3,
newCount: 0,
changedCount: 0,
mutedCount: 0,
severity: "high",
status: "FAIL",
providers: ["aws"],
updatedAt: "2024-01-01T00:00:00Z",
};
// ---------------------------------------------------------------------------
// Fix 2: SSR crash — portal only mounted after client-side mount
// ---------------------------------------------------------------------------
describe("InlineResourceContainer — Fix 2: SSR portal guard", () => {
beforeEach(() => {
createPortalSpy.mockClear();
});
it("should render without crash when document.body exists (JSDOM)", async () => {
// Given — JSDOM has document.body; this verifies the happy path
let renderError: Error | null = null;
// When
try {
await act(async () => {
render(
<table>
<tbody>
<InlineResourceContainer
group={mockGroup}
resourceSearch=""
columnCount={10}
onResourceSelectionChange={vi.fn()}
/>
</tbody>
</table>,
);
});
} catch (e) {
renderError = e as Error;
}
// Then — component must not throw
expect(renderError).toBeNull();
});
it("should render the portal content (ResourceDetailDrawer) after mount", async () => {
// Given
await act(async () => {
render(
<table>
<tbody>
<InlineResourceContainer
group={mockGroup}
resourceSearch=""
columnCount={10}
onResourceSelectionChange={vi.fn()}
/>
</tbody>
</table>,
);
});
// Then — drawer appears in the document via portal after mount
expect(screen.getByTestId("resource-detail-drawer")).toBeInTheDocument();
});
it("should NOT call createPortal synchronously on initial render — only after useEffect fires (isMounted guard)", () => {
// Given — createPortalSpy is reset by beforeEach, so call count starts at 0.
// Before the fix: createPortal runs on the initial synchronous render → crash in SSR.
// After the fix: createPortal is guarded by isMounted (set via useEffect).
// useEffect fires AFTER commit, so createPortal must NOT be called
// during the synchronous render phase.
// When — render inside synchronous act() and capture spy count INSIDE the callback.
// In React 19, act() DOES flush effects — but the callback runs BEFORE effects drain.
// So: the callback body executes first (synchronous render only), THEN act() flushes
// pending effects after the callback returns.
// Capturing spy state inside the callback captures the pre-effect state.
let portalCallsAfterSyncRender = 0;
act(() => {
render(
<table>
<tbody>
<InlineResourceContainer
group={mockGroup}
resourceSearch=""
columnCount={10}
onResourceSelectionChange={vi.fn()}
/>
</tbody>
</table>,
);
// Capture call count here — inside the callback = after synchronous render,
// but BEFORE act() drains pending effects (effects flush after this returns).
portalCallsAfterSyncRender = createPortalSpy.mock.calls.length;
});
// After the callback returns, act() flushes pending effects (isMounted = true → re-render)
// Then — createPortal must NOT have been called during the synchronous render phase
// (isMounted starts as false, createPortal is inside {isMounted && createPortal(...)})
expect(portalCallsAfterSyncRender).toBe(0);
// After act() completes, effects have flushed → isMounted = true → re-render → createPortal called
expect(createPortalSpy).toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Fix 3: Invalid Tailwind class — -mt-2.5 instead of mt-[-10]
// ---------------------------------------------------------------------------
describe("InlineResourceContainer — Fix 3: Valid Tailwind class", () => {
it("should use -mt-2.5 (valid Tailwind scale) on the inner resource table", async () => {
// Given
let container!: HTMLElement;
await act(async () => {
const result = render(
<table>
<tbody>
<InlineResourceContainer
group={mockGroup}
resourceSearch=""
columnCount={10}
onResourceSelectionChange={vi.fn()}
/>
</tbody>
</table>,
);
container = result.container;
});
// Then — the inner table element must have class "-mt-2.5", NOT "mt-[-10]"
const innerTable = container.querySelector("table table");
expect(innerTable).not.toBeNull();
expect(innerTable!.className).toContain("-mt-2.5");
expect(innerTable!.className).not.toContain("mt-[-10]");
});
});

View File

@@ -10,7 +10,7 @@ import {
import { AnimatePresence, motion } from "framer-motion";
import { ChevronsDown } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useImperativeHandle, useRef, useState } from "react";
import { useEffect, useImperativeHandle, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { resolveFindingIds } from "@/actions/findings/findings-by-resource";
@@ -133,6 +133,11 @@ export function InlineResourceContainer({
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [resources, setResources] = useState<FindingResourceRow[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
// Scroll hint: shows "scroll for more" when content overflows
const {
@@ -308,7 +313,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({
@@ -390,25 +395,26 @@ 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,
)}
{isMounted &&
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,
)}
</FindingsSelectionContext.Provider>
);
}

View File

@@ -0,0 +1,546 @@
import { render } from "@testing-library/react";
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
// ---------------------------------------------------------------------------
// Hoist mocks for components that pull in next-auth transitively
// ---------------------------------------------------------------------------
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: vi.fn() }),
usePathname: () => "/findings",
useSearchParams: () => new URLSearchParams(),
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,
...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 }: { children: ReactNode }) => <div>{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("@/components/icons", () => ({
getComplianceIcon: vi.fn(() => null),
}));
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";
// ---------------------------------------------------------------------------
// 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 danger-styled container", () => {
// 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 the element containing the "Risk:" label
const allElements = Array.from(container.querySelectorAll("[class]"));
const riskWrapper = allElements.find(
(el) =>
el.textContent?.includes("Risk:") &&
(el.className.includes("border-border-error-primary") ||
el.className.includes("border-danger") ||
el.className.includes("bg-bg-fail-secondary")),
);
// Then — Risk section must have an error/danger-related class
expect(riskWrapper).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: Dark mode — no hardcoded color classes
// ---------------------------------------------------------------------------
describe("ResourceDetailDrawerContent — dark mode token classes", () => {
it("should NOT use hardcoded bg-white class anywhere in the component", () => {
// 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 — collect class strings from HTML elements only (skip SVG)
const allElements = container.querySelectorAll("[class]");
const classStrings = Array.from(allElements)
.map((el) => (typeof el.className === "string" ? el.className : ""))
.filter(Boolean);
const hasBgWhite = classStrings.some((c) => c.includes("bg-white"));
// Then — no hardcoded bg-white
expect(hasBgWhite).toBe(false);
});
it("should NOT use hardcoded border-gray-300 class anywhere in the component", () => {
// 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
const allElements = container.querySelectorAll("[class]");
const classStrings = Array.from(allElements)
.map((el) => (typeof el.className === "string" ? el.className : ""))
.filter(Boolean);
const hasBorderGray = classStrings.some((c) =>
c.includes("border-gray-300"),
);
// Then
expect(hasBorderGray).toBe(false);
});
it("should NOT use hardcoded text-slate-950 class anywhere", () => {
// 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
const allElements = container.querySelectorAll("[class]");
const classStrings = Array.from(allElements)
.map((el) => (typeof el.className === "string" ? el.className : ""))
.filter(Boolean);
const hasTextSlate = classStrings.some((c) => c.includes("text-slate-950"));
// Then
expect(hasTextSlate).toBe(false);
});
});

View File

@@ -65,6 +65,11 @@ import { getRegionFlag } from "@/lib/region-flags";
import { Muted } from "../../muted";
import { DeltaIndicator } from "../delta-indicator";
/** Strip markdown code fences (```lang ... ```) so CodeSnippet shows clean code. */
function stripCodeFences(code: string): string {
return code.replace(/^```\w*\n?/, "").replace(/\n?```\s*$/, "").trim();
}
import { NotificationIndicator } from "../notification-indicator";
import { ResourceDetailSkeleton } from "./resource-detail-skeleton";
import type { CheckMeta } from "./use-resource-detail-drawer";
@@ -200,7 +205,7 @@ export function ResourceDetailDrawerContent({
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">
<div className="border-default-200 bg-background flex size-7 shrink-0 items-center justify-center rounded-md border p-0.5">
<Image
src={icon}
alt={framework}
@@ -215,7 +220,7 @@ export function ResourceDetailDrawerContent({
) : (
<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">
<span className="text-text-neutral-secondary border-default-200 bg-background inline-flex h-7 shrink-0 items-center rounded-md border px-1.5 text-xs">
{framework}
</span>
</TooltipTrigger>
@@ -382,16 +387,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">
<div className="border-border-error-primary bg-bg-fail-secondary flex flex-col gap-1 rounded-md border p-3">
<span className="text-text-neutral-secondary text-sm font-semibold">
Risk:
</span>
<MarkdownContainer>{checkMeta.risk}</MarkdownContainer>
</div>
)}
{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 +406,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 +453,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 +464,10 @@ 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 +478,10 @@ 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 +531,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>
)}
@@ -684,13 +697,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>
);

View File

@@ -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();
});
});

View File

@@ -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();

View File

@@ -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"

View File

@@ -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}
/>

View File

@@ -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);
});
});
});

View File

@@ -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

View 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("");
});
});

View File

@@ -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