mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
* test(ui-e2e): add admin key creation with a specific proxy model Adds Playwright coverage for creating a key (no team) scoped to a single proxy model, complementing the existing All-Proxy-Models test. Uses a DOM-dispatched click on the antd dropdown option since the popup animation can render the option outside the viewport. * test(ui-e2e): verify scoped key works against mock /chat/completions Extend the "Create a key with a specific proxy model" test to extract the new key from the success modal and POST to /chat/completions for the scoped model, asserting 200 and the mock response body. Without this the test could pass even if the model selection failed to register.
209 lines
8.7 KiB
TypeScript
209 lines
8.7 KiB
TypeScript
import { test, expect } from "@playwright/test";
|
|
import {
|
|
ADMIN_STORAGE_PATH,
|
|
E2E_DELETE_KEY_ALIAS,
|
|
E2E_REGENERATE_KEY_ALIAS,
|
|
E2E_UPDATE_LIMITS_KEY_ALIAS,
|
|
E2E_INTERNAL_USER_KEY_ALIAS,
|
|
E2E_TEAM_CRUD_ALIAS,
|
|
} from "../../constants";
|
|
import { Page } from "../../fixtures/pages";
|
|
import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation";
|
|
|
|
test.describe("Proxy Admin - Keys", () => {
|
|
test.use({ storageState: ADMIN_STORAGE_PATH });
|
|
|
|
test("Create a key in a team", async ({ page }) => {
|
|
await navigateToPage(page, Page.ApiKeys);
|
|
await dismissFeedbackPopup(page);
|
|
|
|
// Click "+ Create New Key" button
|
|
await page.getByRole("button", { name: /Create New Key/i }).click();
|
|
|
|
// Wait for the key creation modal
|
|
await expect(page.getByText("Key Ownership")).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Fill key name (has data-testid="base-input" in the built UI)
|
|
const keyName = `e2e-admin-key-${Date.now()}`;
|
|
await page.getByTestId("base-input").fill(keyName);
|
|
|
|
// Select team — the team dropdown has placeholder "Search or select a team"
|
|
const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" });
|
|
await teamSelect.click();
|
|
await page.keyboard.type(E2E_TEAM_CRUD_ALIAS);
|
|
await page.locator(".ant-select-dropdown:visible").getByText(E2E_TEAM_CRUD_ALIAS).first().click();
|
|
|
|
// Select models
|
|
await page.locator(".ant-select-selection-overflow").click();
|
|
await page.locator(".ant-select-dropdown:visible").getByText("All Team Models").click();
|
|
await page.keyboard.press("Escape");
|
|
|
|
// Submit
|
|
await page.getByRole("button", { name: "Create Key", exact: true }).click();
|
|
|
|
// Success shows "Save your Key" in a second dialog
|
|
await expect(page.getByText("Save your Key")).toBeVisible({ timeout: 10_000 });
|
|
await page.keyboard.press("Escape");
|
|
|
|
// Verify the new key appears in the table
|
|
await expect(page.getByText(keyName)).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test("Regenerate key", async ({ page }) => {
|
|
await navigateToPage(page, Page.ApiKeys);
|
|
await dismissFeedbackPopup(page);
|
|
|
|
// Key IDs are rendered as buttons in the table
|
|
const keyRow = page.locator("tr", { hasText: E2E_REGENERATE_KEY_ALIAS });
|
|
await expect(keyRow).toBeVisible({ timeout: 10_000 });
|
|
await keyRow.locator("button").first().click();
|
|
|
|
await expect(page.getByText("Back to Keys")).toBeVisible({ timeout: 10_000 });
|
|
|
|
await page.getByRole("button", { name: "Regenerate Key" }).click();
|
|
|
|
// Scope to the modal — the Regenerate button has an icon whose aria-label
|
|
// ("sync") is concatenated into the button's accessible name, and the
|
|
// "Regenerate Key" button is still in the DOM behind the modal.
|
|
const modal = page.locator(".ant-modal:visible");
|
|
await modal.getByRole("button", { name: /Regenerate/ }).click();
|
|
|
|
// Success view shows a Copy button in the footer (text varies between modal versions)
|
|
await expect(modal.getByRole("button", { name: /Copy.*Key/ })).toBeVisible({ timeout: 20_000 });
|
|
});
|
|
|
|
test("Update key TPM and RPM limits", async ({ page }) => {
|
|
await navigateToPage(page, Page.ApiKeys);
|
|
await dismissFeedbackPopup(page);
|
|
|
|
const keyRow = page.locator("tr", { hasText: E2E_UPDATE_LIMITS_KEY_ALIAS });
|
|
await expect(keyRow).toBeVisible({ timeout: 10_000 });
|
|
await keyRow.locator("button").first().click();
|
|
|
|
await expect(page.getByText("Back to Keys")).toBeVisible({ timeout: 10_000 });
|
|
|
|
await page.getByRole("tab", { name: "Settings" }).click();
|
|
await page.getByRole("button", { name: "Edit Settings" }).click();
|
|
|
|
await page.getByRole("spinbutton", { name: "TPM Limit" }).fill("123");
|
|
await page.getByRole("spinbutton", { name: "RPM Limit" }).fill("456");
|
|
await page.getByRole("button", { name: "Save Changes" }).click();
|
|
|
|
await expect(
|
|
page.getByRole("paragraph").filter({ hasText: "TPM: 123" })
|
|
).toBeVisible({ timeout: 10_000 });
|
|
await expect(
|
|
page.getByRole("paragraph").filter({ hasText: "RPM: 456" })
|
|
).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test("Delete key", async ({ page }) => {
|
|
await navigateToPage(page, Page.ApiKeys);
|
|
await dismissFeedbackPopup(page);
|
|
|
|
const keyRow = page.locator("tr", { hasText: E2E_DELETE_KEY_ALIAS });
|
|
await expect(keyRow).toBeVisible({ timeout: 10_000 });
|
|
await keyRow.locator("button").first().click();
|
|
|
|
await expect(page.getByText("Back to Keys")).toBeVisible({ timeout: 10_000 });
|
|
|
|
await page.getByRole("button", { name: "Delete Key" }).click();
|
|
|
|
const modal = page.locator(".ant-modal:visible");
|
|
await expect(modal).toBeVisible({ timeout: 5_000 });
|
|
await modal.locator("input").fill(E2E_DELETE_KEY_ALIAS);
|
|
|
|
const deleteButton = modal.getByRole("button", { name: "Delete", exact: true });
|
|
await expect(deleteButton).toBeEnabled();
|
|
await deleteButton.click();
|
|
|
|
await expect(page.getByText(/Key deleted/i).first()).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test("See internal user keys in team", async ({ page }) => {
|
|
await navigateToPage(page, Page.ApiKeys);
|
|
await dismissFeedbackPopup(page);
|
|
|
|
await expect(page.getByText(E2E_INTERNAL_USER_KEY_ALIAS)).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test("Create a key with All Proxy Models (no team)", async ({ page }) => {
|
|
await navigateToPage(page, Page.ApiKeys);
|
|
await dismissFeedbackPopup(page);
|
|
|
|
await page.getByRole("button", { name: /Create New Key/i }).click();
|
|
|
|
await expect(page.getByText("Key Ownership")).toBeVisible({ timeout: 10_000 });
|
|
|
|
const keyName = `e2e-admin-allproxy-${Date.now()}`;
|
|
await page.getByTestId("base-input").fill(keyName);
|
|
|
|
// No team selection — leave team dropdown empty so the key is owned by the admin user
|
|
|
|
// Select models — open the multi-select and pick the all-models meta-option.
|
|
// The Create Key modal labels this "All Team Models" even when no team is selected
|
|
// (see src/components/organisms/create_key_button.tsx:944), unlike the team/user
|
|
// settings screens which use "All Proxy Models".
|
|
await page.locator(".ant-select-selection-overflow").click();
|
|
await page.locator(".ant-select-dropdown:visible").getByText("All Team Models").click();
|
|
await page.keyboard.press("Escape");
|
|
|
|
await page.getByRole("button", { name: "Create Key", exact: true }).click();
|
|
|
|
await expect(page.getByText("Save your Key")).toBeVisible({ timeout: 10_000 });
|
|
await page.keyboard.press("Escape");
|
|
|
|
await expect(page.getByText(keyName)).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test("Create a key with a specific proxy model (no team)", async ({ page }) => {
|
|
await navigateToPage(page, Page.ApiKeys);
|
|
await dismissFeedbackPopup(page);
|
|
|
|
await page.getByRole("button", { name: /Create New Key/i }).click();
|
|
|
|
await expect(page.getByText("Key Ownership")).toBeVisible({ timeout: 10_000 });
|
|
|
|
const keyName = `e2e-admin-specific-${Date.now()}`;
|
|
await page.getByTestId("base-input").fill(keyName);
|
|
|
|
// Open the model multi-select and pick a single specific model. Use
|
|
// getByRole("option", ...) to avoid the strict-mode collision between
|
|
// the option container and its inner text node.
|
|
const modelName = "fake-openai-gpt-4";
|
|
await page.locator(".ant-select-selection-overflow").click();
|
|
const option = page.locator(".ant-select-dropdown:visible").getByRole("option", { name: modelName, exact: true });
|
|
await option.waitFor({ state: "attached" });
|
|
// Dispatch the click via the DOM — antd's dropdown can render the option
|
|
// off-viewport during the open animation, which trips Playwright's
|
|
// visibility/stability checks. The click handler fires regardless.
|
|
await option.evaluate((el: HTMLElement) => el.click());
|
|
await page.keyboard.press("Escape");
|
|
|
|
await page.getByRole("button", { name: "Create Key", exact: true }).click();
|
|
|
|
await expect(page.getByText("Save your Key")).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Grab the new key from the success modal (rendered inside a <pre>) and
|
|
// verify it can call /chat/completions for the model it was scoped to.
|
|
// The mock LLM server (fixtures/mock_llm_server/server.py) replies with
|
|
// a fixed "This is a mock response." body.
|
|
const apiKey = (await page.locator(".ant-modal:visible pre").innerText()).trim();
|
|
expect(apiKey).toMatch(/^sk-/);
|
|
|
|
const response = await page.request.post("/chat/completions", {
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
data: {
|
|
model: modelName,
|
|
messages: [{ role: "user", content: "ping" }],
|
|
},
|
|
});
|
|
expect(response.status()).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.choices?.[0]?.message?.content).toBe("This is a mock response.");
|
|
|
|
await page.keyboard.press("Escape");
|
|
|
|
await expect(page.getByText(keyName)).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
});
|