test(e2e): cover PROXY_LOGOUT_URL redirect on Logout (#29080)

* test(e2e): cover PROXY_LOGOUT_URL redirect on Logout

Env-gated spec mirroring the existing serverRootPathRedirect pattern:
when the proxy is booted with PROXY_LOGOUT_URL set, clicking Logout in
the navbar must navigate to that external URL. The standard run_e2e.sh
exports an empty value so the rest of the suite is unaffected; this
spec self-skips unless the env var is populated.

* test(e2e): run PROXY_LOGOUT_URL spec in the suite + harden logout assertions

Boot the e2e proxy with PROXY_LOGOUT_URL set (job-level env in CircleCI and
run_e2e.sh) so proxyLogoutUrl.spec.ts actually runs instead of self-skipping.
Nothing else in the suite performs a logout, so this only affects the behavior
under test.

Harden the spec to verify the logout flow rather than a URL substring:
- wait for /sso/get/ui_settings before clicking so logoutUrl is populated
  (otherwise window.location.href = "" silently reloads same-origin)
- assert a token cookie exists first, and is cleared after logout
- locate the dropdown via getByRole instead of internal antd CSS classes
- stub the external destination and assert on URL origin + path prefix

* test(e2e): assert exact PROXY_LOGOUT_URL on logout redirect

Replace the origin + startsWith(pathname) checks with a single normalized
href comparison. With PROXY_LOGOUT_URL=https://www.example.com the path was
"/", so startsWith("/") matched any path and left path/query/hash
unchecked. Comparing normalized hrefs pins scheme, host, port, path, query
and hash while still tolerating the browser's trailing-slash/default-port
normalization.
This commit is contained in:
ryan-crabbe-berri 2026-05-30 18:19:04 -07:00 committed by GitHub
parent 7d1bd9d9f4
commit a9cc6ed68c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 94 additions and 3 deletions

View File

@ -2400,6 +2400,11 @@ jobs:
environment:
DATABASE_URL: "postgresql://e2euser:e2epassword@localhost:5432/litellm_e2e"
CI: "true"
# Boot the proxy with an external logout URL so proxyLogoutUrl.spec.ts can
# assert the redirect. Set at job level so both the proxy boot step and the
# Playwright step (whose skip guard reads this) see the same value. Safe for
# the rest of the suite: nothing else performs a logout.
PROXY_LOGOUT_URL: "https://www.example.com"
steps:
- checkout
- setup_google_dns
@ -2476,7 +2481,8 @@ jobs:
MOCK_LLM_URL: "http://127.0.0.1:8090/v1"
DISABLE_SCHEMA_UPDATE: "true"
SERVER_ROOT_PATH: ""
PROXY_LOGOUT_URL: ""
# PROXY_LOGOUT_URL is inherited from the job-level environment so the
# proxy and proxyLogoutUrl.spec.ts agree on the logout target.
# LITELLM_LICENSE is forwarded from the project env so premium-gated
# UI flows can be exercised. license.spec.ts asserts the resulting
# JWT carries premium_user=true; if it ever stops being passed, that

View File

@ -93,8 +93,11 @@ export MOCK_LLM_URL="http://127.0.0.1:8090/v1"
export DISABLE_SCHEMA_UPDATE="true"
# Ensure the proxy serves UI at /ui (not behind a subpath)
export SERVER_ROOT_PATH=""
# Prevent logout from redirecting to an external URL
export PROXY_LOGOUT_URL=""
# Boot with an external logout URL so proxyLogoutUrl.spec.ts can assert the
# redirect. This same value is exported to the Playwright process below (the
# spec's skip guard reads it). Safe for the rest of the suite — nothing else
# performs a logout.
export PROXY_LOGOUT_URL="https://www.example.com"
# Forward LITELLM_LICENSE if set in the outer env so premium-gated UI flows
# (e.g. Team-BYOK Model switch) can be exercised. Tests that depend on a
# premium proxy gate themselves on process.env.LITELLM_LICENSE.

View File

@ -0,0 +1,82 @@
import { test, expect } from "@playwright/test";
import { ADMIN_STORAGE_PATH } from "../../constants";
/**
* Runs as part of the standard e2e suite: both `run_e2e.sh` and the CircleCI
* `e2e_ui_testing` job boot the proxy with PROXY_LOGOUT_URL=https://www.example.com
* and export the same value to this Playwright process. The spec reads it to
* know where the browser is expected to land.
*
* The skip guard below is a safety net for environments that launch the proxy
* without the env var (e.g. an ad-hoc `npx playwright test` against a default
* proxy) there the logout target is empty and this contract can't be checked.
*/
const LOGOUT_URL = process.env.PROXY_LOGOUT_URL ?? "";
test.skip(!LOGOUT_URL, "Requires PROXY_LOGOUT_URL env var");
test.describe("PROXY_LOGOUT_URL redirect", () => {
test.use({ storageState: ADMIN_STORAGE_PATH });
test("Logout clears the session and redirects to PROXY_LOGOUT_URL", async ({ page }) => {
const target = new URL(LOGOUT_URL);
// Stub the external logout destination so the assertion doesn't depend on
// that host being reachable from CI — we only care that the browser is sent
// there, not what it serves back.
await page.route(
(url) => url.origin === target.origin,
(route) =>
route.fulfill({
status: 200,
contentType: "text/html",
body: "<html><body>logged out</body></html>",
}),
);
// navbar.tsx populates the logout target only after the proxy UI settings
// fetch (/sso/get/ui_settings) resolves. Clicking Logout before that lands
// runs `window.location.href = ""` — a same-origin reload, not a redirect —
// so gate the click on the settings response, not just on first paint.
const settingsLoaded = page.waitForResponse(
(r) => r.url().includes("/sso/get/ui_settings") && r.ok(),
{ timeout: 30_000 },
);
await page.goto("/ui");
await expect(page.getByText("Virtual Keys")).toBeVisible({ timeout: 15_000 });
await settingsLoaded;
// Pre-condition: we start authenticated. The admin storage state carries a
// `token` cookie, so a real logout has something to tear down.
const tokensBefore = (await page.context().cookies()).filter((c) => c.name === "token");
expect(tokensBefore.length, "should start logged in with a token cookie").toBeGreaterThan(0);
// Open the navbar account dropdown (trigger=click) and click Logout by role
// rather than internal Ant Design CSS classes, which are not a stable API.
await page.getByRole("button", { name: /^Account menu/ }).click();
const logout = page.getByRole("menuitem", { name: "Logout" });
await expect(logout).toBeVisible({ timeout: 5_000 });
// handleLogout clears cookies/local storage, then assigns window.location.href.
// Arm the navigation wait before the click so we never miss the redirect.
await Promise.all([
page.waitForURL((url) => url.origin === target.origin, { timeout: 15_000 }),
logout.click(),
]);
// The browser landed on exactly the configured logout URL. Compare normalized
// hrefs (both sides through URL()) so trailing-slash / default-port rewrites the
// browser applies are matched on the expected side too — this pins scheme, host,
// port, path, query and hash, not just the origin.
const landed = new URL(page.url());
expect(landed.href).toBe(target.href);
// ...and the client-side session cookie is gone (clearTokenCookies ran before
// the redirect). HttpOnly cookies set server-side can't be cleared from JS,
// so scope the check to the JS-managed token the UI is responsible for.
const clientTokensAfter = (await page.context().cookies()).filter(
(c) => c.name === "token" && !c.httpOnly,
);
expect(clientTokensAfter, "client token cookie should be cleared on logout").toHaveLength(0);
});
});