mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
* fix(ui): navigate to /ui/login/ with trailing slash via hard navigation Logged-out redirects targeted /ui/login without the trailing slash, so Starlette's StaticFiles(html=True) mount answered with a 307 whose absolute Location is built from the scheme the container sees. Behind a TLS-terminating reverse proxy uvicorn does not trust X-Forwarded-Proto by default, so the redirect downgraded https to http and stranded users on an unreachable URL (#33454). The auth guard also used the Next client router for this navigation, which first requests an RSC payload that the static export cannot serve, producing 404s before falling back to a full page load. Centralize the login URL in getLoginUrl(), which always emits the trailing slash so no server redirect fires, and use window.location.replace for the login redirects so no RSC fetch is attempted. * test(ui): expect trailing slash in expired-token login redirect
315 lines
12 KiB
TypeScript
315 lines
12 KiB
TypeScript
import React from "react";
|
||
import { render, screen, waitFor } from "@testing-library/react";
|
||
import { vi, describe, it, beforeEach, afterEach, expect } from "vitest";
|
||
|
||
/** ----------------------------
|
||
* Hoisted helpers for mocks (required by Vitest)
|
||
* --------------------------- */
|
||
const { stub, jwtDecodeMock, consumeReturnUrlMock } = vi.hoisted(() => {
|
||
const React = require("react");
|
||
const stub = (name: string) => () => React.createElement("div", { "data-testid": name });
|
||
return {
|
||
stub,
|
||
jwtDecodeMock: vi.fn(),
|
||
consumeReturnUrlMock: vi.fn(),
|
||
};
|
||
});
|
||
|
||
/** ----------------------------
|
||
* Mocks
|
||
* --------------------------- */
|
||
|
||
vi.mock("@/hooks/useFeatureFlags", () => {
|
||
const React = require("react");
|
||
|
||
// minimal context so useFeatureFlags() returns something stable
|
||
const FeatureFlagsCtx = React.createContext({ get: () => false, flags: {} });
|
||
|
||
// Defensive provider: handle undefined props and allow optional value override
|
||
const FeatureFlagsProvider = (props: any) => {
|
||
const p = props || {};
|
||
const value = p.value ?? { get: () => false, flags: {} };
|
||
return React.createElement(FeatureFlagsCtx.Provider, { value }, p.children);
|
||
};
|
||
|
||
const useFeatureFlags = () => React.useContext(FeatureFlagsCtx);
|
||
|
||
return {
|
||
__esModule: true,
|
||
default: FeatureFlagsProvider, // supports default import
|
||
FeatureFlagsProvider, // supports named import
|
||
useFeatureFlags, // supports named import
|
||
};
|
||
});
|
||
|
||
// next/navigation mock: search params + router + pathname
|
||
vi.mock("next/navigation", () => {
|
||
const router = {
|
||
push: vi.fn(),
|
||
replace: vi.fn(),
|
||
prefetch: vi.fn(),
|
||
back: vi.fn(),
|
||
forward: vi.fn(),
|
||
refresh: vi.fn(),
|
||
};
|
||
|
||
return {
|
||
__esModule: true,
|
||
// what your tests already relied on
|
||
useSearchParams: () => new URLSearchParams(""),
|
||
// added: satisfies useAuthorized / SidebarProvider
|
||
useRouter: () => router,
|
||
// optional helpers some components often read
|
||
usePathname: () => "/",
|
||
// optional: noop versions if code calls them
|
||
redirect: vi.fn(), // App Router server action usually; safe noop here
|
||
notFound: vi.fn(),
|
||
};
|
||
});
|
||
|
||
// Networking layer
|
||
vi.mock("@/components/networking", () => {
|
||
return {
|
||
// Called on mount; we don't care about its contents, only that it resolves
|
||
getUiConfig: vi.fn().mockResolvedValue({}),
|
||
// Fetched by useUISettings(); resolve with empty settings so nudges stay default-on
|
||
getUiSettings: vi.fn().mockResolvedValue({ values: {}, field_schema: {} }),
|
||
// Used to build the redirect URL
|
||
proxyBaseUrl: "https://example.com",
|
||
// Called when decoding a valid token
|
||
setGlobalLitellmHeaderName: vi.fn(),
|
||
Organization: {},
|
||
// Daily activity calls used by UsagePage components in the render tree
|
||
tagDailyActivityCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }),
|
||
teamDailyActivityCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }),
|
||
organizationDailyActivityCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }),
|
||
customerDailyActivityCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }),
|
||
agentDailyActivityCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }),
|
||
userDailyActivityCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }),
|
||
userDailyActivityAggregatedCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }),
|
||
};
|
||
});
|
||
|
||
// jwt-decode: we’ll swap implementation per test via mockImplementation
|
||
vi.mock("jwt-decode", () => ({
|
||
jwtDecode: (token: string) => jwtDecodeMock(token),
|
||
}));
|
||
|
||
vi.mock("@/utils/returnUrlUtils", async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import("@/utils/returnUrlUtils")>();
|
||
return {
|
||
...actual,
|
||
consumeReturnUrl: consumeReturnUrlMock,
|
||
};
|
||
});
|
||
|
||
// Super-light stubs for all heavy components so rendering doesn't explode
|
||
vi.mock("@/components/navbar", () => ({ default: stub("navbar") }));
|
||
vi.mock("@/components/user_dashboard", () => ({ default: stub("user-dashboard") }));
|
||
vi.mock("@/components/templates/model_dashboard", () => ({ default: stub("model-dashboard") }));
|
||
vi.mock("@/components/teams", () => ({ default: stub("teams") }));
|
||
vi.mock("@/app/(dashboard)/organizations/_components/organizations", () => ({
|
||
default: stub("organizations"),
|
||
fetchOrganizations: vi.fn(), // consumed in effects
|
||
}));
|
||
vi.mock("@/components/admins", () => ({ default: stub("admin-panel") }));
|
||
vi.mock("@/components/settings", () => ({ default: stub("settings") }));
|
||
vi.mock("@/app/(dashboard)/router-settings/_components/general_settings", () => ({
|
||
default: stub("general-settings"),
|
||
}));
|
||
vi.mock("@/components/pass_through_settings", () => ({ default: stub("pass-through-settings") }));
|
||
vi.mock("@/components/budgets/budget_panel", () => ({ default: stub("budget-panel") }));
|
||
vi.mock("@/components/view_logs", () => ({ default: stub("spend-logs") }));
|
||
vi.mock("@/components/model_hub_table", () => ({ default: stub("model-hub-table") }));
|
||
vi.mock("@/components/new_usage", () => ({ default: stub("new-usage") }));
|
||
vi.mock("@/components/api_ref", () => ({ default: stub("api-ref") }));
|
||
vi.mock("@/components/chat_ui/ChatUI", () => ({ default: stub("chat-ui") }));
|
||
vi.mock("@/components/leftnav", () => ({ default: stub("sidebar") }));
|
||
vi.mock("@/app/(dashboard)/old-usage/_components/usage", () => ({ default: stub("usage") }));
|
||
vi.mock("@/components/cache_dashboard", () => ({ default: stub("cache-dashboard") }));
|
||
vi.mock("@/app/(dashboard)/guardrails/_components", () => ({ default: stub("guardrails") }));
|
||
vi.mock("@/components/prompts", () => ({ default: stub("prompts") }));
|
||
vi.mock("@/components/transform_request", () => ({ default: stub("transform-request") }));
|
||
vi.mock("@/app/(dashboard)/mcp-servers/_components", () => ({ MCPServers: stub("mcp-servers") }));
|
||
vi.mock("@/app/(dashboard)/tag-management/_components", () => ({ default: stub("tag-management") }));
|
||
vi.mock("@/app/(dashboard)/vector-stores/_components", () => ({ default: stub("vector-stores") }));
|
||
vi.mock("@/components/ui_theme_settings", () => ({ default: stub("ui-theme-settings") }));
|
||
vi.mock("@/components/organisms/create_key_button", () => ({ fetchUserModels: vi.fn() }));
|
||
vi.mock("@/components/common_components/fetch_teams", () => ({ fetchTeams: vi.fn() }));
|
||
vi.mock("@/components/ui/ui-loading-spinner", () => ({
|
||
UiLoadingSpinner: stub("spinner"),
|
||
}));
|
||
vi.mock("@/contexts/ThemeContext", () => {
|
||
const React = require("react");
|
||
return {
|
||
ThemeProvider: ({ children }: any) => React.createElement(React.Fragment, null, children),
|
||
};
|
||
});
|
||
vi.mock("@/lib/cva.config", () => ({
|
||
cx: (...args: string[]) => args.join(" "),
|
||
}));
|
||
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import CreateKeyPage from "@/app/(dashboard)/page";
|
||
import { AuthProvider } from "@/contexts/AuthContext";
|
||
|
||
// The page consumes auth state via useAuth(). Wrap it so the hook resolves
|
||
// against a real provider — the provider's effects (cookie read, JWT decode,
|
||
// redirect-on-expired) are what these tests exercise. The QueryClientProvider
|
||
// mirrors what layout.tsx supplies in production for hooks like useUISettings.
|
||
function PageUnderTest() {
|
||
const [queryClient] = React.useState(() => new QueryClient({ defaultOptions: { queries: { retry: false } } }));
|
||
return (
|
||
<QueryClientProvider client={queryClient}>
|
||
<AuthProvider>
|
||
<CreateKeyPage />
|
||
</AuthProvider>
|
||
</QueryClientProvider>
|
||
);
|
||
}
|
||
|
||
/** ----------------------------
|
||
* Helpers
|
||
* --------------------------- */
|
||
|
||
function setCookie(raw: string) {
|
||
// JSDOM allows simple string assignment to document.cookie
|
||
document.cookie = raw;
|
||
}
|
||
|
||
function clearAllCookies() {
|
||
// JSDOM doesn't give an API to clear; overwrite with empty string
|
||
// plus ensure we wipe known names used by this app.
|
||
document.cookie = "token=; Max-Age=0; Path=/";
|
||
}
|
||
|
||
const originalLocation = window.location;
|
||
|
||
beforeEach(() => {
|
||
// Fresh module state & DOM
|
||
vi.clearAllMocks();
|
||
clearAllCookies();
|
||
consumeReturnUrlMock.mockReturnValue(null);
|
||
|
||
// Make location.replace spy-able to validate redirect
|
||
delete (window as any).location;
|
||
// minimal location object with replace and assign stubs
|
||
(window as any).location = {
|
||
...originalLocation,
|
||
href: "http://localhost/",
|
||
assign: vi.fn(),
|
||
replace: vi.fn(),
|
||
};
|
||
});
|
||
|
||
afterEach(() => {
|
||
// Restore location to avoid leaking across test envs
|
||
delete (window as any).location;
|
||
(window as any).location = originalLocation;
|
||
});
|
||
|
||
/** ----------------------------
|
||
* Tests
|
||
* --------------------------- */
|
||
|
||
describe("CreateKeyPage auth behavior", () => {
|
||
it("redirects to SSO when cookie token is expired and clears it (no spasms)", async () => {
|
||
// Arrange: expired token in cookie
|
||
setCookie("token=expiredtoken");
|
||
|
||
// jwtDecode returns past exp → expired
|
||
jwtDecodeMock.mockImplementation((tok: string) => {
|
||
expect(tok).toBe("expiredtoken");
|
||
return { exp: Math.floor(Date.now() / 1000) - 60 }; // expired 60s ago
|
||
});
|
||
|
||
// Spy on cookie writes to ensure we clear with Max-Age=0
|
||
const cookieSetSpy = vi.spyOn(document, "cookie", "set");
|
||
|
||
// Act
|
||
render(<PageUnderTest />);
|
||
|
||
// Assert: we eventually redirect to SSO login with return URL (single replace, not assign/href)
|
||
await waitFor(() => {
|
||
expect(window.location.replace).toHaveBeenCalledWith(
|
||
expect.stringContaining("https://example.com/ui/login/?redirect_to="),
|
||
);
|
||
});
|
||
|
||
// And we attempted to clear the cookie (defensive deletion)
|
||
const wroteDeletion = cookieSetSpy.mock.calls.some(
|
||
(args) => typeof args[0] === "string" && args[0].includes("Max-Age=0") && args[0].startsWith("token="),
|
||
);
|
||
expect(wroteDeletion).toBe(true);
|
||
});
|
||
|
||
it("does NOT redirect when token is valid and renders the page content", async () => {
|
||
// Arrange: valid token in cookie
|
||
setCookie("token=validtoken");
|
||
|
||
// jwtDecode returns future exp and expected shape
|
||
jwtDecodeMock.mockImplementation((tok: string) => {
|
||
expect(tok).toBe("validtoken");
|
||
return {
|
||
exp: Math.floor(Date.now() / 1000) + 60 * 60, // 1h in the future
|
||
key: "accessKey-123",
|
||
user_role: "app_user",
|
||
user_email: "user@example.com",
|
||
login_method: "username_password",
|
||
premium_user: false,
|
||
auth_header_name: "x-litellm-auth",
|
||
user_id: "u_123",
|
||
};
|
||
});
|
||
|
||
// Act
|
||
render(<PageUnderTest />);
|
||
|
||
// Assert: no redirect
|
||
await waitFor(() => {
|
||
expect(window.location.replace).not.toHaveBeenCalled();
|
||
});
|
||
|
||
// And the default page content appears (UserDashboard stub; chrome now lives in the layout)
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("user-dashboard")).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
it("should not redirect when return URL only differs by query order", async () => {
|
||
setCookie("token=validtoken");
|
||
|
||
jwtDecodeMock.mockImplementation((tok: string) => {
|
||
expect(tok).toBe("validtoken");
|
||
return {
|
||
exp: Math.floor(Date.now() / 1000) + 60 * 60,
|
||
key: "accessKey-123",
|
||
user_role: "app_user",
|
||
user_email: "user@example.com",
|
||
login_method: "username_password",
|
||
premium_user: false,
|
||
auth_header_name: "x-litellm-auth",
|
||
user_id: "u_123",
|
||
};
|
||
});
|
||
|
||
// Current URL has params in a different order
|
||
delete (window as any).location;
|
||
(window as any).location = {
|
||
...originalLocation,
|
||
href: "http://localhost/ui?b=2&a=1",
|
||
origin: "http://localhost",
|
||
assign: vi.fn(),
|
||
replace: vi.fn(),
|
||
};
|
||
|
||
// Return URL has the same params in a different order
|
||
consumeReturnUrlMock.mockReturnValue("http://localhost/ui?a=1&b=2");
|
||
|
||
render(<PageUnderTest />);
|
||
|
||
await waitFor(() => {
|
||
expect(window.location.replace).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
});
|