Files
prowler/ui/components/scans/scans-page-shell.test.tsx
T

710 lines
20 KiB
TypeScript

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OnboardingFlow } from "@/lib/onboarding";
import { useScansStore } from "@/store";
import { ProviderProps } from "@/types";
import { ScansPageShell } from "./scans-page-shell";
const { pushMock, replaceMock, searchParamsValue } = vi.hoisted(() => ({
pushMock: vi.fn(),
replaceMock: vi.fn(),
searchParamsValue: { current: "" },
}));
const { scansFilterBarSpy } = vi.hoisted(() => ({
scansFilterBarSpy: vi.fn(),
}));
const { onboardingTriggerSpy } = vi.hoisted(() => ({
onboardingTriggerSpy: vi.fn(),
}));
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete store[key];
}),
clear: vi.fn(() => {
store = {};
}),
get length() {
return Object.keys(store).length;
},
key: vi.fn((index: number) => Object.keys(store)[index] ?? null),
};
})();
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
writable: true,
});
vi.mock("next/navigation", () => ({
usePathname: () => "/scans",
useRouter: () => ({
push: pushMock,
replace: replaceMock,
}),
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
}));
vi.mock("./scans-filter-bar", () => ({
ScansFilterBar: (props: {
showStatusFilter: boolean;
onScheduleTypeChange: (value: string) => void;
onScanStatusChange: (value: string) => void;
}) => {
scansFilterBarSpy(props);
return (
<>
<div>Shared scan filters</div>
<select
aria-label="All Types"
onChange={(event) => props.onScheduleTypeChange(event.target.value)}
>
<option value="all">All Types</option>
</select>
{props.showStatusFilter && (
<select
aria-label="All Statuses"
onChange={(event) => props.onScanStatusChange(event.target.value)}
>
<option value="all">All Statuses</option>
</select>
)}
</>
);
},
}));
vi.mock("./launch-scan-modal", () => ({
LaunchScanModal: ({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) =>
open ? (
<div role="dialog">
Launch scan
<button type="button" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null,
}));
vi.mock("@/components/providers/muted-findings-config-button", () => ({
MutedFindingsConfigButton: () => <a href="/mutelist">Configure Mutelist</a>,
}));
vi.mock("@/components/onboarding", () => ({
OnboardingTrigger: (props: unknown) => {
onboardingTriggerSpy(props);
return <div data-testid="onboarding-trigger" />;
},
PageReady: () => <div data-testid="page-ready" />,
}));
interface OnboardingTriggerProps {
flow: OnboardingFlow;
}
const getTriggeredTourTargets = () => {
const triggerProps = onboardingTriggerSpy.mock.calls.at(-1)?.[0] as
| OnboardingTriggerProps
| undefined;
return triggerProps?.flow.tour.steps.map((step) => step.target);
};
const providers: ProviderProps[] = [
{
id: "provider-1",
type: "providers" as const,
attributes: {
provider: "aws" as const,
is_dynamic: false,
uid: "123456789012",
alias: "Production",
status: "completed" as const,
resources: 0,
connection: {
connected: true,
last_checked_at: "2026-04-13T00:00:00Z",
},
scanner_args: {
only_logs: false,
excluded_checks: [],
aws_retries_max_attempts: 3,
},
inserted_at: "2026-04-13T00:00:00Z",
updated_at: "2026-04-13T00:00:00Z",
created_by: {
object: "user",
id: "user-1",
},
},
relationships: {
secret: {
data: null,
},
provider_groups: {
meta: {
count: 0,
},
data: [],
},
},
},
];
const disconnectedProviders: ProviderProps[] = [
{
...providers[0],
attributes: {
...providers[0].attributes,
connection: {
...providers[0].attributes.connection,
connected: false,
},
},
},
];
describe("ScansPageShell", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
localStorageMock.clear();
searchParamsValue.current = "";
useScansStore.getState().closeLaunchScanModal();
});
it("uses the shared scan filter bar for scan filters", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.getByText("Shared scan filters")).toBeInTheDocument();
expect(scansFilterBarSpy).toHaveBeenCalledWith(
expect.objectContaining({
providers,
scheduleType: "all",
}),
);
});
it("clears the active sort when switching tabs", async () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=active&sort=trigger";
const user = userEvent.setup();
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
await user.click(screen.getByRole("tab", { name: /completed/i }));
expect(pushMock).toHaveBeenCalled();
const calledUrl = pushMock.mock.calls.at(-1)?.[0] as string;
expect(calledUrl).toContain("tab=completed");
expect(calledUrl).not.toContain("sort=");
});
it("uses a generic type filter label in Cloud", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "true");
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.getByRole("combobox", { name: /all types/i })).toBeVisible();
});
it.each(["Cloud", "Private Cloud"])(
"shows Import Findings in %s with Manage Ingestions",
() => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
render(
<ScansPageShell
providers={providers}
hasManageScansPermission
hasManageIngestionsPermission
>
<div>Scans table</div>
</ScansPageShell>,
);
// Then
expect(
screen.getByRole("button", { name: /import findings/i }),
).toBeVisible();
},
);
it("hides Import Findings without Manage Ingestions", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
render(
<ScansPageShell
providers={providers}
hasManageScansPermission
hasManageIngestionsPermission={false}
>
<div>Scans table</div>
</ScansPageShell>,
);
// Then
expect(
screen.queryByRole("button", { name: /import findings/i }),
).not.toBeInTheDocument();
});
it("hides Import Findings in OSS and Local Server", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
render(
<ScansPageShell
providers={providers}
hasManageScansPermission
hasManageIngestionsPermission
>
<div>Scans table</div>
</ScansPageShell>,
);
// Then
expect(
screen.queryByRole("button", { name: /import findings/i }),
).not.toBeInTheDocument();
});
it("shows the CLI import banner in Cloud", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "true");
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.getByRole("alert")).toHaveTextContent(
/import findings from prowler cli/i,
);
expect(screen.getByRole("link", { name: /learn more/i })).toHaveAttribute(
"href",
"https://docs.prowler.com/user-guide/tutorials/prowler-app-import-findings",
);
});
it("hides the CLI import banner outside Cloud", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("keeps launch scan with filters and mutelist with tabs", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(
screen.getByRole("group", { name: /scan filters and actions/i }),
).toContainElement(screen.getByRole("button", { name: /launch scan/i }));
expect(
screen.getByRole("group", { name: /scan filters and actions/i }),
).not.toContainElement(
screen.getByRole("link", { name: /configure mutelist/i }),
);
expect(screen.getByRole("group", { name: /scan tabs/i })).toContainElement(
screen.getByRole("link", { name: /configure mutelist/i }),
);
});
it("shows the active scans count in the in progress tab", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<ScansPageShell
providers={providers}
hasManageScansPermission
activeScanCount={3}
>
<div>Scans table</div>
</ScansPageShell>,
);
expect(
screen.getByRole("tab", { name: /in progress \(3\)/i }),
).toBeVisible();
expect(screen.getByRole("tab", { name: /^completed$/i })).toBeVisible();
});
it("opens the launch scan modal from the URL", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "launchScan=true";
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.getByRole("dialog")).toHaveTextContent(/launch scan/i);
});
it("does not open the launch scan modal from the URL when no provider is connected", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "launchScan=true";
render(
<ScansPageShell
providers={disconnectedProviders}
hasManageScansPermission
>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("does not open the launch scan modal from client state when no provider is connected", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
useScansStore.getState().openLaunchScanModal();
render(
<ScansPageShell
providers={disconnectedProviders}
hasManageScansPermission
>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("does not open the launch scan modal from the URL without manage scans permission", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "launchScan=true";
render(
<ScansPageShell providers={providers} hasManageScansPermission={false}>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("strips the launchScan URL param via the History API when closing the URL-opened modal", async () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=completed&launchScan=true";
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
const user = userEvent.setup();
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
await user.click(screen.getByRole("button", { name: /close/i }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
// History API (not router.replace) so Next.js does not refetch and reload the page;
// scanOnDemand's revalidatePath already refreshes the list when a scan launches.
expect(replaceStateSpy).toHaveBeenCalledWith(
null,
"",
"/scans?tab=completed",
);
expect(replaceMock).not.toHaveBeenCalled();
expect(pushMock).not.toHaveBeenCalled();
replaceStateSpy.mockRestore();
});
it("opens and closes the launch scan modal from client state without navigation", async () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
const user = userEvent.setup();
useScansStore.getState().openLaunchScanModal();
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.getByRole("dialog")).toHaveTextContent(/launch scan/i);
await user.click(screen.getByRole("button", { name: /close/i }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(pushMock).not.toHaveBeenCalled();
});
it("shows the status filter only on the completed tab", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=completed";
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(
screen.getByRole("combobox", { name: /all statuses/i }),
).toBeVisible();
});
it("hides the status filter outside of the completed tab", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=active";
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(
screen.queryByRole("combobox", { name: /all statuses/i }),
).not.toBeInTheDocument();
});
it("clears status filter when switching scan tabs", async () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=completed&filter%5Bstate__in%5D=failed";
const user = userEvent.setup();
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
await user.click(screen.getByRole("tab", { name: /in progress/i }));
const calledUrl = pushMock.mock.calls.at(-1)?.[0] as string;
expect(calledUrl).toContain("tab=active");
expect(calledUrl).not.toContain("filter%5Bstate__in%5D");
});
it("clears type filter when switching to scheduled scans", async () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=completed&filter%5Btrigger%5D=manual";
const user = userEvent.setup();
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
await user.click(screen.getByRole("tab", { name: /scheduled/i }));
const calledUrl = pushMock.mock.calls.at(-1)?.[0] as string;
expect(calledUrl).toContain("tab=scheduled");
expect(calledUrl).not.toContain("filter%5Btrigger%5D");
});
it("shows a non-blocking hint when no provider is connected, while still rendering the table", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<ScansPageShell
providers={disconnectedProviders}
hasManageScansPermission
>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.getByText("No Connected Providers")).toBeInTheDocument();
// The table (and therefore imported scans) must still render below the hint.
expect(screen.getByText("Scans table")).toBeInTheDocument();
});
it("shows the no-providers hint when there are no providers, while still rendering the table", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<ScansPageShell providers={[]} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
expect(screen.getByText("Scans table")).toBeInTheDocument();
});
it("does not show the providers hint when a provider is connected", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(
screen.queryByText("No Connected Providers"),
).not.toBeInTheDocument();
expect(
screen.queryByText("No Providers Configured"),
).not.toBeInTheDocument();
});
it("starts the view-first-scan tour when a provider is connected", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.getByTestId("onboarding-trigger")).toBeInTheDocument();
});
it("uses only mounted tour targets when an active scan exists on the completed tab", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=completed";
// When
render(
<ScansPageShell
providers={providers}
hasManageScansPermission
activeScanCount={1}
>
<div>Scans table</div>
</ScansPageShell>,
);
// Then
expect(getTriggeredTourTargets()).toEqual([undefined, "launch", "tabs"]);
});
it("targets the running scan when its row is mounted on the in progress tab", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=active";
// When
render(
<ScansPageShell
providers={providers}
hasManageScansPermission
activeScanCount={1}
>
<div>Scans table</div>
</ScansPageShell>,
);
// Then
expect(getTriggeredTourTargets()).toEqual([
undefined,
"in-progress",
"launch",
]);
});
it("suppresses the view-first-scan tour when no provider is connected, since Launch Scan is disabled", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<ScansPageShell
providers={disconnectedProviders}
hasManageScansPermission
>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.queryByTestId("onboarding-trigger")).not.toBeInTheDocument();
// The table (and therefore imported scans) must still render even with the tour suppressed.
expect(screen.getByText("Scans table")).toBeInTheDocument();
});
it("suppresses the view-first-scan tour when there are no providers", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<ScansPageShell providers={[]} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.queryByTestId("onboarding-trigger")).not.toBeInTheDocument();
});
it("suppresses the view-first-scan tour when a provider is connected but manage scans is missing", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<ScansPageShell providers={providers} hasManageScansPermission={false}>
<div>Scans table</div>
</ScansPageShell>,
);
// Launch Scan is disabled without manage_scans, so the tour must not anchor to it.
expect(screen.queryByTestId("onboarding-trigger")).not.toBeInTheDocument();
// The table (and therefore imported scans) must still render.
expect(screen.getByText("Scans table")).toBeInTheDocument();
});
it("still signals page-ready without a connected provider so the navbar replay fallback works", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<ScansPageShell providers={[]} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.getByTestId("page-ready")).toBeInTheDocument();
});
});