From f2d531737adce76ddb84e3c4adeef02cc3f43ec8 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Jul 2026 22:29:09 -0700 Subject: [PATCH] test(ui): pin mcp-servers, tag-management and tool-policies behaviour before the shadcn migration Rewrite the two markup-coupled assertions off antd class selectors and onto role/text queries, and add characterisation tests for the nine route-owned components that had none. Both rewritten tests and all nine new ones are green against the current antd and Tremor components, so the migration that follows can be judged by tests it never touched. --- .../_components/MCPNetworkSettings.test.tsx | 92 ++++++++ .../_components/OpenAPIQuickPicker.test.tsx | 84 ++++++++ .../TruePassthroughWarning.test.tsx | 23 ++ .../_components/mcp_discovery.test.tsx | 118 +++++++++++ .../mcp_server_cost_config.test.tsx | 87 ++++++++ .../mcp_server_cost_display.test.tsx | 48 +++++ .../_components/mcp_server_view.test.tsx | 152 ++++++++++++++ .../_components/mcp_servers.test.tsx | 36 +--- .../src/components/ToolDetail.test.tsx | 197 ++++++++++++++++++ .../ToolPolicies/PolicySelect.test.tsx | 4 +- .../ToolPolicies/ToolPoliciesPanel.test.tsx | 24 ++- .../ToolPoliciesTableColumns.test.tsx | 126 +++++++++++ 12 files changed, 952 insertions(+), 39 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.test.tsx create mode 100644 ui/litellm-dashboard/src/components/ToolDetail.test.tsx create mode 100644 ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTableColumns.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx new file mode 100644 index 00000000000..358968df039 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx @@ -0,0 +1,92 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import MCPNetworkSettings from "./MCPNetworkSettings"; +import { + getGeneralSettingsCall, + updateConfigFieldSetting, + deleteConfigFieldSetting, + fetchMCPClientIp, +} from "@/components/networking"; + +vi.mock("@/components/networking", () => ({ + getGeneralSettingsCall: vi.fn(), + updateConfigFieldSetting: vi.fn(), + deleteConfigFieldSetting: vi.fn(), + fetchMCPClientIp: vi.fn(), +})); + +const renderSettings = () => render(); + +describe("MCPNetworkSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getGeneralSettingsCall).mockResolvedValue([]); + vi.mocked(fetchMCPClientIp).mockResolvedValue(null); + vi.mocked(updateConfigFieldSetting).mockResolvedValue(undefined); + vi.mocked(deleteConfigFieldSetting).mockResolvedValue(undefined); + }); + + it("renders the stored private ranges once settings load", async () => { + vi.mocked(getGeneralSettingsCall).mockResolvedValue([ + { field_name: "mcp_internal_ip_ranges", field_value: ["10.0.0.0/8", "192.168.0.0/16"] }, + ]); + + renderSettings(); + + expect(await screen.findByText("10.0.0.0/8")).toBeInTheDocument(); + expect(screen.getByText("192.168.0.0/16")).toBeInTheDocument(); + }); + + it("ignores unrelated config fields", async () => { + vi.mocked(getGeneralSettingsCall).mockResolvedValue([ + { field_name: "some_other_setting", field_value: ["should-not-show"] }, + ]); + + renderSettings(); + + await screen.findByText("Private IP Ranges"); + expect(screen.queryByText("should-not-show")).not.toBeInTheDocument(); + }); + + it("suggests the caller's /24 range from the detected client IP", async () => { + vi.mocked(fetchMCPClientIp).mockResolvedValue("203.0.113.45"); + + renderSettings(); + + expect(await screen.findByText("203.0.113.45")).toBeInTheDocument(); + expect(screen.getByText("203.0.113.0/24")).toBeInTheDocument(); + }); + + it("adds the suggested range to the list when clicked, and stops suggesting it", async () => { + vi.mocked(fetchMCPClientIp).mockResolvedValue("203.0.113.45"); + + renderSettings(); + await userEvent.click(await screen.findByText("203.0.113.0/24")); + + await waitFor(() => expect(screen.queryByText("Suggested range:")).not.toBeInTheDocument()); + expect(screen.getByText("203.0.113.0/24")).toBeInTheDocument(); + }); + + it("saves the configured ranges", async () => { + vi.mocked(getGeneralSettingsCall).mockResolvedValue([ + { field_name: "mcp_internal_ip_ranges", field_value: ["10.0.0.0/8"] }, + ]); + + renderSettings(); + await userEvent.click(await screen.findByRole("button", { name: /Save/ })); + + await waitFor(() => + expect(updateConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_internal_ip_ranges", ["10.0.0.0/8"]), + ); + expect(deleteConfigFieldSetting).not.toHaveBeenCalled(); + }); + + it("clears the setting instead of saving an empty list", async () => { + renderSettings(); + await userEvent.click(await screen.findByRole("button", { name: /Save/ })); + + await waitFor(() => expect(deleteConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_internal_ip_ranges")); + expect(updateConfigFieldSetting).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.test.tsx new file mode 100644 index 00000000000..f6091f06376 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.test.tsx @@ -0,0 +1,84 @@ +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import OpenAPIQuickPicker, { type OpenAPIRegistryEntry } from "./OpenAPIQuickPicker"; +import { fetchOpenAPIRegistry } from "@/components/networking"; + +vi.mock("@/components/networking", () => ({ + fetchOpenAPIRegistry: vi.fn(), +})); + +const stripe: OpenAPIRegistryEntry = { + name: "stripe", + title: "Stripe", + description: "Payments API", + icon_url: "https://cdn.example.com/stripe.svg", + spec_url: "https://example.com/stripe.json", +}; + +const github: OpenAPIRegistryEntry = { + name: "github", + title: "GitHub", + description: "Code hosting API", + icon_url: "https://cdn.example.com/github.svg", + spec_url: "https://example.com/github.json", +}; + +describe("OpenAPIQuickPicker", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders one selectable entry per registry API", async () => { + vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe, github] }); + + render(); + + expect(await screen.findByRole("button", { name: /Stripe/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /GitHub/ })).toBeInTheDocument(); + expect(screen.getByText("Popular APIs")).toBeInTheDocument(); + }); + + it("passes the whole registry entry to onSelect when one is clicked", async () => { + vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe, github] }); + const onSelect = vi.fn(); + + render(); + await userEvent.click(await screen.findByRole("button", { name: /Stripe/ })); + + expect(onSelect).toHaveBeenCalledWith(stripe); + }); + + it("renders nothing when the registry is empty", async () => { + vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [] }); + + const { container } = render(); + + await waitFor(() => expect(container).toBeEmptyDOMElement()); + }); + + it("renders nothing when the registry fetch fails", async () => { + vi.mocked(fetchOpenAPIRegistry).mockRejectedValue(new Error("boom")); + + const { container } = render(); + + await waitFor(() => expect(container).toBeEmptyDOMElement()); + }); + + it("does not fetch without an access token", () => { + render(); + + expect(fetchOpenAPIRegistry).not.toHaveBeenCalled(); + }); + + it("falls back to a letter avatar when the icon fails to load", async () => { + vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe] }); + + render(); + + fireEvent.error(await screen.findByAltText("Stripe")); + + await waitFor(() => expect(screen.queryByAltText("Stripe")).not.toBeInTheDocument()); + expect(screen.getByText("S")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.test.tsx new file mode 100644 index 00000000000..18a32a384c5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import TruePassthroughWarning from "./TruePassthroughWarning"; +import { AUTH_TYPE } from "@/components/mcp_tools/types"; + +describe("TruePassthroughWarning", () => { + it("warns when auth type is true_passthrough", () => { + render(); + + expect(screen.getByText("True Passthrough disables LiteLLM authentication for this server")).toBeInTheDocument(); + expect(screen.getByText(/Anyone who can reach the gateway can call this server/)).toBeInTheDocument(); + }); + + it("renders nothing for any other auth type", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing when no auth type is set", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.test.tsx new file mode 100644 index 00000000000..4e2456ab7a4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.test.tsx @@ -0,0 +1,118 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import MCPDiscovery from "./mcp_discovery"; +import { fetchDiscoverableMCPServers } from "@/components/networking"; +import type { DiscoverableMCPServer } from "@/components/mcp_tools/types"; + +vi.mock("@/components/networking", () => ({ + fetchDiscoverableMCPServers: vi.fn(), +})); + +const githubServer = { + name: "github", + title: "GitHub", + description: "Code hosting", + category: "Developer Tools", + icon_url: "", +} as DiscoverableMCPServer; + +const slackServer = { + name: "slack", + title: "Slack", + description: "Team chat", + category: "Communication", + icon_url: "", +} as DiscoverableMCPServer; + +const defaultProps = { + isVisible: true, + onClose: vi.fn(), + onSelectServer: vi.fn(), + onCustomServer: vi.fn(), + accessToken: "tok", +}; + +describe("MCPDiscovery", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchDiscoverableMCPServers).mockResolvedValue({ + servers: [githubServer, slackServer], + categories: ["Developer Tools", "Communication"], + }); + }); + + // Each category name renders twice: once as a filter pill (a button) and once + // as the heading of its group. Only the heading is not a button. + const groupHeading = (category: string) => screen.getAllByText(category).filter((el) => el.tagName !== "BUTTON"); + + it("lists every discoverable server grouped under its category", async () => { + render(); + + expect(await screen.findByText("GitHub")).toBeInTheDocument(); + expect(screen.getByText("Slack")).toBeInTheDocument(); + expect(groupHeading("Developer Tools")).toHaveLength(1); + expect(groupHeading("Communication")).toHaveLength(1); + expect(screen.getByText("Add MCP Server")).toBeInTheDocument(); + }); + + it("filters the list down to the chosen category", async () => { + render(); + await screen.findByText("GitHub"); + + await userEvent.click(screen.getByRole("button", { name: "Communication" })); + + await waitFor(() => expect(screen.queryByText("GitHub")).not.toBeInTheDocument()); + expect(screen.getByText("Slack")).toBeInTheDocument(); + }); + + it("filters the list by the search term", async () => { + render(); + await screen.findByText("GitHub"); + + await userEvent.type(screen.getByPlaceholderText("Search servers..."), "chat"); + + await waitFor(() => expect(screen.queryByText("GitHub")).not.toBeInTheDocument()); + expect(screen.getByText("Slack")).toBeInTheDocument(); + }); + + it("hands the picked server back to the caller", async () => { + const onSelectServer = vi.fn(); + render(); + + await userEvent.click(await screen.findByText("GitHub")); + + expect(onSelectServer).toHaveBeenCalledWith(githubServer); + }); + + it("offers a custom-server escape hatch", async () => { + const onCustomServer = vi.fn(); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "+ Custom Server" })); + + expect(onCustomServer).toHaveBeenCalled(); + }); + + it("surfaces a fetch failure", async () => { + vi.mocked(fetchDiscoverableMCPServers).mockRejectedValue(new Error("registry down")); + + render(); + + expect(await screen.findByText(/Failed to load servers: registry down/)).toBeInTheDocument(); + }); + + it("offers the custom-server link when nothing matches", async () => { + vi.mocked(fetchDiscoverableMCPServers).mockResolvedValue({ servers: [], categories: [] }); + + render(); + + expect(await screen.findByText(/No servers found/)).toBeInTheDocument(); + }); + + it("does not fetch while hidden", () => { + render(); + + expect(fetchDiscoverableMCPServers).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.test.tsx new file mode 100644 index 00000000000..a4547e4923f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.test.tsx @@ -0,0 +1,87 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import MCPServerCostConfig from "./mcp_server_cost_config"; + +const tools = [ + { name: "search", description: "Search the index" }, + { name: "fetch", description: "Fetch a document" }, +]; + +describe("MCPServerCostConfig", () => { + it("renders the default cost field with the current value", () => { + render(); + + expect(screen.getByText("Cost Configuration")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("0.0000")).toHaveValue("0.0200"); + }); + + it("reports the edited default cost as a number", async () => { + const onChange = vi.fn(); + render(); + + await userEvent.type(screen.getByPlaceholderText("0.0000"), "0.5"); + + expect(onChange).toHaveBeenLastCalledWith({ default_cost_per_query: 0.5 }); + }); + + it("disables the default cost field when disabled", () => { + render(); + + expect(screen.getByPlaceholderText("0.0000")).toBeDisabled(); + }); + + it("hides the per-tool section when the server exposes no tools", () => { + render(); + + expect(screen.queryByText("Available Tools")).not.toBeInTheDocument(); + }); + + it("offers a per-tool override for every tool once tools are loaded", async () => { + render(); + + await userEvent.click(screen.getByText("Available Tools")); + + expect(screen.getByText("search")).toBeInTheDocument(); + expect(screen.getByText("Search the index")).toBeInTheDocument(); + expect(screen.getByText("fetch")).toBeInTheDocument(); + expect(screen.getAllByPlaceholderText("Use default")).toHaveLength(2); + }); + + it("merges a per-tool override into the existing cost map", async () => { + const onChange = vi.fn(); + render( + , + ); + + await userEvent.click(screen.getByText("Available Tools")); + await userEvent.type(screen.getAllByPlaceholderText("Use default")[0], "3"); + + expect(onChange).toHaveBeenLastCalledWith({ + default_cost_per_query: 0.01, + tool_name_to_cost_per_query: { fetch: 0.2, search: 3 }, + }); + }); + + it("summarises the configured costs", () => { + render( + , + ); + + expect(screen.getByText("• Default cost: $0.0100 per query")).toBeInTheDocument(); + expect(screen.getByText("• search: $0.2500 per query")).toBeInTheDocument(); + }); + + it("shows no summary when nothing is configured", () => { + render(); + + expect(screen.queryByText("Cost Summary:")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.test.tsx new file mode 100644 index 00000000000..466341405c8 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import MCPServerCostDisplay from "./mcp_server_cost_display"; + +describe("MCPServerCostDisplay", () => { + it("explains that calls are free when no cost config exists", () => { + render(); + + expect( + screen.getByText("No cost configuration set for this server. Tool calls will be charged at $0.00 per tool call."), + ).toBeInTheDocument(); + }); + + it("treats a config with only a null default cost as unconfigured", () => { + render(); + + expect(screen.getByText(/No cost configuration set for this server/)).toBeInTheDocument(); + }); + + it("shows a zero default cost rather than falling back to the empty state", () => { + render(); + + expect(screen.getByText("Default Cost per Query")).toBeInTheDocument(); + expect(screen.getByText("$0.0000")).toBeInTheDocument(); + }); + + it("renders the default cost to four decimal places and summarises it", () => { + render(); + + expect(screen.getByText("$0.0125")).toBeInTheDocument(); + expect(screen.getByText("• Default cost: $0.0125 per query")).toBeInTheDocument(); + }); + + it("lists each tool-specific cost and counts them in the summary", () => { + render( + , + ); + + expect(screen.getByText("search")).toBeInTheDocument(); + expect(screen.getByText("$0.5000 per query")).toBeInTheDocument(); + expect(screen.getByText("fetch")).toBeInTheDocument(); + expect(screen.getByText("$0.2500 per query")).toBeInTheDocument(); + expect(screen.queryByText("skipped")).not.toBeInTheDocument(); + expect(screen.getByText("• 3 tool(s) with custom pricing")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.test.tsx new file mode 100644 index 00000000000..02d168bf7f4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.test.tsx @@ -0,0 +1,152 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { MCPServerView } from "./mcp_server_view"; +import type { MCPServer } from "@/components/mcp_tools/types"; + +vi.mock(".", () => ({ + MCPToolsViewer: () =>
tools viewer
, +})); + +vi.mock("./mcp_server_edit", () => ({ + default: () =>
edit form
, + EDIT_OAUTH_UI_STATE_KEY: "litellm-mcp-oauth-edit-state", +})); + +const baseServer = { + server_id: "srv-1", + server_name: "demo server", + alias: "demo_alias", + description: "A demo MCP server", + transport: "http", + url: "https://example.com/mcp", + auth_type: "api_key", +} as MCPServer; + +const renderView = (overrides: Partial = {}, props: Record = {}) => + render( + , + ); + +describe("MCPServerView", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // Name, alias and description each label the header and a Settings row, so + // only the server id is unique to the header. + it("shows the server identity in the header", () => { + renderView(); + + expect(screen.getByText("srv-1")).toBeInTheDocument(); + expect(screen.getAllByText("demo server").length).toBeGreaterThan(0); + expect(screen.getAllByText("A demo MCP server").length).toBeGreaterThan(0); + expect(screen.getAllByText("demo_alias").length).toBeGreaterThan(0); + }); + + it("falls back to a placeholder name when the server has neither name nor alias", () => { + renderView({ server_name: undefined, alias: undefined }); + + expect(screen.getByText("Unnamed Server")).toBeInTheDocument(); + }); + + // "Transport" and "Authentication" label both an Overview card and a Settings + // row, so only Overview-exclusive labels identify the Overview panel. + it("summarises the connection on the Overview tab", () => { + renderView(); + + expect(screen.getByText("Host URL")).toBeInTheDocument(); + expect(screen.getByText("Cost Configuration")).toBeInTheDocument(); + expect(screen.getAllByText("HTTP").length).toBeGreaterThan(0); + expect(screen.getAllByText("https://example.com/mcp").length).toBeGreaterThan(0); + }); + + it("offers a Settings tab to proxy admins only", () => { + renderView(); + expect(screen.getByRole("tab", { name: "Settings" })).toBeInTheDocument(); + }); + + it("hides the Settings tab from non-admins", () => { + renderView({}, { isProxyAdmin: false }); + expect(screen.queryByRole("tab", { name: "Settings" })).not.toBeInTheDocument(); + }); + + it("opens the tools viewer on the MCP Tools tab", async () => { + renderView(); + + await userEvent.click(screen.getByRole("tab", { name: "MCP Tools" })); + + expect(await screen.findByText("tools viewer")).toBeInTheDocument(); + }); + + it("shows the read-only settings summary before editing", async () => { + renderView({ allow_all_keys: true, available_on_public_internet: false }); + + await userEvent.click(screen.getByRole("tab", { name: "Settings" })); + + expect(await screen.findByText("MCP Server Settings")).toBeInTheDocument(); + expect(screen.getByText("Allow All Keys")).toBeInTheDocument(); + expect(screen.getByText("Enabled")).toBeInTheDocument(); + expect(screen.getByText("Internal only")).toBeInTheDocument(); + expect(screen.queryByText("edit form")).not.toBeInTheDocument(); + }); + + it("swaps in the edit form when Edit Settings is pressed", async () => { + renderView(); + + await userEvent.click(screen.getByRole("tab", { name: "Settings" })); + await userEvent.click(await screen.findByRole("button", { name: "Edit Settings" })); + + expect(await screen.findByText("edit form")).toBeInTheDocument(); + }); + + it("opens straight into the edit form when isEditing is set", async () => { + renderView({}, { isEditing: true }); + + await userEvent.click(screen.getByRole("tab", { name: "Settings" })); + + expect(await screen.findByText("edit form")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Edit Settings" })).not.toBeInTheDocument(); + }); + + it("opens on the tab named by initialTabIndex", async () => { + renderView({}, { initialTabIndex: 1 }); + + expect(await screen.findByText("tools viewer")).toBeInTheDocument(); + }); + + it("returns to the server list when Back is pressed", async () => { + const onBack = vi.fn(); + renderView({}, { onBack }); + + await userEvent.click(screen.getByRole("button", { name: /Back to All Servers/ })); + + expect(onBack).toHaveBeenCalled(); + }); + + it("lists the allowed tools, or says all tools are enabled", async () => { + renderView({ allowed_tools: ["search", "fetch"] }); + await userEvent.click(screen.getByRole("tab", { name: "Settings" })); + + expect(await screen.findByText("search")).toBeInTheDocument(); + expect(screen.getByText("fetch")).toBeInTheDocument(); + expect(screen.queryByText("All tools enabled")).not.toBeInTheDocument(); + }); + + it("says all tools are enabled when no allowlist is stored", async () => { + renderView({ allowed_tools: [] }); + await userEvent.click(screen.getByRole("tab", { name: "Settings" })); + + expect(await screen.findByText("All tools enabled")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx index d61bc23c757..f9f3d20ca15 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { render, waitFor, screen, fireEvent, act } from "@testing-library/react"; +import { render, waitFor, screen, act, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import MCPServers from "./mcp_servers"; @@ -307,36 +308,15 @@ describe("MCPServers", () => { expect(screen.getByText("Team B Server")).toBeInTheDocument(); expect(screen.getByText("Team A Server 2")).toBeInTheDocument(); - // Find the team select dropdown by looking for the "Team" label + // Find the team select by its "Team" label, then the combobox it labels const teamLabel = screen.getByText("Team"); - const teamSelectContainer = teamLabel.closest("div")?.querySelector(".ant-select"); - expect(teamSelectContainer).toBeTruthy(); + const teamSelect = within(teamLabel.parentElement!).getByRole("combobox"); - // Open the dropdown by clicking on the selector - const selectSelector = teamSelectContainer?.querySelector(".ant-select-selector"); - expect(selectSelector).toBeTruthy(); + await userEvent.click(teamSelect); - act(() => { - fireEvent.mouseDown(selectSelector!); - }); - - // Wait for dropdown to open - await waitFor( - () => { - const dropdownOptions = document.querySelectorAll(".ant-select-item-option"); - expect(dropdownOptions.length).toBeGreaterThan(0); - }, - { timeout: 5000 }, - ); - - // Find and click on "Team A" option - const dropdownOptions = document.querySelectorAll(".ant-select-item-option"); - const teamAOption = Array.from(dropdownOptions).find((option) => option.textContent?.includes("Team A")); - expect(teamAOption).toBeTruthy(); - - act(() => { - fireEvent.click(teamAOption!); - }); + // Pick the "Team A" option once the listbox opens + const teamAOption = await screen.findByText("Team A"); + await userEvent.click(teamAOption); // Wait for filtering to complete await waitFor(() => { diff --git a/ui/litellm-dashboard/src/components/ToolDetail.test.tsx b/ui/litellm-dashboard/src/components/ToolDetail.test.tsx new file mode 100644 index 00000000000..db5047145c4 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolDetail.test.tsx @@ -0,0 +1,197 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ToolDetail } from "./ToolDetail"; +import { + deleteToolPolicyOverride, + fetchToolDetail, + fetchToolPolicyOptions, + getToolUsageLogs, + keyListCall, + teamListCall, + updateToolPolicy, + type ToolDetailResponse, + type ToolPolicyOption, + type ToolPolicyOverrideRow, + type ToolRow, + type ToolUsageLogsResponse, +} from "@/components/networking"; + +vi.mock("@/components/networking", () => ({ + deleteToolPolicyOverride: vi.fn(), + fetchToolDetail: vi.fn(), + fetchToolPolicyOptions: vi.fn(), + getToolUsageLogs: vi.fn(), + keyListCall: vi.fn(), + teamListCall: vi.fn(), + updateToolPolicy: vi.fn(), +})); + +vi.mock("@/components/common_components/team_dropdown", () => ({ + default: ({ onChange }: { onChange: (id: string) => void }) => ( + + ), +})); + +vi.mock("@/components/GuardrailsMonitor/LogViewer", () => ({ + LogViewer: ({ totalLogs }: { totalLogs: number }) =>
log viewer ({totalLogs})
, +})); + +const detail = { + tool: { + tool_name: "search_docs", + input_policy: "untrusted", + output_policy: "trusted", + origin: "mcp", + call_count: 42, + user_agent: "litellm-python/1.0", + created_at: "2026-03-04T10:00:00Z", + }, + overrides: [], +} as unknown as ToolDetailResponse; + +const renderDetail = (onBack = vi.fn()) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + return render( + + + , + ); +}; + +describe("ToolDetail", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchToolDetail).mockResolvedValue(detail); + vi.mocked(fetchToolPolicyOptions).mockResolvedValue({ input_policies: [], output_policies: [] }); + vi.mocked(teamListCall).mockResolvedValue({ data: [] }); + vi.mocked(keyListCall).mockResolvedValue({ keys: [] }); + vi.mocked(getToolUsageLogs).mockResolvedValue({ logs: [], total: 0 } as unknown as ToolUsageLogsResponse); + vi.mocked(updateToolPolicy).mockResolvedValue(undefined as unknown as ToolRow); + vi.mocked(deleteToolPolicyOverride).mockResolvedValue( + undefined as unknown as { deleted: boolean; tool_name: string }, + ); + }); + + it("shows the tool identity once loaded", async () => { + renderDetail(); + + expect(await screen.findByText("search_docs")).toBeInTheDocument(); + expect(screen.getByText("mcp")).toBeInTheDocument(); + expect(screen.getByText("42 calls")).toBeInTheDocument(); + expect(screen.getByText("litellm-python/1.0")).toBeInTheDocument(); + }); + + it("renders both policy panels with the tool's current policies", async () => { + renderDetail(); + + expect(await screen.findByText("Input Policy")).toBeInTheDocument(); + expect(screen.getByText("Output Policy")).toBeInTheDocument(); + expect(screen.getByText("untrusted")).toBeInTheDocument(); + expect(screen.getByText("trusted")).toBeInTheDocument(); + }); + + it("uses the policy option descriptions when the backend supplies them", async () => { + vi.mocked(fetchToolPolicyOptions).mockResolvedValue({ + input_policies: [{ value: "untrusted", description: "Treat inputs as hostile" } as ToolPolicyOption], + output_policies: [{ value: "trusted", description: "Outputs may be chained" } as ToolPolicyOption], + }); + + renderDetail(); + + expect(await screen.findByText("Treat inputs as hostile")).toBeInTheDocument(); + expect(screen.getByText("Outputs may be chained")).toBeInTheDocument(); + }); + + it("returns to the list when Back is pressed", async () => { + const onBack = vi.fn(); + renderDetail(onBack); + + await userEvent.click(await screen.findByRole("button", { name: /Back to Tool Policies/ })); + + expect(onBack).toHaveBeenCalled(); + }); + + it("reports a failed detail load and still offers a way back", async () => { + vi.mocked(fetchToolDetail).mockRejectedValue(new Error("nope")); + + renderDetail(); + + expect(await screen.findByText("Failed to load tool details.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Back to Tool Policies/ })).toBeInTheDocument(); + }); + + it("hides the overrides panel when the tool has none", async () => { + renderDetail(); + + await screen.findByText("Input Policy"); + expect(screen.queryByText("Blocked for team or key")).not.toBeInTheDocument(); + }); + + it("lists existing overrides and removes the chosen one", async () => { + vi.mocked(fetchToolDetail).mockResolvedValue({ + ...detail, + overrides: [ + { + override_id: "o1", + team_id: "team-alpha", + key_hash: null, + key_alias: null, + } as unknown as ToolPolicyOverrideRow, + ], + }); + + renderDetail(); + + expect(await screen.findByText("Team: team-alpha")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Remove" })); + + await waitFor(() => + expect(deleteToolPolicyOverride).toHaveBeenCalledWith("tok", "search_docs", { + team_id: "team-alpha", + key_hash: undefined, + }), + ); + }); + + it("keeps the block button disabled until a team is chosen, then blocks that team", async () => { + renderDetail(); + + const blockButton = await screen.findByRole("button", { name: /Block for team/ }); + expect(blockButton).toBeDisabled(); + + await userEvent.click(screen.getByRole("button", { name: "pick team" })); + await waitFor(() => expect(screen.getByRole("button", { name: /Block for team/ })).toBeEnabled()); + await userEvent.click(screen.getByRole("button", { name: /Block for team/ })); + + await waitFor(() => + expect(updateToolPolicy).toHaveBeenCalledWith( + "tok", + "search_docs", + { input_policy: "blocked" }, + { team_id: "team-1", key_hash: undefined, key_alias: undefined }, + ), + ); + }); + + it("switches the block scope to a key", async () => { + renderDetail(); + + await screen.findByText("Block for team or key"); + await userEvent.click(screen.getByRole("radio", { name: "Key" })); + + expect(await screen.findByRole("button", { name: /Block for key/ })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "pick team" })).not.toBeInTheDocument(); + }); + + it("passes the usage-log total through to the log viewer", async () => { + vi.mocked(getToolUsageLogs).mockResolvedValue({ logs: [], total: 7 } as unknown as ToolUsageLogsResponse); + + renderDetail(); + + expect(await screen.findByText("log viewer (7)")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/PolicySelect.test.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/PolicySelect.test.tsx index e65f79edd99..f2890e77347 100644 --- a/ui/litellm-dashboard/src/components/ToolPolicies/PolicySelect.test.tsx +++ b/ui/litellm-dashboard/src/components/ToolPolicies/PolicySelect.test.tsx @@ -30,12 +30,12 @@ describe("PolicySelect", () => { it("should be disabled when saving is true", () => { renderWithProviders(); expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false"); - expect(screen.getByRole("combobox").closest(".ant-select")).toHaveClass("ant-select-disabled"); + expect(screen.getByRole("combobox")).toBeDisabled(); }); it("should not be disabled when saving is false", () => { renderWithProviders(); - expect(screen.getByRole("combobox").closest(".ant-select")).not.toHaveClass("ant-select-disabled"); + expect(screen.getByRole("combobox")).toBeEnabled(); }); }); diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx index 721c215cfb1..0a0b1c09fbb 100644 --- a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx @@ -67,21 +67,27 @@ const row = (toolId: string): HTMLElement => { const policySelect = (toolId: string, kind: "input" | "output"): HTMLElement => within(row(toolId)).getAllByRole("combobox")[kind === "input" ? 0 : 1]; -/** Exact selected-value text. Never assert with toHaveTextContent here: it substring-matches, so "untrusted" satisfies "trusted". */ -const policyValue = (toolId: string, kind: "input" | "output"): string => - policySelect(toolId, kind).closest(".ant-select")?.querySelector(".ant-select-selection-item")?.textContent ?? ""; +/** + * Exact selected-value text, read off the policy cell and stripped of anything + * that is not a letter (the control draws a status dot and a chevron around the + * label). Never assert with toHaveTextContent here: it substring-matches, so + * "untrusted" satisfies "trusted". + */ +const policyValue = (toolId: string, kind: "input" | "output"): string => { + const cell = policySelect(toolId, kind).closest("td"); + return (cell?.textContent ?? "").replace(/[^a-z]/gi, ""); +}; const isSaving = (toolId: string, kind: "input" | "output"): boolean => - policySelect(toolId, kind).closest(".ant-select")?.classList.contains("ant-select-disabled") ?? false; + policySelect(toolId, kind).hasAttribute("disabled"); const chooseOption = async (user: ReturnType, trigger: HTMLElement, label: string) => { await user.click(trigger); + // The label also renders in the trigger once selected, so take the last match: + // the popup is portalled after the table in document order. const option = await waitFor(() => { - const match = Array.from(document.querySelectorAll(".ant-select-item-option")).find( - (element) => element.textContent === label, - ); - if (match === undefined) throw new Error(`option ${label} not open`); - return match as HTMLElement; + const matches = screen.getAllByText(label); + return matches[matches.length - 1]; }); await user.click(option); }; diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTableColumns.test.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTableColumns.test.tsx new file mode 100644 index 00000000000..bb4cd8a463a --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTableColumns.test.tsx @@ -0,0 +1,126 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { flexRender, getCoreRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table"; +import { getToolPoliciesTableColumns } from "./ToolPoliciesTableColumns"; +import type { ToolRow } from "@/components/networking"; + +const row: ToolRow = { + tool_name: "search_docs", + input_policy: "untrusted", + output_policy: "trusted", + call_count: 1234, + team_id: "team-alpha", + key_hash: "abc123def456", + key_alias: "prod-key", + user_agent: "litellm-python/1.0", + created_at: "2026-03-04T10:00:00Z", +} as ToolRow; + +const defaultDeps = { + onSelectTool: vi.fn(), + savingInput: new Set(), + savingOutput: new Set(), + onInputPolicyChange: vi.fn(), + onOutputPolicyChange: vi.fn(), +}; + +// Renders the column definitions through a real TanStack table so each `cell` +// renderer runs exactly as the DataTable runs it. +function TableHarness({ columns, data }: { columns: ColumnDef[]; data: ToolRow[] }) { + const table = useReactTable({ columns, data, getCoreRowModel: getCoreRowModel() }); + return ( + + + {table.getRowModel().rows.map((r) => ( + + {r.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+ ); +} + +const renderTable = (deps = {}, data: ToolRow[] = [row]) => + render(); + +describe("getToolPoliciesTableColumns", () => { + it("defines the expected columns in order", () => { + const columns = getToolPoliciesTableColumns(defaultDeps); + + expect(columns.map((c) => c.id)).toEqual([ + "created_at", + "tool_name", + "input_policy", + "output_policy", + "call_count", + "team_id", + "key_hash", + "key_alias", + "user_agent", + ]); + }); + + it("renders the row's identifying fields", () => { + renderTable(); + + expect(screen.getByText("search_docs")).toBeInTheDocument(); + expect(screen.getByText("team-alpha")).toBeInTheDocument(); + expect(screen.getByText("prod-key")).toBeInTheDocument(); + expect(screen.getByText("litellm-python/1.0")).toBeInTheDocument(); + }); + + it("formats the call count with thousands separators", () => { + renderTable(); + + expect(screen.getByText("1,234")).toBeInTheDocument(); + }); + + it("renders a zero call count rather than a blank cell", () => { + renderTable({}, [{ ...row, call_count: undefined } as ToolRow]); + + expect(screen.getByText("0")).toBeInTheDocument(); + }); + + it("falls back to a dash for a missing key alias and user agent", () => { + renderTable({}, [{ ...row, key_alias: undefined, user_agent: undefined } as ToolRow]); + + expect(screen.getAllByText("-").length).toBeGreaterThanOrEqual(2); + }); + + it("notifies the caller when the tool name is clicked", async () => { + const onSelectTool = vi.fn(); + renderTable({ onSelectTool }); + + await userEvent.click(screen.getByText("search_docs")); + + expect(onSelectTool).toHaveBeenCalledWith("search_docs"); + }); + + it("renders a policy control for each direction, showing the row's current policies", () => { + renderTable(); + + expect(screen.getByText("untrusted")).toBeInTheDocument(); + expect(screen.getByText("trusted")).toBeInTheDocument(); + expect(screen.getAllByRole("combobox")).toHaveLength(2); + }); + + it("disables only the input policy control while that direction is saving", () => { + renderTable({ savingInput: new Set(["search_docs"]) }); + + const [input, output] = screen.getAllByRole("combobox"); + expect(input).toBeDisabled(); + expect(output).toBeEnabled(); + }); + + it("disables only the output policy control while that direction is saving", () => { + renderTable({ savingOutput: new Set(["search_docs"]) }); + + const [input, output] = screen.getAllByRole("combobox"); + expect(input).toBeEnabled(); + expect(output).toBeDisabled(); + }); +});