Huixin615 133a82c6c2
fix: isolate MCP server toggles from invalid peer configs (#4577)
* fix: isolate MCP server toggle updates

* fix: write extensions config atomically

* fix: normalize MCP transport aliases
2026-07-31 08:32:39 +08:00

158 lines
5.0 KiB
TypeScript

/**
* 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,
});
});
});