mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
fix(ui): use shared scan launch action errors (#11664)
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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.";
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user