diff --git a/ui/litellm-dashboard/e2e_tests/constants.ts b/ui/litellm-dashboard/e2e_tests/constants.ts index dbc73432f65e..ee0113d3d80e 100644 --- a/ui/litellm-dashboard/e2e_tests/constants.ts +++ b/ui/litellm-dashboard/e2e_tests/constants.ts @@ -5,6 +5,12 @@ export const INTERNAL_USER_STORAGE_PATH = "internalUser.storageState.json"; export const INTERNAL_VIEWER_STORAGE_PATH = "internalViewer.storageState.json"; export const TEAM_ADMIN_STORAGE_PATH = "teamAdmin.storageState.json"; +// Seeded user identities (match seed.sql) +export const E2E_PROXY_ADMIN_USER_ID = "e2e-proxy-admin"; +export const E2E_PROXY_ADMIN_EMAIL = "admin@test.local"; +export const E2E_INTERNAL_USER_ID = "e2e-internal-user"; +export const E2E_INTERNAL_USER_EMAIL = "internal@test.local"; + // Key aliases for seeded test keys (match seed.sql) export const E2E_UPDATE_LIMITS_KEY_ALIAS = "e2eUpdateLimitsKey"; export const E2E_DELETE_KEY_ALIAS = "e2eDeleteKey"; diff --git a/ui/litellm-dashboard/e2e_tests/tests/login/internalUserIdentity.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/login/internalUserIdentity.spec.ts new file mode 100644 index 000000000000..cbe952769290 --- /dev/null +++ b/ui/litellm-dashboard/e2e_tests/tests/login/internalUserIdentity.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from "@playwright/test"; +import { + E2E_INTERNAL_USER_EMAIL, + E2E_INTERNAL_USER_ID, + E2E_PROXY_ADMIN_EMAIL, + E2E_PROXY_ADMIN_USER_ID, + INTERNAL_USER_STORAGE_PATH, +} from "../../constants"; + +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +test.describe("Navbar identity scoping", () => { + test.use({ storageState: INTERNAL_USER_STORAGE_PATH }); + + test("Internal user navbar dropdown shows their own role and user id, not the admin's", async ({ page }) => { + await page.goto("/ui"); + await expect(page.getByText("Virtual Keys")).toBeVisible({ timeout: 10_000 }); + + // The account menu button carries the user's role and email/id in its + // aria-label (see UserDropdown.tsx). Match by partial role. + const accountButton = page.locator('button[aria-label^="Account menu"]').first(); + await expect(accountButton).toHaveAttribute("aria-label", /Internal User/, { timeout: 5_000 }); + await expect(accountButton).toHaveAttribute( + "aria-label", + new RegExp( + `signed in as (${escapeRegExp(E2E_INTERNAL_USER_EMAIL)}|${escapeRegExp(E2E_INTERNAL_USER_ID)})`, + ), + { timeout: 5_000 }, + ); + + // Open the dropdown (UserDropdown configures trigger=["click"]). + await accountButton.click(); + + // Locate the panel by its test id (data-testid on the popupRender div in + // UserDropdown.tsx) rather than Ant/Tailwind class names, so styling + // refactors don't silently break the identity-scoping assertions below. + const popup = page.getByTestId("user-dropdown-panel"); + await expect(popup).toBeVisible({ timeout: 5_000 }); + + // The popup must show the internal user's identity — not the seeded + // proxy admin's email/id, which would indicate a session/scope leak. + await expect(popup.getByText(E2E_INTERNAL_USER_EMAIL)).toBeVisible({ timeout: 5_000 }); + await expect(popup.getByText(E2E_INTERNAL_USER_ID)).toBeVisible({ timeout: 5_000 }); + await expect(popup.getByText("Internal User", { exact: true })).toBeVisible({ timeout: 5_000 }); + await expect(popup.getByText(E2E_PROXY_ADMIN_EMAIL)).toHaveCount(0); + await expect(popup.getByText(E2E_PROXY_ADMIN_USER_ID)).toHaveCount(0); + }); +}); diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index 64a2f1260ba4..03312faaa396 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx @@ -221,7 +221,7 @@ const UserDropdown: React.FC = ({ onLogout }) => { trigger={["click"]} menu={{ items: userItems }} popupRender={(menu) => ( -
+
{renderUserInfoSection()} {React.cloneElement(menu as React.ReactElement, {