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 3a94e733..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 @@ -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, REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE } from "@/api/api-error"; import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api"; import type { CreateWorkOrderUpliftInput, @@ -14,6 +16,25 @@ import type { import { queryKeys } from "@/infra/query-key/query-key"; import { requireQueryParam } from "@/lib/query/require-query-param"; +const CREATE_UPLIFT_ERROR_FALLBACK = "Failed to create uplift"; +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 && error.message === REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE) { + return REQUEST_UPLIFTS_ROLE_DENIAL_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 +69,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/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 new file mode 100644 index 00000000..41c11c17 --- /dev/null +++ b/src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx @@ -0,0 +1,89 @@ +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 { 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"; + +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, 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("You do not have permission to perform this action."); + }); + + 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); + }); +});