feat(ui): link usage-limit refusals to Prowler pricing (#13184)

This commit is contained in:
Alejandro Bailo authored and GitHub committed 2026-10-28 12:32:50 +01:00
1 parent c0891591a3
commit 06a20bcc7c
42 files changed
+755 -87

No files matched your search

@@ -623,6 +623,29 @@ describe("findings triage actions", () => {
expect(createMuteRuleMock).not.toHaveBeenCalled();
});
it("should return the usage-limit marker when the plan refuses the triage patch", async () => {
// Given
const { updateFindingTriage } = await importActions();
handleApiResponseMock.mockResolvedValue({
error: "Payment required",
status: 402,
});
// When
const result = await updateFindingTriage({
findingId: "finding-snapshot-id",
findingUid: "finding/stable/uid",
triageId: "triage-1",
notesCount: 0,
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
});
// Then
expect(result).toEqual({ usageLimited: true });
expect(createMuteRuleMock).not.toHaveBeenCalled();
});
it("should rollback triage status when automatic muting fails", async () => {
// Given
const { updateFindingTriage } = await importActions();
+22
View File
@@ -9,6 +9,7 @@ import {
} from "@/actions/findings/findings-triage.adapter";
import { createMuteRule } from "@/actions/mute-rules";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { ACTION_ERROR_STATUS, UsageLimitError } from "@/lib/action-errors";
import { handleApiResponse } from "@/lib/server-actions-helper";
import {
FINDING_TRIAGE_STATUS_LABELS,
@@ -17,6 +18,7 @@ import {
type FindingTriageDetail,
type FindingTriageLoadedNote,
type FindingTriageSummary,
type FindingTriageUsageLimitedResult,
isMutelistShortcutStatus,
type UpdateFindingTriageInput,
} from "@/types/findings-triage";
@@ -70,6 +72,15 @@ async function getJsonApi(path: `/${string}`) {
}
const throwIfApiError = (result: unknown) => {
if (
result &&
typeof result === "object" &&
"status" in result &&
result.status === ACTION_ERROR_STATUS.PAYMENT_REQUIRED
) {
throw new UsageLimitError();
}
if (
result &&
typeof result === "object" &&
@@ -270,6 +281,17 @@ export async function loadFindingTriageDetail(
}
export async function updateFindingTriage(input: UpdateFindingTriageInput) {
try {
return await applyFindingTriageUpdate(input);
} catch (error) {
if (error instanceof UsageLimitError) {
return { usageLimited: true } satisfies FindingTriageUsageLimitedResult;
}
throw error;
}
}
async function applyFindingTriageUpdate(input: UpdateFindingTriageInput) {
let findingUid: string | undefined;
let triagePath: `/${string}`;
+1
View File
@@ -170,6 +170,7 @@ describe("issues actions", () => {
await expect(addIssueNote("issue-1", "note")).resolves.toEqual({
error: "You do not have permission.",
status: 403,
});
expect(fetchMock.mock.calls[0][0]).toBe(
"https://api.example.com/api/v1/issues/issue-1/triage/notes",
+4 -2
View File
@@ -128,7 +128,7 @@ export const getIssueFindings = async (
}
};
/** Write one issue triage request; any non-2xx comes back as an error to show. */
/** Write one issue triage request; any non-2xx comes back as an error to show, with its status. */
const writeIssueTriage = async (
path: string,
method: IssueTriageWriteMethod,
@@ -143,7 +143,9 @@ const writeIssueTriage = async (
});
// The list shows each issue's triage status.
const result = await handleApiResponse(response, "/issues");
return result?.error ? { error: result.error } : { success: true };
return result?.error
? { error: result.error, status: result.status }
: { success: true };
} catch (error) {
return handleApiError(error);
}
+3 -1
View File
@@ -49,7 +49,9 @@ export interface IssueTriageNotesDocument
included?: JsonApiResource<Partial<Pick<UserAttributes, "name" | "email">>>[];
}
export type IssueWriteResult = { success: true } | { error: string };
export type IssueWriteResult =
| { success: true }
| { error: string; status?: number };
export interface UpdateIssueTriageInput {
status: FindingTriageManualStatus;
@@ -164,6 +164,14 @@ export class ScansPageHarness extends BrowserHarness<IngestionFixture> {
await this.waitForText(message);
}
plansLink(): HTMLAnchorElement | null {
return (
Array.from(document.querySelectorAll<HTMLAnchorElement>("a")).find(
(link) => link.textContent?.trim() === "View plans",
) ?? null
);
}
async waitForUploadInProgress(): Promise<void> {
await this.waitFor(
() => this.buttonByText(/Importing/i),
@@ -7,6 +7,7 @@ import {
ingestionRejectionFixture,
partiallyProcessedIngestionFixture,
} from "@/__tests__/msw/handlers/ingestions.fixtures";
import { PRICING_URL } from "@/lib/external-urls";
import { ScansPageHarness } from "./scans-page.harness";
@@ -112,6 +113,36 @@ describe("Scans page import findings", () => {
});
}
it("offers the plans when the subscription refuses the upload", async () => {
const rejectionFixture = ingestionRejectionFixture(
INGESTION_REJECTION.SUBSCRIPTION_REQUIRED,
);
const harness = new ScansPageHarness(ingestionFixture());
await harness.mount({ uploadRejection: rejectionFixture });
await harness.openImportFindings();
await harness.selectFile(new File(["[]"], "findings.ocsf.json"));
await harness.submitImport();
await harness.waitForUploadError(new RegExp(rejectionFixture.message, "i"));
const plansLink = harness.plansLink();
expect(plansLink?.href).toBe(PRICING_URL);
expect(plansLink?.target).toBe("_blank");
});
it("offers no plans when the upload fails for another reason", async () => {
const rejectionFixture = ingestionRejectionFixture(
INGESTION_REJECTION.PERMISSION_DENIED,
);
const harness = new ScansPageHarness(ingestionFixture());
await harness.mount({ uploadRejection: rejectionFixture });
await harness.openImportFindings();
await harness.selectFile(new File(["[]"], "findings.ocsf.json"));
await harness.submitImport();
await harness.waitForUploadError(new RegExp(rejectionFixture.message, "i"));
expect(harness.plansLink()).toBeNull();
});
it("continues tracking an accepted import while the dialog is closed", async () => {
const harness = new ScansPageHarness(ingestionFixture());
await harness.mount({ statusSequence: ["processing", "completed"] });
@@ -6,7 +6,6 @@ import { useSearchParams } from "next/navigation";
import {
loadFindingTriageDetail,
loadLatestFindingTriageNote,
updateFindingTriage,
} from "@/actions/findings";
import {
getStandaloneFindingColumns,
@@ -19,6 +18,7 @@ import { FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib";
import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons";
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage";
import { updateFindingTriageOrThrow } from "@/lib/finding-triage-update";
import { CheckProviderTypesMap, Requirement } from "@/types/compliance";
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
@@ -82,7 +82,7 @@ export const ClientAccordionContent = ({
});
const handleTriageUpdate = async (input: UpdateFindingTriageInput) => {
const result = await updateFindingTriage(input);
const result = await updateFindingTriageOrThrow(input);
// Mutelist-shortcut statuses mute the finding server-side; refetch so the
// list honors the muted filter, matching the resource drawer behavior.
@@ -1,4 +1,4 @@
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -50,6 +50,8 @@ vi.mock("@/components/shadcn/modal", () => ({
) : null,
}));
import { USAGE_LIMIT_MESSAGE } from "@/lib/action-errors";
import { PRICING_URL } from "@/lib/external-urls";
import { PARTIAL_SCAN_LAUNCH_ERROR } from "@/lib/partial-scans";
import {
@@ -172,6 +174,25 @@ describe("RecheckResourceModal", () => {
);
});
it("offers the plans when the usage limit refuses the re-check", async () => {
createPartialScanMock.mockResolvedValue({
error: "An active subscription is required.",
status: 402,
});
render(
<RecheckResourceModal isOpen onOpenChange={vi.fn()} target={target} />,
);
await submit();
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent(USAGE_LIMIT_MESSAGE);
expect(
within(alert).getByRole("link", { name: "View plans" }),
).toHaveAttribute("href", PRICING_URL);
expect(toastMock).not.toHaveBeenCalled();
});
it("treats a response without a scan id as a failed launch", async () => {
// An empty 2xx becomes { success: true } and there is no scan to follow.
createPartialScanMock.mockResolvedValue({ success: true, status: 202 });
@@ -9,6 +9,8 @@ import { createPartialScan } from "@/actions/scans";
import { Button } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { toast, ToastAction } from "@/components/shadcn/toast";
import { UsageLimitMessage } from "@/components/shared/usage-limit-message";
import { isUsageLimitError } from "@/lib/action-errors";
import {
findProviderIdForTarget,
getPartialScanErrorMessage,
@@ -36,6 +38,7 @@ export function RecheckResourceModal({
const router = useRouter();
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isUsageLimited, setIsUsageLimited] = useState(false);
const resourceLabel = hasDisplayName(target.resourceName)
? target.resourceName
@@ -61,6 +64,7 @@ export function RecheckResourceModal({
setIsPending(true);
setError(null);
setIsUsageLimited(false);
try {
const providerId = await resolveProviderId();
@@ -76,6 +80,7 @@ export function RecheckResourceModal({
const errorMessage = getPartialScanErrorMessage(result);
if (errorMessage) {
setError(errorMessage);
setIsUsageLimited(isUsageLimitError(result));
return;
}
// An empty 2xx has no scan to follow, so it is not a launch.
@@ -150,11 +155,16 @@ export function RecheckResourceModal({
</p>
</div>
{error && (
<p role="alert" className="text-text-error-primary text-sm">
{error}
</p>
)}
{error &&
(isUsageLimited ? (
<div role="alert">
<UsageLimitMessage />
</div>
) : (
<p role="alert" className="text-text-error-primary text-sm">
{error}
</p>
))}
<div className="flex w-full justify-end gap-4">
<Button
@@ -86,7 +86,8 @@ afterAll(() => {
}
});
import { DOCS_URLS } from "@/lib/external-urls";
import { USAGE_LIMIT_MESSAGE, UsageLimitError } from "@/lib/action-errors";
import { DOCS_URLS, PRICING_URL } from "@/lib/external-urls";
import { useCloudUpgradeStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { FINDING_STATUS } from "@/types/components";
@@ -338,6 +339,31 @@ describe("FindingNoteModal", () => {
expect(toastMock).not.toHaveBeenCalled();
});
it("should keep the modal open and offer the plans when the usage limit refuses the note", async () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
const onTriageUpdateAction = vi
.fn()
.mockRejectedValue(new UsageLimitError());
renderNoteModal({ onOpenChange, onTriageUpdateAction });
// When
await user.clear(screen.getByLabelText("Note text"));
await user.type(screen.getByLabelText("Note text"), "Changed note");
await user.click(screen.getByRole("button", { name: "Save" }));
// Then
expect(
await screen.findByText(USAGE_LIMIT_MESSAGE, { exact: false }),
).toBeVisible();
expect(screen.getByRole("link", { name: "View plans" })).toHaveAttribute(
"href",
PRICING_URL,
);
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});
it("should expose a pending manual pass submission and keep controls disabled", async () => {
// Given
const user = userEvent.setup();
@@ -20,6 +20,8 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { ViewPlansLink } from "@/components/shared/usage-limit-message";
import { UsageLimitError } from "@/lib/action-errors";
import { formatLocalDate } from "@/lib/date-utils";
import { DOCS_URLS } from "@/lib/external-urls";
import { useCloudUpgradeStore } from "@/store";
@@ -55,6 +57,11 @@ interface FindingNoteModalProps {
onTriageUpdateAction?: FindingTriageUpdateHandler;
}
interface FindingNoteSubmitError {
message: string;
isUsageLimited: boolean;
}
export const FINDING_NOTE_MODAL_MODE = {
EDIT: "edit",
MANUAL_PASS_DETAILS: "manual-pass-details",
@@ -90,7 +97,9 @@ export function FindingNoteModal({
useState<FindingTriageModalStatus>(initialSelectedStatus);
const [note, setNote] = useState(triage.noteBody);
const [manualPassEvidence, setManualPassEvidence] = useState("");
const [submitError, setSubmitError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<FindingNoteSubmitError | null>(
null,
);
const [isSubmitting, setIsSubmitting] = useState(false);
const noteTextareaRef = useRef<HTMLTextAreaElement>(null);
const isManualPassDetails =
@@ -189,11 +198,13 @@ export function FindingNoteModal({
handleOpenChange(false);
} catch (error) {
setSubmitError(
error instanceof Error
? error.message
: "Could not update the note. Please try again.",
);
setSubmitError({
message:
error instanceof Error
? error.message
: "Could not update the note. Please try again.",
isUsageLimited: error instanceof UsageLimitError,
});
} finally {
setIsSubmitting(false);
}
@@ -380,7 +391,15 @@ export function FindingNoteModal({
{submitError && (
<Alert variant="error">
<AlertDescription>{submitError}</AlertDescription>
<AlertDescription>
{submitError.message}
{submitError.isUsageLimited && (
<>
{" "}
<ViewPlansLink />
</>
)}
</AlertDescription>
</Alert>
)}
@@ -80,6 +80,8 @@ beforeAll(() => {
});
});
import { USAGE_LIMIT_MESSAGE, UsageLimitError } from "@/lib/action-errors";
import { PRICING_URL } from "@/lib/external-urls";
import { useCloudUpgradeStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { FINDING_STATUS } from "@/types/components";
@@ -1280,6 +1282,37 @@ describe("finding triage cells", () => {
expect(statusControl).toHaveTextContent("Open");
});
it("should offer the plans when the usage limit refuses the status update", async () => {
// Given
const user = userEvent.setup();
const onTriageUpdateAction = vi
.fn()
.mockRejectedValue(new UsageLimitError());
render(
<FindingTriageStatusCell
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.OPEN,
label: "Open",
})}
onTriageUpdateAction={onTriageUpdateAction}
/>,
);
// When
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
await user.click(screen.getByRole("option", { name: "Remediating" }));
// Then
await waitFor(() => expect(toastMock).toHaveBeenCalled());
const toastOptions = toastMock.mock.calls[0][0];
expect(toastOptions.description).toBe(USAGE_LIMIT_MESSAGE);
render(<>{toastOptions.action}</>);
expect(screen.getByRole("link", { name: "View plans" })).toHaveAttribute(
"href",
PRICING_URL,
);
});
it("should not confirm when moving between Mutelist shortcut statuses", async () => {
// Given
const user = userEvent.setup();
@@ -6,6 +6,8 @@ import { Button } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/shadcn/toast/use-toast";
import { TriageStatusPicker } from "@/components/shared/triage/triage-status-picker";
import { usageLimitToastAction } from "@/components/shared/usage-limit-message";
import { USAGE_LIMIT_MESSAGE, UsageLimitError } from "@/lib/action-errors";
import { FINDING_STATUS } from "@/types/components";
import {
FINDING_TRIAGE_MANUAL_STATUS_VALUES,
@@ -93,11 +95,13 @@ export function FindingTriageStatusControl(
previousStatus: triage.status,
isMuted: triage.isMuted,
});
} catch {
} catch (error) {
const isUsageLimited = error instanceof UsageLimitError;
toast({
variant: "destructive",
title: "Could not update triage status.",
description: "Please try again.",
description: isUsageLimited ? USAGE_LIMIT_MESSAGE : "Please try again.",
...(isUsageLimited && { action: usageLimitToastAction() }),
});
} finally {
setIsTableUpdating(false);
@@ -11,7 +11,6 @@ import { useSearchParams } from "next/navigation";
import {
loadFindingTriageDetail,
loadLatestFindingTriageNote,
updateFindingTriage,
} from "@/actions/findings";
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import {
@@ -26,6 +25,7 @@ import {
} from "@/components/shadcn/table";
import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state";
import { cn, hasHistoricalFindingFilter } from "@/lib";
import { updateFindingTriageOrThrow } from "@/lib/finding-triage-update";
import {
getFilteredFindingGroupDelta,
getFindingGroupImpactedCounts,
@@ -96,7 +96,7 @@ export function FindingsGroupDrillDown({
selectableRowCount,
findingTitle: group.checkTitle,
onTriageUpdateAction: (input) =>
updateTriageOptimistically(input, updateFindingTriage),
updateTriageOptimistically(input, updateFindingTriageOrThrow),
onTriageNoteLoadAction: loadLatestFindingTriageNote,
onTriageDetailLoadAction: loadFindingTriageDetail,
});
@@ -12,7 +12,6 @@ import { useImperativeHandle, useRef, useState } from "react";
import {
loadFindingTriageDetail,
loadLatestFindingTriageNote,
updateFindingTriage,
} from "@/actions/findings";
import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
@@ -21,6 +20,7 @@ import { TableCell, TableRow } from "@/components/shadcn/table";
import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state";
import { useScrollHint } from "@/hooks/use-scroll-hint";
import { useScrollportPin } from "@/hooks/use-scrollport-pin";
import { updateFindingTriageOrThrow } from "@/lib/finding-triage-update";
import { buildFindingResourceContext } from "@/lib/lighthouse/context/contributions";
import { cn } from "@/lib/utils";
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
@@ -249,7 +249,7 @@ export function InlineResourceContainer({
drawer.openDrawer(rowIndex);
},
onTriageUpdateAction: (input) =>
updateTriageOptimistically(input, updateFindingTriage),
updateTriageOptimistically(input, updateFindingTriageOrThrow),
onTriageNoteLoadAction: loadLatestFindingTriageNote,
onTriageDetailLoadAction: loadFindingTriageDetail,
});
@@ -17,7 +17,6 @@ import {
loadFindingTriageDetail,
loadLatestFindingTriageNote,
type ResourceDrawerFinding,
updateFindingTriage,
} from "@/actions/findings";
import {
requestPanelChatMessage,
@@ -81,6 +80,7 @@ import { TriageStatusBadge } from "@/components/shared/triage/triage-status-badg
import { useLighthouseCurrentContext } from "@/hooks/use-lighthouse-context";
import { getFailingForLabel, formatDuration } from "@/lib/date-utils";
import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage";
import { updateFindingTriageOrThrow } from "@/lib/finding-triage-update";
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
import { getRegionFlag } from "@/lib/region-flags";
@@ -365,7 +365,7 @@ export function ResourceDetailDrawerContent({
const showOverviewStatusExtended = Boolean(overviewStatusExtended);
const handleDrawerTriageUpdate = async (input: UpdateFindingTriageInput) => {
const result = await updateFindingTriage(input);
const result = await updateFindingTriageOrThrow(input);
if (shouldRefreshAfterTriageUpdate(input)) {
onMuteComplete();
return result;
@@ -2,6 +2,9 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { USAGE_LIMIT_MESSAGE } from "@/lib/action-errors";
import { PRICING_URL } from "@/lib/external-urls";
import { IssueTriage } from "./issue-triage";
const {
@@ -186,6 +189,30 @@ describe("IssueTriage", () => {
);
});
it("offers the plans when the usage limit refuses the save", async () => {
// Given
updateIssueTriageMock.mockResolvedValue({
error: USAGE_LIMIT_MESSAGE,
status: 402,
});
const user = userEvent.setup();
render(<IssueTriage id="issue-1" status="open" />);
// When
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
await user.click(screen.getByRole("option", { name: "False Positive" }));
await user.click(screen.getByRole("button", { name: "Save triage" }));
// Then
await waitFor(() => expect(toastMock).toHaveBeenCalled());
const toastOptions = toastMock.mock.calls[0][0];
render(<>{toastOptions.action}</>);
expect(screen.getByRole("link", { name: "View plans" })).toHaveAttribute(
"href",
PRICING_URL,
);
});
it("says so when the notes cannot be loaded, and loads them on retry", async () => {
const user = userEvent.setup();
getIssueNotesMock.mockResolvedValueOnce(undefined);
+3
View File
@@ -14,6 +14,8 @@ import { DateWithTime } from "@/components/shadcn/entities";
import { Textarea } from "@/components/shadcn/textarea/textarea";
import { useToast } from "@/components/shadcn/toast/use-toast";
import { TriageStatusPicker } from "@/components/shared/triage/triage-status-picker";
import { usageLimitToastAction } from "@/components/shared/usage-limit-message";
import { isUsageLimitError } from "@/lib/action-errors";
import {
FINDING_TRIAGE_MANUAL_STATUS_VALUES,
FINDING_TRIAGE_NOTE_MAX_LENGTH,
@@ -75,6 +77,7 @@ export const IssueTriage = ({ id, status }: IssueTriageProps) => {
variant: "destructive",
title: "Triage not saved",
description: result.error,
...(isUsageLimitError(result) && { action: usageLimitToastAction() }),
});
return;
}
@@ -6,10 +6,10 @@ import type { ReactNode } from "react";
import {
loadFindingTriageDetail,
loadLatestFindingTriageNote,
updateFindingTriage,
} from "@/actions/findings";
import { getStandaloneFindingColumns } from "@/components/findings/table/column-standalone-findings";
import { DataTable } from "@/components/shadcn/table";
import { updateFindingTriageOrThrow } from "@/lib/finding-triage-update";
import type { FindingProps } from "@/types";
import type { FindingTriageUpdateHandler } from "@/types/findings-triage";
@@ -25,7 +25,7 @@ export function LatestFindingsTable({
const router = useRouter();
const handleTriageUpdate: FindingTriageUpdateHandler = async (input) => {
const result = await updateFindingTriage(input);
const result = await updateFindingTriageOrThrow(input);
router.refresh();
return result;
};
@@ -8,6 +8,7 @@ import {
ACTION_ERROR_MESSAGES,
ACTION_ERROR_STATUS,
} from "@/lib/action-errors";
import { PRICING_URL } from "@/lib/external-urls";
import { useOrgSetupStore } from "@/store/organizations/store";
import {
SCAN_JOBS_TAB,
@@ -248,6 +249,39 @@ describe("OrgLaunchScan", () => {
expect(launchOrganizationScansMock).not.toHaveBeenCalled();
expect(pushMock).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(
toastMock.mock.calls[0]?.[0].action.props.children.props.href,
).toBe(PRICING_URL);
});
it("should offer the plans when the schedules saved but the limit refused the initial scans", async () => {
// Given
const onFooterChange = vi.fn();
launchOrganizationScansMock.mockResolvedValue({
error: ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED],
status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED,
});
render(
<OrgLaunchScan
onClose={vi.fn()}
onBack={vi.fn()}
onFooterChange={onFooterChange}
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
/>,
);
// When
await screen.findByText("Scan Schedule");
await act(async () => {
lastFooterConfig(onFooterChange)?.onAction?.();
});
// Then
await waitFor(() => expect(toastMock).toHaveBeenCalled());
expect(toastMock.mock.calls[0]?.[0].title).toBe("Scan schedules saved");
expect(
toastMock.mock.calls[0]?.[0].action.props.children.props.href,
).toBe(PRICING_URL);
});
});
@@ -326,6 +360,37 @@ describe("OrgLaunchScan", () => {
toastMock.mock.calls[0]?.[0].action.props.children.props.href,
).toBe(`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`);
});
it("should offer the plans when the limit refuses the single scan", async () => {
// Given
const onFooterChange = vi.fn();
launchOrganizationScansMock.mockResolvedValue({
error: ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED],
status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED,
});
render(
<OrgLaunchScan
onClose={vi.fn()}
onBack={vi.fn()}
onFooterChange={onFooterChange}
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
/>,
);
// When
await act(async () => {
lastFooterConfig(onFooterChange)?.onAction?.();
});
// Then
await waitFor(() => expect(toastMock).toHaveBeenCalled());
expect(toastMock.mock.calls[0]?.[0].title).toBe(
"Unable to launch organization scan",
);
expect(
toastMock.mock.calls[0]?.[0].action.props.children.props.href,
).toBe(PRICING_URL);
});
});
describe("when capability is BLOCKED", () => {
@@ -26,8 +26,15 @@ import {
} from "@/components/shadcn/select/select";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon";
import { UsageLimitMessage } from "@/components/shared/usage-limit-message";
import { getActionErrorMessage, hasActionError } from "@/lib/action-errors";
import {
UsageLimitMessage,
usageLimitToastAction,
} from "@/components/shared/usage-limit-message";
import {
getActionErrorMessage,
hasActionError,
isUsageLimitError,
} from "@/lib/action-errors";
import {
buildScheduleUpdatePayload,
describeSchedulesBulkFailures,
@@ -173,6 +180,7 @@ export function OrgLaunchScan({
variant: "destructive",
title: "Unable to save scan schedules",
description: getActionErrorMessage(result),
action: isUsageLimitError(result) ? usageLimitToastAction() : undefined,
});
return;
}
@@ -203,6 +211,7 @@ export function OrgLaunchScan({
let initialScanFailureCount = 0;
let initialScanSuccessCount = 0;
let initialScanError: string | undefined;
let initialScanUsageLimited = false;
if (values.launchInitialScan) {
const scanResult = organizationId
@@ -212,6 +221,7 @@ export function OrgLaunchScan({
if ("error" in scanResult) {
initialScanFailureCount = updatedProviderIds.length;
initialScanError = getActionErrorMessage(scanResult);
initialScanUsageLimited = isUsageLimitError(scanResult);
} else {
initialScanSuccessCount = scanResult.data.length;
}
@@ -241,7 +251,10 @@ export function OrgLaunchScan({
? `${description} The initial organization scan could not be launched: ${initialScanError}`
: `${description} Initial scans failed for ${formatCandidateCount(initialScanFailureCount, noun)}.`
: description,
action: (
// Over the limit, the plans unblock the scans; the scans page would not.
action: initialScanUsageLimited ? (
usageLimitToastAction()
) : (
<ToastAction altText="Go to scans" asChild>
<Link href={getScansHref(targetTab)}>Go to scans</Link>
</ToastAction>
@@ -269,6 +282,9 @@ export function OrgLaunchScan({
variant: "destructive",
title: "Unable to launch organization scan",
description: getActionErrorMessage(result),
action: isUsageLimitError(result)
? usageLimitToastAction()
: undefined,
});
return;
}
@@ -3,6 +3,8 @@ import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Toaster } from "@/components/shadcn/toast/Toaster";
import { PRICING_URL } from "@/lib/external-urls";
import {
PROVIDER_FUNNEL_EVENT,
type ProviderFunnelDetail,
@@ -308,6 +310,50 @@ describe("ProviderConnectStep", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "true");
});
it("offers the plans when the usage limit refuses the account", async () => {
// Given
addProvider.mockResolvedValueOnce({
errors: [
{
detail: "Your trial has ended.",
status: "402",
source: { pointer: "/data" },
code: "payment_required",
},
],
status: 402,
});
const { onConnected, user } = renderStep();
render(<Toaster />);
await user.click(
screen.getByRole("radio", { name: /Static access keys/ }),
);
await user.type(
screen.getByRole("textbox", { name: /Account ID/ }),
"210987654321",
);
await user.type(
screen.getByPlaceholderText("Enter the AWS Access Key ID"),
"AKIAEXAMPLE",
);
await user.type(
screen.getByPlaceholderText("Enter the AWS Secret Access Key"),
"secret-value",
);
await waitFor(() => expect(connectButton()).toBeEnabled());
// When
await user.click(connectButton());
// Then
expect(await screen.findByText("Your trial has ended.")).toBeVisible();
expect(screen.getByRole("link", { name: "View plans" })).toHaveAttribute(
"href",
PRICING_URL,
);
expect(onConnected).not.toHaveBeenCalled();
});
it("shows the refusal on the Account ID field and stays on the step", async () => {
// Given
addProvider.mockResolvedValueOnce({
@@ -13,6 +13,7 @@ import { SCAN_JOBS_TAB } from "@/types";
import {
SCAN_SCHEDULE_CAPABILITY,
SCHEDULE_FREQUENCY,
type ScanScheduleCapability,
} from "@/types/schedules";
import { LaunchStep } from "./launch-step";
@@ -518,4 +519,88 @@ describe("LaunchStep", () => {
expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(true);
});
});
describe("when the usage limit blocks the scan", () => {
const PRICING = "https://prowler.com/pricing";
const overLimit = {
error: ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED],
status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED,
};
// The toast action wraps the link it opens.
const toastLink = (call = 0) =>
toastMock.mock.calls[call]?.[0].action?.props.children.props;
beforeEach(() => {
vi.stubEnv("UI_CLOUD_ENABLED", "true");
updateScheduleMock.mockResolvedValue({ data: { id: "provider-1" } });
scanOnDemandMock.mockResolvedValue(overLimit);
seedConnectedProvider();
});
async function launch(capability: ScanScheduleCapability) {
const onFooterChange = vi.fn();
render(
<LaunchStep
onBack={vi.fn()}
onClose={vi.fn()}
onFooterChange={onFooterChange}
capability={capability}
/>,
);
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
await act(async () => {
lastFooterConfig(onFooterChange)?.onAction?.();
});
await waitFor(() => expect(toastMock).toHaveBeenCalled());
}
it("offers the pricing page in a new tab when a manual scan is refused", async () => {
// When
await launch(SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY);
// Then
expect(toastMock.mock.calls[0]?.[0].title).toBe("Unable to launch scan");
expect(toastLink()).toMatchObject({
href: PRICING,
target: "_blank",
rel: "noopener noreferrer",
children: "View plans",
});
});
it("offers the pricing page when the schedule saved but its first scan was refused", async () => {
// When
await launch(SCAN_SCHEDULE_CAPABILITY.ADVANCED);
// Then
expect(toastMock.mock.calls[0]?.[0].title).toBe("Scan schedule saved");
expect(toastLink()).toMatchObject({ href: PRICING, target: "_blank" });
});
it("offers the pricing page when the schedule itself was refused", async () => {
// Given
updateScheduleMock.mockResolvedValue(overLimit);
// When
await launch(SCAN_SCHEDULE_CAPABILITY.ADVANCED);
// Then
expect(scanOnDemandMock).not.toHaveBeenCalled();
expect(toastMock.mock.calls[0]?.[0].title).toBe(
"Unable to save scan schedule",
);
expect(toastLink()).toMatchObject({ href: PRICING, target: "_blank" });
});
it("keeps the plain error when a manual scan fails for another reason", async () => {
// Given
scanOnDemandMock.mockResolvedValue({ error: "Scanner unavailable" });
// When
await launch(SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY);
// Then
expect(toastMock.mock.calls[0]?.[0].action).toBeUndefined();
});
});
});
@@ -20,11 +20,15 @@ import {
} from "@/components/shadcn/radio-group/radio-group";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon";
import { UsageLimitMessage } from "@/components/shared/usage-limit-message";
import {
UsageLimitMessage,
usageLimitToastAction,
} from "@/components/shared/usage-limit-message";
import {
type ActionErrorResult,
getActionErrorMessage,
hasActionError,
isUsageLimitError,
} from "@/lib/action-errors";
import {
getScanScheduleCapability,
@@ -154,6 +158,9 @@ export function LaunchStep({
variant: "destructive",
title: "Unable to launch scan",
description: getActionErrorMessage(scanResult),
action: isUsageLimitError(scanResult)
? usageLimitToastAction()
: undefined,
});
return;
}
@@ -191,6 +198,7 @@ export function LaunchStep({
variant: "destructive",
title: "Unable to save scan schedule",
description: result.message,
action: result.usageLimited ? usageLimitToastAction() : undefined,
});
return;
}
@@ -209,7 +217,8 @@ export function LaunchStep({
toast({
title: "Scan schedule saved",
description: `The schedule was saved, but the initial scan could not be launched: ${result.message}`,
action: goToScans,
// Over the limit, the plans unblock the scan; the scans page would not.
action: result.usageLimited ? usageLimitToastAction() : goToScans,
});
return;
}
@@ -7,7 +7,6 @@ import { useState } from "react";
import {
loadFindingTriageDetail,
loadLatestFindingTriageNote,
updateFindingTriage,
} from "@/actions/findings";
import { FloatingSelectionActions } from "@/components/findings/floating-selection-actions";
import { FindingDetailDrawer } from "@/components/findings/table";
@@ -36,6 +35,7 @@ import { ResourceMetadataPanel } from "@/components/shared/resource-metadata-pan
import { useFindingDetails } from "@/hooks/use-finding-details";
import { getGroupLabel } from "@/lib/categories";
import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage";
import { updateFindingTriageOrThrow } from "@/lib/finding-triage-update";
import { getRegionFlag } from "@/lib/region-flags";
import { ProviderType, ResourceProps } from "@/types";
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
@@ -148,7 +148,7 @@ export const ResourceDetailContent = ({
};
const handleTriageUpdate = async (input: UpdateFindingTriageInput) => {
const result = await updateFindingTriage(input);
const result = await updateFindingTriageOrThrow(input);
if (shouldRefreshAfterTriageUpdate(input)) {
setFindingsReloadNonce((value) => value + 1);
+13 -1
View File
@@ -7,6 +7,8 @@ import { Button } from "@/components/shadcn/button/button";
import { FileUploadDropzone } from "@/components/shadcn/file-upload/file-upload-dropzone";
import { Modal } from "@/components/shadcn/modal/modal";
import { useToast } from "@/components/shadcn/toast/use-toast";
import { ViewPlansLink } from "@/components/shared/usage-limit-message";
import { ACTION_ERROR_STATUS } from "@/lib/action-errors";
import { type Ingestion, type IngestionResponse } from "@/types";
import {
@@ -63,6 +65,7 @@ interface FailedImportState {
file: File;
// Absent when the upload itself was rejected: no job, so no counters.
ingestion?: Ingestion;
usageLimited?: boolean;
}
interface InvalidImportState {
@@ -193,6 +196,7 @@ export function ImportFindingsModal() {
? await responseError(response, START_ERROR)
: START_ERROR,
file,
usageLimited: response?.status === ACTION_ERROR_STATUS.PAYMENT_REQUIRED,
});
return;
}
@@ -262,7 +266,15 @@ export function ImportFindingsModal() {
)}
{state.type === IMPORT_STATE.FAILED && (
<>
<p role="alert">{state.error}</p>
<p role="alert">
{state.error}
{state.usageLimited && (
<>
{" "}
<ViewPlansLink />
</>
)}
</p>
{state.ingestion && (
<p>
Reported progress: {state.ingestion.processedRecords} of{" "}
@@ -130,6 +130,7 @@ import {
ACTION_ERROR_MESSAGES,
ACTION_ERROR_STATUS,
} from "@/lib/action-errors";
import { PRICING_URL } from "@/lib/external-urls";
import { ProviderProps } from "@/types";
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
@@ -386,6 +387,10 @@ describe("LaunchScanModal", () => {
ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED],
),
).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "View plans" })).toHaveAttribute(
"href",
PRICING_URL,
);
expect(toastMock).not.toHaveBeenCalled();
expect(refreshMock).not.toHaveBeenCalled();
expect(onOpenChange).not.toHaveBeenCalledWith(false);
@@ -536,6 +541,46 @@ describe("LaunchScanModal", () => {
);
});
it("offers the plans when the usage limit refuses the schedule", async () => {
const user = userEvent.setup();
updateScheduleMock.mockResolvedValueOnce({
error: ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED],
status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED,
});
renderAdvanced();
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
await user.click(screen.getByRole("radio", { name: "On a schedule" }));
await user.click(screen.getByRole("button", { name: /save schedule/i }));
expect(
await screen.findByRole("link", { name: "View plans" }),
).toHaveAttribute("href", PRICING_URL);
expect(toastMock).not.toHaveBeenCalled();
});
it("offers the plans when the schedule saved but the limit refused its first scan", async () => {
const user = userEvent.setup();
scanOnDemandMock.mockResolvedValueOnce({
error: ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED],
status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED,
});
renderAdvanced();
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
await user.click(screen.getByRole("radio", { name: "On a schedule" }));
await user.click(
await screen.findByLabelText(
"Launch an initial scan now for immediate findings",
),
);
await user.click(screen.getByRole("button", { name: /save schedule/i }));
await waitFor(() => expect(toastMock).toHaveBeenCalled());
const action = toastMock.mock.calls[0]?.[0].action;
expect(action.props.children.props.href).toBe(PRICING_URL);
});
it("disables Save Schedule until a provider is selected", async () => {
const user = userEvent.setup();
renderAdvanced();
+30 -6
View File
@@ -25,8 +25,16 @@ import {
RadioGroupItem,
} from "@/components/shadcn/radio-group/radio-group";
import { toast, ToastAction } from "@/components/shadcn/toast";
import { UsageLimitMessage } from "@/components/shared/usage-limit-message";
import { getActionErrorMessage, hasActionError } from "@/lib/action-errors";
import {
UsageLimitMessage,
usageLimitToastAction,
} from "@/components/shared/usage-limit-message";
import {
getActionErrorMessage,
hasActionError,
isUsageLimitError,
USAGE_LIMIT_ERROR_TYPE,
} from "@/lib/action-errors";
import {
buildScheduleAttributesFromProvider,
getScanScheduleCapability,
@@ -200,7 +208,10 @@ function LaunchScanForm({
const result = await scanOnDemand(formData);
if (hasActionError(result)) {
form.setError("root", { message: getActionErrorMessage(result) });
form.setError("root", {
type: isUsageLimitError(result) ? USAGE_LIMIT_ERROR_TYPE : "server",
message: getActionErrorMessage(result),
});
return;
}
@@ -238,7 +249,10 @@ function LaunchScanForm({
});
if (result.status === SAVE_SCHEDULE_STATUS.ERROR) {
form.setError("root", { message: result.message });
form.setError("root", {
type: result.usageLimited ? USAGE_LIMIT_ERROR_TYPE : "server",
message: result.message,
});
return;
}
@@ -254,7 +268,10 @@ function LaunchScanForm({
: launched
? "The schedule was saved and the initial scan was launched."
: "The scan schedule was saved successfully.",
action: (
// Over the limit, the plans unblock the scan; the schedule would not.
action: result.usageLimited ? (
usageLimitToastAction()
) : (
<ToastAction altText="View scheduled scans" asChild>
<Link href={`/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`}>
View schedule
@@ -281,6 +298,8 @@ function LaunchScanForm({
const providerError = form.formState.errors.providerId?.message;
const aliasError = form.formState.errors.scanAlias?.message;
const rootError = form.formState.errors.root?.message;
const isRootUsageLimit =
form.formState.errors.root?.type === USAGE_LIMIT_ERROR_TYPE;
const isSubmitting =
form.formState.isSubmitting || scheduleForm.formState.isSubmitting;
const isScheduleLoading = scheduleLoad === SCHEDULE_LOAD_STATE.LOADING;
@@ -381,7 +400,12 @@ function LaunchScanForm({
/>
)}
{rootError && <FieldError>{rootError}</FieldError>}
{rootError &&
(isRootUsageLimit ? (
<UsageLimitMessage />
) : (
<FieldError>{rootError}</FieldError>
))}
<FormButtons
onCancel={onClose}
@@ -85,6 +85,7 @@ import {
ACTION_ERROR_MESSAGES,
ACTION_ERROR_STATUS,
} from "@/lib/action-errors";
import { PRICING_URL } from "@/lib/external-urls";
import type { ScheduleProps } from "@/types/schedules";
import {
@@ -352,6 +353,10 @@ describe("EditScanScheduleModal remove flow", () => {
ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED],
),
).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "View plans" })).toHaveAttribute(
"href",
PRICING_URL,
);
});
it("uses the shared subscription error copy when removing is blocked", async () => {
@@ -382,6 +387,10 @@ describe("EditScanScheduleModal remove flow", () => {
ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED],
),
).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "View plans" })).toHaveAttribute(
"href",
PRICING_URL,
);
});
it("shows one logo per selected provider type in bulk mode", () => {
@@ -20,7 +20,13 @@ import { EntityInfo } from "@/components/shadcn/entities";
import { FormButtons } from "@/components/shadcn/form";
import { Modal } from "@/components/shadcn/modal";
import { toast } from "@/components/shadcn/toast";
import { getActionErrorMessage, hasActionError } from "@/lib/action-errors";
import { UsageLimitMessage } from "@/components/shared/usage-limit-message";
import {
getActionErrorMessage,
hasActionError,
isUsageLimitError,
USAGE_LIMIT_ERROR_TYPE,
} from "@/lib/action-errors";
import { runWithConcurrencyLimit } from "@/lib/concurrency";
import {
buildScheduleUpdatePayload,
@@ -134,7 +140,10 @@ function EditScanScheduleForm({
: await updateSchedule(targetProviderIds[0], payload);
if (hasActionError(result)) {
form.setError("root", { message: getActionErrorMessage(result) });
form.setError("root", {
type: isUsageLimitError(result) ? USAGE_LIMIT_ERROR_TYPE : "server",
message: getActionErrorMessage(result),
});
return;
}
@@ -189,7 +198,12 @@ function EditScanScheduleForm({
const failedResult = results.find(hasActionError);
if (failedResult) {
form.setError("root", { message: getActionErrorMessage(failedResult) });
form.setError("root", {
type: isUsageLimitError(failedResult)
? USAGE_LIMIT_ERROR_TYPE
: "server",
message: getActionErrorMessage(failedResult),
});
return;
}
@@ -206,6 +220,8 @@ function EditScanScheduleForm({
const isSubmitting = form.formState.isSubmitting;
const rootError = form.formState.errors.root?.message;
const isRootUsageLimit =
form.formState.errors.root?.type === USAGE_LIMIT_ERROR_TYPE;
return (
<form onSubmit={onSubmit} className="flex flex-col gap-8">
@@ -253,7 +269,12 @@ function EditScanScheduleForm({
}
/>
{rootError && <FieldError>{rootError}</FieldError>}
{rootError &&
(isRootUsageLimit ? (
<UsageLimitMessage />
) : (
<FieldError>{rootError}</FieldError>
))}
<FormButtons
onCancel={onClose}
@@ -89,6 +89,7 @@ describe("saveScheduleWithInitialScan", () => {
expect(result).toEqual({
status: SAVE_SCHEDULE_STATUS.ERROR,
message: "boom",
usageLimited: false,
});
expect(scanOnDemandMock).not.toHaveBeenCalled();
});
@@ -107,6 +108,7 @@ describe("saveScheduleWithInitialScan", () => {
expect(result).toEqual({
status: SAVE_SCHEDULE_STATUS.ERROR,
message: ACTION_ERROR_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED],
usageLimited: true,
});
expect(scanOnDemandMock).not.toHaveBeenCalled();
});
@@ -133,6 +135,7 @@ describe("saveScheduleWithInitialScan", () => {
expect(result).toEqual({
status: SAVE_SCHEDULE_STATUS.SAVED_SCAN_FAILED,
message: "limit reached",
usageLimited: false,
});
});
@@ -150,6 +153,7 @@ describe("saveScheduleWithInitialScan", () => {
expect(result).toEqual({
status: SAVE_SCHEDULE_STATUS.SAVED_SCAN_FAILED,
message: ACTION_ERROR_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED],
usageLimited: true,
});
});
});
@@ -1,7 +1,11 @@
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 {
getActionErrorMessage,
hasActionError,
isUsageLimitError,
} from "@/lib/action-errors";
import { buildScheduleUpdatePayload } from "@/lib/schedules";
import type { ScheduleFormValues } from "@/types/schedules";
@@ -25,6 +29,8 @@ export interface SaveScheduleParams {
export interface SaveScheduleResult {
status: SaveScheduleStatus;
message?: string;
/** The schedule or its initial scan was refused by the plan's usage limit. */
usageLimited?: boolean;
}
/** Saves a provider's scan schedule and optionally launches the initial scan. */
@@ -50,6 +56,7 @@ export async function saveScheduleWithInitialScan({
return {
status: SAVE_SCHEDULE_STATUS.ERROR,
message: getActionErrorMessage(scheduleResult),
usageLimited: isUsageLimitError(scheduleResult),
};
}
@@ -65,6 +72,7 @@ export async function saveScheduleWithInitialScan({
return {
status: SAVE_SCHEDULE_STATUS.SAVED_SCAN_FAILED,
message: getActionErrorMessage(scanResult),
usageLimited: isUsageLimitError(scanResult),
};
}
@@ -2,36 +2,22 @@ import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { USAGE_LIMIT_MESSAGE } from "@/lib/action-errors";
import { BILLING_URL } from "@/lib/external-urls";
import { PRICING_URL } from "@/lib/external-urls";
import { UsageLimitMessage } from "./usage-limit-message";
describe("UsageLimitMessage", () => {
it("renders the shared usage-limit copy", () => {
it("explains the limit and opens the plans in a new tab", () => {
// When
render(<UsageLimitMessage />);
expect(screen.getByText(/exceeded the usage limit/i)).toBeInTheDocument();
});
it("links to Prowler Cloud billing", () => {
render(<UsageLimitMessage />);
const link = screen.getByRole("link", { name: /manage billing/i });
expect(link).toHaveAttribute("href", BILLING_URL);
// Then
expect(
screen.getByText(USAGE_LIMIT_MESSAGE, { exact: false }),
).toBeVisible();
const link = screen.getByRole("link", { name: "View plans" });
expect(link).toHaveAttribute("href", PRICING_URL);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
it("keeps the copy in sync with the 402 action-error message", () => {
render(<UsageLimitMessage />);
expect(screen.getByText(USAGE_LIMIT_MESSAGE)).toBeInTheDocument();
});
it("merges a custom className with the base styles", () => {
const { container } = render(<UsageLimitMessage className="mt-4" />);
expect(container.firstChild).toHaveClass("mt-4");
expect(container.firstChild).toHaveClass("text-text-error-primary");
});
});
+27 -14
View File
@@ -1,25 +1,38 @@
import Link from "next/link";
import { ToastAction } from "@/components/shadcn/toast";
import { USAGE_LIMIT_MESSAGE } from "@/lib/action-errors";
import { BILLING_URL } from "@/lib/external-urls";
import { PRICING_URL } from "@/lib/external-urls";
import { cn } from "@/lib/utils";
interface UsageLimitMessageProps {
className?: string;
}
// Over-limit (trial-expired) notice shown in scan launch flows. Pairs the shared
// usage-limit copy with a link to Prowler Cloud billing.
const VIEW_PLANS_LABEL = "View plans";
/** Inline link to Prowler pricing, in a new tab, for copy that explains the refusal itself. */
export const ViewPlansLink = () => (
<a
href={PRICING_URL}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{VIEW_PLANS_LABEL}
</a>
);
// Over-limit (trial-expired) notice: the shared copy plus Prowler pricing.
export const UsageLimitMessage = ({ className }: UsageLimitMessageProps) => (
<p className={cn("text-text-error-primary text-sm", className)}>
{USAGE_LIMIT_MESSAGE}{" "}
<Link
href={BILLING_URL}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Manage Billing
</Link>
{USAGE_LIMIT_MESSAGE} <ViewPlansLink />
</p>
);
/** Toast action for a usage-limit refusal: Prowler pricing, in a new tab. */
export const usageLimitToastAction = () => (
<ToastAction altText={VIEW_PLANS_LABEL} asChild>
<a href={PRICING_URL} target="_blank" rel="noopener noreferrer">
{VIEW_PLANS_LABEL}
</a>
</ToastAction>
);
+5
View File
@@ -1,6 +1,8 @@
import { UseFormReturn } from "react-hook-form";
import { useToast } from "@/components/shadcn";
import { usageLimitToastAction } from "@/components/shared/usage-limit-message";
import { isUsageLimitApiError } from "@/lib/action-errors";
import { ApiError } from "@/types";
/**
@@ -33,6 +35,9 @@ export const useFormServerErrors = <T extends Record<string, any>>(
variant: "destructive",
title: "Oops! Something went wrong",
description: errorMessage,
...(isUsageLimitApiError(error) && {
action: usageLimitToastAction(),
}),
});
}
});
+21 -1
View File
@@ -7,7 +7,7 @@ export type ActionErrorStatus =
(typeof ACTION_ERROR_STATUS)[keyof typeof ACTION_ERROR_STATUS];
// Shown whenever the API returns 402 for an over-limit (trial-expired) tenant.
// Rendered with a billing link by he `UsageLimitMessage` component, and as
// Rendered with a pricing link by the `UsageLimitMessage` component, and as
// plain text in toasts/field errors.
export const USAGE_LIMIT_MESSAGE =
"You have exceeded the usage limit of one provider. You can add more providers and run unlimited scans by adding a subscription.";
@@ -42,6 +42,26 @@ export const isActionErrorStatus = (
const isHttpErrorStatus = (status: number | undefined): boolean =>
typeof status === "number" && status >= 400;
/** Form error `type` marking a usage-limit refusal, rendered with the plans link. */
export const USAGE_LIMIT_ERROR_TYPE = "usage_limit";
/** Thrown client-side when a usage-limit refusal must unwind a handler. */
export class UsageLimitError extends Error {
constructor() {
super(USAGE_LIMIT_MESSAGE);
this.name = "UsageLimitError";
}
}
/** The API refused the action because the tenant is over its plan's usage limit. */
export const isUsageLimitError = (
result: ActionErrorResult | null | undefined,
): boolean => result?.status === ACTION_ERROR_STATUS.PAYMENT_REQUIRED;
/** Same check for one JSON:API error object, whose status is a string. */
export const isUsageLimitApiError = (error: { status?: string }): boolean =>
error.status === String(ACTION_ERROR_STATUS.PAYMENT_REQUIRED);
export const hasActionError = (
result: ActionErrorResult | null | undefined,
): result is ActionErrorResult =>
+1 -1
View File
@@ -1,3 +1,4 @@
import { PRICING_URL } from "@/lib/external-urls";
import {
CLOUD_UPGRADE_FEATURE,
type CloudUpgradeFeature,
@@ -21,7 +22,6 @@ export const CLOUD_UPGRADE_FOOTER_NOTE =
"Prowler Cloud opens in a new tab. Your Prowler Local Server remains unchanged.";
const CLOUD_SIGN_UP_URL = "https://cloud.prowler.com/sign-up";
const PRICING_URL = "https://prowler.com/pricing";
const LOCAL_SERVER_UTM_SOURCE = "prowler-local-server";
const CLOUD_UPGRADE_UTM_CONTENT = {
+1
View File
@@ -46,6 +46,7 @@ export const PROWLER_CF_TEMPLATE_URL =
// Prowler Cloud billing/subscription management page.
export const BILLING_URL = "https://cloud.prowler.com/billing";
export const PRICING_URL = "https://prowler.com/pricing";
// Base URL for the CloudFormation "quick create stack" console flow.
// Hardcoded to us-east-1 because the public template is hosted for that flow.
+48
View File
@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { UsageLimitError } from "@/lib/action-errors";
import {
FINDING_TRIAGE_STATUS,
type UpdateFindingTriageInput,
} from "@/types/findings-triage";
const { updateFindingTriageMock } = vi.hoisted(() => ({
updateFindingTriageMock: vi.fn(),
}));
vi.mock("@/actions/findings", () => ({
updateFindingTriage: updateFindingTriageMock,
}));
import { updateFindingTriageOrThrow } from "./finding-triage-update";
const input: UpdateFindingTriageInput = {
findingId: "finding-1",
findingUid: "finding/uid",
triageId: "triage-1",
notesCount: 0,
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
};
describe("updateFindingTriageOrThrow", () => {
beforeEach(() => {
updateFindingTriageMock.mockReset();
});
it("throws a usage-limit error when the plan refuses the update", async () => {
updateFindingTriageMock.mockResolvedValue({ usageLimited: true });
await expect(updateFindingTriageOrThrow(input)).rejects.toBeInstanceOf(
UsageLimitError,
);
});
it("returns the update result otherwise", async () => {
updateFindingTriageMock.mockResolvedValue({ manualPassExpiresAt: null });
await expect(updateFindingTriageOrThrow(input)).resolves.toEqual({
manualPassExpiresAt: null,
});
});
});
+14
View File
@@ -0,0 +1,14 @@
import { updateFindingTriage } from "@/actions/findings";
import { UsageLimitError } from "@/lib/action-errors";
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
/** Client-side triage write: a usage-limit refusal throws, so callers skip their local patch. */
export const updateFindingTriageOrThrow = async (
input: UpdateFindingTriageInput,
) => {
const result = await updateFindingTriage(input);
if (result && typeof result === "object" && "usageLimited" in result) {
throw new UsageLimitError();
}
return result;
};
+5
View File
@@ -132,6 +132,11 @@ export interface FindingTriageUpdateResult {
manualPassExpiresAt: string | null;
}
/** Returned instead of thrown: a server action's thrown message is sanitized in production. */
export interface FindingTriageUsageLimitedResult {
usageLimited: true;
}
export interface FindingTriageContext {
title: string;
resource?: string;