mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-13 15:04:19 +00:00
640 lines
19 KiB
TypeScript
640 lines
19 KiB
TypeScript
import { render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import type { ComponentProps } from "react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const {
|
|
getScheduleMock,
|
|
refreshMock,
|
|
scanOnDemandMock,
|
|
scheduleDailyMock,
|
|
searchParamsValue,
|
|
toastMock,
|
|
updateScheduleMock,
|
|
} = vi.hoisted(() => ({
|
|
getScheduleMock: vi.fn(),
|
|
refreshMock: vi.fn(),
|
|
scanOnDemandMock: vi.fn(),
|
|
scheduleDailyMock: vi.fn(),
|
|
searchParamsValue: { current: "" },
|
|
toastMock: vi.fn(),
|
|
updateScheduleMock: vi.fn(),
|
|
}));
|
|
|
|
class ResizeObserverMock {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
}
|
|
|
|
Object.defineProperty(globalThis, "ResizeObserver", {
|
|
writable: true,
|
|
configurable: true,
|
|
value: ResizeObserverMock,
|
|
});
|
|
|
|
vi.mock("next/navigation", () => ({
|
|
useRouter: () => ({
|
|
refresh: refreshMock,
|
|
}),
|
|
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
|
|
}));
|
|
|
|
vi.mock("@/actions/scans", () => ({
|
|
scanOnDemand: scanOnDemandMock,
|
|
scheduleDaily: scheduleDailyMock,
|
|
}));
|
|
|
|
vi.mock("@/actions/schedules", () => ({
|
|
getSchedule: getScheduleMock,
|
|
updateSchedule: updateScheduleMock,
|
|
}));
|
|
|
|
vi.mock("@/components/shadcn/toast", () => ({
|
|
ToastAction: ({ children, ...props }: ComponentProps<"button">) => (
|
|
<button {...props}>{children}</button>
|
|
),
|
|
toast: toastMock,
|
|
}));
|
|
|
|
vi.mock("@/components/shadcn/modal", () => ({
|
|
Modal: ({
|
|
children,
|
|
open,
|
|
title,
|
|
}: {
|
|
children: React.ReactNode;
|
|
open: boolean;
|
|
title: string;
|
|
}) =>
|
|
open ? (
|
|
<div role="dialog" aria-label={title}>
|
|
{children}
|
|
</div>
|
|
) : null,
|
|
}));
|
|
|
|
vi.mock("@/components/shadcn/entities", () => ({
|
|
EntityInfo: ({
|
|
entityAlias,
|
|
entityId,
|
|
}: {
|
|
entityAlias?: string;
|
|
entityId?: string;
|
|
}) => <>{entityAlias || entityId}</>,
|
|
}));
|
|
|
|
vi.mock("@/app/(prowler)/_overview/_components/accounts-selector", () => ({
|
|
AccountsSelector: ({
|
|
disabledValues = [],
|
|
providers,
|
|
onBatchChange,
|
|
selectedValues,
|
|
id,
|
|
placeholder = "All Providers",
|
|
}: {
|
|
disabledValues?: string[];
|
|
providers: { id: string; attributes: { alias: string; uid: string } }[];
|
|
onBatchChange: (filterKey: string, values: string[]) => void;
|
|
selectedValues: string[];
|
|
id?: string;
|
|
placeholder?: string;
|
|
}) => (
|
|
<div>
|
|
<input aria-label="Search Providers" placeholder="Search Providers..." />
|
|
<select
|
|
id={id}
|
|
aria-label="Providers"
|
|
value={selectedValues[0] ?? ""}
|
|
onChange={(event) =>
|
|
onBatchChange("provider_id__in", [event.target.value])
|
|
}
|
|
>
|
|
<option value="">{placeholder}</option>
|
|
{providers.map((provider) => (
|
|
<option
|
|
key={provider.id}
|
|
value={provider.id}
|
|
disabled={disabledValues.includes(provider.id)}
|
|
>
|
|
{provider.attributes.alias || provider.attributes.uid}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
import {
|
|
ACTION_ERROR_API_MESSAGES,
|
|
ACTION_ERROR_MESSAGES,
|
|
ACTION_ERROR_STATUS,
|
|
} from "@/lib/action-errors";
|
|
import { ProviderProps } from "@/types";
|
|
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
|
|
|
|
import { LaunchScanModal } from "./launch-scan-modal";
|
|
|
|
const provider: 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 disconnectedProvider = {
|
|
...provider,
|
|
id: "provider-2",
|
|
attributes: {
|
|
...provider.attributes,
|
|
alias: "Disconnected",
|
|
uid: "210987654321",
|
|
connection: {
|
|
connected: false,
|
|
last_checked_at: "2026-05-20T11:46:38.834045Z",
|
|
},
|
|
},
|
|
};
|
|
|
|
describe("LaunchScanModal", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
searchParamsValue.current = "";
|
|
scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("shows a searchable provider selector", () => {
|
|
render(
|
|
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
|
|
);
|
|
|
|
expect(screen.getByPlaceholderText("Search Providers...")).toBeVisible();
|
|
});
|
|
|
|
it("uses a single-provider placeholder in the launch selector", () => {
|
|
render(
|
|
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
|
|
);
|
|
|
|
expect(
|
|
screen.getByRole("option", { name: "Select a Provider" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("disables disconnected providers in the launch selector", () => {
|
|
render(
|
|
<LaunchScanModal
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
providers={[provider, disconnectedProvider]}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByRole("option", { name: "Disconnected" })).toBeDisabled();
|
|
});
|
|
|
|
it("submits alias as scanName so the API stores it as the scan alias", async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(
|
|
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
|
|
);
|
|
|
|
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
|
|
await user.type(screen.getByLabelText("Alias"), "Production audit");
|
|
await user.click(screen.getByRole("button", { name: /launch scan/i }));
|
|
|
|
await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalled());
|
|
|
|
const formData = scanOnDemandMock.mock.calls[0][0] as FormData;
|
|
expect(formData.get("providerId")).toBe(provider.id);
|
|
expect(formData.get("scanName")).toBe("Production audit");
|
|
expect(formData.get("scanNote")).toBeNull();
|
|
});
|
|
|
|
it("accepts scan aliases up to the API limit of 100 characters", async () => {
|
|
const user = userEvent.setup();
|
|
const alias = "a".repeat(100);
|
|
|
|
render(
|
|
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
|
|
);
|
|
|
|
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
|
|
await user.type(screen.getByLabelText("Alias"), alias);
|
|
await user.click(screen.getByRole("button", { name: /launch scan/i }));
|
|
|
|
await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalled());
|
|
|
|
const formData = scanOnDemandMock.mock.calls[0][0] as FormData;
|
|
expect(formData.get("scanName")).toBe(alias);
|
|
});
|
|
|
|
it("adds a toast action to view the scan in progress when another tab is active", async () => {
|
|
const user = userEvent.setup();
|
|
searchParamsValue.current =
|
|
"tab=completed&filter%5Bstate__in%5D=failed&page=3";
|
|
|
|
render(
|
|
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
|
|
);
|
|
|
|
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
|
|
await user.click(screen.getByRole("button", { name: /launch scan/i }));
|
|
|
|
await waitFor(() => expect(toastMock).toHaveBeenCalled());
|
|
|
|
const toastPayload = toastMock.mock.calls[0]?.[0];
|
|
expect(toastPayload.action).toBeDefined();
|
|
expect(toastPayload.action.props.children.props.href).toBe(
|
|
"/scans?tab=active",
|
|
);
|
|
expect(toastPayload.action.props.children.props.children).toBe("View scan");
|
|
});
|
|
|
|
it("does not add a toast action when the in progress tab is active", async () => {
|
|
const user = userEvent.setup();
|
|
searchParamsValue.current = "tab=active";
|
|
|
|
render(
|
|
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
|
|
);
|
|
|
|
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
|
|
await user.click(screen.getByRole("button", { name: /launch scan/i }));
|
|
|
|
await waitFor(() => expect(toastMock).toHaveBeenCalled());
|
|
|
|
const toastPayload = toastMock.mock.calls[0]?.[0];
|
|
expect(toastPayload.action).toBeUndefined();
|
|
});
|
|
|
|
it("rejects scan aliases over the API limit of 100 characters", async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(
|
|
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
|
|
);
|
|
|
|
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
|
|
await user.type(screen.getByLabelText("Alias"), "a".repeat(101));
|
|
await user.click(screen.getByRole("button", { name: /launch scan/i }));
|
|
|
|
expect(
|
|
await screen.findByText(/alias must not exceed 100 characters/i),
|
|
).toBeInTheDocument();
|
|
expect(scanOnDemandMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not show the old scan note label", () => {
|
|
render(
|
|
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
|
|
);
|
|
|
|
expect(screen.queryByLabelText("Scan Note")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Scan Note (optional)")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("surfaces JSON:API errors from scanOnDemand and skips the success toast", async () => {
|
|
const user = userEvent.setup();
|
|
const onOpenChange = vi.fn();
|
|
scanOnDemandMock.mockResolvedValueOnce({
|
|
errors: [{ detail: "Provider already has a scan in progress" }],
|
|
});
|
|
|
|
render(
|
|
<LaunchScanModal
|
|
open
|
|
onOpenChange={onOpenChange}
|
|
providers={[provider]}
|
|
/>,
|
|
);
|
|
|
|
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
|
|
await user.click(screen.getByRole("button", { name: /launch scan/i }));
|
|
|
|
expect(
|
|
await screen.findByText("Provider already has a scan in progress"),
|
|
).toBeInTheDocument();
|
|
expect(toastMock).not.toHaveBeenCalled();
|
|
expect(refreshMock).not.toHaveBeenCalled();
|
|
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("maps payment-required scan errors to the shared subscription message", async () => {
|
|
const user = userEvent.setup();
|
|
const onOpenChange = vi.fn();
|
|
scanOnDemandMock.mockResolvedValueOnce({
|
|
error: ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED],
|
|
status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED,
|
|
});
|
|
|
|
render(
|
|
<LaunchScanModal
|
|
open
|
|
onOpenChange={onOpenChange}
|
|
providers={[provider]}
|
|
/>,
|
|
);
|
|
|
|
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
|
|
await user.click(screen.getByRole("button", { name: /launch scan/i }));
|
|
|
|
expect(
|
|
await screen.findByText(
|
|
ACTION_ERROR_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED],
|
|
),
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.queryByText(
|
|
ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED],
|
|
),
|
|
).not.toBeInTheDocument();
|
|
expect(toastMock).not.toHaveBeenCalled();
|
|
expect(refreshMock).not.toHaveBeenCalled();
|
|
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
describe("schedule mode", () => {
|
|
const weeklyScheduleResponse = {
|
|
data: {
|
|
type: "schedules",
|
|
id: provider.id,
|
|
attributes: {
|
|
scan_enabled: true,
|
|
scan_frequency: "WEEKLY",
|
|
scan_hour: 9,
|
|
scan_timezone: "Europe/Madrid",
|
|
scan_interval_hours: null,
|
|
scan_day_of_week: 1,
|
|
scan_day_of_month: null,
|
|
},
|
|
},
|
|
};
|
|
|
|
beforeEach(() => {
|
|
getScheduleMock.mockResolvedValue(weeklyScheduleResponse);
|
|
updateScheduleMock.mockResolvedValue({ data: { id: provider.id } });
|
|
});
|
|
|
|
const renderAdvanced = () =>
|
|
render(
|
|
<LaunchScanModal
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
providers={[provider]}
|
|
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
|
/>,
|
|
);
|
|
|
|
it("prefills the provider schedule and saves it through the new API", async () => {
|
|
const user = userEvent.setup();
|
|
renderAdvanced();
|
|
|
|
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
|
|
await user.click(screen.getByRole("radio", { name: "On a schedule" }));
|
|
|
|
await waitFor(() =>
|
|
expect(getScheduleMock).toHaveBeenCalledWith(provider.id),
|
|
);
|
|
|
|
await user.click(
|
|
await screen.findByRole("button", { name: /save schedule/i }),
|
|
);
|
|
|
|
// The payload carries the fetched WEEKLY schedule, proving the prefill.
|
|
await waitFor(() =>
|
|
expect(updateScheduleMock).toHaveBeenCalledWith(
|
|
provider.id,
|
|
expect.objectContaining({
|
|
scan_enabled: true,
|
|
scan_frequency: "WEEKLY",
|
|
scan_hour: 9,
|
|
scan_day_of_week: 1,
|
|
}),
|
|
),
|
|
);
|
|
expect(scanOnDemandMock).not.toHaveBeenCalled();
|
|
expect(toastMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ title: "Scan schedule saved" }),
|
|
);
|
|
});
|
|
|
|
it("keeps the upcoming local hour when provider scan fields say there is no schedule", async () => {
|
|
// Given
|
|
const user = userEvent.setup();
|
|
vi.setSystemTime(new Date(2026, 5, 10, 11, 59, 0, 0));
|
|
getScheduleMock.mockResolvedValue({
|
|
data: {
|
|
type: "schedules",
|
|
id: provider.id,
|
|
attributes: {
|
|
scan_enabled: true,
|
|
scan_frequency: "DAILY",
|
|
scan_hour: 0,
|
|
scan_timezone: "UTC",
|
|
scan_interval_hours: null,
|
|
scan_day_of_week: null,
|
|
scan_day_of_month: null,
|
|
},
|
|
},
|
|
});
|
|
const providerWithoutSchedule = {
|
|
...provider,
|
|
attributes: {
|
|
...provider.attributes,
|
|
scan_enabled: true,
|
|
scan_frequency: "DAILY" as const,
|
|
scan_hour: null,
|
|
scan_timezone: "UTC",
|
|
scan_interval_hours: null,
|
|
scan_day_of_week: null,
|
|
scan_day_of_month: null,
|
|
next_scan_at: null,
|
|
last_scan_at: null,
|
|
},
|
|
};
|
|
|
|
render(
|
|
<LaunchScanModal
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
providers={[providerWithoutSchedule]}
|
|
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
|
/>,
|
|
);
|
|
|
|
// When
|
|
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
|
|
await user.click(screen.getByRole("radio", { name: "On a schedule" }));
|
|
|
|
// Then
|
|
expect(
|
|
await screen.findByRole("combobox", { name: "Scan Time" }),
|
|
).toHaveTextContent("12:00pm");
|
|
expect(
|
|
screen.getByRole("combobox", { name: "Scan Time" }),
|
|
).not.toHaveTextContent("12:00am");
|
|
expect(getScheduleMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("launches the initial scan when the checkbox is checked", async () => {
|
|
const user = userEvent.setup();
|
|
renderAdvanced();
|
|
|
|
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
|
|
await user.click(screen.getByRole("radio", { name: "On a schedule" }));
|
|
await user.click(
|
|
await screen.findByLabelText(
|
|
"Launch an initial scan now for immediate findings",
|
|
),
|
|
);
|
|
await user.click(screen.getByRole("button", { name: /save schedule/i }));
|
|
|
|
await waitFor(() => expect(updateScheduleMock).toHaveBeenCalled());
|
|
await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1));
|
|
expect(toastMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
title: "Scan schedule saved and initial scan launched",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("disables Save Schedule until a provider is selected", async () => {
|
|
const user = userEvent.setup();
|
|
renderAdvanced();
|
|
|
|
await user.click(screen.getByRole("radio", { name: "On a schedule" }));
|
|
|
|
expect(
|
|
screen.getByRole("button", { name: /save schedule/i }),
|
|
).toBeDisabled();
|
|
expect(getScheduleMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("preserves legacy daily scheduling outside Cloud", async () => {
|
|
const user = userEvent.setup();
|
|
scheduleDailyMock.mockResolvedValue({ data: { id: provider.id } });
|
|
render(
|
|
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
|
|
);
|
|
|
|
expect(
|
|
screen.getByRole("radio", { name: "On a schedule" }),
|
|
).toBeEnabled();
|
|
|
|
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
|
|
await user.click(screen.getByRole("radio", { name: "On a schedule" }));
|
|
expect(screen.getByRole("combobox", { name: "Repeats" })).toBeDisabled();
|
|
|
|
await user.click(screen.getByRole("button", { name: /save schedule/i }));
|
|
|
|
await waitFor(() => expect(scheduleDailyMock).toHaveBeenCalledTimes(1));
|
|
expect(getScheduleMock).not.toHaveBeenCalled();
|
|
expect(updateScheduleMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("hides schedule mode but allows manual scans in MANUAL_ONLY", async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(
|
|
<LaunchScanModal
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
providers={[provider]}
|
|
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByRole("radio")).not.toBeInTheDocument();
|
|
|
|
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
|
|
await user.click(screen.getByRole("button", { name: /launch scan/i }));
|
|
|
|
await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1));
|
|
expect(getScheduleMock).not.toHaveBeenCalled();
|
|
expect(updateScheduleMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("hides the mode selector and blocks over-limit accounts in MANUAL_ONLY", () => {
|
|
render(
|
|
<LaunchScanModal
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
providers={[provider]}
|
|
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
|
|
isScanLimitReached
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByRole("radio")).not.toBeInTheDocument();
|
|
expect(screen.getByText(/exceeded the usage limit/i)).toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole("button", { name: /launch scan/i }),
|
|
).toBeDisabled();
|
|
});
|
|
|
|
it("blocks scans and schedules in BLOCKED", async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(
|
|
<LaunchScanModal
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
providers={[provider]}
|
|
capability={SCAN_SCHEDULE_CAPABILITY.BLOCKED}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByRole("radio")).not.toBeInTheDocument();
|
|
expect(screen.getByText(/exceeded the usage limit/i)).toBeInTheDocument();
|
|
|
|
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
|
|
await user.click(screen.getByRole("button", { name: /launch scan/i }));
|
|
|
|
expect(
|
|
screen.getByRole("button", { name: /launch scan/i }),
|
|
).toBeDisabled();
|
|
expect(scanOnDemandMock).not.toHaveBeenCalled();
|
|
expect(getScheduleMock).not.toHaveBeenCalled();
|
|
expect(updateScheduleMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|