import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode, } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; const { isCloudMock, isGroupedJiraDispatchEnabledMock, launchSkillMock } = vi.hoisted(() => ({ isCloudMock: vi.fn(() => false), isGroupedJiraDispatchEnabledMock: vi.fn(() => true), launchSkillMock: vi.fn(), })); // CustomLink pulls the "@/lib" barrel (and next-auth with it) into the unit env. vi.mock("@/components/shadcn/custom/custom-link", () => ({ CustomLink: ({ href, children }: { href: string; children: ReactNode }) => ( {children} ), })); vi.mock("@/components/shadcn", () => ({ Button: ({ children, ...props }: ButtonHTMLAttributes) => ( ), Checkbox: ({ "aria-label": ariaLabel, onCheckedChange, ...props }: InputHTMLAttributes & { "aria-label"?: string; size?: string; onCheckedChange?: (checked: boolean) => void; }) => ( onCheckedChange?.(event.target.checked)} {...props} /> ), })); vi.mock("@/components/findings/mute-findings-modal", () => ({ MuteFindingsModal: () => null, })); vi.mock("@/components/icons/services/IconServices", () => ({ JiraIcon: () => null, })); vi.mock("@/components/shadcn/dropdown", () => ({ ActionDropdown: ({ children }: { children: ReactNode }) => (
{children}
), ActionDropdownItem: ({ label, onSelect, disabled, disabledTooltip, }: { label: string; onSelect?: () => void; disabled?: boolean; disabledTooltip?: string; }) => ( ), DropdownMenuLabel: ({ children }: { children?: ReactNode }) => (
{children}
), DropdownMenuSeparator: () =>
, DropdownMenuSub: ({ children }: { children: ReactNode }) => (
{children}
), DropdownMenuSubContent: ({ children }: { children: ReactNode }) => (
{children}
), DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => ( {children} ), })); vi.mock("@/components/shadcn/info-field/info-field", () => ({ InfoField: ({ children, label, }: { children: ReactNode; label: string; variant?: string; }) => (
{label}
{children}
), })); vi.mock("@/components/shadcn/spinner/spinner", () => ({ Spinner: () => null, })); vi.mock("@/components/shadcn/select/select", () => ({ Select: ({ children }: { children: ReactNode }) =>
{children}
, SelectContent: ({ children }: { children: ReactNode }) => (
{children}
), SelectItem: ({ children }: { children: ReactNode }) =>
{children}
, SelectTrigger: ({ children, disabled, "aria-label": ariaLabel, }: { children: ReactNode; disabled?: boolean; "aria-label"?: string; }) => ( ), SelectValue: ({ children }: { children?: ReactNode }) => ( {children} ), })); vi.mock("@/components/shadcn/tooltip", () => ({ Tooltip: ({ children }: { children: ReactNode }) => <>{children}, TooltipContent: ({ children }: { children: ReactNode }) => ( {children} ), TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, })); vi.mock("@/components/shadcn/entities", () => ({ DateWithTime: ({ dateTime, inline, }: { dateTime: string | null; inline?: boolean; }) => , })); vi.mock("@/components/shadcn/entities/entity-info", () => ({ EntityInfo: ({ entityAlias, entityId, }: { entityAlias?: string; entityId?: string; }) => (
{entityAlias} {entityId}
), })); vi.mock("@/components/shadcn/table", () => ({ SeverityBadge: ({ severity }: { severity: string }) => ( {severity} ), })); vi.mock("@/components/shadcn/table/data-table-column-header", () => ({ DataTableColumnHeader: ({ title }: { title: string }) => {title}, })); vi.mock("@/components/shadcn/table/status-finding-badge", () => ({ StatusFindingBadge: ({ status }: { status: string }) => {status}, })); vi.mock("@/lib/date-utils", () => ({ getFailingForLabel: () => "2d", })); vi.mock("@/lib/deployment", () => ({ isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock, PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud", })); vi.mock("@/lib/shared/env", () => ({ isCloud: isCloudMock, })); vi.mock("./lighthouse-skills-launch", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useLighthouseSkillLaunch: () => launchSkillMock, }; }); const notificationIndicatorMock = vi.fn((_props: unknown) => null); vi.mock("./notification-indicator", () => ({ NotificationIndicator: (props: unknown) => { notificationIndicatorMock(props); return null; }, })); import { useJiraDispatchStore } from "@/store/jira-dispatch/store"; import type { FindingResourceRow } from "@/types"; import { FINDING_TRIAGE_DISABLED_REASON, FINDING_TRIAGE_STATUS, type FindingTriageSummary, } from "@/types/findings-triage"; import { getColumnFindingResources } from "./column-finding-resources"; import { CLOUD_ONLY_TOOLTIP_COPY, EDITING_UNAVAILABLE_COPY, } from "./finding-triage-cells"; function makeTriageSummary( overrides?: Partial, ): FindingTriageSummary { return { findingId: "finding-1", findingUid: "prowler-finding-uid-1", triageId: "triage-1", notesCount: 0, status: FINDING_TRIAGE_STATUS.UNDER_REVIEW, label: "Under Review", hasVisibleNote: false, isMuted: false, canEdit: true, billingHref: "https://prowler.com/pricing", ...overrides, }; } function makeResource( overrides?: Partial, ): FindingResourceRow { return { id: "resource-row-1", rowType: "resource", findingId: "finding-1", checkId: "s3_check", providerType: "aws", providerAlias: "production", providerUid: "123456789", resourceName: "my-bucket", resourceType: "bucket", resourceGroup: "default", resourceUid: "arn:aws:s3:::my-bucket", service: "s3", region: "us-east-1", severity: "critical", status: "FAIL", delta: "new", isMuted: false, firstSeenAt: null, lastSeenAt: "2024-01-01T00:00:00Z", ...overrides, }; } function getColumnIds(columns: ReturnType) { return columns.map( (column) => (column as { id?: string; accessorKey?: string }).id ?? (column as { id?: string; accessorKey?: string }).accessorKey, ); } function renderResourceActionsCell({ resource = makeResource(), onSkillLaunchOpenDrawer, onTriageUpdateAction, onTriageNoteLoadAction, }: { resource?: FindingResourceRow; onSkillLaunchOpenDrawer?: Parameters< typeof getColumnFindingResources >[0]["onSkillLaunchOpenDrawer"]; onTriageUpdateAction?: Parameters< typeof getColumnFindingResources >[0]["onTriageUpdateAction"]; onTriageNoteLoadAction?: Parameters< typeof getColumnFindingResources >[0]["onTriageNoteLoadAction"]; } = {}) { const columns = getColumnFindingResources({ rowSelection: {}, selectableRowCount: 1, onSkillLaunchOpenDrawer, onTriageUpdateAction, onTriageNoteLoadAction, }); const actionsColumn = columns.find( (col) => (col as { id?: string }).id === "actions", ); if (!actionsColumn?.cell) { throw new Error("actions column not found"); } const CellComponent = actionsColumn.cell as (props: { row: { original: FindingResourceRow; index: number }; }) => ReactNode; render(
{CellComponent({ row: { original: resource, index: 0 } })}
); } describe("column-finding-resources", () => { beforeEach(() => { vi.clearAllMocks(); isCloudMock.mockReturnValue(false); isGroupedJiraDispatchEnabledMock.mockReturnValue(true); useJiraDispatchStore.getState().closeJiraDispatch(); }); it("opens the finding drawer and launches a row skill with full context", async () => { // Given const user = userEvent.setup(); const onSkillLaunchOpenDrawer = vi.fn(); isCloudMock.mockReturnValue(true); renderResourceActionsCell({ onSkillLaunchOpenDrawer }); // When await user.click(screen.getByRole("button", { name: "Triage Decision" })); // Then expect(onSkillLaunchOpenDrawer).toHaveBeenCalledWith(0); expect(launchSkillMock).toHaveBeenCalledWith( expect.objectContaining({ id: "triage-decision" }), expect.objectContaining({ kind: "finding", findingId: "finding-1", checkId: "s3_check", providerUid: "123456789", resourceUid: "arn:aws:s3:::my-bucket", region: "us-east-1", }), ); expect(onSkillLaunchOpenDrawer.mock.invocationCallOrder[0]).toBeLessThan( launchSkillMock.mock.invocationCallOrder[0], ); }); it("should render actions as the last visible column after Triage without Notes", () => { // Given const columns = getColumnFindingResources({ rowSelection: {}, selectableRowCount: 1, }); // When const columnIds = getColumnIds(columns); // Then expect(columnIds.slice(-2)).toEqual(["triage", "actions"]); expect(columnIds).not.toContain("status"); expect(columnIds).not.toContain("notes"); expect( (columns.at(-1) as { id?: string; size?: number } | undefined)?.size, ).toBe(56); }); it("should render Open note in resource actions without exposing note preview metadata", () => { // Given renderResourceActionsCell({ resource: makeResource({ triage: makeTriageSummary({ hasVisibleNote: true }), }), onTriageUpdateAction: vi.fn(), onTriageNoteLoadAction: vi.fn(), }); // Then expect(screen.getByRole("button", { name: "Open note" })).toBeEnabled(); expect(screen.queryByText("Sensitive note body")).not.toBeInTheDocument(); expect(screen.queryByText(/author/i)).not.toBeInTheDocument(); expect(screen.queryByText(/timestamp/i)).not.toBeInTheDocument(); }); it("should disable Add Triage Note when no update handler is wired", () => { // Given renderResourceActionsCell({ resource: makeResource({ triage: makeTriageSummary({ hasVisibleNote: false }), }), }); // Then expect( screen.getByRole("button", { name: "Add Triage Note" }), ).toBeDisabled(); }); it("should enable Add Triage Note when an update handler is wired", () => { // Given renderResourceActionsCell({ resource: makeResource({ triage: makeTriageSummary({ hasVisibleNote: false }), }), onTriageUpdateAction: vi.fn(), }); // Then expect( screen.getByRole("button", { name: "Add Triage Note" }), ).toBeEnabled(); }); it("should enable Add Triage Note for Cloud-only rows so users can open the billing upsell modal", () => { // Given renderResourceActionsCell({ resource: makeResource({ triage: makeTriageSummary({ canEdit: false, hasVisibleNote: false, disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY, }), }), }); // Then expect( screen.getByRole("button", { name: "Add Triage Note" }), ).toBeEnabled(); }); it("should disable editable triage control when no update handler is wired", () => { // Given const columns = getColumnFindingResources({ rowSelection: {}, selectableRowCount: 1, }); const triageColumn = columns.find( (col) => (col as { id?: string }).id === "triage", ); if (!triageColumn?.cell) { throw new Error("triage column not found"); } const CellComponent = triageColumn.cell as (props: { row: { original: FindingResourceRow }; }) => ReactNode; // When render(
{CellComponent({ row: { original: makeResource({ triage: makeTriageSummary({ canEdit: true }), }), }, })}
, ); // Then expect( screen.getByRole("button", { name: "Triage status" }), ).toBeDisabled(); expect(screen.getByText(EDITING_UNAVAILABLE_COPY)).toBeInTheDocument(); }); it("should keep the compact Triage label on resource cells for headerless nested rows", () => { // Given const columns = getColumnFindingResources({ rowSelection: {}, selectableRowCount: 1, }); const triageColumn = columns.find( (col) => (col as { id?: string }).id === "triage", ); if (!triageColumn?.cell) { throw new Error("triage column not found"); } const CellComponent = triageColumn.cell as (props: { row: { original: FindingResourceRow }; }) => ReactNode; // When render(
{CellComponent({ row: { original: makeResource({ triage: makeTriageSummary() }), }, })}
, ); // Then — expanded finding-group rows render without a header row, so the // cell itself must carry the label, like Service/Region/Last seen do. expect(screen.getByText("Triage")).toBeInTheDocument(); }); it("should disable non-paying Cloud triage control with only-in-Cloud tooltip copy", () => { // Given const columns = getColumnFindingResources({ rowSelection: {}, selectableRowCount: 1, }); const triageColumn = columns.find( (col) => (col as { id?: string }).id === "triage", ); if (!triageColumn?.cell) { throw new Error("triage column not found"); } const CellComponent = triageColumn.cell as (props: { row: { original: FindingResourceRow }; }) => ReactNode; // When render(
{CellComponent({ row: { original: makeResource({ triage: makeTriageSummary({ canEdit: false, disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY, }), }), }, })}
, ); // Then expect( screen.getByRole("button", { name: "Triage status" }), ).toBeDisabled(); expect(screen.getByText(CLOUD_ONLY_TOOLTIP_COPY)).toBeInTheDocument(); }); it("should open Jira dispatch with the finding UUID directly", async () => { // Given const user = userEvent.setup(); const columns = getColumnFindingResources({ rowSelection: {}, selectableRowCount: 1, }); const actionColumn = columns.find( (col) => (col as { id?: string }).id === "actions", ); if (!actionColumn?.cell) { throw new Error("actions column not found"); } const CellComponent = actionColumn.cell as (props: { row: { original: FindingResourceRow }; }) => ReactNode; render(
{CellComponent({ row: { original: makeResource({ findingId: "real-finding-uuid", }), }, })}
, ); // When await user.click( screen.getByRole("button", { name: "Send 1 Finding to Jira" }), ); // Then expect(useJiraDispatchStore.getState().activePayload?.selection).toEqual({ kind: "single", targetId: "real-finding-uuid", targetType: "finding_id", }); }); });