diff --git a/.github/workflows/test_server_root_path.yml b/.github/workflows/test_server_root_path.yml deleted file mode 100644 index 01f70511e79..00000000000 --- a/.github/workflows/test_server_root_path.yml +++ /dev/null @@ -1,151 +0,0 @@ -name: Test Proxy SERVER_ROOT_PATH Routing -permissions: - contents: read - -on: - pull_request: - branches: - - main - - litellm_internal_staging - - litellm_oss_staging - - "litellm_**" - -jobs: - test-server-root-path: - runs-on: ubuntu-latest - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - root_path: ["/api/v1", "/llmproxy"] - - steps: - - name: Checkout repository - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - with: - persist-credentials: false - - - name: Free up disk space - run: | - sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/share/boost - sudo apt-get clean - df -h / - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - - name: Build Docker image - uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6.14.0 - with: - context: . - file: ./docker/Dockerfile.non_root - tags: litellm-test:${{ github.sha }} - load: true - push: false - - - name: Start LiteLLM container with SERVER_ROOT_PATH - run: | - docker run -d \ - --name litellm-test \ - -p 4000:4000 \ - -e SERVER_ROOT_PATH="${{ matrix.root_path }}" \ - -e LITELLM_MASTER_KEY="sk-1234" \ - litellm-test:${{ github.sha }} \ - --detailed_debug - - - name: Wait for container to be healthy - run: | - echo "Waiting for LiteLLM to start..." - max_attempts=30 - attempt=0 - - while [ $attempt -lt $max_attempts ]; do - if docker logs litellm-test 2>&1 | grep -q "Uvicorn running"; then - echo "LiteLLM started successfully" - break - fi - attempt=$((attempt + 1)) - echo "Attempt $attempt/$max_attempts - waiting for server to start..." - sleep 2 - done - - if [ $attempt -eq $max_attempts ]; then - echo "Server failed to start within timeout" - docker logs litellm-test - exit 1 - fi - - sleep 5 - - - name: Show container logs - if: always() - run: docker logs litellm-test - - - name: Test UI endpoint with root path - run: | - ROOT_PATH="${{ matrix.root_path }}" - echo "Testing UI at: http://localhost:4000${ROOT_PATH}/ui/" - - for i in 1 2 3; do - content=$(curl -sL --max-time 5 -H "Authorization: Bearer sk-1234" "http://localhost:4000${ROOT_PATH}/ui/") - if echo "$content" | grep -q -E "(html| { - // Matches both `/litellm/.well-known/litellm-ui-config` and - // `${SERVER_ROOT_PATH}/.well-known/litellm-ui-config` (the proxy rewrites the - // bundle at boot when a root path is set). - await page.route("**/.well-known/litellm-ui-config", async (route) => { - await new Promise((resolve) => setTimeout(resolve, 500)); - await route.continue(); - }); - - await page.context().clearCookies(); - - await page.goto(`http://localhost:4000${ROOT_PATH}/ui/?page=virtual-keys`); - - await page.waitForURL((url) => url.pathname.includes("/ui/login"), { timeout: 15_000 }); - - // The redirect target is built by joining proxyBaseUrl (assembled by - // resolveApiBase from the origin + SERVER_ROOT_PATH) with "/ui/login". A - // regression in that join surfaces as a doubled separator, which the loose - // toContain above would still accept, so assert the prefix joins exactly once. - const { pathname } = new URL(page.url()); - expect(pathname.startsWith(`${ROOT_PATH}/ui/login`)).toBe(true); - expect(pathname).not.toContain("//"); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.serverRootPath.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.serverRootPath.test.ts new file mode 100644 index 00000000000..228811ac37c --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.serverRootPath.test.ts @@ -0,0 +1,87 @@ +/* @vitest-environment jsdom */ +import React from "react"; +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import useAuthorized from "./useAuthorized"; + +vi.unmock("@/app/(dashboard)/hooks/useAuthorized"); + +const replaceMock = vi.fn(); + +const UI_CONFIG_DELAY_MS = 50; + +const uiConfigResponse = { + server_root_path: "/llmproxy", + proxy_base_url: null, + auto_redirect_to_sso: false, + admin_ui_disabled: false, + sso_configured: false, +}; + +const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (!url.includes("/litellm/.well-known/litellm-ui-config")) { + throw new Error(`unexpected fetch: ${url}`); + } + await new Promise((resolve) => setTimeout(resolve, UI_CONFIG_DELAY_MS)); + return { ok: true, json: async () => uiConfigResponse } as unknown as Response; +}); + +const wrapper = ({ children }: { children: React.ReactNode }) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + return React.createElement(QueryClientProvider, { client: queryClient }, children); +}; + +describe("useAuthorized under SERVER_ROOT_PATH", () => { + const originalLocation = window.location; + + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + Object.defineProperty(window, "location", { + value: { + href: "http://proxy.example/llmproxy/ui/?page=virtual-keys", + origin: "http://proxy.example", + hostname: "proxy.example", + pathname: "/llmproxy/ui/", + search: "?page=virtual-keys", + protocol: "http:", + replace: replaceMock, + }, + writable: true, + }); + document.cookie = "token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; + }); + + afterEach(() => { + Object.defineProperty(window, "location", { value: originalLocation, writable: true }); + vi.unstubAllGlobals(); + replaceMock.mockReset(); + fetchMock.mockClear(); + }); + + it("sends an unauthenticated visitor to a login URL that keeps the server root path", async () => { + renderHook(() => useAuthorized(), { wrapper }); + + await waitFor(() => { + expect(replaceMock).toHaveBeenCalled(); + }); + + expect(replaceMock).toHaveBeenCalledTimes(1); + const { origin, pathname } = new URL(replaceMock.mock.calls[0][0] as string); + expect(origin).toBe("http://proxy.example"); + expect(pathname).toBe("/llmproxy/ui/login/"); + }); + + it("does not redirect before the UI config resolves the server root path", async () => { + renderHook(() => useAuthorized(), { wrapper }); + + expect(replaceMock).not.toHaveBeenCalled(); + await new Promise((resolve) => setTimeout(resolve, UI_CONFIG_DELAY_MS / 2)); + expect(replaceMock).not.toHaveBeenCalled(); + + await waitFor(() => { + expect(replaceMock).toHaveBeenCalled(); + }); + }); +});