diff --git a/ui/components/providers/organizations/org-launch-scan.test.tsx b/ui/components/providers/organizations/org-launch-scan.test.tsx index 9d98cef5af..f7f7fb11a3 100644 --- a/ui/components/providers/organizations/org-launch-scan.test.tsx +++ b/ui/components/providers/organizations/org-launch-scan.test.tsx @@ -3,6 +3,11 @@ import userEvent from "@testing-library/user-event"; import type { ComponentProps } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + ACTION_ERROR_API_MESSAGES, + ACTION_ERROR_MESSAGES, + ACTION_ERROR_STATUS, +} from "@/lib/action-errors"; import { useOrgSetupStore } from "@/store/organizations/store"; import { SCAN_JOBS_TAB, @@ -203,7 +208,10 @@ describe("OrgLaunchScan", () => { // Given const onClose = vi.fn(); const onFooterChange = vi.fn(); - updateSchedulesBulkMock.mockResolvedValue({ error: "Denied" }); + updateSchedulesBulkMock.mockResolvedValue({ + error: ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED], + status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED, + }); render( { expect.objectContaining({ variant: "destructive", title: "Unable to save scan schedules", + description: + ACTION_ERROR_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED], }), ), ); diff --git a/ui/components/providers/organizations/org-launch-scan.tsx b/ui/components/providers/organizations/org-launch-scan.tsx index 70b0cc46d3..b8fde57c65 100644 --- a/ui/components/providers/organizations/org-launch-scan.tsx +++ b/ui/components/providers/organizations/org-launch-scan.tsx @@ -24,6 +24,7 @@ import { import { Spinner } from "@/components/shadcn/spinner/spinner"; import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon"; import { ToastAction, useToast } from "@/components/ui"; +import { getActionErrorMessage, hasActionError } from "@/lib/action-errors"; import { buildScheduleUpdatePayload, getScanScheduleCapability, @@ -169,12 +170,12 @@ export function OrgLaunchScan({ buildScheduleUpdatePayload(values), ); - if (result.error) { + if (hasActionError(result)) { setIsLaunching(false); toast({ variant: "destructive", title: "Unable to save scan schedules", - description: String(result.error), + description: getActionErrorMessage(result), }); return; } diff --git a/ui/components/providers/wizard/steps/launch-step.test.tsx b/ui/components/providers/wizard/steps/launch-step.test.tsx index 22802e6424..6cf7d3cfa5 100644 --- a/ui/components/providers/wizard/steps/launch-step.test.tsx +++ b/ui/components/providers/wizard/steps/launch-step.test.tsx @@ -3,6 +3,11 @@ 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 { @@ -376,6 +381,47 @@ describe("LaunchStep", () => { 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(); diff --git a/ui/components/providers/wizard/steps/launch-step.tsx b/ui/components/providers/wizard/steps/launch-step.tsx index 7e2f0348dd..51a5727f63 100644 --- a/ui/components/providers/wizard/steps/launch-step.tsx +++ b/ui/components/providers/wizard/steps/launch-step.tsx @@ -24,6 +24,11 @@ import { } from "@/components/shared/cloud-feature-badge"; import { ToastAction, useToast } from "@/components/ui"; import { EntityInfo } from "@/components/ui/entities"; +import { + type ActionErrorResult, + getActionErrorMessage, + hasActionError, +} from "@/lib/action-errors"; import { getScanScheduleCapability, getScheduleFormDefaults, @@ -129,7 +134,7 @@ export function LaunchStep({ } }, [isAdvanced, mode]); - const launchOnDemandScan = async (): Promise<{ error?: unknown } | null> => { + const launchOnDemandScan = async (): Promise => { if (!providerId || isBlocked) return null; const formData = new FormData(); formData.set("providerId", providerId); @@ -144,12 +149,12 @@ export function LaunchStep({ setIsLaunching(true); const scanResult = await launchOnDemandScan(); - if (scanResult?.error) { + if (hasActionError(scanResult)) { setIsLaunching(false); toast({ variant: "destructive", title: "Unable to launch scan", - description: String(scanResult.error), + description: getActionErrorMessage(scanResult), }); return; } diff --git a/ui/components/scans/launch-scan-modal.test.tsx b/ui/components/scans/launch-scan-modal.test.tsx index bfcfb62c07..73e648a691 100644 --- a/ui/components/scans/launch-scan-modal.test.tsx +++ b/ui/components/scans/launch-scan-modal.test.tsx @@ -123,6 +123,11 @@ vi.mock("@/app/(prowler)/_overview/_components/accounts-selector", () => ({ ), })); +import { + ACTION_ERROR_API_MESSAGES, + ACTION_ERROR_MESSAGES, + ACTION_ERROR_STATUS, +} from "@/lib/action-errors"; import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules"; import { LaunchScanModal } from "./launch-scan-modal"; @@ -334,6 +339,40 @@ describe("LaunchScanModal", () => { 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: { diff --git a/ui/components/scans/launch-scan-modal.tsx b/ui/components/scans/launch-scan-modal.tsx index 62721b9e7a..d3f9993622 100644 --- a/ui/components/scans/launch-scan-modal.tsx +++ b/ui/components/scans/launch-scan-modal.tsx @@ -20,6 +20,7 @@ import { import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge"; import { FormButtons } from "@/components/ui/form"; import { toast, ToastAction } from "@/components/ui/toast"; +import { getActionErrorMessage, hasActionError } from "@/lib/action-errors"; import { getScanScheduleCapability, getScheduleFormDefaults, @@ -172,8 +173,8 @@ function LaunchScanForm({ const result = await scanOnDemand(formData); - if (result?.error) { - form.setError("root", { message: String(result.error) }); + if (hasActionError(result)) { + form.setError("root", { message: getActionErrorMessage(result) }); return; } diff --git a/ui/components/scans/schedule/edit-scan-schedule-modal.test.tsx b/ui/components/scans/schedule/edit-scan-schedule-modal.test.tsx index a3a715110a..27f5c302de 100644 --- a/ui/components/scans/schedule/edit-scan-schedule-modal.test.tsx +++ b/ui/components/scans/schedule/edit-scan-schedule-modal.test.tsx @@ -80,6 +80,11 @@ vi.mock("@/components/shadcn/modal", () => ({ ) : null, })); +import { + ACTION_ERROR_API_MESSAGES, + ACTION_ERROR_MESSAGES, + ACTION_ERROR_STATUS, +} from "@/lib/action-errors"; import type { ScheduleProps } from "@/types/schedules"; import { @@ -292,6 +297,59 @@ describe("EditScanScheduleModal remove flow", () => { ); }); + it("uses the shared subscription error copy when saving is blocked", async () => { + const user = userEvent.setup(); + updateScheduleMock.mockResolvedValue({ + error: ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED], + status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED, + }); + + renderLoaded(); + + await user.click(screen.getByRole("button", { name: "Save" })); + + 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(); + }); + + it("uses the shared subscription error copy when removing is blocked", async () => { + const user = userEvent.setup(); + removeScheduleMock.mockResolvedValue({ + error: ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED], + status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED, + }); + renderLoaded(); + + await user.click( + screen.getByRole("button", { name: /remove scan schedule/i }), + ); + const confirmDialog = screen.getByRole("dialog", { + name: "Are you absolutely sure?", + }); + await user.click( + within(confirmDialog).getByRole("button", { name: "Remove" }), + ); + + 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(); + }); + it("shows one logo per selected provider type in bulk mode", () => { render( result?.error); - if (failedResult?.error) { - form.setError("root", { message: String(failedResult.error) }); + const failedResult = results.find(hasActionError); + if (failedResult) { + form.setError("root", { message: getActionErrorMessage(failedResult) }); return; } diff --git a/ui/components/scans/schedule/save-schedule.test.ts b/ui/components/scans/schedule/save-schedule.test.ts index cf56f1fb18..5db4ce2491 100644 --- a/ui/components/scans/schedule/save-schedule.test.ts +++ b/ui/components/scans/schedule/save-schedule.test.ts @@ -1,5 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + ACTION_ERROR_API_MESSAGES, + ACTION_ERROR_MESSAGES, + ACTION_ERROR_STATUS, +} from "@/lib/action-errors"; import { SCHEDULE_FREQUENCY, type ScheduleFormValues } from "@/types/schedules"; const { scanOnDemandMock, scheduleDailyMock, updateScheduleMock } = vi.hoisted( @@ -88,6 +93,24 @@ describe("saveScheduleWithInitialScan", () => { expect(scanOnDemandMock).not.toHaveBeenCalled(); }); + it("maps subscription errors when the schedule save is blocked", async () => { + updateScheduleMock.mockResolvedValue({ + error: ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED], + status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED, + }); + + const result = await saveScheduleWithInitialScan({ + providerId: "p1", + values, + }); + + expect(result).toEqual({ + status: SAVE_SCHEDULE_STATUS.ERROR, + message: ACTION_ERROR_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED], + }); + expect(scanOnDemandMock).not.toHaveBeenCalled(); + }); + it("launches the initial scan when requested", async () => { const result = await saveScheduleWithInitialScan({ providerId: "p1", @@ -112,4 +135,21 @@ describe("saveScheduleWithInitialScan", () => { message: "limit reached", }); }); + + it("maps subscription errors when the initial scan is blocked", async () => { + scanOnDemandMock.mockResolvedValue({ + error: ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED], + status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED, + }); + + const result = await saveScheduleWithInitialScan({ + providerId: "p1", + values: { ...values, launchInitialScan: true }, + }); + + expect(result).toEqual({ + status: SAVE_SCHEDULE_STATUS.SAVED_SCAN_FAILED, + message: ACTION_ERROR_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED], + }); + }); }); diff --git a/ui/components/scans/schedule/save-schedule.ts b/ui/components/scans/schedule/save-schedule.ts index 7e3cfabb45..fce391bcf9 100644 --- a/ui/components/scans/schedule/save-schedule.ts +++ b/ui/components/scans/schedule/save-schedule.ts @@ -1,5 +1,7 @@ import { scanOnDemand, scheduleDaily } from "@/actions/scans"; import { updateSchedule } from "@/actions/schedules"; +import type { ActionErrorResult } from "@/lib/action-errors"; +import { getActionErrorMessage, hasActionError } from "@/lib/action-errors"; import { buildScheduleUpdatePayload } from "@/lib/schedules"; import type { ScheduleFormValues } from "@/types/schedules"; @@ -31,7 +33,7 @@ export async function saveScheduleWithInitialScan({ values, useLegacyDaily = false, }: SaveScheduleParams): Promise { - let scheduleResult: { error?: unknown } | null; + let scheduleResult: ActionErrorResult | null; if (useLegacyDaily) { const formData = new FormData(); @@ -44,10 +46,10 @@ export async function saveScheduleWithInitialScan({ ); } - if (scheduleResult?.error) { + if (hasActionError(scheduleResult)) { return { status: SAVE_SCHEDULE_STATUS.ERROR, - message: String(scheduleResult.error), + message: getActionErrorMessage(scheduleResult), }; } @@ -59,10 +61,10 @@ export async function saveScheduleWithInitialScan({ formData.set("providerId", providerId); const scanResult = await scanOnDemand(formData); - if (scanResult?.error) { + if (hasActionError(scanResult)) { return { status: SAVE_SCHEDULE_STATUS.SAVED_SCAN_FAILED, - message: String(scanResult.error), + message: getActionErrorMessage(scanResult), }; } diff --git a/ui/lib/action-errors.test.ts b/ui/lib/action-errors.test.ts index 5731d90a4c..b770f6b7af 100644 --- a/ui/lib/action-errors.test.ts +++ b/ui/lib/action-errors.test.ts @@ -4,6 +4,7 @@ import { ACTION_ERROR_MESSAGES, ACTION_ERROR_STATUS, getActionErrorMessage, + hasActionError, } from "./action-errors"; describe("getActionErrorMessage", () => { @@ -69,4 +70,37 @@ describe("getActionErrorMessage", () => { // Then expect(message).toBe(result.error); }); + + it("should identify action errors by error payload", () => { + // Given + const result = { error: "Payment required." }; + + // When + const hasError = hasActionError(result); + + // Then + expect(hasError).toBe(true); + }); + + it("should identify action errors by HTTP error status", () => { + // Given + const result = { status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED }; + + // When + const hasError = hasActionError(result); + + // Then + expect(hasError).toBe(true); + }); + + it("should ignore successful status-only action results", () => { + // Given + const result = { status: 204 }; + + // When + const hasError = hasActionError(result); + + // Then + expect(hasError).toBe(false); + }); }); diff --git a/ui/lib/action-errors.ts b/ui/lib/action-errors.ts index 74ae4803fa..46fc3ae4b8 100644 --- a/ui/lib/action-errors.ts +++ b/ui/lib/action-errors.ts @@ -13,8 +13,13 @@ export const ACTION_ERROR_MESSAGES = { "You don't have permission to perform this action. Ask an administrator to update your role.", } as const satisfies Record; -interface ActionErrorResult { - error?: string; +export const ACTION_ERROR_API_MESSAGES = { + [ACTION_ERROR_STATUS.PAYMENT_REQUIRED]: + "An active subscription is required to use this API endpoint in Prowler Cloud.", +} as const satisfies Partial>; + +export interface ActionErrorResult { + error?: unknown; status?: number; } @@ -29,6 +34,17 @@ const isActionErrorStatus = ( status === ACTION_ERROR_STATUS.PAYMENT_REQUIRED || status === ACTION_ERROR_STATUS.FORBIDDEN; +const isHttpErrorStatus = (status: number | undefined): boolean => + typeof status === "number" && status >= 400; + +export const hasActionError = ( + result: ActionErrorResult | null | undefined, +): result is ActionErrorResult => + result !== undefined && + result !== null && + ((result.error !== undefined && result.error !== null) || + isHttpErrorStatus(result.status)); + export const getActionErrorMessage = ( result: ActionErrorResult, options: GetActionErrorMessageOptions = {}, @@ -39,5 +55,9 @@ export const getActionErrorMessage = ( ); } - return result.error ?? options.fallback ?? "Oops! Something went wrong."; + if (result.error !== undefined && result.error !== null) { + return String(result.error); + } + + return options.fallback ?? "Oops! Something went wrong."; };