Merge pull request #250 from Sea-Haven-Industries/fix/sh-327-uplift-denial-message
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions

fix: preserve uplift denial message
This commit is contained in:
Alexandre Brandizzi 2026-09-25 00:52:28 +00:00 • committed by GitHub
commit 8b58eb5f64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 148 additions and 2 deletions

View file

@ -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<string, unknown>;
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) {

View file

@ -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<typeof useQueryClient>,
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));
},
});
}

View file

@ -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")),

View file

@ -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(<WorkOrderUpliftsDialog row={row} open onClose={vi.fn()} />, {
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);
});
});