mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
482 lines
15 KiB
TypeScript
482 lines
15 KiB
TypeScript
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">) => (
|
|
<button {...props}>{children}</button>
|
|
),
|
|
useToast: () => ({
|
|
toast: toastMock,
|
|
}),
|
|
}));
|
|
|
|
const seedConnectedProvider = () => {
|
|
useProviderWizardStore.setState({
|
|
providerId: "provider-1",
|
|
providerType: "gcp",
|
|
providerUid: "project-123",
|
|
mode: "add",
|
|
});
|
|
};
|
|
|
|
const lastFooterConfig = (onFooterChange: ReturnType<typeof vi.fn>) =>
|
|
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(
|
|
<LaunchStep
|
|
onBack={vi.fn()}
|
|
onClose={vi.fn()}
|
|
onFooterChange={onFooterChange}
|
|
/>,
|
|
);
|
|
|
|
// 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(
|
|
<LaunchStep
|
|
onBack={vi.fn()}
|
|
onClose={onClose}
|
|
onFooterChange={onFooterChange}
|
|
/>,
|
|
);
|
|
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(
|
|
<LaunchStep
|
|
onBack={vi.fn()}
|
|
onClose={onClose}
|
|
onFooterChange={onFooterChange}
|
|
/>,
|
|
);
|
|
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(
|
|
<LaunchStep
|
|
onBack={vi.fn()}
|
|
onClose={onClose}
|
|
onFooterChange={onFooterChange}
|
|
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
|
/>,
|
|
);
|
|
|
|
// 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(
|
|
<LaunchStep
|
|
onBack={vi.fn()}
|
|
onClose={vi.fn()}
|
|
onFooterChange={onFooterChange}
|
|
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
|
/>,
|
|
);
|
|
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(
|
|
<LaunchStep
|
|
onBack={vi.fn()}
|
|
onClose={onClose}
|
|
onFooterChange={onFooterChange}
|
|
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
|
/>,
|
|
);
|
|
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(
|
|
<LaunchStep
|
|
onBack={vi.fn()}
|
|
onClose={vi.fn()}
|
|
onFooterChange={onFooterChange}
|
|
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
|
/>,
|
|
);
|
|
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(
|
|
<LaunchStep
|
|
onBack={vi.fn()}
|
|
onClose={vi.fn()}
|
|
onFooterChange={onFooterChange}
|
|
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
|
isScheduleCapabilityLoading
|
|
/>,
|
|
);
|
|
|
|
// 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(
|
|
<LaunchStep
|
|
onBack={vi.fn()}
|
|
onClose={onClose}
|
|
onFooterChange={onFooterChange}
|
|
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
|
|
/>,
|
|
);
|
|
|
|
// 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(
|
|
<LaunchStep
|
|
onBack={vi.fn()}
|
|
onClose={onClose}
|
|
onFooterChange={onFooterChange}
|
|
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
|
|
/>,
|
|
);
|
|
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(
|
|
<LaunchStep
|
|
onBack={vi.fn()}
|
|
onClose={vi.fn()}
|
|
onFooterChange={onFooterChange}
|
|
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
|
|
isScanLimitReached
|
|
/>,
|
|
);
|
|
|
|
// Then
|
|
expect(screen.getByText(/exceeded the usage limit/i)).toBeInTheDocument();
|
|
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
|
|
expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(true);
|
|
});
|
|
});
|
|
});
|