mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-30 07:14:20 +00:00
feat(ui): link usage-limit refusals to Prowler pricing (#13184)
This commit is contained in:
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();
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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
@@ -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,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 = {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user