From c472ce2a91cb73441dd01fc1076ae41925df9d7f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 22 Sep 2026 23:04:13 -0300 Subject: [PATCH 1/4] fix: preserve uplift denial message --- .../_components/uplift-requests-section.tsx | 8 +++- .../app/v/uplift-requests-section.test.tsx | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 src/test/app/v/uplift-requests-section.test.tsx diff --git a/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx b/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx index 9fa98227..820c186e 100644 --- a/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx +++ b/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx @@ -96,8 +96,12 @@ function useVendorUpliftActions({ if (outcome.outcome === "NoApprovalRequired") { setInfoMessage("No approval required — the existing NTE already covers this amount."); } - } catch { - setFormError("Unable to request the NTE uplift. Please try again."); + } catch (error) { + setFormError( + error instanceof Error && error.message + ? error.message + : "Unable to request the NTE uplift. Please try again.", + ); } finally { setSubmitting(false); } diff --git a/src/test/app/v/uplift-requests-section.test.tsx b/src/test/app/v/uplift-requests-section.test.tsx new file mode 100644 index 00000000..306848fd --- /dev/null +++ b/src/test/app/v/uplift-requests-section.test.tsx @@ -0,0 +1,43 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { UpliftRequestsSection } from "@/app/v/[token]/dispatch/_components/uplift-requests-section"; +import { renderWithProviders } from "@/test/test-utils"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("UpliftRequestsSection create workflow", () => { + it("renders the server denial message when requestUplifts is denied", async () => { + const denialMessage = "Your role can't request uplifts on this work order."; + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ message: denialMessage }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }), + ); + + renderWithProviders( + undefined)} + />, + { withAuth: false }, + ); + + fireEvent.change(screen.getByLabelText("New total NTE"), { target: { value: "100" } }); + fireEvent.click(screen.getByRole("button", { name: "Request uplift" })); + + await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(denialMessage)); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringMatching(/\/api\/vendor-portal\/dispatches\/7\/uplift-request$/), + expect.objectContaining({ method: "POST" }), + ); + }); +}); From c6e27041754aafcca3620e83391df7ac17b14ee9 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 22 Sep 2026 23:23:52 -0300 Subject: [PATCH 2/4] fix: allowlist uplift denial message --- .../_components/uplift-requests-section.tsx | 14 +++-- .../app/v/uplift-requests-section.test.tsx | 58 ++++++++++++++----- 2 files changed, 51 insertions(+), 21 deletions(-) diff --git a/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx b/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx index 820c186e..e157b2bf 100644 --- a/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx +++ b/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx @@ -15,6 +15,8 @@ import { } from "@/app/v/[token]/dispatch/_components/use-uplift-evidence"; const ACTIVE_STATUSES = new Set(["Pending", "ChangesRequested"]); +const UPLIFT_REQUEST_PERMISSION_DENIAL = "Your role can't request uplifts on this work order."; +const UPLIFT_REQUEST_ERROR_FALLBACK = "Unable to request the NTE uplift. Please try again."; type UpliftRequestsSectionProps = { token: string; @@ -41,6 +43,12 @@ function scanErrorMessage(scan: EvidenceScanState): string { return "Security scan timed out. Please try again once the file finishes scanning."; } +function mapUpliftRequestError(error: unknown): string { + return error instanceof Error && error.message === UPLIFT_REQUEST_PERMISSION_DENIAL + ? UPLIFT_REQUEST_PERMISSION_DENIAL + : UPLIFT_REQUEST_ERROR_FALLBACK; +} + function useVendorUpliftActions({ token, dispatchId, @@ -97,11 +105,7 @@ function useVendorUpliftActions({ setInfoMessage("No approval required — the existing NTE already covers this amount."); } } catch (error) { - setFormError( - error instanceof Error && error.message - ? error.message - : "Unable to request the NTE uplift. Please try again.", - ); + setFormError(mapUpliftRequestError(error)); } finally { setSubmitting(false); } diff --git a/src/test/app/v/uplift-requests-section.test.tsx b/src/test/app/v/uplift-requests-section.test.tsx index 306848fd..51416c19 100644 --- a/src/test/app/v/uplift-requests-section.test.tsx +++ b/src/test/app/v/uplift-requests-section.test.tsx @@ -8,6 +8,26 @@ afterEach(() => { vi.restoreAllMocks(); }); +function renderCreateForm() { + renderWithProviders( + undefined)} + />, + { withAuth: false }, + ); +} + +function submitCreateForm() { + fireEvent.change(screen.getByLabelText("New total NTE"), { target: { value: "100" } }); + fireEvent.click(screen.getByRole("button", { name: "Request uplift" })); +} + describe("UpliftRequestsSection create workflow", () => { it("renders the server denial message when requestUplifts is denied", async () => { const denialMessage = "Your role can't request uplifts on this work order."; @@ -18,26 +38,32 @@ describe("UpliftRequestsSection create workflow", () => { }), ); - renderWithProviders( - undefined)} - />, - { withAuth: false }, - ); + renderCreateForm(); + submitCreateForm(); - fireEvent.change(screen.getByLabelText("New total NTE"), { target: { value: "100" } }); - fireEvent.click(screen.getByRole("button", { name: "Request uplift" })); - - await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(denialMessage)); + await waitFor(() => expect(screen.getByRole("alert").textContent).toBe(denialMessage)); expect(fetchMock).toHaveBeenCalledWith( expect.stringMatching(/\/api\/vendor-portal\/dispatches\/7\/uplift-request$/), expect.objectContaining({ method: "POST" }), ); }); + + it("keeps unexpected server messages behind the generic fallback", async () => { + const unexpectedMessage = "SqlException: work order row failed"; + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ message: unexpectedMessage }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }), + ); + + renderCreateForm(); + submitCreateForm(); + + await waitFor(() => + expect(screen.getByRole("alert").textContent).toBe( + "Unable to request the NTE uplift. Please try again.", + ), + ); + }); }); From 7b30895acad59da7c141663e82f35ac2b6c9c7c1 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 22 Sep 2026 23:35:44 -0300 Subject: [PATCH 3/4] fix: map protected uplift create denial safely --- .../_components/uplift-requests-section.tsx | 12 +-- .../use-cases/use-work-order-uplifts.ts | 24 +++++- ...k-order-uplift-create-permissions.test.tsx | 82 +++++++++++++++++++ .../app/v/uplift-requests-section.test.tsx | 69 ---------------- 4 files changed, 107 insertions(+), 80 deletions(-) create mode 100644 src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx delete mode 100644 src/test/app/v/uplift-requests-section.test.tsx diff --git a/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx b/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx index e157b2bf..9fa98227 100644 --- a/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx +++ b/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx @@ -15,8 +15,6 @@ import { } from "@/app/v/[token]/dispatch/_components/use-uplift-evidence"; const ACTIVE_STATUSES = new Set(["Pending", "ChangesRequested"]); -const UPLIFT_REQUEST_PERMISSION_DENIAL = "Your role can't request uplifts on this work order."; -const UPLIFT_REQUEST_ERROR_FALLBACK = "Unable to request the NTE uplift. Please try again."; type UpliftRequestsSectionProps = { token: string; @@ -43,12 +41,6 @@ function scanErrorMessage(scan: EvidenceScanState): string { return "Security scan timed out. Please try again once the file finishes scanning."; } -function mapUpliftRequestError(error: unknown): string { - return error instanceof Error && error.message === UPLIFT_REQUEST_PERMISSION_DENIAL - ? UPLIFT_REQUEST_PERMISSION_DENIAL - : UPLIFT_REQUEST_ERROR_FALLBACK; -} - function useVendorUpliftActions({ token, dispatchId, @@ -104,8 +96,8 @@ function useVendorUpliftActions({ if (outcome.outcome === "NoApprovalRequired") { setInfoMessage("No approval required — the existing NTE already covers this amount."); } - } catch (error) { - setFormError(mapUpliftRequestError(error)); + } catch { + setFormError("Unable to request the NTE uplift. Please try again."); } finally { setSubmitting(false); } diff --git a/src/domain/work-orders/use-cases/use-work-order-uplifts.ts b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts index 3a94e733..eb16a041 100644 --- a/src/domain/work-orders/use-cases/use-work-order-uplifts.ts +++ b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts @@ -5,7 +5,9 @@ import { type UseMutationResult, type UseQueryResult, } from "@tanstack/react-query"; +import { isHTTPError, isNetworkError, isTimeoutError } from "ky"; import { toast } from "react-toastify"; +import { mapHttpStatusToMessage } from "@/api/api-error"; import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api"; import type { CreateWorkOrderUpliftInput, @@ -14,6 +16,26 @@ import type { import { queryKeys } from "@/infra/query-key/query-key"; import { requireQueryParam } from "@/lib/query/require-query-param"; +const CREATE_PERMISSION_DENIED_MESSAGE = "Your role can't request uplifts on this work order."; +const CREATE_UPLIFT_ERROR_FALLBACK = "Failed to create uplift"; +const CREATE_UPLIFT_FIXED_STATUS_COPY = new Set([401, 404, 500]); + +function createUpliftErrorMessage(error: unknown): string { + if (isHTTPError(error)) { + const status = error.response.status; + if (status === 403) { + return CREATE_PERMISSION_DENIED_MESSAGE; + } + return CREATE_UPLIFT_FIXED_STATUS_COPY.has(status) + ? mapHttpStatusToMessage(status) + : CREATE_UPLIFT_ERROR_FALLBACK; + } + if (isNetworkError(error) || isTimeoutError(error)) { + return error.message; + } + return CREATE_UPLIFT_ERROR_FALLBACK; +} + function invalidateUpliftQueries( queryClient: ReturnType, workOrderId: string | number, @@ -48,7 +70,7 @@ export function useCreateWorkOrderUplift( toast.success("Uplift request created"); }, onError: (error) => { - toast.error(error.message || "Failed to create uplift"); + toast.error(createUpliftErrorMessage(error)); }, }); } diff --git a/src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx b/src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx new file mode 100644 index 00000000..8859512c --- /dev/null +++ b/src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx @@ -0,0 +1,82 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { toast } from "react-toastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("react-toastify", () => ({ + toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() }, +})); + +const row = { + id: 42, + woNumber: "WO-42", + type: "PM", + status: "Scheduled", +} as WorkOrderTableRow; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function respondToCreate(status: number, message: string) { + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + if (request.method === "POST") { + return new Response(JSON.stringify({ code: "Forbidden", message }), { + status, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({ status: "Success", data: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + return fetchMock; +} + +async function submitInternalCreate(status: number, serverMessage: string) { + const fetchMock = respondToCreate(status, serverMessage); + renderWithProviders(, { + withAuth: true, + }); + + fireEvent.change(await screen.findByLabelText("Amount"), { target: { value: "125" } }); + fireEvent.change(screen.getByLabelText("Notes"), { target: { value: "Additional work" } }); + fireEvent.click(screen.getByRole("button", { name: "Create uplift" })); + + await waitFor(() => expect(vi.mocked(toast.error)).toHaveBeenCalled()); + expect( + fetchMock.mock.calls.some(([input, init]) => { + const request = input instanceof Request ? input : new Request(input, init); + return ( + request.method === "POST" && + new URL(request.url).pathname.endsWith("/api/workorders/42/uplifts") + ); + }), + ).toBe(true); + return vi.mocked(toast.error).mock.calls[0]?.[0]; +} + +describe("internal work-order uplift create error copy", () => { + it("shows the exact role denial for a 403", async () => { + const message = await submitInternalCreate(403, "accountId 42 is forbidden"); + + expect(message).toBe("Your role can't request uplifts on this work order."); + }); + + it("keeps unknown non-403 server diagnostics generic", async () => { + const diagnostic = "SqlException: workOrderId 42 row rejected"; + const message = await submitInternalCreate(422, diagnostic); + + expect(message).toBe("Failed to create uplift"); + expect(message).not.toContain(diagnostic); + }); +}); diff --git a/src/test/app/v/uplift-requests-section.test.tsx b/src/test/app/v/uplift-requests-section.test.tsx deleted file mode 100644 index 51416c19..00000000 --- a/src/test/app/v/uplift-requests-section.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { fireEvent, screen, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { UpliftRequestsSection } from "@/app/v/[token]/dispatch/_components/uplift-requests-section"; -import { renderWithProviders } from "@/test/test-utils"; - -afterEach(() => { - vi.restoreAllMocks(); -}); - -function renderCreateForm() { - renderWithProviders( - undefined)} - />, - { withAuth: false }, - ); -} - -function submitCreateForm() { - fireEvent.change(screen.getByLabelText("New total NTE"), { target: { value: "100" } }); - fireEvent.click(screen.getByRole("button", { name: "Request uplift" })); -} - -describe("UpliftRequestsSection create workflow", () => { - it("renders the server denial message when requestUplifts is denied", async () => { - const denialMessage = "Your role can't request uplifts on this work order."; - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify({ message: denialMessage }), { - status: 403, - headers: { "Content-Type": "application/json" }, - }), - ); - - renderCreateForm(); - submitCreateForm(); - - await waitFor(() => expect(screen.getByRole("alert").textContent).toBe(denialMessage)); - expect(fetchMock).toHaveBeenCalledWith( - expect.stringMatching(/\/api\/vendor-portal\/dispatches\/7\/uplift-request$/), - expect.objectContaining({ method: "POST" }), - ); - }); - - it("keeps unexpected server messages behind the generic fallback", async () => { - const unexpectedMessage = "SqlException: work order row failed"; - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify({ message: unexpectedMessage }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }), - ); - - renderCreateForm(); - submitCreateForm(); - - await waitFor(() => - expect(screen.getByRole("alert").textContent).toBe( - "Unable to request the NTE uplift. Please try again.", - ), - ); - }); -}); From 80640642ccb7e6bbd0b23521767c0136c6853909 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 22 Sep 2026 23:41:17 -0300 Subject: [PATCH 4/4] fix: allowlist internal uplift denial copy --- src/api/api-error.ts | 10 +++++++ .../use-cases/use-work-order-uplifts.ts | 9 +++--- src/test/api/api-error.test.ts | 28 ++++++++++++++++++- ...k-order-uplift-create-permissions.test.tsx | 9 +++++- 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/api/api-error.ts b/src/api/api-error.ts index d9dabc83..3fdabbb3 100644 --- a/src/api/api-error.ts +++ b/src/api/api-error.ts @@ -10,11 +10,21 @@ export class ApiError extends Error { } } +export const REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE = + "Your role can't request uplifts on this work order."; + export function mapHttpStatusToMessage(status: number, data?: unknown): string { if (status === 401) { return "You are not authorized to access this page."; } if (status === 403) { + if (data && typeof data === "object") { + const record = data as Record; + const message = record.message ?? record.Message ?? record.error; + if (message === REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE) { + return REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE; + } + } return "You do not have permission to perform this action."; } if (status === 404) { diff --git a/src/domain/work-orders/use-cases/use-work-order-uplifts.ts b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts index eb16a041..368a6767 100644 --- a/src/domain/work-orders/use-cases/use-work-order-uplifts.ts +++ b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts @@ -7,7 +7,7 @@ import { } from "@tanstack/react-query"; import { isHTTPError, isNetworkError, isTimeoutError } from "ky"; import { toast } from "react-toastify"; -import { mapHttpStatusToMessage } from "@/api/api-error"; +import { mapHttpStatusToMessage, REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE } from "@/api/api-error"; import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api"; import type { CreateWorkOrderUpliftInput, @@ -16,15 +16,14 @@ import type { import { queryKeys } from "@/infra/query-key/query-key"; import { requireQueryParam } from "@/lib/query/require-query-param"; -const CREATE_PERMISSION_DENIED_MESSAGE = "Your role can't request uplifts on this work order."; const CREATE_UPLIFT_ERROR_FALLBACK = "Failed to create uplift"; -const CREATE_UPLIFT_FIXED_STATUS_COPY = new Set([401, 404, 500]); +const CREATE_UPLIFT_FIXED_STATUS_COPY = new Set([401, 403, 404, 500]); function createUpliftErrorMessage(error: unknown): string { if (isHTTPError(error)) { const status = error.response.status; - if (status === 403) { - return CREATE_PERMISSION_DENIED_MESSAGE; + if (status === 403 && error.message === REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE) { + return REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE; } return CREATE_UPLIFT_FIXED_STATUS_COPY.has(status) ? mapHttpStatusToMessage(status) diff --git a/src/test/api/api-error.test.ts b/src/test/api/api-error.test.ts index 71c3d1c9..af435ad9 100644 --- a/src/test/api/api-error.test.ts +++ b/src/test/api/api-error.test.ts @@ -1,6 +1,6 @@ import ky from "ky"; import { describe, expect, it, vi } from "vitest"; -import { normalizeApiRequestError } from "@/api/api-error"; +import { normalizeApiRequestError, REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE } from "@/api/api-error"; const RESOURCE_URL = "https://example.test/api/resource"; @@ -29,6 +29,32 @@ describe("normalizeApiRequestError", () => { ); }); + it("preserves only the allowlisted public denial for 403 responses", async () => { + const allowedFetch = vi.fn(() => + Promise.resolve( + new Response(JSON.stringify({ message: REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }), + ), + ) as unknown as typeof fetch; + const unrelatedFetch = vi.fn(() => + Promise.resolve( + new Response(JSON.stringify({ message: "accountId 42 is forbidden" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }), + ), + ) as unknown as typeof fetch; + + await expect(createClient(allowedFetch).get(RESOURCE_URL).json()).rejects.toThrow( + REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE, + ); + await expect(createClient(unrelatedFetch).get(RESOURCE_URL).json()).rejects.toThrow( + "You do not have permission to perform this action.", + ); + }); + it("reports a connection failure for network errors", async () => { const fetchImpl = vi.fn(() => Promise.reject(new TypeError("fetch failed")), diff --git a/src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx b/src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx index 8859512c..41c11c17 100644 --- a/src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx +++ b/src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx @@ -3,6 +3,7 @@ import { toast } from "react-toastify"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog"; +import { REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE } from "@/api/api-error"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { renderWithProviders } from "@/test/test-utils"; @@ -67,9 +68,15 @@ async function submitInternalCreate(status: number, serverMessage: string) { describe("internal work-order uplift create error copy", () => { it("shows the exact role denial for a 403", async () => { + const message = await submitInternalCreate(403, REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE); + + expect(message).toBe(REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE); + }); + + it("keeps unrelated 403 permission denials generic", async () => { const message = await submitInternalCreate(403, "accountId 42 is forbidden"); - expect(message).toBe("Your role can't request uplifts on this work order."); + expect(message).toBe("You do not have permission to perform this action."); }); it("keeps unknown non-403 server diagnostics generic", async () => {