import { act, 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"; import { ACTION_ERROR_API_MESSAGES, ACTION_ERROR_MESSAGES, ACTION_ERROR_STATUS, } from "@/lib/action-errors"; import { useProviderWizardStore } from "@/store/provider-wizard/store"; import { SCAN_JOBS_TAB } from "@/types"; import { SCAN_SCHEDULE_CAPABILITY, SCHEDULE_FREQUENCY, } from "@/types/schedules"; import { LaunchStep } from "./launch-step"; const { scanOnDemandMock, scheduleDailyMock, toastMock, updateScheduleMock } = vi.hoisted(() => ({ scanOnDemandMock: vi.fn(), scheduleDailyMock: vi.fn(), toastMock: vi.fn(), updateScheduleMock: vi.fn(), })); vi.mock("@/actions/scans", () => ({ scanOnDemand: scanOnDemandMock, scheduleDaily: scheduleDailyMock, })); vi.mock("@/actions/schedules", () => ({ updateSchedule: updateScheduleMock, })); vi.mock("@/components/ui", () => ({ ToastAction: ({ children, ...props }: ComponentProps<"button">) => ( ), useToast: () => ({ toast: toastMock, }), })); const seedConnectedProvider = () => { useProviderWizardStore.setState({ providerId: "provider-1", providerType: "gcp", providerUid: "project-123", mode: "add", }); }; const lastFooterConfig = (onFooterChange: ReturnType) => onFooterChange.mock.calls.at(-1)?.[0]; describe("LaunchStep", () => { beforeEach(() => { vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ resolvedOptions: () => ({ timeZone: "Europe/Madrid" }), } as Intl.DateTimeFormat); sessionStorage.clear(); localStorage.clear(); updateScheduleMock.mockReset(); scheduleDailyMock.mockReset(); scanOnDemandMock.mockReset(); toastMock.mockReset(); useProviderWizardStore.getState().reset(); }); afterEach(() => { vi.unstubAllEnvs(); }); describe("Prowler OSS (non-Cloud)", () => { beforeEach(() => { vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } }); }); it("defaults to daily schedule mode and locks advanced cadence outside Cloud", async () => { // Given const onFooterChange = vi.fn(); seedConnectedProvider(); render( , ); // Then expect(screen.getByText("Provider Connected!")).toBeInTheDocument(); expect( screen.getByRole("radio", { name: "On a schedule" }), ).toBeChecked(); expect(screen.getByRole("radio", { name: "Run now" })).not.toBeChecked(); expect( screen.getByRole("radio", { name: "On a schedule" }), ).toBeEnabled(); expect(screen.getByRole("combobox", { name: /repeats/i })).toBeDisabled(); await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Save"); }); it("saves a legacy daily schedule by default", async () => { // Given const onClose = vi.fn(); const onFooterChange = vi.fn(); seedConnectedProvider(); render( , ); await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); // When await act(async () => { lastFooterConfig(onFooterChange)?.onAction?.(); }); // Then await waitFor(() => expect(scheduleDailyMock).toHaveBeenCalledTimes(1)); const sentFormData = scheduleDailyMock.mock.calls[0]?.[0] as FormData; expect(sentFormData.get("providerId")).toBe("provider-1"); expect(scanOnDemandMock).not.toHaveBeenCalled(); expect(updateScheduleMock).not.toHaveBeenCalled(); expect(onClose).toHaveBeenCalledTimes(1); }); it("launches only an on-demand scan when run now is selected", async () => { // Given const user = userEvent.setup(); const onClose = vi.fn(); const onFooterChange = vi.fn(); seedConnectedProvider(); render( , ); await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); // When await user.click(screen.getByRole("radio", { name: "Run now" })); await waitFor(() => expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe( "Launch scan", ), ); await act(async () => { lastFooterConfig(onFooterChange)?.onAction?.(); }); // Then await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1)); expect(scheduleDailyMock).not.toHaveBeenCalled(); expect(updateScheduleMock).not.toHaveBeenCalled(); expect(onClose).toHaveBeenCalledTimes(1); }); }); describe("Prowler Cloud subscribed", () => { beforeEach(() => { vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); updateScheduleMock.mockResolvedValue({ data: { id: "provider-1" } }); }); it("defaults to schedule mode and saves through the new schedule API", async () => { // Given const onClose = vi.fn(); const onFooterChange = vi.fn(); seedConnectedProvider(); render( , ); // Then advanced cadence selector is enabled expect( screen.getByRole("radio", { name: "On a schedule" }), ).toBeChecked(); expect( screen.getByRole("combobox", { name: /repeats/i }), ).not.toBeDisabled(); await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); // When await act(async () => { lastFooterConfig(onFooterChange)?.onAction?.(); }); // Then await waitFor(() => expect(updateScheduleMock).toHaveBeenCalledTimes(1)); expect(updateScheduleMock).toHaveBeenCalledWith( "provider-1", expect.objectContaining({ scan_enabled: true, scan_frequency: SCHEDULE_FREQUENCY.DAILY, scan_hour: expect.any(Number), scan_timezone: "Europe/Madrid", scan_day_of_week: null, scan_day_of_month: null, }), ); expect(scheduleDailyMock).not.toHaveBeenCalled(); expect(scanOnDemandMock).not.toHaveBeenCalled(); expect(onClose).toHaveBeenCalledTimes(1); const toastPayload = toastMock.mock.calls[0]?.[0]; expect(toastPayload.action.props.children.props.href).toBe( `/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`, ); }); it("also launches an on-demand scan when the checkbox is checked", async () => { // Given const user = userEvent.setup(); const onFooterChange = vi.fn(); seedConnectedProvider(); scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } }); render( , ); await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); // When await user.click( screen.getByRole("checkbox", { name: /launch an initial scan now/i }), ); await act(async () => { lastFooterConfig(onFooterChange)?.onAction?.(); }); // Then await waitFor(() => expect(updateScheduleMock).toHaveBeenCalledTimes(1)); expect(scanOnDemandMock).toHaveBeenCalledTimes(1); expect(scheduleDailyMock).not.toHaveBeenCalled(); const toastPayload = toastMock.mock.calls[0]?.[0]; expect(toastPayload.action.props.children.props.href).toBe( `/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`, ); }); it("launches only an on-demand scan when run now is selected", async () => { // Given const user = userEvent.setup(); const onClose = vi.fn(); const onFooterChange = vi.fn(); seedConnectedProvider(); scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } }); render( , ); await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); // When await user.click( screen.getByRole("radio", { name: "Run now", }), ); await waitFor(() => expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe( "Launch scan", ), ); await act(async () => { lastFooterConfig(onFooterChange)?.onAction?.(); }); // Then await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1)); expect(updateScheduleMock).not.toHaveBeenCalled(); expect(scheduleDailyMock).not.toHaveBeenCalled(); expect(onClose).toHaveBeenCalledTimes(1); }); it("does not launch an initial scan when the schedule save fails", async () => { // Given const user = userEvent.setup(); const onFooterChange = vi.fn(); seedConnectedProvider(); updateScheduleMock.mockResolvedValue({ error: "Schedule failed" }); render( , ); await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); // When await user.click( screen.getByRole("checkbox", { name: /launch an initial scan now/i }), ); await act(async () => { lastFooterConfig(onFooterChange)?.onAction?.(); }); // Then await waitFor(() => expect(updateScheduleMock).toHaveBeenCalledTimes(1)); expect(scanOnDemandMock).not.toHaveBeenCalled(); expect(toastMock).toHaveBeenCalledWith( expect.objectContaining({ title: "Unable to save scan schedule" }), ); }); it("disables launch actions while schedule capability is loading", async () => { // Given const onFooterChange = vi.fn(); seedConnectedProvider(); render( , ); // When await screen.findByText("Loading scan options..."); await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); await act(async () => { lastFooterConfig(onFooterChange)?.onAction?.(); }); // Then expect(lastFooterConfig(onFooterChange)?.backDisabled).toBe(true); expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(true); expect(scanOnDemandMock).not.toHaveBeenCalled(); expect(updateScheduleMock).not.toHaveBeenCalled(); expect(scheduleDailyMock).not.toHaveBeenCalled(); }); }); describe("Prowler Cloud trial/onboarding (manual scan only)", () => { beforeEach(() => { vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } }); }); it("defaults to run now, locks schedule mode, and only launches a manual scan", async () => { // Given const onClose = vi.fn(); const onFooterChange = vi.fn(); seedConnectedProvider(); render( , ); // Then no schedule cadence selector is rendered expect( screen.queryByRole("combobox", { name: /repeats/i }), ).not.toBeInTheDocument(); expect(screen.getByRole("radio", { name: "Run now" })).toBeChecked(); expect( screen.getByRole("radio", { name: "On a schedule" }), ).toBeDisabled(); expect( screen.getByText(/scheduled scans are not available/i), ).toBeInTheDocument(); await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(false); // The action launches a scan here, so it must not be labeled "Save". expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Launch scan"); // When await act(async () => { lastFooterConfig(onFooterChange)?.onAction?.(); }); // Then await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1)); expect(updateScheduleMock).not.toHaveBeenCalled(); expect(scheduleDailyMock).not.toHaveBeenCalled(); expect(onClose).toHaveBeenCalledTimes(1); }); it("uses the shared subscription error copy when a manual scan is blocked", async () => { // Given const onClose = vi.fn(); const onFooterChange = vi.fn(); const rawError = ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED]; seedConnectedProvider(); scanOnDemandMock.mockResolvedValue({ error: rawError, status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED, }); render( , ); await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); // When await act(async () => { lastFooterConfig(onFooterChange)?.onAction?.(); }); // Then await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1)); expect(toastMock).toHaveBeenCalledWith( expect.objectContaining({ variant: "destructive", title: "Unable to launch scan", description: ACTION_ERROR_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED], }), ); expect(toastMock.mock.calls[0]?.[0].description).not.toContain(rawError); expect(onClose).not.toHaveBeenCalled(); }); it("disables the action and shows the limit copy when over limit", async () => { // Given const onFooterChange = vi.fn(); seedConnectedProvider(); render( , ); // Then expect(screen.getByText(/exceeded the usage limit/i)).toBeInTheDocument(); await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(true); }); }); });