mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
587187419f
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
511 lines
14 KiB
TypeScript
511 lines
14 KiB
TypeScript
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";
|
|
|
|
const { notificationIndicatorMock } = vi.hoisted(() => ({
|
|
notificationIndicatorMock: vi.fn(),
|
|
}));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Hoist mocks for dependencies
|
|
// ---------------------------------------------------------------------------
|
|
|
|
vi.mock("next/navigation", () => ({
|
|
redirect: vi.fn(),
|
|
useRouter: () => ({ refresh: vi.fn() }),
|
|
usePathname: () => "/findings",
|
|
useSearchParams: () => new URLSearchParams(),
|
|
}));
|
|
|
|
vi.mock("@/components/shadcn", () => ({
|
|
Button: ({ children, ...props }: { children: ReactNode }) => (
|
|
<button {...props}>{children}</button>
|
|
),
|
|
Checkbox: ({
|
|
"aria-label": ariaLabel,
|
|
onCheckedChange,
|
|
...props
|
|
}: InputHTMLAttributes<HTMLInputElement> & {
|
|
"aria-label"?: string;
|
|
size?: string;
|
|
onCheckedChange?: (checked: boolean) => void;
|
|
}) => (
|
|
<input
|
|
type="checkbox"
|
|
aria-label={ariaLabel}
|
|
onChange={(event) => onCheckedChange?.(event.target.checked)}
|
|
{...props}
|
|
/>
|
|
),
|
|
Textarea: (props: InputHTMLAttributes<HTMLTextAreaElement>) => (
|
|
<textarea {...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-resources-cell", () => ({
|
|
ImpactedResourcesCell: ({
|
|
impacted,
|
|
total,
|
|
}: {
|
|
impacted: number;
|
|
total: number;
|
|
}) => <span>{`${impacted}/${total}`}</span>,
|
|
}));
|
|
|
|
vi.mock("./notification-indicator", () => ({
|
|
DeltaValues: { NEW: "new", CHANGED: "changed", NONE: "none" },
|
|
NotificationIndicator: (props: unknown) => {
|
|
notificationIndicatorMock(props);
|
|
return null;
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/components/shadcn/modal", () => ({
|
|
Modal: ({
|
|
children,
|
|
open,
|
|
title,
|
|
}: {
|
|
children: ReactNode;
|
|
open: boolean;
|
|
title?: string;
|
|
}) =>
|
|
open ? (
|
|
<div role="dialog" aria-label={title}>
|
|
{children}
|
|
</div>
|
|
) : null,
|
|
}));
|
|
|
|
vi.mock("@/components/shadcn/select/select", () => ({
|
|
Select: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
|
SelectContent: ({ children }: { children: ReactNode }) => (
|
|
<div>{children}</div>
|
|
),
|
|
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
|
SelectTrigger: ({
|
|
children,
|
|
disabled,
|
|
"aria-label": ariaLabel,
|
|
}: {
|
|
children: ReactNode;
|
|
disabled?: boolean;
|
|
"aria-label"?: string;
|
|
}) => (
|
|
<button aria-label={ariaLabel} disabled={disabled}>
|
|
{children}
|
|
</button>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/shadcn/tooltip", () => ({
|
|
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
|
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
|
|
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
|
}));
|
|
|
|
vi.mock("./provider-icon-cell", () => ({
|
|
ProviderIconCell: ({ provider }: { provider: string }) => (
|
|
<span data-testid={`provider-icon-${provider}`}>{provider}</span>
|
|
),
|
|
}));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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",
|
|
muted: false,
|
|
resourcesTotal: 5,
|
|
resourcesFail: 3,
|
|
newCount: 0,
|
|
changedCount: 0,
|
|
newFailCount: 0,
|
|
newFailMutedCount: 0,
|
|
newPassCount: 0,
|
|
newPassMutedCount: 0,
|
|
newManualCount: 0,
|
|
newManualMutedCount: 0,
|
|
changedFailCount: 0,
|
|
changedFailMutedCount: 0,
|
|
changedPassCount: 0,
|
|
changedPassMutedCount: 0,
|
|
changedManualCount: 0,
|
|
changedManualMutedCount: 0,
|
|
mutedCount: 0,
|
|
providers: ["aws"],
|
|
updatedAt: "2024-01-01T00:00:00Z",
|
|
...overrides,
|
|
} as FindingGroupRow;
|
|
}
|
|
|
|
function renderFindingCell(
|
|
checkTitle: string,
|
|
onDrillDown: (checkId: string, group: FindingGroupRow) => void,
|
|
overrides?: Partial<FindingGroupRow>,
|
|
) {
|
|
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, ...overrides });
|
|
// 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>);
|
|
}
|
|
|
|
function renderFindingGroupTitleCell(overrides?: Partial<FindingGroupRow>) {
|
|
const columns = getColumnFindingGroups({
|
|
rowSelection: {},
|
|
selectableRowCount: 1,
|
|
onDrillDown: vi.fn(),
|
|
});
|
|
|
|
const findingColumn = columns.find(
|
|
(col) => (col as { accessorKey?: string }).accessorKey === "finding",
|
|
);
|
|
if (!findingColumn?.cell) throw new Error("finding column not found");
|
|
|
|
const group = makeGroup(overrides);
|
|
const CellComponent = findingColumn.cell as (props: {
|
|
row: { original: FindingGroupRow };
|
|
}) => ReactNode;
|
|
|
|
render(<div>{CellComponent({ row: { original: group } })}</div>);
|
|
}
|
|
|
|
function renderImpactedResourcesCell(overrides?: Partial<FindingGroupRow>) {
|
|
const columns = getColumnFindingGroups({
|
|
rowSelection: {},
|
|
selectableRowCount: 1,
|
|
onDrillDown: vi.fn(),
|
|
});
|
|
|
|
const impactedResourcesColumn = columns.find(
|
|
(col) => (col as { id?: string }).id === "impactedResources",
|
|
);
|
|
if (!impactedResourcesColumn?.cell) {
|
|
throw new Error("impactedResources column not found");
|
|
}
|
|
|
|
const group = makeGroup(overrides);
|
|
const CellComponent = impactedResourcesColumn.cell as (props: {
|
|
row: { original: FindingGroupRow };
|
|
}) => ReactNode;
|
|
|
|
render(<div>{CellComponent({ row: { original: group } })}</div>);
|
|
}
|
|
|
|
function renderSelectCell(overrides?: Partial<FindingGroupRow>) {
|
|
const onDrillDown =
|
|
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
|
|
const toggleSelected = vi.fn();
|
|
const columns = getColumnFindingGroups({
|
|
rowSelection: {},
|
|
selectableRowCount: 1,
|
|
onDrillDown,
|
|
});
|
|
|
|
const selectColumn = columns.find(
|
|
(col) => (col as { id?: string }).id === "select",
|
|
);
|
|
if (!selectColumn?.cell) {
|
|
throw new Error("select column not found");
|
|
}
|
|
|
|
const group = makeGroup(overrides);
|
|
const CellComponent = selectColumn.cell as (props: {
|
|
row: {
|
|
id: string;
|
|
original: FindingGroupRow;
|
|
toggleSelected: (selected: boolean) => void;
|
|
};
|
|
}) => ReactNode;
|
|
|
|
render(
|
|
<div>
|
|
{CellComponent({
|
|
row: {
|
|
id: "0",
|
|
original: group,
|
|
toggleSelected,
|
|
},
|
|
})}
|
|
</div>,
|
|
);
|
|
|
|
return { onDrillDown, toggleSelected };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fix 5: Accessibility — <p onClick> → <button>
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("column-finding-groups — accessibility of check title cell", () => {
|
|
it("should not expose triage and notes columns on group-level rows", () => {
|
|
// Given
|
|
const columns = getColumnFindingGroups({
|
|
rowSelection: {},
|
|
selectableRowCount: 1,
|
|
onDrillDown: vi.fn(),
|
|
});
|
|
|
|
// When
|
|
const columnIds = columns.map(
|
|
(column) =>
|
|
(column as { id?: string; accessorKey?: string }).id ??
|
|
(column as { id?: string; accessorKey?: string }).accessorKey,
|
|
);
|
|
|
|
// Then
|
|
expect(columnIds).not.toContain("triage");
|
|
expect(columnIds).not.toContain("notes");
|
|
expect(columnIds.at(-1)).toBe("actions");
|
|
});
|
|
|
|
it("should render the first provider icon with its provider name", () => {
|
|
// Given
|
|
renderFindingGroupTitleCell({ providers: ["iac"] });
|
|
|
|
// Then
|
|
expect(screen.getByTestId("provider-icon-iac")).toBeInTheDocument();
|
|
expect(screen.getByText("Infrastructure as Code")).toBeInTheDocument();
|
|
});
|
|
|
|
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 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 allow expanding a group that only has PASS resources", async () => {
|
|
// Given
|
|
const user = userEvent.setup();
|
|
const onDrillDown =
|
|
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
|
|
|
|
renderFindingCell("My Passing Check", onDrillDown, {
|
|
resourcesTotal: 2,
|
|
resourcesFail: 0,
|
|
status: "PASS",
|
|
});
|
|
|
|
// When
|
|
await user.click(
|
|
screen.getByRole("button", {
|
|
name: "My Passing Check",
|
|
}),
|
|
);
|
|
|
|
// Then
|
|
expect(onDrillDown).toHaveBeenCalledTimes(1);
|
|
expect(onDrillDown).toHaveBeenCalledWith(
|
|
"s3_check",
|
|
expect.objectContaining({
|
|
resourcesTotal: 2,
|
|
resourcesFail: 0,
|
|
status: "PASS",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("should keep zero-resource fallback groups non-clickable even when fallback counts are present", () => {
|
|
// Given
|
|
const onDrillDown =
|
|
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
|
|
|
|
renderFindingCell("Fallback IaC Check", onDrillDown, {
|
|
resourcesTotal: 0,
|
|
resourcesFail: 0,
|
|
failCount: 0,
|
|
passCount: 2,
|
|
manualCount: 1,
|
|
});
|
|
|
|
// Then
|
|
expect(
|
|
screen.queryByRole("button", { name: "Fallback IaC Check" }),
|
|
).not.toBeInTheDocument();
|
|
expect(screen.getByText("Fallback IaC Check")).toBeInTheDocument();
|
|
expect(onDrillDown).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("column-finding-groups — impacted resources count", () => {
|
|
it("should keep impacted resources based on failing resources only", () => {
|
|
// Given/When
|
|
renderImpactedResourcesCell({
|
|
resourcesTotal: 5,
|
|
resourcesFail: 3,
|
|
});
|
|
|
|
// Then
|
|
expect(screen.getByText("3/5")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should fall back to finding counts when resources total is zero", () => {
|
|
// Given/When
|
|
renderImpactedResourcesCell({
|
|
resourcesTotal: 0,
|
|
resourcesFail: 0,
|
|
failCount: 3,
|
|
passCount: 2,
|
|
muted: false,
|
|
});
|
|
|
|
// Then
|
|
expect(screen.getByText("3/5")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should include muted findings in the denominator when the row is muted", () => {
|
|
// Given/When
|
|
renderImpactedResourcesCell({
|
|
resourcesTotal: 0,
|
|
resourcesFail: 0,
|
|
failCount: 3,
|
|
passCount: 2,
|
|
failMutedCount: 4,
|
|
passMutedCount: 1,
|
|
muted: true,
|
|
});
|
|
|
|
// Then
|
|
expect(screen.getByText("3/10")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("column-finding-groups — group selection", () => {
|
|
it("should disable the row checkbox when the group has zero impacted resources", () => {
|
|
renderSelectCell({
|
|
resourcesTotal: 2,
|
|
resourcesFail: 0,
|
|
status: "PASS",
|
|
});
|
|
|
|
expect(screen.getByRole("checkbox", { name: "Select row" })).toBeDisabled();
|
|
});
|
|
|
|
it("should hide the chevron for zero-resource fallback groups even when fallback counts are present", () => {
|
|
// Given
|
|
const { onDrillDown } = renderSelectCell({
|
|
resourcesTotal: 0,
|
|
resourcesFail: 0,
|
|
failCount: 0,
|
|
passCount: 2,
|
|
manualCount: 1,
|
|
});
|
|
|
|
// Then
|
|
expect(
|
|
screen.queryByRole("button", {
|
|
name: "Expand S3 Bucket Public Access",
|
|
}),
|
|
).not.toBeInTheDocument();
|
|
expect(onDrillDown).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("column-finding-groups — indicators", () => {
|
|
it("should prefer the new indicator when the new delta exists only in the breakdown fields", () => {
|
|
notificationIndicatorMock.mockClear();
|
|
|
|
renderSelectCell({
|
|
muted: true,
|
|
newCount: 0,
|
|
changedCount: 0,
|
|
newFailMutedCount: 1,
|
|
changedFailCount: 2,
|
|
});
|
|
|
|
expect(notificationIndicatorMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
delta: "new",
|
|
isMuted: true,
|
|
showDeltaWhenMuted: true,
|
|
}),
|
|
);
|
|
});
|
|
});
|