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 () => {