diff --git a/ui/actions/findings/findings-triage.test.ts b/ui/actions/findings/findings-triage.test.ts index ce0d0059b2..352f1a9c47 100644 --- a/ui/actions/findings/findings-triage.test.ts +++ b/ui/actions/findings/findings-triage.test.ts @@ -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(); diff --git a/ui/actions/findings/findings-triage.ts b/ui/actions/findings/findings-triage.ts index 8720a08807..8afb68a205 100644 --- a/ui/actions/findings/findings-triage.ts +++ b/ui/actions/findings/findings-triage.ts @@ -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}`; diff --git a/ui/actions/issues/issues.test.ts b/ui/actions/issues/issues.test.ts index 3e03dfb52e..0ab1533d80 100644 --- a/ui/actions/issues/issues.test.ts +++ b/ui/actions/issues/issues.test.ts @@ -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", diff --git a/ui/actions/issues/issues.ts b/ui/actions/issues/issues.ts index 1bdb23ba9b..db21d92bab 100644 --- a/ui/actions/issues/issues.ts +++ b/ui/actions/issues/issues.ts @@ -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); } diff --git a/ui/actions/issues/types.ts b/ui/actions/issues/types.ts index ca8b48ad72..2b59f15b8d 100644 --- a/ui/actions/issues/types.ts +++ b/ui/actions/issues/types.ts @@ -49,7 +49,9 @@ export interface IssueTriageNotesDocument included?: JsonApiResource>>[]; } -export type IssueWriteResult = { success: true } | { error: string }; +export type IssueWriteResult = + | { success: true } + | { error: string; status?: number }; export interface UpdateIssueTriageInput { status: FindingTriageManualStatus; diff --git a/ui/app/(prowler)/scans/scans-page.harness.tsx b/ui/app/(prowler)/scans/scans-page.harness.tsx index 0b2688e68b..0631f5a07c 100644 --- a/ui/app/(prowler)/scans/scans-page.harness.tsx +++ b/ui/app/(prowler)/scans/scans-page.harness.tsx @@ -164,6 +164,14 @@ export class ScansPageHarness extends BrowserHarness { await this.waitForText(message); } + plansLink(): HTMLAnchorElement | null { + return ( + Array.from(document.querySelectorAll("a")).find( + (link) => link.textContent?.trim() === "View plans", + ) ?? null + ); + } + async waitForUploadInProgress(): Promise { await this.waitFor( () => this.buttonByText(/Importing/i), diff --git a/ui/app/(prowler)/scans/scans-page.integration.test.tsx b/ui/app/(prowler)/scans/scans-page.integration.test.tsx index d150a2d0f8..223b0b3d06 100644 --- a/ui/app/(prowler)/scans/scans-page.integration.test.tsx +++ b/ui/app/(prowler)/scans/scans-page.integration.test.tsx @@ -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"] }); diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx index 3cda99780b..ed866e498e 100644 --- a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx +++ b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx @@ -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. diff --git a/ui/components/findings/recheck-resource-modal.test.tsx b/ui/components/findings/recheck-resource-modal.test.tsx index 52b236dfdc..48c9b743d8 100644 --- a/ui/components/findings/recheck-resource-modal.test.tsx +++ b/ui/components/findings/recheck-resource-modal.test.tsx @@ -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( + , + ); + + 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 }); diff --git a/ui/components/findings/recheck-resource-modal.tsx b/ui/components/findings/recheck-resource-modal.tsx index 29177d8bfa..83953e23a3 100644 --- a/ui/components/findings/recheck-resource-modal.tsx +++ b/ui/components/findings/recheck-resource-modal.tsx @@ -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(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({

- {error && ( -

- {error} -

- )} + {error && + (isUsageLimited ? ( +
+ +
+ ) : ( +

+ {error} +

+ ))}