diff --git a/.circleci/config.yml b/.circleci/config.yml index cf69ff68da..1462891fa7 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -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 diff --git a/ui/litellm-dashboard/e2e_tests/run_e2e.sh b/ui/litellm-dashboard/e2e_tests/run_e2e.sh index 36619dce9b..ed0641d04e 100755 --- a/ui/litellm-dashboard/e2e_tests/run_e2e.sh +++ b/ui/litellm-dashboard/e2e_tests/run_e2e.sh @@ -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. diff --git a/ui/litellm-dashboard/e2e_tests/tests/auth/proxyLogoutUrl.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/auth/proxyLogoutUrl.spec.ts new file mode 100644 index 0000000000..4a233ed1bb --- /dev/null +++ b/ui/litellm-dashboard/e2e_tests/tests/auth/proxyLogoutUrl.spec.ts @@ -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: "logged out", + }), + ); + + // 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); + }); +});