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(); + }); +});