mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
fix(ui): pass is_proxy_admin for proxy admins on the models page team drill-in (#43003)
* fix(ui): pass is_proxy_admin for proxy admins on the models page team drill-in Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(ui): drop explanatory comments from the models page team drill-in tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): exclude view-only admins from is_proxy_admin on the models page team drill-in Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(ui): add browser integration contract for the team guardrail kill switch on the models page Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yucheng <yucheng@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
0ac435bd01
commit
12960f3edf
5 changed files with 340 additions and 6 deletions
|
|
@ -1,5 +1,6 @@
|
|||
[
|
||||
"tests/e2e/ui/tests/integrationCritical/projectDetachment.spec.ts::project creation and explicit detachment preserve saved scope and restore serving",
|
||||
"tests/e2e/ui/tests/integrationCritical/teamGlobalGuardrailKillSwitch.spec.ts::proxy admin can enable the global guardrail kill switch from the models page team drill-in",
|
||||
"tests/e2e/ui/tests/integrationCritical/mcpUserEnvVars.spec.ts::per-user MCP env var stays updatable and clearable from the card after it is set",
|
||||
"tests/e2e/ui/tests/integrationCritical/mcpUserEnvVars.spec.ts::cancelling the clear confirmation keeps the stored value and sends no delete",
|
||||
"tests/e2e/ui/tests/integrationCritical/mcpUserEnvVars.spec.ts::pressing Enter on Update opens the credentials modal instead of the server editor",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import {
|
||||
test,
|
||||
expect,
|
||||
APIRequestContext,
|
||||
Page as PlaywrightPage,
|
||||
} from "@playwright/test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const master = process.env.LITELLM_MASTER_KEY ?? "sk-integration-master";
|
||||
const headers = { Authorization: `Bearer ${master}` };
|
||||
|
||||
async function createTeam(request: APIRequestContext): Promise<string> {
|
||||
const created = await request.post("/team/new", {
|
||||
headers,
|
||||
data: {
|
||||
team_alias: `int_kill_switch_${randomUUID().replace(/-/g, "").slice(0, 12)}`,
|
||||
},
|
||||
});
|
||||
expect(created.ok(), await created.text()).toBe(true);
|
||||
return (await created.json()).team_id as string;
|
||||
}
|
||||
|
||||
async function loginAsAdmin(page: PlaywrightPage): Promise<void> {
|
||||
await page.goto("/ui/login");
|
||||
await page.getByPlaceholder("Enter your username").fill("admin");
|
||||
await page.getByPlaceholder("Enter your password").fill(master);
|
||||
await page.getByRole("button", { name: "Login", exact: true }).click();
|
||||
await expect(page).toHaveURL(
|
||||
(url) => url.pathname.startsWith("/ui") && !url.pathname.includes("login"),
|
||||
);
|
||||
}
|
||||
|
||||
test("proxy admin can enable the global guardrail kill switch from the models page team drill-in", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const teamId = await createTeam(request);
|
||||
try {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`/ui/models-and-endpoints?team=${teamId}`);
|
||||
await page.getByRole("tab", { name: "Settings" }).click();
|
||||
await page.getByRole("button", { name: /edit settings/i }).click();
|
||||
await expect(page.getByLabel(/Team Name/)).toBeVisible();
|
||||
const killSwitch = page.getByRole("switch", {
|
||||
name: /disable all global guardrails/i,
|
||||
});
|
||||
await expect(killSwitch).toBeVisible();
|
||||
await expect(killSwitch).not.toBeChecked();
|
||||
await killSwitch.click();
|
||||
await page.getByRole("button", { name: "Save Changes" }).click();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const response = await request.get(`/team/info?team_id=${teamId}`, {
|
||||
headers,
|
||||
});
|
||||
expect(response.ok(), await response.text()).toBe(true);
|
||||
const json = await response.json();
|
||||
return json.team_info?.metadata?.disable_global_guardrails;
|
||||
})
|
||||
.toBe(true);
|
||||
await page.reload();
|
||||
await page.getByRole("tab", { name: "Settings" }).click();
|
||||
await page.getByRole("button", { name: /edit settings/i }).click();
|
||||
await expect(page.getByLabel(/Team Name/)).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("switch", { name: /disable all global guardrails/i }),
|
||||
).toBeChecked();
|
||||
} finally {
|
||||
const removed = await request.post("/team/delete", {
|
||||
headers,
|
||||
data: { team_ids: [teamId] },
|
||||
});
|
||||
expect(removed.ok() || removed.status() === 404, await removed.text()).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as networking from "@/components/networking";
|
||||
import { renderWithProviders } from "../../../../tests/test-utils";
|
||||
import ModelsAndEndpointsPage from "./page";
|
||||
|
||||
vi.mock("./panels/AllModelsPanel", () => ({ default: () => <div data-testid="panel-all-models" /> }));
|
||||
vi.mock("./panels/AddModelPanel", () => ({ default: () => <div data-testid="panel-add" /> }));
|
||||
vi.mock("./panels/AutoRoutersTabPanel", () => ({ default: () => <div data-testid="panel-auto-routers" /> }));
|
||||
vi.mock("./panels/LlmCredentialsPanel", () => ({ default: () => <div data-testid="panel-credentials" /> }));
|
||||
vi.mock("./panels/PassThroughPanel", () => ({ default: () => <div data-testid="panel-pass-through" /> }));
|
||||
vi.mock("./panels/HealthStatusPanel", () => ({ default: () => <div data-testid="panel-health" /> }));
|
||||
vi.mock("./panels/ModelRetrySettingsPanel", () => ({ default: () => <div data-testid="panel-retry" /> }));
|
||||
vi.mock("./panels/ModelGroupAliasPanel", () => ({ default: () => <div data-testid="panel-alias" /> }));
|
||||
vi.mock("./panels/PriceDataPanel", () => ({ default: () => <div data-testid="panel-price" /> }));
|
||||
vi.mock("./panels/AccessGroupBudgetsPanel", () => ({ default: () => <div data-testid="panel-budgets" /> }));
|
||||
vi.mock("@/components/molecules/cost_optimization_feedback_banner", () => ({ default: () => null }));
|
||||
vi.mock("@/components/model_info_view", () => ({
|
||||
default: ({ modelId }: { modelId: string }) => <div data-testid="model-info">model:{modelId}</div>,
|
||||
}));
|
||||
vi.mock("./useModelDashboardData", () => ({
|
||||
useModelDashboardData: () => ({ availableModelAccessGroups: [], allModelsOnProxy: [], availableModelGroups: [] }),
|
||||
}));
|
||||
|
||||
const authState = vi.hoisted(() => ({ userRole: "Admin", isViewOnly: false }));
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: () => ({
|
||||
token: "123",
|
||||
accessToken: "123",
|
||||
userId: "user-1",
|
||||
userEmail: "admin@example.com",
|
||||
userRole: authState.userRole,
|
||||
premiumUser: true,
|
||||
isViewOnly: authState.isViewOnly,
|
||||
disabledPersonalKeyCreation: null,
|
||||
showSSOBanner: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
serverRootPath: "",
|
||||
teamInfoCall: vi.fn(),
|
||||
teamMemberDeleteCall: vi.fn(),
|
||||
teamMemberAddCall: vi.fn(),
|
||||
teamMemberUpdateCall: vi.fn(),
|
||||
teamUpdateCall: vi.fn(),
|
||||
getGuardrailsList: vi.fn(),
|
||||
getPoliciesList: vi.fn(),
|
||||
getPolicyInfoWithGuardrails: vi.fn(),
|
||||
fetchMCPAccessGroups: vi.fn(),
|
||||
getTeamPermissionsCall: vi.fn(),
|
||||
organizationInfoCall: vi.fn(),
|
||||
getRouterSettingsCall: vi.fn().mockResolvedValue({ fields: [] }),
|
||||
getPassThroughEndpointsCall: vi.fn().mockResolvedValue({ endpoints: [] }),
|
||||
fetchMCPServers: vi.fn().mockResolvedValue([]),
|
||||
fetchMCPToolsets: vi.fn().mockResolvedValue([]),
|
||||
listMCPTools: vi.fn().mockResolvedValue({ tools: [] }),
|
||||
vectorStoreListCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
getAgentsList: vi.fn().mockResolvedValue({ agents: [] }),
|
||||
getClaudeCodePluginsList: vi.fn().mockResolvedValue({ plugins: [], count: 0 }),
|
||||
}));
|
||||
|
||||
const can = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/useCan", () => ({
|
||||
default: (...args: unknown[]) => can(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/utils/dataUtils", () => ({
|
||||
copyToClipboard: vi.fn().mockResolvedValue(true),
|
||||
formatNumberWithCommas: vi.fn((value: number) => value.toLocaleString()),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeamMetadataSchema", () => ({
|
||||
useTeamMetadataSchema: vi.fn(() => ({ data: [], isLoading: false })),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
|
||||
useUISettings: vi.fn(() => ({ data: { values: {} }, isLoading: false })),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
|
||||
useAllProxyModels: vi.fn(() => ({ data: { data: [] }, isLoading: false })),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
|
||||
useTeams: vi.fn(() => ({ data: [], isLoading: false })),
|
||||
useTeam: vi.fn(() => ({ data: undefined, isLoading: false })),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
|
||||
organizationKeys: { all: ["organizations"] },
|
||||
useOrganization: vi.fn(() => ({ data: undefined, isLoading: false })),
|
||||
useOrganizations: vi.fn().mockReturnValue({ data: [], isLoading: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({
|
||||
useCurrentUser: vi.fn(() => ({ data: { models: [] }, isLoading: false })),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPServers", () => ({
|
||||
useMCPServers: vi.fn(() => ({ data: [], isLoading: false, isError: false })),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPToolsets", () => ({
|
||||
useMCPToolsets: vi.fn(() => ({ data: [], isLoading: false, isError: false })),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/mcp_server_management/MCPServerSelector", () => ({
|
||||
default: () => <div>mcp server selector</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/team/TeamMemberTab", () => ({
|
||||
default: vi.fn(() => <div>member tab</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/common_components/user_search_modal", () => ({
|
||||
default: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/team/EditMembership", () => ({
|
||||
default: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/common_components/DeleteResourceModal", () => ({
|
||||
default: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/team/member_permissions", () => ({
|
||||
default: vi.fn(() => <div>Member Permissions</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/common_components/ModelAliasManager", () => ({
|
||||
default: vi.fn(() => <div>alias manager</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({
|
||||
useAccessGroups: vi.fn().mockReturnValue({ data: [], isLoading: false, isError: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/common_components/AccessGroupSelector", () => ({
|
||||
default: () => <div>access group selector</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => {
|
||||
const keysResult = {
|
||||
data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 },
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
return { useKeys: vi.fn(() => keysResult) };
|
||||
});
|
||||
|
||||
vi.mock("@/components/key_team_helpers/filter_helpers", () => ({
|
||||
fetchTeamFilterOptions: vi.fn().mockResolvedValue({ keyAliases: [], organizationIds: [], userIds: [] }),
|
||||
fetchAllKeyAliases: vi.fn().mockResolvedValue([]),
|
||||
fetchAllOrganizations: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const createMockTeamData = (overrides = {}) => ({
|
||||
team_id: "team-a1b2",
|
||||
team_info: {
|
||||
team_alias: "Test Team",
|
||||
team_id: "team-a1b2",
|
||||
organization_id: null,
|
||||
admins: ["admin@test.com"],
|
||||
members: ["user1@test.com"],
|
||||
members_with_roles: [
|
||||
{ user_id: "user1@test.com", user_email: "user1@test.com", role: "member", spend: 0, budget_id: "budget1" },
|
||||
],
|
||||
metadata: { disable_global_guardrails: true },
|
||||
tpm_limit: null,
|
||||
rpm_limit: null,
|
||||
max_budget: null,
|
||||
budget_duration: null,
|
||||
models: [],
|
||||
blocked: false,
|
||||
spend: 0,
|
||||
max_parallel_requests: null,
|
||||
budget_reset_at: null,
|
||||
model_id: null,
|
||||
litellm_model_table: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
team_member_budget_table: null,
|
||||
guardrails: [],
|
||||
policies: [],
|
||||
object_permission: null,
|
||||
...overrides,
|
||||
},
|
||||
keys: [],
|
||||
team_memberships: [],
|
||||
});
|
||||
|
||||
describe("ModelsAndEndpointsPage ?team drill-in", () => {
|
||||
beforeEach(() => {
|
||||
authState.userRole = "Admin";
|
||||
authState.isViewOnly = false;
|
||||
can.mockReturnValue(true);
|
||||
vi.mocked(networking.getGuardrailsList).mockResolvedValue({ guardrails: [] });
|
||||
vi.mocked(networking.getPoliciesList).mockResolvedValue({ policies: [] });
|
||||
vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]);
|
||||
vi.mocked(networking.getTeamPermissionsCall).mockResolvedValue({
|
||||
all_available_permissions: [],
|
||||
team_member_permissions: [],
|
||||
} as never);
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData() as never);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- jsdom has no ResizeObserver global to type against
|
||||
(global as any).ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows the Disable all global guardrails switch to a proxy admin session", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderWithProviders(<ModelsAndEndpointsPage />, { searchParams: { team: "team-a1b2" } });
|
||||
|
||||
await user.click(await screen.findByRole("tab", { name: "Settings" }));
|
||||
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
|
||||
await screen.findByLabelText(/Team Name/);
|
||||
|
||||
expect(screen.getByRole("switch", { name: /Disable all global guardrails/i })).toBeChecked();
|
||||
});
|
||||
|
||||
it("keeps the switch hidden from an internal user session on the same team", async () => {
|
||||
authState.userRole = "Internal User";
|
||||
renderWithProviders(<ModelsAndEndpointsPage />, { searchParams: { team: "team-a1b2" } });
|
||||
|
||||
await screen.findByText("Test Team");
|
||||
|
||||
expect(screen.queryByRole("button", { name: /edit settings/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Disable all global guardrails")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -25,12 +25,16 @@ vi.mock("@/components/molecules/cost_optimization_feedback_banner", () => ({ def
|
|||
vi.mock("@/components/model_info_view", () => ({
|
||||
default: ({ modelId }: { modelId: string }) => <div data-testid="model-info">model:{modelId}</div>,
|
||||
}));
|
||||
const teamInfoProps = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/components/team/TeamInfo", () => ({
|
||||
default: ({ teamId, is_team_admin }: { teamId: string; is_team_admin: boolean }) => (
|
||||
<div data-testid="team-info" data-team-admin={String(is_team_admin)}>
|
||||
team:{teamId}
|
||||
</div>
|
||||
),
|
||||
default: (props: { teamId: string; is_team_admin: boolean; is_proxy_admin: boolean }) => {
|
||||
teamInfoProps(props);
|
||||
return (
|
||||
<div data-testid="team-info" data-team-admin={String(props.is_team_admin)}>
|
||||
team:{props.teamId}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const mockUseAuthorized = vi.fn();
|
||||
|
|
@ -107,12 +111,21 @@ describe("ModelsAndEndpointsPage", () => {
|
|||
expect(screen.getByTestId("team-info")).toHaveAttribute("data-team-admin", "true");
|
||||
});
|
||||
|
||||
it("passes is_proxy_admin for an admin session on the ?team drill-in", () => {
|
||||
detailState.teamId = "team-a1b2";
|
||||
renderPage();
|
||||
expect(teamInfoProps).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ is_proxy_admin: true, is_team_admin: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the ?team drill-in without edit rights for a view-only admin", () => {
|
||||
mockUseAuthorized.mockReturnValue(VIEW_ONLY_ADMIN);
|
||||
detailState.teamId = "team-9";
|
||||
renderPage();
|
||||
expect(screen.getByTestId("team-info")).toHaveTextContent("team:team-9");
|
||||
expect(screen.getByTestId("team-info")).toHaveAttribute("data-team-admin", "false");
|
||||
expect(teamInfoProps).toHaveBeenLastCalledWith(expect.objectContaining({ is_proxy_admin: false }));
|
||||
});
|
||||
|
||||
it("hides admin-only tabs for a non-admin user", () => {
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ export default function ModelsAndEndpointsPage() {
|
|||
onClose={close}
|
||||
accessToken={accessToken}
|
||||
is_team_admin={userRole === "Admin" && !isViewOnly}
|
||||
is_proxy_admin={userRole === "Proxy Admin"}
|
||||
is_proxy_admin={userRole === "Admin" && !isViewOnly}
|
||||
userModels={allModelsOnProxy}
|
||||
editTeam={false}
|
||||
onUpdate={invalidateModels}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue