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:
parent
7d1bd9d9f4
commit
a9cc6ed68c
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user