diff --git a/ui/actions/finding-groups/finding-groups.adapter.test.ts b/ui/actions/finding-groups/finding-groups.adapter.test.ts new file mode 100644 index 0000000000..5874ddf7e5 --- /dev/null +++ b/ui/actions/finding-groups/finding-groups.adapter.test.ts @@ -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"); + }); +}); diff --git a/ui/actions/finding-groups/finding-groups.adapter.ts b/ui/actions/finding-groups/finding-groups.adapter.ts index 36d14cdfb9..593171078d 100644 --- a/ui/actions/finding-groups/finding-groups.adapter.ts +++ b/ui/actions/finding-groups/finding-groups.adapter.ts @@ -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, diff --git a/ui/actions/findings/findings-by-resource.adapter.test.ts b/ui/actions/findings/findings-by-resource.adapter.test.ts new file mode 100644 index 0000000000..b8781f0036 --- /dev/null +++ b/ui/actions/findings/findings-by-resource.adapter.test.ts @@ -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"); + }); +}); diff --git a/ui/actions/findings/findings-by-resource.adapter.ts b/ui/actions/findings/findings-by-resource.adapter.ts index 9104092261..75e8f5a81b 100644 --- a/ui/actions/findings/findings-by-resource.adapter.ts +++ b/ui/actions/findings/findings-by-resource.adapter.ts @@ -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; + check_metadata?: Record; +} + +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; + relationships?: Record; +} + +/** Lookup dict returned by createDict(). */ +type IncludedDict = Record; + /** * 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[]; +} + +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; + const remediationRaw = meta.remediation as + | Record + | 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 | undefined; + const providerRelId = + (( + (scanRels?.provider as Record | undefined)?.data as + | Record + | 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 + | undefined; + const remCode = remediation.code as Record | 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, }; diff --git a/ui/components/findings/floating-mute-button.test.tsx b/ui/components/findings/floating-mute-button.test.tsx new file mode 100644 index 0000000000..0123e2e44e --- /dev/null +++ b/ui/components/findings/floating-mute-button.test.tsx @@ -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( + , + ); + + 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( + , + ); + + // 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( + , + ); + + // 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); + }); + }); +}); diff --git a/ui/components/findings/floating-mute-button.tsx b/ui/components/findings/floating-mute-button.tsx index 9ecab3b846..f3b7933ce8 100644 --- a/ui/components/findings/floating-mute-button.tsx +++ b/ui/components/findings/floating-mute-button.tsx @@ -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); diff --git a/ui/components/findings/table/column-finding-groups.test.tsx b/ui/components/findings/table/column-finding-groups.test.tsx new file mode 100644 index 0000000000..ab5d26bc62 --- /dev/null +++ b/ui/components/findings/table/column-finding-groups.test.tsx @@ -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 & { + "aria-label"?: string; + size?: string; + }) => , +})); + +vi.mock("@/components/ui/table", () => ({ + DataTableColumnHeader: ({ + title, + }: { + column: unknown; + title: string; + param?: string; + }) => {title}, + SeverityBadge: ({ severity }: { severity: string }) => ( + {severity} + ), + StatusFindingBadge: ({ status }: { status: string }) => {status}, +})); + +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 { + 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(
{CellComponent({ row: { original: group } })}
); +} + +// --------------------------------------------------------------------------- +// Fix 5: Accessibility —

); }, diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx new file mode 100644 index 0000000000..69003bcbc5 --- /dev/null +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx @@ -0,0 +1,357 @@ +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 }) => , +})); + +vi.mock("next/link", () => ({ + default: ({ children, href }: { children: ReactNode; href: string }) => ( + {children} + ), +})); + +// 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; + }) => {children}, + Button: ({ + children, + ...props + }: ButtonHTMLAttributes & { + variant?: string; + size?: string; + asChild?: boolean; + }) => , + InfoField: ({ + children, + label, + }: { + children: ReactNode; + label: string; + variant?: string; + }) => ( +

+ {label} + {children} +
+ ), + Tabs: Passthrough, + TabsContent: ({ + children, + value, + }: { + children: ReactNode; + value: string; + }) =>
{children}
, + TabsList: Passthrough, + TabsTrigger: ({ + children, + value, + }: { + children: ReactNode; + value: string; + }) => , + Tooltip: Passthrough, + TooltipContent: Passthrough, + TooltipTrigger: Passthrough, + }; +}); + +vi.mock("@/components/shadcn/card/card", () => ({ + Card: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock("@/components/shadcn/dropdown", () => ({ + ActionDropdown: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + ActionDropdownItem: () => null, +})); + +vi.mock("@/components/shadcn/skeleton/skeleton", () => ({ + Skeleton: () =>
, +})); + +vi.mock("@/components/shadcn/spinner/spinner", () => ({ + 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 }) => {value}, +})); + +vi.mock("@/components/ui/custom/custom-link", () => ({ + CustomLink: ({ children, href }: { children: ReactNode; href: string }) => ( + {children} + ), +})); + +vi.mock("@/components/ui/entities/date-with-time", () => ({ + DateWithTime: ({ dateTime }: { dateTime: string }) => {dateTime}, +})); + +vi.mock("@/components/ui/entities/entity-info", () => ({ + EntityInfo: () => null, +})); + +vi.mock("@/components/ui/table", () => ({ + Table: ({ children }: { children: ReactNode }) => {children}
, + TableBody: ({ children }: { children: ReactNode }) => ( + {children} + ), + TableCell: ({ children }: { children: ReactNode }) => {children}, + TableHead: ({ children }: { children: ReactNode }) => {children}, + TableHeader: ({ children }: { children: ReactNode }) => ( + {children} + ), + TableRow: ({ children, ...props }: HTMLAttributes) => ( + {children} + ), +})); + +vi.mock("@/components/ui/table/severity-badge", () => ({ + SeverityBadge: ({ severity }: { severity: string }) => ( + {severity} + ), +})); + +vi.mock("@/components/ui/table/status-finding-badge", () => ({ + FindingStatus: {}, + StatusFindingBadge: ({ status }: { status: string }) => {status}, +})); + +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: () =>
, +})); + +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 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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); + }); +}); diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx index e54839ea54..6094732421 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx @@ -200,7 +200,7 @@ export function ResourceDetailDrawerContent({ return icon ? ( -
+
{framework} - + {framework} @@ -684,7 +684,7 @@ export function ResourceDetailDrawerContent({ {/* Lighthouse AI button */} ({ + 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 { + 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(); + }); +}); diff --git a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts index bd39b3d63c..d1d9eb96d3 100644 --- a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts +++ b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts @@ -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(null); const fetchControllerRef = useRef(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(); diff --git a/ui/lib/region-flags.test.ts b/ui/lib/region-flags.test.ts new file mode 100644 index 0000000000..8a2211b96d --- /dev/null +++ b/ui/lib/region-flags.test.ts @@ -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(""); + }); +}); diff --git a/ui/lib/region-flags.ts b/ui/lib/region-flags.ts index 1b844ffcf0..26b1ea23b0 100644 --- a/ui/lib/region-flags.ts +++ b/ui/lib/region-flags.ts @@ -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