/** * Tests for the error-handling behaviour of the MCP config API client. * * Issue #3527: when a non-admin user opens Settings → Tools, the gateway * returns 403 `{detail: "Admin privileges required to manage MCP * configuration."}` for `GET /api/mcp/config`. The previous client * silently treated the 403 body as a valid `MCPConfig`, so the UI then * crashed with `Cannot convert undefined or null to object` when it tried * `Object.entries(config.mcp_servers)`. * * These tests pin the contract that non-2xx responses are surfaced as * `MCPConfigRequestError` carrying the HTTP status and backend `detail`, * so the React Query hook's `error` branch can render a friendly empty * state (admin-required for 403) instead of crashing. */ import { beforeEach, describe, expect, test, rs } from "@rstest/core"; rs.mock("@/core/api/fetcher", () => ({ fetch: rs.fn(), })); rs.mock("@/core/config", () => ({ getBackendBaseURL: () => "", })); import { fetch as fetcher } from "@/core/api/fetcher"; import { MCPConfigRequestError, loadMCPConfig, updateMCPConfig, updateMCPServerState, } from "@/core/mcp/api"; const mockedFetch = rs.mocked(fetcher); function jsonResponse(status: number, body: unknown): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" }, }); } beforeEach(() => { mockedFetch.mockReset(); }); describe("loadMCPConfig", () => { test("returns parsed config on 200", async () => { const config = { mcp_servers: { foo: { enabled: true } } }; mockedFetch.mockResolvedValueOnce(jsonResponse(200, config)); await expect(loadMCPConfig()).resolves.toEqual(config); }); test("throws MCPConfigRequestError with isAdminRequired on 403 (issue #3527)", async () => { mockedFetch.mockResolvedValueOnce( jsonResponse(403, { detail: "Admin privileges required to manage MCP configuration.", }), ); await expect(loadMCPConfig()).rejects.toMatchObject({ name: "MCPConfigRequestError", status: 403, isAdminRequired: true, message: "Admin privileges required to manage MCP configuration.", }); }); test("throws MCPConfigRequestError with isAdminRequired=false on non-403 errors", async () => { mockedFetch.mockResolvedValueOnce( new Response("", { status: 500, statusText: "Internal Server Error" }), ); await expect(loadMCPConfig()).rejects.toMatchObject({ name: "MCPConfigRequestError", status: 500, isAdminRequired: false, message: "Failed to load MCP configuration", }); }); test("the rejected value is an instance of MCPConfigRequestError", async () => { mockedFetch.mockResolvedValueOnce(jsonResponse(403, { detail: "nope" })); await expect(loadMCPConfig()).rejects.toBeInstanceOf(MCPConfigRequestError); }); }); describe("updateMCPConfig", () => { test("returns parsed body on 200", async () => { mockedFetch.mockResolvedValueOnce(jsonResponse(200, { ok: true })); await expect(updateMCPConfig({ mcp_servers: {} })).resolves.toEqual({ ok: true, }); }); test("throws MCPConfigRequestError with isAdminRequired on 403", async () => { mockedFetch.mockResolvedValueOnce( jsonResponse(403, { detail: "Admin privileges required to manage MCP configuration.", }), ); await expect(updateMCPConfig({ mcp_servers: {} })).rejects.toMatchObject({ name: "MCPConfigRequestError", status: 403, isAdminRequired: true, message: "Admin privileges required to manage MCP configuration.", }); }); test("falls back to generic message on non-403 errors", async () => { mockedFetch.mockResolvedValueOnce( new Response("", { status: 500, statusText: "Internal Server Error" }), ); await expect(updateMCPConfig({ mcp_servers: {} })).rejects.toMatchObject({ name: "MCPConfigRequestError", status: 500, isAdminRequired: false, message: "Failed to update MCP configuration", }); }); }); describe("updateMCPServerState", () => { test("patches only the requested server state", async () => { mockedFetch.mockResolvedValueOnce( jsonResponse(200, { mcp_servers: { github: { enabled: false } }, }), ); await expect(updateMCPServerState("github", false)).resolves.toEqual({ mcp_servers: { github: { enabled: false } }, }); expect(mockedFetch).toHaveBeenCalledWith("/api/mcp/config", { method: "PATCH", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ server_name: "github", enabled: false, }), }); }); test("surfaces backend validation detail", async () => { const detail = "MCP server 'semantic-scholar' uses disallowed stdio command 's2-mcp-server'."; mockedFetch.mockResolvedValueOnce(jsonResponse(400, { detail })); await expect( updateMCPServerState("semantic-scholar", true), ).rejects.toMatchObject({ name: "MCPConfigRequestError", status: 400, message: detail, }); }); });