fix(ui): use shared scan launch action errors (#11664)

This commit is contained in:
Alejandro Bailo
2026-06-23 09:52:20 +02:00
committed by GitHub
parent 0610866b73
commit c6c07957a6
12 changed files with 278 additions and 21 deletions
@@ -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(
<OrgLaunchScan
@@ -226,6 +234,8 @@ describe("OrgLaunchScan", () => {
expect.objectContaining({
variant: "destructive",
title: "Unable to save scan schedules",
description:
ACTION_ERROR_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED],
}),
),
);
@@ -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;
}
@@ -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(
<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();
@@ -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<ActionErrorResult | null> => {
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;
}
@@ -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(
<LaunchScanModal
open
onOpenChange={onOpenChange}
providers={[provider]}
/>,
);
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: {
+3 -2
View File
@@ -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;
}
@@ -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(
<EditScanScheduleModal
@@ -20,6 +20,7 @@ import { Modal } from "@/components/shadcn/modal";
import { EntityInfo } from "@/components/ui/entities";
import { FormButtons } from "@/components/ui/form";
import { toast } from "@/components/ui/toast";
import { getActionErrorMessage, hasActionError } from "@/lib/action-errors";
import { runWithConcurrencyLimit } from "@/lib/concurrency";
import {
buildScheduleUpdatePayload,
@@ -129,8 +130,8 @@ function EditScanScheduleForm({
? await updateSchedulesBulk(targetProviderIds, payload)
: await updateSchedule(targetProviderIds[0], payload);
if (result?.error) {
form.setError("root", { message: String(result.error) });
if (hasActionError(result)) {
form.setError("root", { message: getActionErrorMessage(result) });
return;
}
@@ -155,9 +156,9 @@ function EditScanScheduleForm({
setIsRemoving(false);
setIsConfirmRemoveOpen(false);
const failedResult = results.find((result) => 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;
}
@@ -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],
});
});
});
@@ -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<SaveScheduleResult> {
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),
};
}
+34
View File
@@ -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);
});
});
+23 -3
View File
@@ -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<ActionErrorStatus, string>;
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<Record<ActionErrorStatus, string>>;
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.";
};