diff --git a/ui/actions/mute-rules/mute-rules.test.ts b/ui/actions/mute-rules/mute-rules.test.ts new file mode 100644 index 0000000000..cfb7f5daf9 --- /dev/null +++ b/ui/actions/mute-rules/mute-rules.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchMock, getAuthHeadersMock, revalidatePathMock } = vi.hoisted( + () => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + revalidatePathMock: vi.fn(), + }), +); + +vi.mock("@/lib/helper", () => ({ + apiBaseUrl: "https://api.test/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("next/cache", () => ({ + revalidatePath: revalidatePathMock, +})); + +import { createMuteRule } from "./mute-rules"; + +const NAME_CONFLICT_DETAIL = "A mute rule with this name already exists."; + +const errorResponse = (contentType: string, body: string, status = 400) => + new Response(body, { + status, + headers: { "Content-Type": contentType }, + }); + +const nameConflictBody = JSON.stringify({ + errors: [ + { + detail: NAME_CONFLICT_DETAIL, + status: "400", + source: { pointer: "/data/attributes/name" }, + code: "invalid", + }, + ], +}); + +const muteRuleFormData = () => { + const formData = new FormData(); + formData.set("name", "Root account has a hardware MFA device enabled"); + formData.set("reason", "Not our approach here with SSO"); + formData.set("finding_ids", JSON.stringify(["finding-1"])); + return formData; +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "error").mockImplementation(() => {}); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); +}); + +describe("createMuteRule", () => { + it("should return only the error detail for a JSON:API error response", async () => { + fetchMock.mockResolvedValue( + errorResponse("application/vnd.api+json", nameConflictBody), + ); + + const result = await createMuteRule(null, muteRuleFormData()); + + expect(result?.errors?.general).toBe(NAME_CONFLICT_DETAIL); + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + + it("should return only the error detail for a plain JSON error response", async () => { + fetchMock.mockResolvedValue( + errorResponse("application/json", nameConflictBody), + ); + + const result = await createMuteRule(null, muteRuleFormData()); + + expect(result?.errors?.general).toBe(NAME_CONFLICT_DETAIL); + }); + + it("should return the response text for a non-JSON error response", async () => { + fetchMock.mockResolvedValue( + errorResponse("text/plain", "Bad gateway", 502), + ); + + const result = await createMuteRule(null, muteRuleFormData()); + + expect(result?.errors?.general).toBe("Bad gateway"); + }); +}); diff --git a/ui/actions/mute-rules/mute-rules.ts b/ui/actions/mute-rules/mute-rules.ts index c74c4a7e2a..1ce9a8b3bc 100644 --- a/ui/actions/mute-rules/mute-rules.ts +++ b/ui/actions/mute-rules/mute-rules.ts @@ -156,7 +156,8 @@ export const createMuteRule = async ( let errorMessage = `Failed to create mute rule: ${response.statusText}`; const responseContentType = response.headers.get("content-type"); try { - if (responseContentType?.includes("application/json")) { + // The API answers with application/vnd.api+json + if (responseContentType?.includes("json")) { const errorData = await response.json(); const jsonApiError = ( errorData as { diff --git a/ui/changelog.d/mute-rule-error-toast-raw-json.fixed.md b/ui/changelog.d/mute-rule-error-toast-raw-json.fixed.md new file mode 100644 index 0000000000..d59b218727 --- /dev/null +++ b/ui/changelog.d/mute-rule-error-toast-raw-json.fixed.md @@ -0,0 +1 @@ +Mute rule creation errors show the API error message instead of the raw JSON:API response body