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">) => ( ), toast: toastMock, })); vi.mock("@/components/shadcn/modal", () => ({ Modal: ({ children, open, title, }: { children: React.ReactNode; open: boolean; title: string; }) => open ? (
{children}
) : 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; }) => (
), })); 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( , ); expect(screen.getByPlaceholderText("Search Providers...")).toBeVisible(); }); it("uses a single-provider placeholder in the launch selector", () => { render( , ); expect( screen.getByRole("option", { name: "Select a Provider" }), ).toBeInTheDocument(); }); it("disables disconnected providers in the launch selector", () => { render( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); // 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( , ); 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( , ); 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( , ); 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( , ); 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(); }); }); });