diff --git a/ui/app/(prowler)/providers/providers-page.harness.tsx b/ui/app/(prowler)/providers/providers-page.harness.tsx index 61a96f64c2..49abce58c5 100644 --- a/ui/app/(prowler)/providers/providers-page.harness.tsx +++ b/ui/app/(prowler)/providers/providers-page.harness.tsx @@ -29,8 +29,6 @@ import type { SearchParamsProps } from "@/types"; import { ProvidersTabContent } from "./providers-tab-content"; -const INITIAL_SCAN_LABEL = "Launch an initial scan now for immediate findings"; - interface MountOptions { /** Seed `?addProvider=true` so the wizard opens on mount. Default true. */ openWizard?: boolean; @@ -911,14 +909,6 @@ export class ProvidersPageHarness extends BrowserHarness { // --- Wizard: launch step ------------------------------------------------ - /** Tick "launch an initial scan now" on the launch step's schedule form. */ - async enableInitialScan(): Promise { - const checkbox = await this.waitFor(() => - this.q(`[role="checkbox"][aria-label="${INITIAL_SCAN_LABEL}"]`), - ); - await this.clickElement(checkbox); - } - /** Save the scan schedules and launch the initial scans (footer action). */ async saveScheduleAndLaunch(): Promise { await this.clickPrimary(/Save and launch scan/); diff --git a/ui/app/(prowler)/providers/providers-page.integration.test.tsx b/ui/app/(prowler)/providers/providers-page.integration.test.tsx index 5f3fd66bfc..070c43be68 100644 --- a/ui/app/(prowler)/providers/providers-page.integration.test.tsx +++ b/ui/app/(prowler)/providers/providers-page.integration.test.tsx @@ -203,7 +203,6 @@ describe("Organization onboarding wizard", () => { expect(harness.applyCallCount).toBe(1); - await harness.enableInitialScan(); await harness.saveScheduleAndLaunch(); await harness.waitForLaunchComplete(); @@ -695,7 +694,6 @@ describe("Organization onboarding wizard", () => { await harness.testConnections(); await harness.waitForProjectsConnected(); - await harness.enableInitialScan(); await harness.saveScheduleAndLaunch(); await harness.waitForPartialScheduleSave(1, 1); @@ -725,7 +723,6 @@ describe("Organization onboarding wizard", () => { await harness.testConnections(); await harness.waitForProjectsConnected(); - await harness.enableInitialScan(); await harness.saveScheduleAndLaunch(); await harness.waitForScheduleSaveFailure(); @@ -746,7 +743,6 @@ describe("Organization onboarding wizard", () => { await harness.testConnections(); await harness.waitForProjectsConnected(); - await harness.enableInitialScan(); await harness.saveScheduleAndLaunch(); await harness.waitForLaunchComplete(); @@ -778,7 +774,6 @@ describe("Organization onboarding wizard", () => { expect(harness.usesAccountWording()).toBe(false); expect(harness.applyCallCount).toBe(1); - await harness.enableInitialScan(); await harness.saveScheduleAndLaunch(); await harness.waitForLaunchComplete(); @@ -1164,7 +1159,6 @@ describe("Organization onboarding wizard", () => { await harness.testConnections(); await harness.waitForSubscriptionsConnected(); - await harness.enableInitialScan(); await harness.saveScheduleAndLaunch(); await harness.waitForPartialScheduleSave(1, 1); @@ -1189,7 +1183,6 @@ describe("Organization onboarding wizard", () => { await harness.testConnections(); await harness.waitForSubscriptionsConnected(); - await harness.enableInitialScan(); await harness.saveScheduleAndLaunch(); await harness.waitForScheduleSaveFailure(); @@ -1208,7 +1201,6 @@ describe("Organization onboarding wizard", () => { await harness.testConnections(); await harness.waitForSubscriptionsConnected(); - await harness.enableInitialScan(); await harness.saveScheduleAndLaunch(); await harness.waitForLaunchComplete(); diff --git a/ui/changelog.d/initial-scan-default-after-setup.changed.md b/ui/changelog.d/initial-scan-default-after-setup.changed.md new file mode 100644 index 0000000000..faacee88a2 --- /dev/null +++ b/ui/changelog.d/initial-scan-default-after-setup.changed.md @@ -0,0 +1 @@ +"Launch an initial scan now" is ticked by default on the provider and organization setup launch steps, so saving a schedule also runs a first scan unless the box is unticked diff --git a/ui/components/providers/organizations/org-launch-scan.test.tsx b/ui/components/providers/organizations/org-launch-scan.test.tsx index 0f84bd0c31..910d67d2d7 100644 --- a/ui/components/providers/organizations/org-launch-scan.test.tsx +++ b/ui/components/providers/organizations/org-launch-scan.test.tsx @@ -1,4 +1,5 @@ import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { ComponentProps } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -98,7 +99,7 @@ describe("OrgLaunchScan", () => { }); describe("when capability is ADVANCED", () => { - it("should save schedules through the bulk endpoint", async () => { + it("should save schedules through the bulk endpoint and launch the initial scans by default", async () => { // Given const onFooterChange = vi.fn(); @@ -113,6 +114,14 @@ describe("OrgLaunchScan", () => { // When await screen.findByText("Scan Schedule"); + expect( + screen.getByRole("checkbox", { name: /launch an initial scan now/i }), + ).toBeChecked(); + await waitFor(() => + expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe( + "Save and launch scan", + ), + ); await act(async () => { lastFooterConfig(onFooterChange)?.onAction?.(); }); @@ -130,8 +139,44 @@ describe("OrgLaunchScan", () => { scan_timezone: "Europe/Madrid", }), ); - expect(launchOrganizationScansMock).not.toHaveBeenCalled(); + expect(launchOrganizationScansMock).toHaveBeenCalledWith("org-1"); expect(pushMock).toHaveBeenCalledWith("/providers"); + expect( + toastMock.mock.calls[0]?.[0].action.props.children.props.href, + ).toBe(`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`); + }); + + it("should save only the schedules when the initial scan is unticked", async () => { + // Given + const user = userEvent.setup(); + const onFooterChange = vi.fn(); + + render( + , + ); + + // When + await screen.findByText("Scan Schedule"); + await user.click( + screen.getByRole("checkbox", { name: /launch an initial scan now/i }), + ); + await waitFor(() => + expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Save"), + ); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => + expect(updateSchedulesBulkMock).toHaveBeenCalledTimes(1), + ); + expect(launchOrganizationScansMock).not.toHaveBeenCalled(); expect( toastMock.mock.calls[0]?.[0].action.props.children.props.href, ).toBe(`/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`); diff --git a/ui/components/providers/organizations/org-launch-scan.tsx b/ui/components/providers/organizations/org-launch-scan.tsx index 06fa2435d5..cb4c168173 100644 --- a/ui/components/providers/organizations/org-launch-scan.tsx +++ b/ui/components/providers/organizations/org-launch-scan.tsx @@ -32,7 +32,7 @@ import { buildScheduleUpdatePayload, describeSchedulesBulkFailures, getScanScheduleCapability, - getScheduleFormDefaults, + getSetupScheduleFormDefaults, parseSchedulesBulkResult, scheduleFormSchema, } from "@/lib/schedules"; @@ -121,7 +121,7 @@ export function OrgLaunchScan({ ); const form = useForm({ resolver: zodResolver(scheduleFormSchema), - defaultValues: getScheduleFormDefaults(), + defaultValues: getSetupScheduleFormDefaults(), }); const launchInitialScan = useWatch({ control: form.control, diff --git a/ui/components/providers/wizard/steps/launch-step.test.tsx b/ui/components/providers/wizard/steps/launch-step.test.tsx index 11bd3bbba6..3d24500af5 100644 --- a/ui/components/providers/wizard/steps/launch-step.test.tsx +++ b/ui/components/providers/wizard/steps/launch-step.test.tsx @@ -104,11 +104,16 @@ describe("LaunchStep", () => { ).toBeEnabled(); expect(screen.getByRole("combobox", { name: /repeats/i })).toBeDisabled(); + expect( + screen.getByRole("checkbox", { name: /launch an initial scan now/i }), + ).toBeChecked(); await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); - expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Save"); + expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe( + "Save and launch scan", + ); }); - it("saves a legacy daily schedule by default", async () => { + it("saves a legacy daily schedule and launches the initial scan by default", async () => { // Given const onClose = vi.fn(); const onFooterChange = vi.fn(); @@ -132,9 +137,13 @@ describe("LaunchStep", () => { 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(scanOnDemandMock).toHaveBeenCalledTimes(1); expect(updateScheduleMock).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.ACTIVE}`, + ); }); it("launches only an on-demand scan when run now is selected", async () => { @@ -176,9 +185,10 @@ describe("LaunchStep", () => { beforeEach(() => { vi.stubEnv("UI_CLOUD_ENABLED", "true"); updateScheduleMock.mockResolvedValue({ data: { id: "provider-1" } }); + scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } }); }); - it("defaults to schedule mode and saves through the new schedule API", async () => { + it("defaults to schedule mode with the initial scan ticked, saves the schedule and launches the scan", async () => { // Given const onClose = vi.fn(); const onFooterChange = vi.fn(); @@ -193,14 +203,20 @@ describe("LaunchStep", () => { />, ); - // Then advanced cadence selector is enabled + // Then advanced cadence selector is enabled and the initial scan is on expect( screen.getByRole("radio", { name: "On a schedule" }), ).toBeChecked(); expect( screen.getByRole("combobox", { name: /repeats/i }), ).not.toBeDisabled(); + expect( + screen.getByRole("checkbox", { name: /launch an initial scan now/i }), + ).toBeChecked(); await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); + expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe( + "Save and launch scan", + ); // When await act(async () => { @@ -221,20 +237,19 @@ describe("LaunchStep", () => { }), ); expect(scheduleDailyMock).not.toHaveBeenCalled(); - expect(scanOnDemandMock).not.toHaveBeenCalled(); + expect(scanOnDemandMock).toHaveBeenCalledTimes(1); 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}`, + `/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`, ); }); - it("also launches an on-demand scan when the checkbox is checked", async () => { + it("saves only the schedule when the initial scan is unticked", async () => { // Given const user = userEvent.setup(); const onFooterChange = vi.fn(); seedConnectedProvider(); - scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } }); render( { await user.click( screen.getByRole("checkbox", { name: /launch an initial scan now/i }), ); + await waitFor(() => + expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Save"), + ); await act(async () => { lastFooterConfig(onFooterChange)?.onAction?.(); }); // Then await waitFor(() => expect(updateScheduleMock).toHaveBeenCalledTimes(1)); - expect(scanOnDemandMock).toHaveBeenCalledTimes(1); + expect(scanOnDemandMock).not.toHaveBeenCalled(); 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}`, + `/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`, ); }); @@ -304,9 +322,8 @@ describe("LaunchStep", () => { expect(onClose).toHaveBeenCalledTimes(1); }); - it("does not launch an initial scan when the schedule save fails", async () => { + it("does not launch the initial scan when the schedule save fails", async () => { // Given - const user = userEvent.setup(); const onFooterChange = vi.fn(); seedConnectedProvider(); updateScheduleMock.mockResolvedValue({ error: "Schedule failed" }); @@ -322,9 +339,6 @@ describe("LaunchStep", () => { 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?.(); }); diff --git a/ui/components/providers/wizard/steps/launch-step.tsx b/ui/components/providers/wizard/steps/launch-step.tsx index 0474821a71..862f7ec302 100644 --- a/ui/components/providers/wizard/steps/launch-step.tsx +++ b/ui/components/providers/wizard/steps/launch-step.tsx @@ -28,7 +28,7 @@ import { } from "@/lib/action-errors"; import { getScanScheduleCapability, - getScheduleFormDefaults, + getSetupScheduleFormDefaults, scheduleFormSchema, } from "@/lib/schedules"; import { isCloud } from "@/lib/shared/env"; @@ -99,7 +99,7 @@ export function LaunchStep({ ); const form = useForm({ resolver: zodResolver(scheduleFormSchema), - defaultValues: getScheduleFormDefaults(), + defaultValues: getSetupScheduleFormDefaults(), }); const isScheduleMode = canUseScheduleMode && mode === LAUNCH_MODE.SCHEDULE; diff --git a/ui/lib/schedules.test.ts b/ui/lib/schedules.test.ts index da4a895fc6..8430d66d50 100644 --- a/ui/lib/schedules.test.ts +++ b/ui/lib/schedules.test.ts @@ -13,6 +13,7 @@ import { getScanScheduleCapability, getScheduleFormDefaults, getScheduleFormValues, + getSetupScheduleFormDefaults, isScheduleConfigured, parseSchedulesBulkResult, } from "@/lib/schedules"; @@ -219,6 +220,18 @@ describe("getScheduleFormDefaults", () => { }); }); +describe("getSetupScheduleFormDefaults", () => { + it("keeps the schedule defaults but ticks the initial scan", () => { + const now = new Date(2026, 5, 10, 10, 30, 0, 0); + + expect(getSetupScheduleFormDefaults(now)).toEqual({ + ...getScheduleFormDefaults(now), + launchInitialScan: true, + }); + expect(getScheduleFormDefaults(now).launchInitialScan).toBe(false); + }); +}); + describe("getScheduleFormValues", () => { const buildAttributes = ( overrides: Partial = {}, diff --git a/ui/lib/schedules.ts b/ui/lib/schedules.ts index 6ac7a9407e..0b707f9a4a 100644 --- a/ui/lib/schedules.ts +++ b/ui/lib/schedules.ts @@ -107,6 +107,13 @@ export function getScheduleFormDefaults(now = new Date()): ScheduleFormValues { }; } +/** Post-setup launch steps tick "launch an initial scan now" by default. */ +export function getSetupScheduleFormDefaults( + now = new Date(), +): ScheduleFormValues { + return { ...getScheduleFormDefaults(now), launchInitialScan: true }; +} + export function getScheduleFormValues( schedule?: ScheduleAttributes | null, now = new Date(),