mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
test(ui): unit-test key edit route payload normalization
Extract route payload construction into a pure helper and move payload-shape assertions out of the rendered component suite.
This commit is contained in:
parent
485ceda428
commit
599290aa81
4 changed files with 84 additions and 260 deletions
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { keyTypeFromRoutes, normalizeKeyEditRoutePayload } from "./keyEditFieldNormalizers";
|
||||
|
||||
describe("key edit route payload normalization", () => {
|
||||
it("parses comma-separated custom routes", () => {
|
||||
expect(normalizeKeyEditRoutePayload({ allowed_routes: "route1, route2, route3" }, [])).toEqual({
|
||||
allowed_routes: ["route1", "route2", "route3"],
|
||||
});
|
||||
});
|
||||
|
||||
it("submits the default preset without derived routes", () => {
|
||||
expect(normalizeKeyEditRoutePayload({ allowed_routes: "" }, ["llm_api_routes"])).toEqual({
|
||||
key_type: "default",
|
||||
});
|
||||
});
|
||||
|
||||
it("submits the AI APIs preset without derived routes", () => {
|
||||
expect(normalizeKeyEditRoutePayload({ allowed_routes: "llm_api_routes" }, [])).toEqual({
|
||||
key_type: "llm_api",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves mixed allowlists without applying a preset", () => {
|
||||
expect(normalizeKeyEditRoutePayload({ allowed_routes: "llm_api_routes, /custom" }, [])).toEqual({
|
||||
allowed_routes: ["llm_api_routes", "/custom"],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ original: ["llm_api_routes"], submitted: "llm_api_routes" },
|
||||
{ original: null, submitted: "" },
|
||||
{ original: ["beta_routes", "alpha_routes"], submitted: "alpha_routes, beta_routes" },
|
||||
])("omits unchanged routes: $original", ({ original, submitted }) => {
|
||||
expect(normalizeKeyEditRoutePayload({ allowed_routes: submitted }, original)).toEqual({});
|
||||
});
|
||||
|
||||
it("does not label a mixed allowlist as Full Access", () => {
|
||||
expect(keyTypeFromRoutes(["llm_api_routes", "/custom"])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -50,3 +50,45 @@ export const currentValuePlaceholder = (
|
|||
if (!premiumUser) return premiumHint;
|
||||
return Array.isArray(current) && current.length > 0 ? `Current: ${current.join(", ")}` : emptyHint;
|
||||
};
|
||||
|
||||
export type KeyEditRoutePayload = {
|
||||
allowed_routes?: string[] | string | null;
|
||||
key_type?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export const normalizeKeyEditRoutePayload = (
|
||||
values: KeyEditRoutePayload,
|
||||
originalAllowedRoutes: string[] | null | undefined,
|
||||
): KeyEditRoutePayload => {
|
||||
const normalized = { ...values };
|
||||
|
||||
if (typeof normalized.allowed_routes === "string") {
|
||||
normalized.allowed_routes = normalized.allowed_routes
|
||||
.split(",")
|
||||
.map((route) => route.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const originalRoutes = new Set(originalAllowedRoutes ?? []);
|
||||
const submittedRoutes = new Set(Array.isArray(normalized.allowed_routes) ? normalized.allowed_routes : []);
|
||||
const routesUnchanged =
|
||||
originalRoutes.size === submittedRoutes.size && [...submittedRoutes].every((route) => originalRoutes.has(route));
|
||||
|
||||
if (routesUnchanged) {
|
||||
delete normalized.allowed_routes;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const preset = exactKeyTypePresetFromRoutes(
|
||||
Array.isArray(normalized.allowed_routes) ? normalized.allowed_routes : undefined,
|
||||
);
|
||||
if (preset) {
|
||||
normalized.key_type = preset;
|
||||
delete normalized.allowed_routes;
|
||||
} else {
|
||||
delete normalized.key_type;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
modelAvailableCall,
|
||||
vectorStoreListCall,
|
||||
} from "../networking";
|
||||
import { keyTypeFromRoutes } from "./keyEditFieldNormalizers";
|
||||
import { KeyEditView } from "./key_edit_view";
|
||||
|
||||
const can = vi.fn();
|
||||
|
|
@ -135,13 +134,6 @@ vi.mock("../agent_management/AgentSelector", () => ({
|
|||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("keyTypeFromRoutes", () => {
|
||||
it("does not label a mixed allowlist as Full Access", () => {
|
||||
expect(keyTypeFromRoutes(["llm_api_routes", "/custom"])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("../common_components/AccessGroupSelector", () => ({
|
||||
default: ({ value = [], onChange }: { value?: string[]; onChange?: (v: string[]) => void }) => (
|
||||
<input
|
||||
|
|
@ -690,222 +682,6 @@ describe("KeyEditView", () => {
|
|||
});
|
||||
});
|
||||
|
||||
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 submit the default preset without derived allowed routes", async () => {
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
const keyDataWithRoutes = {
|
||||
...MOCK_KEY_DATA,
|
||||
allowed_routes: ["llm_api_routes"],
|
||||
};
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={keyDataWithRoutes}
|
||||
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).toBeUndefined();
|
||||
expect(callArgs.key_type).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
it("should submit the AI APIs preset without derived allowed routes", 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}
|
||||
/>,
|
||||
);
|
||||
|
||||
const allowedRoutesInput = await screen.findByLabelText(/allowed routes/i);
|
||||
await userEvent.type(allowedRoutesInput, "llm_api_routes");
|
||||
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const callArgs = onSubmitMock.mock.calls[0][0];
|
||||
expect(callArgs.allowed_routes).toBeUndefined();
|
||||
expect(callArgs.key_type).toBe("llm_api");
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve mixed route allowlists without applying a preset", 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}
|
||||
/>,
|
||||
);
|
||||
|
||||
const allowedRoutesInput = await screen.findByLabelText(/allowed routes/i);
|
||||
await userEvent.clear(allowedRoutesInput);
|
||||
await userEvent.type(allowedRoutesInput, "llm_api_routes, /custom");
|
||||
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const callArgs = onSubmitMock.mock.calls[0][0];
|
||||
expect(callArgs.allowed_routes).toEqual(["llm_api_routes", "/custom"]);
|
||||
expect(callArgs.key_type).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should omit allowed_routes from submit when value is unchanged", async () => {
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
const aiApisKeyData = {
|
||||
...MOCK_KEY_DATA,
|
||||
allowed_routes: ["llm_api_routes"],
|
||||
};
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={aiApisKeyData}
|
||||
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();
|
||||
const callArgs = onSubmitMock.mock.calls[0][0];
|
||||
expect("allowed_routes" in callArgs).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("should omit allowed_routes from submit when keyData.allowed_routes is null and form is untouched", async () => {
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
const keyDataNullRoutes = {
|
||||
...MOCK_KEY_DATA,
|
||||
allowed_routes: null as unknown as string[],
|
||||
};
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={keyDataNullRoutes}
|
||||
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();
|
||||
const callArgs = onSubmitMock.mock.calls[0][0];
|
||||
expect("allowed_routes" in callArgs).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("should omit allowed_routes from submit when server returned routes in a different order", async () => {
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
const keyDataReordered = {
|
||||
...MOCK_KEY_DATA,
|
||||
allowed_routes: ["beta_routes", "alpha_routes"],
|
||||
};
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={keyDataReordered}
|
||||
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();
|
||||
const callArgs = onSubmitMock.mock.calls[0][0];
|
||||
expect("allowed_routes" in callArgs).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("should pass access_group_ids to onSubmit when saving key with access groups", async () => {
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
const keyDataWithAccessGroups = {
|
||||
|
|
|
|||
|
|
@ -27,11 +27,10 @@ import RouterSettingsAccordion, { RouterSettingsAccordionRef } from "../common_c
|
|||
import { routerSettingsEditorValue, routerSettingsUpdate } from "../common_components/routerSettingsPayload";
|
||||
import { estimateTooltips, withNormalizedEstimates } from "./estimatedOutputTokens";
|
||||
import {
|
||||
canonicalBudgetDuration,
|
||||
currentValuePlaceholder,
|
||||
exactKeyTypePresetFromRoutes,
|
||||
keyTypeFromRoutes,
|
||||
modelSentinelOptions,
|
||||
normalizeKeyEditRoutePayload,
|
||||
parseAllowedRoutes,
|
||||
} from "./keyEditFieldNormalizers";
|
||||
import { KeyTypeSelect, labelWithHint } from "./KeyEditViewControls";
|
||||
|
|
@ -215,40 +214,7 @@ export function KeyEditView({
|
|||
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
|
||||
|
||||
// Backend rejects non-empty allowed_routes from non-admins, so re-sending
|
||||
// an unchanged value 403s a team admin. Set compare tolerates reorder.
|
||||
const originalRoutesSet = new Set<string>(Array.isArray(keyData.allowed_routes) ? keyData.allowed_routes : []);
|
||||
const submittedRoutesSet = new Set<string>(Array.isArray(values.allowed_routes) ? values.allowed_routes : []);
|
||||
const allowedRoutesUnchanged =
|
||||
originalRoutesSet.size === submittedRoutesSet.size &&
|
||||
[...submittedRoutesSet].every((r) => originalRoutesSet.has(r));
|
||||
if (allowedRoutesUnchanged) {
|
||||
delete values.allowed_routes;
|
||||
} else {
|
||||
const exactKeyTypePreset = exactKeyTypePresetFromRoutes(values.allowed_routes);
|
||||
if (exactKeyTypePreset) {
|
||||
values.key_type = exactKeyTypePreset;
|
||||
// The backend derives allowed_routes from key_type. Omitting the
|
||||
// derived routes also lets authorized non-admins use safe presets.
|
||||
delete values.allowed_routes;
|
||||
} else {
|
||||
delete values.key_type;
|
||||
}
|
||||
}
|
||||
values = normalizeKeyEditRoutePayload(values, keyData.allowed_routes);
|
||||
|
||||
if (neverExpire) {
|
||||
values.duration = null;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue