diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx index 29655e5910a..88e52e0cdd1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx @@ -35,10 +35,19 @@ vi.mock("./guardrail_info", () => ({ default: () =>
Mock Guardrail Info View
, })); -vi.mock("./GuardrailTestPlayground", () => ({ - __esModule: true, - default: () =>
Mock Guardrail Test Playground
, -})); +vi.mock("./GuardrailTestPlayground", async () => { + const { useState } = await import("react"); + const MockGuardrailTestPlayground = () => { + const [draft, setDraft] = useState(""); + return ( +
+
Mock Guardrail Test Playground
+ setDraft(e.target.value)} /> +
+ ); + }; + return { __esModule: true, default: MockGuardrailTestPlayground }; +}); vi.mock("./TeamGuardrailsTab", () => ({ TeamGuardrailsTab: () =>
Mock Team Guardrails Tab
, @@ -129,6 +138,21 @@ describe("GuardrailsPanel", () => { expect(mockGetGuardrailsList).toHaveBeenCalledTimes(2); }); + it("should keep test playground state when switching tabs away and back", async () => { + render(); + + fireEvent.click(screen.getByText("Test Playground")); + + const draft = await screen.findByLabelText("playground draft"); + fireEvent.change(draft, { target: { value: "keep me" } }); + expect(draft).toHaveValue("keep me"); + + fireEvent.click(screen.getByText("Guardrails")); + fireEvent.click(screen.getByText("Test Playground")); + + expect(await screen.findByLabelText("playground draft")).toHaveValue("keep me"); + }); + it("should not delete anything when the modal is cancelled", async () => { render(); fireEvent.click(screen.getByText("Guardrails")); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterConfiguration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterConfiguration.test.tsx new file mode 100644 index 00000000000..37a3d40128e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterConfiguration.test.tsx @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderWithProviders, screen } from "@/../tests/test-utils"; +import userEvent from "@testing-library/user-event"; +import ContentFilterConfiguration from "./ContentFilterConfiguration"; + +vi.mock("@/components/networking", () => ({ + validateBlockedWordsFile: vi.fn(), + getCategoryYaml: vi.fn(), +})); + +vi.mock("@/components/molecules/notifications_manager", () => ({ + default: { success: vi.fn(), error: vi.fn(), fromBackend: vi.fn() }, +})); + +const PREBUILT = [ + { name: "us_ssn", display_name: "US Social Security Number", category: "PII Patterns", description: "d" }, +]; + +describe("ContentFilterConfiguration", () => { + const handlers = { + onPatternAdd: vi.fn(), + onPatternRemove: vi.fn(), + onPatternActionChange: vi.fn(), + onBlockedWordAdd: vi.fn(), + onBlockedWordRemove: vi.fn(), + onBlockedWordUpdate: vi.fn(), + }; + + const renderConfig = (overrides = {}) => + renderWithProviders( + , + ); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the pattern and keyword sections", () => { + renderConfig(); + + expect(screen.getByText("Pattern Detection")).toBeInTheDocument(); + expect( + screen.getByText("Detect sensitive information using regex patterns (SSN, credit cards, API keys, etc.)"), + ).toBeInTheDocument(); + expect(screen.getByText("Blocked Keywords")).toBeInTheDocument(); + expect(screen.getByText("Block or mask specific sensitive terms and phrases")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /add prebuilt pattern/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /add custom regex/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /add keyword/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /upload yaml file/i })).toBeInTheDocument(); + }); + + it("should show the empty states for patterns and keywords", () => { + renderConfig(); + + expect(screen.getByText("No patterns added.")).toBeInTheDocument(); + expect(screen.getByText("No keywords added.")).toBeInTheDocument(); + }); + + it("should open the prebuilt pattern modal", async () => { + const user = userEvent.setup(); + renderConfig(); + + expect(screen.queryByText("Pattern type")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /add prebuilt pattern/i })); + + expect(await screen.findByText("Pattern type")).toBeInTheDocument(); + }); + + it("should open the custom regex modal", async () => { + const user = userEvent.setup(); + renderConfig(); + + expect(screen.queryByText("Add custom regex pattern")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /add custom regex/i })); + + expect(await screen.findByText("Add custom regex pattern")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("e.g., ID-[0-9]{6}")).toBeInTheDocument(); + }); + + it("should open the keyword modal", async () => { + const user = userEvent.setup(); + renderConfig(); + + expect(screen.queryByText("Add blocked keyword")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /add keyword/i })); + + expect(await screen.findByText("Add blocked keyword")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Enter sensitive keyword or phrase")).toBeInTheDocument(); + }); + + it("should list already selected patterns and keywords", () => { + renderConfig({ + selectedPatterns: [ + { + id: "pattern-1", + type: "prebuilt" as const, + name: "us_ssn", + display_name: "US Social Security Number", + action: "BLOCK" as const, + }, + ], + blockedWords: [{ id: "word-1", keyword: "secret", action: "MASK" as const, description: "Sensitive" }], + }); + + expect(screen.getByText("US Social Security Number")).toBeInTheDocument(); + expect(screen.getByText("secret")).toBeInTheDocument(); + expect(screen.queryByText("No patterns added.")).not.toBeInTheDocument(); + expect(screen.queryByText("No keywords added.")).not.toBeInTheDocument(); + }); + + it("should show only the keyword section when the keywords step is requested", () => { + renderConfig({ showStep: "keywords" }); + + expect(screen.getByText("Blocked Keywords")).toBeInTheDocument(); + expect(screen.queryByText("Pattern Detection")).not.toBeInTheDocument(); + }); + + it("should show only the pattern section when the patterns step is requested", () => { + renderConfig({ showStep: "patterns" }); + + expect(screen.getByText("Pattern Detection")).toBeInTheDocument(); + expect(screen.queryByText("Blocked Keywords")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterDisplay.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterDisplay.test.tsx new file mode 100644 index 00000000000..8fb48791f76 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterDisplay.test.tsx @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import { renderWithProviders, screen } from "@/../tests/test-utils"; +import ContentFilterDisplay from "./ContentFilterDisplay"; + +const PATTERN = { + id: "pattern-1", + type: "prebuilt" as const, + name: "email", + display_name: "Email address", + action: "BLOCK" as const, +}; + +const KEYWORD = { + id: "word-1", + keyword: "secret", + action: "MASK" as const, + description: "Sensitive term", +}; + +const CATEGORY = { + id: "category-1", + category: "self_harm", + display_name: "Self Harm", + action: "BLOCK" as const, + severity_threshold: "high" as const, +}; + +describe("ContentFilterDisplay", () => { + it("should render nothing when there is no content filter data", () => { + const { container } = renderWithProviders(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("should render the categories section with a configured count", () => { + renderWithProviders(); + + expect(screen.getByText("Content Categories")).toBeInTheDocument(); + expect(screen.getByText("1 categories configured")).toBeInTheDocument(); + expect(screen.getByText("Self Harm")).toBeInTheDocument(); + expect(screen.queryByText("Pattern Detection")).not.toBeInTheDocument(); + expect(screen.queryByText("Blocked Keywords")).not.toBeInTheDocument(); + }); + + it("should render the patterns section with a configured count", () => { + renderWithProviders(); + + expect(screen.getByText("Pattern Detection")).toBeInTheDocument(); + expect(screen.getByText("1 patterns configured")).toBeInTheDocument(); + expect(screen.getByText("Email address")).toBeInTheDocument(); + expect(screen.queryByText("Content Categories")).not.toBeInTheDocument(); + }); + + it("should render the keywords section with a configured count", () => { + renderWithProviders(); + + expect(screen.getByText("Blocked Keywords")).toBeInTheDocument(); + expect(screen.getByText("1 keywords configured")).toBeInTheDocument(); + expect(screen.getByText("secret")).toBeInTheDocument(); + expect(screen.getByText("Sensitive term")).toBeInTheDocument(); + }); + + it("should render every section when all three kinds of data are present", () => { + renderWithProviders(); + + expect(screen.getByText("Content Categories")).toBeInTheDocument(); + expect(screen.getByText("Pattern Detection")).toBeInTheDocument(); + expect(screen.getByText("Blocked Keywords")).toBeInTheDocument(); + }); + + it("should render category severity and action as static text in read-only mode", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("HIGH")).toBeInTheDocument(); + expect(screen.getByText("BLOCK")).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: /delete/i })).toHaveLength(2); + }); + + it("should render category severity and action as editable controls when not read-only", () => { + renderWithProviders( + , + ); + + expect(screen.queryByText("HIGH")).not.toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: /delete/i })).toHaveLength(3); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterManager.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterManager.test.tsx index 66758ac72f1..c3fd2e3eb59 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterManager.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterManager.test.tsx @@ -2,7 +2,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import ContentFilterManager, { formatContentFilterDataForAPI } from "./ContentFilterManager"; -import React from "react"; const CONTENT_FILTER_GUARDRAIL_DATA = { litellm_params: { @@ -85,18 +84,7 @@ vi.mock("./ContentFilterDisplay", () => ({ ), })); -vi.mock("antd", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - Divider: ({ children }: { children: React.ReactNode }) =>
{children}
, - Alert: ({ message, type }: { message: React.ReactNode; type: string }) => ( -
- {message} -
- ), - }; -}); +const UNSAVED_CHANGES_TEXT = /You have unsaved changes to patterns or keywords/; describe("ContentFilterManager", () => { beforeEach(() => { @@ -117,7 +105,7 @@ describe("ContentFilterManager", () => { expect(screen.getByTestId("content-filter-config")).toBeInTheDocument(); }); - expect(screen.getByTestId("divider")).toHaveTextContent("Content Filter Configuration"); + expect(screen.getByText("Content Filter Configuration")).toBeInTheDocument(); }); it("should return null when guardrail is not litellm_content_filter", () => { @@ -275,15 +263,15 @@ describe("ContentFilterManager", () => { expect(screen.getByTestId("content-filter-config")).toBeInTheDocument(); }); - expect(screen.queryByTestId("unsaved-alert")).not.toBeInTheDocument(); + expect(screen.queryByText(UNSAVED_CHANGES_TEXT)).not.toBeInTheDocument(); await user.click(screen.getByRole("button", { name: /add pattern/i })); await waitFor(() => { - expect(screen.getByTestId("unsaved-alert")).toBeInTheDocument(); + expect(screen.getByText(UNSAVED_CHANGES_TEXT)).toBeInTheDocument(); }); - expect(screen.getByTestId("unsaved-alert")).toHaveTextContent(/unsaved changes.*Save Changes/i); + expect(screen.getByText(UNSAVED_CHANGES_TEXT)).toHaveTextContent(/Save Changes/i); }); it("should call onDataChange when patterns or keywords change", async () => { @@ -371,7 +359,7 @@ describe("ContentFilterManager", () => { ); await waitFor(() => { - expect(screen.getByTestId("divider")).toBeInTheDocument(); + expect(screen.getByText("Content Filter Configuration")).toBeInTheDocument(); }); expect(screen.queryByTestId("content-filter-config")).not.toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterTables.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterTables.test.tsx index cc27394f2c3..c56ee8ff528 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterTables.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterTables.test.tsx @@ -130,4 +130,77 @@ describe("content filter tables", () => { expect(onCategoryRemove).toHaveBeenCalledWith("category-1"); }); + + it("should report a pattern action change", async () => { + const onActionChange = vi.fn(); + const user = userEvent.setup(); + + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + const maskOptions = await screen.findAllByText("Mask"); + await user.click(maskOptions[maskOptions.length - 1]); + + expect(onActionChange).toHaveBeenCalledWith("pattern-1", "MASK"); + }); + + it("should report a keyword action change", async () => { + const onActionChange = vi.fn(); + const user = userEvent.setup(); + + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + const maskOptions = await screen.findAllByText("Mask"); + await user.click(maskOptions[maskOptions.length - 1]); + + expect(onActionChange).toHaveBeenCalledWith("keyword-1", "action", "MASK"); + }); + + it("should report category severity and action changes", async () => { + const onSeverityChange = vi.fn(); + const onActionChange = vi.fn(); + const user = userEvent.setup(); + + renderWithProviders( + , + ); + + await user.click(screen.getAllByRole("combobox")[0]); + const lowOptions = await screen.findAllByText("Low"); + await user.click(lowOptions[lowOptions.length - 1]); + + expect(onSeverityChange).toHaveBeenCalledWith("category-1", "low"); + + await user.click(screen.getAllByRole("combobox")[1]); + const maskOptions = await screen.findAllByText("Mask"); + await user.click(maskOptions[maskOptions.length - 1]); + + expect(onActionChange).toHaveBeenCalledWith("category-1", "MASK"); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.test.tsx new file mode 100644 index 00000000000..dda479f1579 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import KeywordModal from "./KeywordModal"; + +describe("KeywordModal", () => { + const handlers = { + onKeywordChange: vi.fn(), + onActionChange: vi.fn(), + onDescriptionChange: vi.fn(), + onAdd: vi.fn(), + onCancel: vi.fn(), + }; + + const renderModal = (overrides: Partial> = {}) => + render(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the keyword, action and description fields", async () => { + renderModal(); + + expect(await screen.findByText("Add blocked keyword")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Enter sensitive keyword or phrase")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Explain why this keyword is sensitive")).toBeInTheDocument(); + expect(screen.getByText("Description (optional)")).toBeInTheDocument(); + expect( + screen.getByText("Choose what action the guardrail should take when this keyword is detected"), + ).toBeInTheDocument(); + }); + + it("should report keyword edits", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(await screen.findByPlaceholderText("Enter sensitive keyword or phrase"), "s"); + + expect(handlers.onKeywordChange).toHaveBeenCalledWith("s"); + }); + + it("should report description edits", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(await screen.findByPlaceholderText("Explain why this keyword is sensitive"), "x"); + + expect(handlers.onDescriptionChange).toHaveBeenCalledWith("x"); + }); + + it("should report the chosen action", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.click(await screen.findByRole("combobox")); + const maskOptions = await screen.findAllByText("Mask"); + await user.click(maskOptions[maskOptions.length - 1]); + + expect(handlers.onActionChange).toHaveBeenCalled(); + expect(handlers.onActionChange.mock.calls[0][0]).toBe("MASK"); + }); + + it("should show the current keyword and description values", async () => { + renderModal({ keyword: "secret", description: "sensitive term" }); + + expect(await screen.findByDisplayValue("secret")).toBeInTheDocument(); + expect(screen.getByDisplayValue("sensitive term")).toBeInTheDocument(); + }); + + it("should add and cancel through the footer buttons", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.click(await screen.findByRole("button", { name: "Add" })); + expect(handlers.onAdd).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(handlers.onCancel).toHaveBeenCalledTimes(1); + }); + + it("should not render its content when not visible", () => { + renderModal({ visible: false }); + + expect(screen.queryByText("Add blocked keyword")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.test.tsx index 4e6fd0db809..4311f4e13aa 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import PatternModal from "./PatternModal"; @@ -10,25 +10,25 @@ describe("PatternModal", () => { const mockOnActionChange = vi.fn(); const mockPrebuiltPatterns = [ - { name: "us_ssn", category: "PII Patterns", description: "US Social Security Number" }, - { name: "email", category: "PII Patterns", description: "Email addresses" }, - { name: "visa", category: "Financial Patterns", description: "Visa credit card numbers" }, - { name: "aws_access_key", category: "Credential Patterns", description: "AWS Access Keys" }, + { + name: "us_ssn", + display_name: "US Social Security Number", + category: "PII Patterns", + description: "US Social Security Number", + }, + { name: "email", display_name: "Email address", category: "PII Patterns", description: "Email addresses" }, + { name: "visa", display_name: "Visa card", category: "Financial Patterns", description: "Visa credit cards" }, + { + name: "aws_access_key", + display_name: "AWS access key", + category: "Credential Patterns", + description: "AWS Access Keys", + }, ]; const mockCategories = ["PII Patterns", "Financial Patterns", "Credential Patterns"]; - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should show dropdown with prebuilt pattern options grouped by category", async () => { - /** - * Tests that the modal displays a dropdown with prebuilt patterns - * organized by category. This verifies the pattern selection UI is working. - */ - const user = userEvent.setup(); - + const renderModal = () => render( { />, ); - // Wait for modal to be visible - await waitFor(() => { - expect(screen.getByText("Add prebuilt pattern")).toBeInTheDocument(); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); - // Find the pattern type dropdown by looking for the first combobox input - const comboboxes = screen.getAllByRole("combobox"); - const dropdown = comboboxes[0]; // First combobox is the pattern selector - expect(dropdown).toBeInTheDocument(); + it("should show prebuilt pattern options grouped by category and report the picked pattern", async () => { + const user = userEvent.setup(); + renderModal(); - // Click to open the dropdown - await user.click(dropdown); + expect(await screen.findByText("Add prebuilt pattern")).toBeInTheDocument(); - // Verify that pattern options are available in the dropdown - // Ant Design renders Select options in a portal, so we need to query the whole document - await waitFor(() => { - const options = document.querySelectorAll(".ant-select-item-option"); - expect(options.length).toBeGreaterThan(0); - }); + await user.click(screen.getAllByRole("combobox")[0]); - // Verify categories are shown as group labels - await waitFor(() => { - expect(document.body).toHaveTextContent("PII Patterns"); - expect(document.body).toHaveTextContent("Financial Patterns"); - expect(document.body).toHaveTextContent("Credential Patterns"); - }); + expect(await screen.findByText("PII Patterns")).toBeInTheDocument(); + expect(screen.getByText("Financial Patterns")).toBeInTheDocument(); + expect(screen.getByText("Credential Patterns")).toBeInTheDocument(); - // Verify pattern options are available - expect(document.body).toHaveTextContent("us_ssn"); - expect(document.body).toHaveTextContent("email"); - expect(document.body).toHaveTextContent("visa"); - expect(document.body).toHaveTextContent("aws_access_key"); + expect(screen.getByText("Email address")).toBeInTheDocument(); + expect(screen.getByText("Visa card")).toBeInTheDocument(); + expect(screen.getByText("AWS access key")).toBeInTheDocument(); - // Select a pattern by clicking on its option element - const ssnOption = Array.from(document.querySelectorAll(".ant-select-item-option")).find( - (el) => el.textContent === "us_ssn", - ) as HTMLElement; - await user.click(ssnOption); + const ssnOptions = await screen.findAllByText("US Social Security Number"); + await user.click(ssnOptions[ssnOptions.length - 1]); - // Verify the change handler was called with the pattern name - // Note: Ant Design Select calls onChange with (value, option), so we check if it was called expect(mockOnPatternNameChange).toHaveBeenCalled(); - const callArgs = mockOnPatternNameChange.mock.calls[0]; - expect(callArgs[0]).toBe("us_ssn"); + expect(mockOnPatternNameChange.mock.calls[0][0]).toBe("us_ssn"); + }); + + it("should report the chosen action", async () => { + const user = userEvent.setup(); + renderModal(); + + expect(await screen.findByText("Add prebuilt pattern")).toBeInTheDocument(); + + await user.click(screen.getAllByRole("combobox")[1]); + const maskOptions = await screen.findAllByText("Mask"); + await user.click(maskOptions[maskOptions.length - 1]); + + expect(mockOnActionChange).toHaveBeenCalled(); + expect(mockOnActionChange.mock.calls[0][0]).toBe("MASK"); + }); + + it("should add and cancel through the footer buttons", async () => { + const user = userEvent.setup(); + renderModal(); + + expect(await screen.findByText("Add prebuilt pattern")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Add" })); + expect(mockOnAdd).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(mockOnCancel).toHaveBeenCalledTimes(1); + }); + + it("should not render its content when not visible", () => { + render( + , + ); + + expect(screen.queryByText("Add prebuilt pattern")).not.toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.test.tsx new file mode 100644 index 00000000000..ff3432a0d82 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.test.tsx @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import CustomCodeModal from "./CustomCodeModal"; +import { createGuardrailCall, updateGuardrailCall, testCustomCodeGuardrail } from "@/components/networking"; + +vi.mock("@/components/networking", () => ({ + createGuardrailCall: vi.fn(), + updateGuardrailCall: vi.fn(), + testCustomCodeGuardrail: vi.fn(), +})); + +vi.mock("@/components/molecules/notifications_manager", () => ({ + default: { success: vi.fn(), error: vi.fn(), fromBackend: vi.fn() }, +})); + +const mockCreate = vi.mocked(createGuardrailCall); +const mockUpdate = vi.mocked(updateGuardrailCall); +const mockTest = vi.mocked(testCustomCodeGuardrail); + +describe("CustomCodeModal", () => { + const onClose = vi.fn(); + const onSuccess = vi.fn(); + + const renderModal = (overrides = {}) => + render(); + + beforeEach(() => { + vi.clearAllMocks(); + mockCreate.mockResolvedValue({} as never); + mockUpdate.mockResolvedValue({} as never); + }); + + it("should render the create heading and the editor scaffolding", async () => { + renderModal(); + + expect(await screen.findByText("Create Custom Guardrail")).toBeInTheDocument(); + expect(screen.getByText("Define custom logic using Python-like syntax")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("e.g., block-pii-custom")).toBeInTheDocument(); + expect(screen.getByText("Guardrail Name")).toBeInTheDocument(); + expect(screen.getByText("Mode (can select multiple)")).toBeInTheDocument(); + expect(screen.getByText("Available Primitives")).toBeInTheDocument(); + expect(screen.getByText("Python Logic")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /save guardrail/i })).toBeInTheDocument(); + }); + + it("should seed the editor with the empty template", async () => { + renderModal(); + + const editor = await screen.findByDisplayValue(/async def apply_guardrail/); + expect(editor).toBeInTheDocument(); + }); + + it("should not render its content when not visible", () => { + renderModal({ visible: false }); + + expect(screen.queryByText("Create Custom Guardrail")).not.toBeInTheDocument(); + }); + + it("should render the edit heading and existing values in edit mode", async () => { + renderModal({ + editData: { + guardrail_id: "g-1", + guardrail_name: "existing-guardrail", + litellm_params: { mode: "post_call", default_on: true, custom_code: "def apply_guardrail(): pass" }, + }, + }); + + expect(await screen.findByText("Edit Custom Guardrail")).toBeInTheDocument(); + expect(screen.getByDisplayValue("existing-guardrail")).toBeInTheDocument(); + expect(screen.getByDisplayValue("def apply_guardrail(): pass")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /update guardrail/i })).toBeInTheDocument(); + }); + + it("should keep save disabled until a guardrail name is entered", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.click(await screen.findByRole("button", { name: /save guardrail/i })); + expect(mockCreate).not.toHaveBeenCalled(); + + await user.type(screen.getByPlaceholderText("e.g., block-pii-custom"), "my-guardrail"); + await user.click(screen.getByRole("button", { name: /save guardrail/i })); + + await waitFor(() => { + expect(mockCreate).toHaveBeenCalledTimes(1); + }); + }); + + it("should create the guardrail with the entered name, mode and code", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(await screen.findByPlaceholderText("e.g., block-pii-custom"), "block-pii"); + await user.click(screen.getByRole("button", { name: /save guardrail/i })); + + await waitFor(() => { + expect(mockCreate).toHaveBeenCalled(); + }); + + const [token, payload] = mockCreate.mock.calls[0] as [string, Record]; + expect(token).toBe("test-token"); + expect(payload).toMatchObject({ + guardrail_name: "block-pii", + litellm_params: { guardrail: "custom_code", mode: ["pre_call"], default_on: false }, + }); + await waitFor(() => { + expect(onSuccess).toHaveBeenCalled(); + }); + }); + + it("should switch the editor contents when a template is chosen", async () => { + const user = userEvent.setup(); + renderModal(); + + expect(await screen.findByDisplayValue(/async def apply_guardrail/)).toBeInTheDocument(); + + const comboboxes = screen.getAllByRole("combobox"); + await user.click(comboboxes[comboboxes.length - 1]); + const options = await screen.findAllByText("Block SSN"); + await user.click(options[options.length - 1]); + + expect(await screen.findByDisplayValue(/SSN detected/)).toBeInTheDocument(); + }); + + it("should expand the test section and run a test against the backend", async () => { + const user = userEvent.setup(); + mockTest.mockResolvedValue({ success: true, result: { action: "allow" } } as never); + renderModal(); + + await user.click(await screen.findByText("Test Your Guardrail")); + + const runButton = await screen.findByRole("button", { name: /run test/i }); + await user.click(runButton); + + await waitFor(() => { + expect(mockTest).toHaveBeenCalled(); + }); + expect(await screen.findByText("Allowed")).toBeInTheDocument(); + }); + + it("should surface a backend test error", async () => { + const user = userEvent.setup(); + mockTest.mockResolvedValue({ success: false, error: "boom", error_type: "SyntaxError" } as never); + renderModal(); + + await user.click(await screen.findByText("Test Your Guardrail")); + await user.click(await screen.findByRole("button", { name: /run test/i })); + + expect(await screen.findByText("boom")).toBeInTheDocument(); + expect(screen.getByText("[SyntaxError]")).toBeInTheDocument(); + }); + + it("should cancel through the footer button", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.click(await screen.findByRole("button", { name: "Cancel" })); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden.test.tsx new file mode 100644 index 00000000000..e20c8a6fa55 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden.test.tsx @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import GuardrailGarden from "./guardrail_garden"; +import { ALL_CARDS } from "./guardrail_garden_data"; + +vi.mock("./guardrail_garden_detail", () => ({ + __esModule: true, + default: ({ card, onBack }: { card: { name: string }; onBack: () => void }) => ( +
+ Detail for {card.name} + +
+ ), +})); + +const LITELLM_CARDS = ALL_CARDS.filter((c) => c.category === "litellm"); +const PARTNER_CARDS = ALL_CARDS.filter((c) => c.category === "partner"); + +describe("GuardrailGarden", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderGarden = () => render(); + + it("should render both sections with their descriptions", () => { + renderGarden(); + + expect(screen.getByText("LiteLLM Content Filter")).toBeInTheDocument(); + expect( + screen.getByText( + "Built-in guardrails powered by LiteLLM. Zero latency, no external dependencies, no additional cost.", + ), + ).toBeInTheDocument(); + expect(screen.getByText("Partner Guardrails")).toBeInTheDocument(); + expect( + screen.getByText("Third-party guardrail integrations from leading AI security providers."), + ).toBeInTheDocument(); + }); + + it("should show a capped set of litellm cards behind a show all toggle", async () => { + const user = userEvent.setup(); + renderGarden(); + + expect(screen.getByText(`Show all (${LITELLM_CARDS.length})`)).toBeInTheDocument(); + expect(screen.getByText(LITELLM_CARDS[0].name)).toBeInTheDocument(); + expect(screen.queryByText(LITELLM_CARDS[LITELLM_CARDS.length - 1].name)).not.toBeInTheDocument(); + + await user.click(screen.getByText(`Show all (${LITELLM_CARDS.length})`)); + + expect(screen.getByText("Show less")).toBeInTheDocument(); + expect(screen.getByText(LITELLM_CARDS[LITELLM_CARDS.length - 1].name)).toBeInTheDocument(); + }); + + it("should always render every partner card", () => { + renderGarden(); + + PARTNER_CARDS.forEach((card) => { + expect(screen.getByText(card.name)).toBeInTheDocument(); + }); + }); + + it("should filter cards by the search query", async () => { + const user = userEvent.setup(); + renderGarden(); + + const target = PARTNER_CARDS[0]; + await user.type(screen.getByPlaceholderText("Search guardrails"), target.name); + + expect(await screen.findByText(target.name)).toBeInTheDocument(); + const otherPartner = PARTNER_CARDS.find((c) => c.name !== target.name); + if (otherPartner) { + expect(screen.queryByText(otherPartner.name)).not.toBeInTheDocument(); + } + }); + + it("should show an empty result set for a query that matches nothing", async () => { + const user = userEvent.setup(); + renderGarden(); + + await user.type(screen.getByPlaceholderText("Search guardrails"), "zzzzznotaguardrailzzzzz"); + + expect(screen.getByText("Show all (0)")).toBeInTheDocument(); + PARTNER_CARDS.forEach((card) => { + expect(screen.queryByText(card.name)).not.toBeInTheDocument(); + }); + }); + + it("should open the detail view for a clicked card and return to the garden", async () => { + const user = userEvent.setup(); + renderGarden(); + + const target = PARTNER_CARDS[0]; + await user.click(screen.getByText(target.name)); + + expect(await screen.findByText(`Detail for ${target.name}`)).toBeInTheDocument(); + expect(screen.queryByPlaceholderText("Search guardrails")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Back to garden" })); + + expect(await screen.findByPlaceholderText("Search guardrails")).toBeInTheDocument(); + }); +});