mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-05 08:23:17 +00:00
Merge pull request #20369 from BerriAI/litellm_ui_key_settings_routes
[Feature] UI - Keys: Allowed Routes to Key Info and Edit Pages
This commit is contained in:
@@ -1,24 +1,61 @@
|
||||
import { fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "../../../tests/test-utils";
|
||||
import { KeyResponse } from "../key_team_helpers/key_list";
|
||||
import { KeyEditView } from "./key_edit_view";
|
||||
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
vi.mock("../networking", async () => {
|
||||
const actual = await vi.importActual("../networking");
|
||||
return {
|
||||
...actual,
|
||||
getPromptsList: vi.fn().mockResolvedValue({
|
||||
prompts: [{ prompt_id: "prompt-1" }, { prompt_id: "prompt-2" }],
|
||||
}),
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({
|
||||
data: [{ id: "gpt-4" }, { id: "gpt-3.5-turbo" }],
|
||||
}),
|
||||
tagListCall: vi.fn().mockResolvedValue({
|
||||
tag1: { name: "tag1", description: "Test tag 1" },
|
||||
tag2: { name: "tag2", description: "Test tag 2" },
|
||||
}),
|
||||
getGuardrailsList: vi.fn().mockResolvedValue({
|
||||
guardrails: [{ guardrail_name: "guardrail-1" }],
|
||||
}),
|
||||
getPoliciesList: vi.fn().mockResolvedValue({
|
||||
policies: [{ policy_name: "policy-1" }],
|
||||
}),
|
||||
getPassThroughEndpointsCall: vi.fn().mockResolvedValue({
|
||||
endpoints: [],
|
||||
}),
|
||||
vectorStoreListCall: vi.fn().mockResolvedValue({
|
||||
data: [],
|
||||
}),
|
||||
mcpToolsCall: vi.fn().mockResolvedValue({
|
||||
data: [],
|
||||
}),
|
||||
agentListCall: vi.fn().mockResolvedValue({
|
||||
data: [],
|
||||
}),
|
||||
fetchMCPServers: vi.fn().mockResolvedValue([]),
|
||||
fetchMCPAccessGroups: vi.fn().mockResolvedValue([]),
|
||||
listMCPTools: vi.fn().mockResolvedValue({
|
||||
tools: [],
|
||||
error: null,
|
||||
message: null,
|
||||
stack_trace: null,
|
||||
}),
|
||||
getAgentsList: vi.fn().mockResolvedValue({
|
||||
agents: [],
|
||||
}),
|
||||
getAgentAccessGroups: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../organisms/create_key_button", () => ({
|
||||
fetchTeamModels: vi.fn().mockResolvedValue(["team-model-1", "team-model-2"]),
|
||||
}));
|
||||
|
||||
describe("KeyEditView", () => {
|
||||
const MOCK_KEY_DATA: KeyResponse = {
|
||||
token: "test-token-123",
|
||||
@@ -93,8 +130,8 @@ describe("KeyEditView", () => {
|
||||
const { getByText } = renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onCancel={() => {}}
|
||||
onSubmit={async () => {}}
|
||||
onCancel={() => { }}
|
||||
onSubmit={async () => { }}
|
||||
accessToken={""}
|
||||
userID={""}
|
||||
userRole={""}
|
||||
@@ -111,8 +148,8 @@ describe("KeyEditView", () => {
|
||||
const { getByText } = renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onCancel={() => {}}
|
||||
onSubmit={async () => {}}
|
||||
onCancel={() => { }}
|
||||
onSubmit={async () => { }}
|
||||
accessToken={""}
|
||||
userID={""}
|
||||
userRole={""}
|
||||
@@ -129,8 +166,8 @@ describe("KeyEditView", () => {
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onCancel={() => {}}
|
||||
onSubmit={async () => {}}
|
||||
onCancel={() => { }}
|
||||
onSubmit={async () => { }}
|
||||
accessToken={""}
|
||||
userID={""}
|
||||
userRole={""}
|
||||
@@ -144,13 +181,17 @@ describe("KeyEditView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should call onCancel when cancel button is clicked", async () => {
|
||||
const onCancelMock = vi.fn();
|
||||
const { getByText } = renderWithProviders(
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onCancel={onCancelMock}
|
||||
onSubmit={async () => {}}
|
||||
onSubmit={async () => { }}
|
||||
accessToken={""}
|
||||
userID={""}
|
||||
userRole={""}
|
||||
@@ -159,12 +200,272 @@ describe("KeyEditView", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText("Cancel")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const cancelButton = getByText("Cancel");
|
||||
fireEvent.click(cancelButton);
|
||||
const cancelButton = screen.getByRole("button", { name: /cancel/i });
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
expect(onCancelMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should display key alias input field", async () => {
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onCancel={() => { }}
|
||||
onSubmit={async () => { }}
|
||||
accessToken={""}
|
||||
userID={""}
|
||||
userRole={""}
|
||||
premiumUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Key Alias")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display models select field", async () => {
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onCancel={() => { }}
|
||||
onSubmit={async () => { }}
|
||||
accessToken={""}
|
||||
userID={""}
|
||||
userRole={""}
|
||||
premiumUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Models")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display max budget input field", async () => {
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onCancel={() => { }}
|
||||
onSubmit={async () => { }}
|
||||
accessToken={""}
|
||||
userID={""}
|
||||
userRole={""}
|
||||
premiumUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Max Budget (USD)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display allowed routes input field", async () => {
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onCancel={() => { }}
|
||||
onSubmit={async () => { }}
|
||||
accessToken={""}
|
||||
userID={""}
|
||||
userRole={""}
|
||||
premiumUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/allowed routes/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should call onSubmit with form values when form is submitted", async () => {
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onCancel={() => { }}
|
||||
onSubmit={onSubmitMock}
|
||||
accessToken={"test-token"}
|
||||
userID={"test-user"}
|
||||
userRole={"admin"}
|
||||
premiumUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: /save changes/i });
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmitMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable models field when management routes are selected", async () => {
|
||||
const keyDataWithManagementRoutes = {
|
||||
...MOCK_KEY_DATA,
|
||||
allowed_routes: ["management_routes"],
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={keyDataWithManagementRoutes}
|
||||
onCancel={() => { }}
|
||||
onSubmit={async () => { }}
|
||||
accessToken={""}
|
||||
userID={""}
|
||||
userRole={""}
|
||||
premiumUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Models field is disabled for this key type")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable models field when info routes are selected", async () => {
|
||||
const keyDataWithInfoRoutes = {
|
||||
...MOCK_KEY_DATA,
|
||||
allowed_routes: ["info_routes"],
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={keyDataWithInfoRoutes}
|
||||
onCancel={() => { }}
|
||||
onSubmit={async () => { }}
|
||||
accessToken={""}
|
||||
userID={""}
|
||||
userRole={""}
|
||||
premiumUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Models field is disabled for this key type")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable guardrails selector when user is not premium", async () => {
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onCancel={() => { }}
|
||||
onSubmit={async () => { }}
|
||||
accessToken={"test-token"}
|
||||
userID={""}
|
||||
userRole={""}
|
||||
premiumUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Guardrails")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse comma-separated allowed routes on submit", async () => {
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onCancel={() => { }}
|
||||
onSubmit={onSubmitMock}
|
||||
accessToken={"test-token"}
|
||||
userID={"test-user"}
|
||||
userRole={"admin"}
|
||||
premiumUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/allowed routes/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const allowedRoutesInput = screen.getByLabelText(/allowed routes/i);
|
||||
await userEvent.clear(allowedRoutesInput);
|
||||
await userEvent.type(allowedRoutesInput, "route1, route2, route3");
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: /save changes/i });
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmitMock).toHaveBeenCalled();
|
||||
const callArgs = onSubmitMock.mock.calls[0][0];
|
||||
expect(Array.isArray(callArgs.allowed_routes)).toBe(true);
|
||||
expect(callArgs.allowed_routes).toEqual(["route1", "route2", "route3"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle empty allowed routes string on submit", async () => {
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onCancel={() => { }}
|
||||
onSubmit={onSubmitMock}
|
||||
accessToken={"test-token"}
|
||||
userID={"test-user"}
|
||||
userRole={"admin"}
|
||||
premiumUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/allowed routes/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const allowedRoutesInput = screen.getByLabelText(/allowed routes/i);
|
||||
await userEvent.clear(allowedRoutesInput);
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: /save changes/i });
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmitMock).toHaveBeenCalled();
|
||||
const callArgs = onSubmitMock.mock.calls[0][0];
|
||||
expect(callArgs.allowed_routes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("should disable cancel button during submission", async () => {
|
||||
const onSubmitMock = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 100);
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onCancel={() => { }}
|
||||
onSubmit={onSubmitMock}
|
||||
accessToken={"test-token"}
|
||||
userID={"test-user"}
|
||||
userRole={"admin"}
|
||||
premiumUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: /save changes/i });
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const cancelButton = screen.getByRole("button", { name: /cancel/i });
|
||||
expect(cancelButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,6 @@ interface KeyEditViewProps {
|
||||
// Add this helper function
|
||||
const getAvailableModelsForKey = (keyData: KeyResponse, teams: any[] | null): string[] => {
|
||||
// If no teams data is available, return empty array
|
||||
console.log("getAvailableModelsForKey:", teams);
|
||||
if (!teams || !keyData.team_id) {
|
||||
return [];
|
||||
}
|
||||
@@ -172,7 +171,9 @@ export function KeyEditView({
|
||||
: [],
|
||||
auto_rotate: keyData.auto_rotate || false,
|
||||
...(keyData.rotation_interval && { rotation_interval: keyData.rotation_interval }),
|
||||
allowed_routes: keyData.allowed_routes,
|
||||
allowed_routes: Array.isArray(keyData.allowed_routes) && keyData.allowed_routes.length > 0
|
||||
? keyData.allowed_routes.join(", ")
|
||||
: "",
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -197,7 +198,9 @@ export function KeyEditView({
|
||||
: [],
|
||||
auto_rotate: keyData.auto_rotate || false,
|
||||
...(keyData.rotation_interval && { rotation_interval: keyData.rotation_interval }),
|
||||
allowed_routes: keyData.allowed_routes,
|
||||
allowed_routes: Array.isArray(keyData.allowed_routes) && keyData.allowed_routes.length > 0
|
||||
? keyData.allowed_routes.join(", ")
|
||||
: "",
|
||||
});
|
||||
}, [keyData, form]);
|
||||
|
||||
@@ -226,11 +229,24 @@ export function KeyEditView({
|
||||
fetchTags();
|
||||
}, [accessToken]);
|
||||
|
||||
console.log("premiumUser:", premiumUser);
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
setIsKeySaving(true);
|
||||
|
||||
// Parse allowed_routes from comma-separated string to array
|
||||
if (typeof values.allowed_routes === "string") {
|
||||
const trimmedInput = values.allowed_routes.trim();
|
||||
if (trimmedInput === "") {
|
||||
values.allowed_routes = [];
|
||||
} else {
|
||||
values.allowed_routes = trimmedInput
|
||||
.split(",")
|
||||
.map((route: string) => route.trim())
|
||||
.filter((route: string) => route.length > 0);
|
||||
}
|
||||
}
|
||||
// If it's already an array (shouldn't happen, but handle it), keep as is
|
||||
|
||||
await onSubmit(values);
|
||||
} finally {
|
||||
setIsKeySaving(false);
|
||||
@@ -251,7 +267,11 @@ export function KeyEditView({
|
||||
}
|
||||
>
|
||||
{({ getFieldValue, setFieldValue }) => {
|
||||
const allowedRoutes = getFieldValue("allowed_routes") || [];
|
||||
const allowedRoutesValue = getFieldValue("allowed_routes") || "";
|
||||
// Convert string to array for checking
|
||||
const allowedRoutes = typeof allowedRoutesValue === "string" && allowedRoutesValue.trim() !== ""
|
||||
? allowedRoutesValue.split(",").map((r: string) => r.trim()).filter((r: string) => r.length > 0)
|
||||
: [];
|
||||
const isDisabled = allowedRoutes.includes("management_routes") || allowedRoutes.includes("info_routes");
|
||||
const models = getFieldValue("models") || [];
|
||||
|
||||
@@ -290,7 +310,11 @@ export function KeyEditView({
|
||||
shouldUpdate={(prevValues, currentValues) => prevValues.allowed_routes !== currentValues.allowed_routes}
|
||||
>
|
||||
{({ getFieldValue, setFieldValue }) => {
|
||||
const allowedRoutes = getFieldValue("allowed_routes");
|
||||
const allowedRoutesValue = getFieldValue("allowed_routes") || "";
|
||||
// Convert string to array for getKeyTypeFromRoutes
|
||||
const allowedRoutes = typeof allowedRoutesValue === "string" && allowedRoutesValue.trim() !== ""
|
||||
? allowedRoutesValue.split(",").map((r: string) => r.trim()).filter((r: string) => r.length > 0)
|
||||
: [];
|
||||
const keyTypeValue = getKeyTypeFromRoutes(allowedRoutes);
|
||||
|
||||
return (
|
||||
@@ -302,13 +326,13 @@ export function KeyEditView({
|
||||
onChange={(value) => {
|
||||
switch (value) {
|
||||
case "default":
|
||||
setFieldValue("allowed_routes", []);
|
||||
setFieldValue("allowed_routes", "");
|
||||
break;
|
||||
case "llm_api":
|
||||
setFieldValue("allowed_routes", ["llm_api_routes"]);
|
||||
setFieldValue("allowed_routes", "llm_api_routes");
|
||||
break;
|
||||
case "management":
|
||||
setFieldValue("allowed_routes", ["management_routes"]);
|
||||
setFieldValue("allowed_routes", "management_routes");
|
||||
setFieldValue("models", []);
|
||||
break;
|
||||
}
|
||||
@@ -344,6 +368,22 @@ export function KeyEditView({
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Allowed Routes{" "}
|
||||
<Tooltip title="List of allowed routes for the key (comma-separated). Can be specific routes (e.g., '/chat/completions') or route patterns (e.g., 'llm_api_routes', 'management_routes', '/keys/*'). Leave empty to allow all routes.">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="allowed_routes"
|
||||
>
|
||||
<Input
|
||||
placeholder="Enter allowed routes (comma-separated). Special values: llm_api_routes, management_routes. Examples: llm_api_routes, /chat/completions, /keys/*. Leave empty to allow all routes"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Max Budget (USD)" name="max_budget">
|
||||
<NumericalInput step={0.01} style={{ width: "100%" }} placeholder="Enter a numerical value" />
|
||||
</Form.Item>
|
||||
@@ -473,7 +513,7 @@ export function KeyEditView({
|
||||
!premiumUser
|
||||
? "Premium feature - Upgrade to set allowed pass through routes by key"
|
||||
: Array.isArray(keyData.metadata?.allowed_passthrough_routes) &&
|
||||
keyData.metadata.allowed_passthrough_routes.length > 0
|
||||
keyData.metadata.allowed_passthrough_routes.length > 0
|
||||
? `Current: ${keyData.metadata.allowed_passthrough_routes.join(", ")}`
|
||||
: "Select or enter allowed pass through routes"
|
||||
}
|
||||
@@ -590,11 +630,6 @@ export function KeyEditView({
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
{/* Hidden form field for allowed_routes */}
|
||||
<Form.Item name="allowed_routes" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
{/* Hidden form field for disabled callbacks */}
|
||||
<Form.Item name="disabled_callbacks" hidden>
|
||||
<Input />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import useTeams from "@/app/(dashboard)/hooks/useTeams";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import useTeams from "@/app/(dashboard)/hooks/useTeams";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { KeyResponse, Team } from "../key_team_helpers/key_list";
|
||||
import KeyInfoView from "./key_info_view";
|
||||
@@ -13,6 +14,21 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
keyDeleteCall: vi.fn().mockResolvedValue({}),
|
||||
keyUpdateCall: vi.fn().mockResolvedValue({}),
|
||||
getPolicyInfoWithGuardrails: vi.fn().mockResolvedValue({
|
||||
resolved_guardrails: ["guardrail-1", "guardrail-2"],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/dataUtils", () => ({
|
||||
copyToClipboard: vi.fn().mockResolvedValue(true),
|
||||
formatNumberWithCommas: vi.fn((value: number, decimals?: number) => {
|
||||
return value.toFixed(decimals ?? 2);
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("KeyInfoView", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useTeams).mockReturnValue({
|
||||
@@ -105,34 +121,34 @@ describe("KeyInfoView", () => {
|
||||
it("should render tags", async () => {
|
||||
vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock);
|
||||
|
||||
const { getByText } = render(
|
||||
render(
|
||||
<KeyInfoView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onClose={() => {}}
|
||||
onClose={() => { }}
|
||||
keyId={"test-key-id"}
|
||||
onKeyDataUpdate={() => {}}
|
||||
onKeyDataUpdate={() => { }}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(getByText("test-tag")).toBeInTheDocument();
|
||||
expect(screen.getByText("test-tag")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not render tags in metadata textarea", async () => {
|
||||
vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock);
|
||||
|
||||
const { container, getByText } = render(
|
||||
const { container } = render(
|
||||
<KeyInfoView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onClose={() => {}}
|
||||
onClose={() => { }}
|
||||
keyId={"test-key-id"}
|
||||
onKeyDataUpdate={() => {}}
|
||||
onKeyDataUpdate={() => { }}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(getByText("Metadata")).toBeInTheDocument();
|
||||
expect(screen.getByText("Metadata")).toBeInTheDocument();
|
||||
const metadataBlock = container.querySelector("pre");
|
||||
expect(metadataBlock).toBeInTheDocument();
|
||||
expect(metadataBlock?.textContent?.trim()).toBe("{}");
|
||||
@@ -153,7 +169,7 @@ describe("KeyInfoView", () => {
|
||||
|
||||
const keyData = { ...MOCK_KEY_DATA, user_id: "other-user-id" };
|
||||
render(
|
||||
<KeyInfoView keyData={keyData} onClose={() => {}} keyId={"test-key-id"} onKeyDataUpdate={() => {}} teams={[]} />,
|
||||
<KeyInfoView keyData={keyData} onClose={() => { }} keyId={"test-key-id"} onKeyDataUpdate={() => { }} teams={[]} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -182,6 +198,7 @@ describe("KeyInfoView", () => {
|
||||
role: "admin",
|
||||
},
|
||||
],
|
||||
spend: 0,
|
||||
};
|
||||
|
||||
vi.mocked(useTeams).mockReturnValue({
|
||||
@@ -197,7 +214,7 @@ describe("KeyInfoView", () => {
|
||||
|
||||
const keyData = { ...MOCK_KEY_DATA, team_id: teamId, user_id: "other-user-id" };
|
||||
render(
|
||||
<KeyInfoView keyData={keyData} onClose={() => {}} keyId={"test-key-id"} onKeyDataUpdate={() => {}} teams={[]} />,
|
||||
<KeyInfoView keyData={keyData} onClose={() => { }} keyId={"test-key-id"} onKeyDataUpdate={() => { }} teams={[]} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -221,7 +238,7 @@ describe("KeyInfoView", () => {
|
||||
const ownerUserId = "owner-user-id";
|
||||
const keyData = { ...MOCK_KEY_DATA, user_id: ownerUserId };
|
||||
render(
|
||||
<KeyInfoView keyData={keyData} onClose={() => {}} keyId={"test-key-id"} onKeyDataUpdate={() => {}} teams={[]} />,
|
||||
<KeyInfoView keyData={keyData} onClose={() => { }} keyId={"test-key-id"} onKeyDataUpdate={() => { }} teams={[]} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -244,7 +261,7 @@ describe("KeyInfoView", () => {
|
||||
|
||||
const keyData = { ...MOCK_KEY_DATA, user_id: "owner-user-id" };
|
||||
render(
|
||||
<KeyInfoView keyData={keyData} onClose={() => {}} keyId={"test-key-id"} onKeyDataUpdate={() => {}} teams={[]} />,
|
||||
<KeyInfoView keyData={keyData} onClose={() => { }} keyId={"test-key-id"} onKeyDataUpdate={() => { }} teams={[]} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -268,7 +285,7 @@ describe("KeyInfoView", () => {
|
||||
const ownerUserId = "internal-viewer-user-id";
|
||||
const keyData = { ...MOCK_KEY_DATA, user_id: ownerUserId };
|
||||
render(
|
||||
<KeyInfoView keyData={keyData} onClose={() => {}} keyId={"test-key-id"} onKeyDataUpdate={() => {}} teams={[]} />,
|
||||
<KeyInfoView keyData={keyData} onClose={() => { }} keyId={"test-key-id"} onKeyDataUpdate={() => { }} teams={[]} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -296,6 +313,7 @@ describe("KeyInfoView", () => {
|
||||
role: "admin",
|
||||
},
|
||||
],
|
||||
spend: 0,
|
||||
};
|
||||
|
||||
vi.mocked(useTeams).mockReturnValue({
|
||||
@@ -309,10 +327,9 @@ describe("KeyInfoView", () => {
|
||||
userRole: "user",
|
||||
});
|
||||
|
||||
// Key has a different team_id that doesn't match any team in teamsData
|
||||
const keyData = { ...MOCK_KEY_DATA, team_id: "non-matching-team-id", user_id: "other-user-id" };
|
||||
render(
|
||||
<KeyInfoView keyData={keyData} onClose={() => {}} keyId={"test-key-id"} onKeyDataUpdate={() => {}} teams={[]} />,
|
||||
<KeyInfoView keyData={keyData} onClose={() => { }} keyId={"test-key-id"} onKeyDataUpdate={() => { }} teams={[]} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -320,4 +337,129 @@ describe("KeyInfoView", () => {
|
||||
expect(screen.queryByText("Delete Key")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should call onClose when back button is clicked", async () => {
|
||||
vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock);
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
render(
|
||||
<KeyInfoView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onClose={onCloseMock}
|
||||
keyId={"test-key-id"}
|
||||
onKeyDataUpdate={() => { }}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /back to keys/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const backButton = screen.getByRole("button", { name: /back to keys/i });
|
||||
await userEvent.click(backButton);
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
it("should show edit button in settings tab when user has write access", async () => {
|
||||
vi.mocked(useAuthorized).mockReturnValue({
|
||||
...baseUseAuthorizedMock,
|
||||
userRole: "Admin",
|
||||
});
|
||||
|
||||
render(
|
||||
<KeyInfoView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onClose={() => { }}
|
||||
keyId={"test-key-id"}
|
||||
onKeyDataUpdate={() => { }}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const settingsTab = screen.getByRole("tab", { name: /settings/i });
|
||||
expect(settingsTab).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const settingsTab = screen.getByRole("tab", { name: /settings/i });
|
||||
await userEvent.click(settingsTab);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("should display guardrails when present", async () => {
|
||||
vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock);
|
||||
|
||||
const keyDataWithGuardrails = {
|
||||
...MOCK_KEY_DATA,
|
||||
metadata: {
|
||||
...MOCK_KEY_DATA.metadata,
|
||||
guardrails: ["guardrail-1", "guardrail-2"],
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<KeyInfoView
|
||||
keyData={keyDataWithGuardrails}
|
||||
onClose={() => { }}
|
||||
keyId={"test-key-id"}
|
||||
onKeyDataUpdate={() => { }}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Guardrails")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display policies when present", async () => {
|
||||
vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock);
|
||||
|
||||
const keyDataWithPolicies = {
|
||||
...MOCK_KEY_DATA,
|
||||
metadata: {
|
||||
...MOCK_KEY_DATA.metadata,
|
||||
policies: ["policy-1"],
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<KeyInfoView
|
||||
keyData={keyDataWithPolicies}
|
||||
onClose={() => { }}
|
||||
keyId={"test-key-id"}
|
||||
onKeyDataUpdate={() => { }}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Policies")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display no key found message when keyData is undefined", async () => {
|
||||
vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock);
|
||||
|
||||
render(
|
||||
<KeyInfoView
|
||||
keyData={undefined}
|
||||
onClose={() => { }}
|
||||
keyId={"test-key-id"}
|
||||
onKeyDataUpdate={() => { }}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Key not found")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import useTeams from "@/app/(dashboard)/hooks/useTeams";
|
||||
import { formatNumberWithCommas, copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
|
||||
import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils";
|
||||
import { ArrowLeftIcon, RefreshIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import { Badge, Button, Card, Grid, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react";
|
||||
import { Button as AntdButton, Form, Tooltip } from "antd";
|
||||
import { Button as AntdButton, Form, Tag, Tooltip } from "antd";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { isProxyAdminRole, isUserTeamAdminForSingleTeam, rolesWithWriteAccess } from "../../utils/roles";
|
||||
@@ -14,12 +15,11 @@ import { extractLoggingSettings, formatMetadataForDisplay, stripTagsFromMetadata
|
||||
import { KeyResponse } from "../key_team_helpers/key_list";
|
||||
import LoggingSettingsView from "../logging_settings_view";
|
||||
import NotificationManager from "../molecules/notifications_manager";
|
||||
import { keyDeleteCall, keyUpdateCall, getPolicyInfoWithGuardrails } from "../networking";
|
||||
import { getPolicyInfoWithGuardrails, keyDeleteCall, keyUpdateCall } from "../networking";
|
||||
import ObjectPermissionsView from "../object_permissions_view";
|
||||
import { RegenerateKeyModal } from "../organisms/regenerate_key_modal";
|
||||
import { parseErrorMessage } from "../shared/errorUtils";
|
||||
import { KeyEditView } from "./key_edit_view";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
interface KeyInfoViewProps {
|
||||
keyId: string;
|
||||
@@ -206,8 +206,8 @@ export default function KeyInfoView({
|
||||
...(formValues.logging_settings ? { logging: formValues.logging_settings } : {}),
|
||||
...(formValues.disabled_callbacks?.length > 0
|
||||
? {
|
||||
litellm_disabled_callbacks: mapDisplayToInternalNames(formValues.disabled_callbacks),
|
||||
}
|
||||
litellm_disabled_callbacks: mapDisplayToInternalNames(formValues.disabled_callbacks),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -225,8 +225,8 @@ export default function KeyInfoView({
|
||||
...(formValues.logging_settings ? { logging: formValues.logging_settings } : {}),
|
||||
...(formValues.disabled_callbacks?.length > 0
|
||||
? {
|
||||
litellm_disabled_callbacks: mapDisplayToInternalNames(formValues.disabled_callbacks),
|
||||
}
|
||||
litellm_disabled_callbacks: mapDisplayToInternalNames(formValues.disabled_callbacks),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -334,7 +334,6 @@ export default function KeyInfoView({
|
||||
});
|
||||
return `${dateStr} at ${timeStr}`;
|
||||
};
|
||||
console.log("userRole", userRole);
|
||||
|
||||
const canModifyKey =
|
||||
isProxyAdminRole(userRole || "") ||
|
||||
@@ -364,11 +363,10 @@ export default function KeyInfoView({
|
||||
size="small"
|
||||
icon={copiedStates["key-id"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
|
||||
onClick={() => copyToClipboard(currentKeyData.token_id || currentKeyData.token, "key-id")}
|
||||
className={`ml-2 transition-all duration-200${
|
||||
copiedStates["key-id"]
|
||||
? "text-green-600 bg-green-50 border-green-200"
|
||||
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
className={`ml-2 transition-all duration-200${copiedStates["key-id"]
|
||||
? "text-green-600 bg-green-50 border-green-200"
|
||||
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -691,10 +689,10 @@ export default function KeyInfoView({
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{Array.isArray(currentKeyData.metadata?.tags) && currentKeyData.metadata.tags.length > 0
|
||||
? currentKeyData.metadata.tags.map((tag, index) => (
|
||||
<span key={index} className="px-2 mr-2 py-1 bg-blue-100 rounded text-xs">
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
<span key={index} className="px-2 mr-2 py-1 bg-blue-100 rounded text-xs">
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
: "No tags specified"}
|
||||
</div>
|
||||
</div>
|
||||
@@ -704,24 +702,39 @@ export default function KeyInfoView({
|
||||
<Text>
|
||||
{Array.isArray(currentKeyData.metadata?.prompts) && currentKeyData.metadata.prompts.length > 0
|
||||
? currentKeyData.metadata.prompts.map((prompt, index) => (
|
||||
<span key={index} className="px-2 mr-2 py-1 bg-blue-100 rounded text-xs">
|
||||
{prompt}
|
||||
</span>
|
||||
))
|
||||
<span key={index} className="px-2 mr-2 py-1 bg-blue-100 rounded text-xs">
|
||||
{prompt}
|
||||
</span>
|
||||
))
|
||||
: "No prompts specified"}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Allowed Routes</Text>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{Array.isArray(currentKeyData.allowed_routes) && currentKeyData.allowed_routes.length > 0 ? (
|
||||
currentKeyData.allowed_routes.map((route, index) => (
|
||||
<span key={index} className="px-2 py-1 bg-blue-100 rounded text-xs">
|
||||
{route}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<Tag color="green">All routes allowed</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Allowed Pass Through Routes</Text>
|
||||
<Text>
|
||||
{Array.isArray(currentKeyData.metadata?.allowed_passthrough_routes) &&
|
||||
currentKeyData.metadata.allowed_passthrough_routes.length > 0
|
||||
currentKeyData.metadata.allowed_passthrough_routes.length > 0
|
||||
? currentKeyData.metadata.allowed_passthrough_routes.map((route, index) => (
|
||||
<span key={index} className="px-2 mr-2 py-1 bg-blue-100 rounded text-xs">
|
||||
{route}
|
||||
</span>
|
||||
))
|
||||
<span key={index} className="px-2 mr-2 py-1 bg-blue-100 rounded text-xs">
|
||||
{route}
|
||||
</span>
|
||||
))
|
||||
: "No pass through routes specified"}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user