Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import SpendLogsTable from "./index";
import { renderWithProviders, testQueryClient } from "../../../tests/test-utils";

const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));

vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: useAuthorizedMock,
}));

vi.mock("./RequestLogsPanel", () => ({
default: function RequestLogsPanelMock() {
return <div data-testid="request-logs-panel" />;
},
}));

const fetchMock = vi.fn();

const jsonResponse = (body: unknown) => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => body,
});

const requestedUrls = () => fetchMock.mock.calls.map(([url]) => String(url));

const emptyAuditLogs = { audit_logs: [], total: 0, page: 1, page_size: 50, total_pages: 0 };

const defaultProps = {
accessToken: "sk-test",
token: "jwt-test",
userRole: "Admin",
userID: "user-1",
premiumUser: true,
};

const renderAs = (sessionRole: string) => {
useAuthorizedMock.mockReturnValue({ accessToken: "sk-test", userRole: sessionRole, premiumUser: true });
return renderWithProviders(<SpendLogsTable {...defaultProps} userRole={sessionRole} />);
};

describe("SpendLogsTable network access by role", () => {
beforeEach(() => {
testQueryClient.clear();
vi.clearAllMocks();
fetchMock.mockImplementation(async (url: string) => {
if (String(url).includes("/audit")) {
return jsonResponse(emptyAuditLogs);
}
if (String(url).includes("/v2/team/list")) {
return jsonResponse({ teams: [] });
}
return jsonResponse({ keys: [], total_count: 0 });
});
vi.stubGlobal("fetch", fetchMock);
});

it("fires neither the audit nor the deleted-teams request for an internal user", async () => {
const user = userEvent.setup();
renderAs("Internal User");

// Liveness gate: the sibling Deleted Keys panel does reach the network, so a
// silent absence below means the gate worked, not that nothing rendered.
await waitFor(() => expect(requestedUrls().some((url) => url.includes("/key/list"))).toBe(true));

await user.click(screen.getByRole("tab", { name: "Deleted Keys" }));
await user.click(screen.getByRole("tab", { name: "Request Logs" }));

expect(requestedUrls().filter((url) => url.includes("/audit"))).toEqual([]);
expect(requestedUrls().filter((url) => url.includes("/v2/team/list"))).toEqual([]);
});

it("fetches deleted teams and audit logs for an admin", async () => {
const user = userEvent.setup();
renderAs("Admin");

await waitFor(() =>
expect(requestedUrls().some((url) => url.includes("/v2/team/list") && url.includes("status=deleted"))).toBe(true),
);

expect(requestedUrls().filter((url) => url.includes("/audit"))).toEqual([]);

await user.click(screen.getByRole("tab", { name: "Audit Logs" }));

await waitFor(() => expect(requestedUrls().some((url) => url.includes("/audit"))).toBe(true));
});

it("leaves the audit request unsent when an admin selects a tab after Audit Logs", async () => {
const user = userEvent.setup();
renderAs("Admin");

await user.click(screen.getByRole("tab", { name: "Deleted Teams" }));

expect(screen.getByRole("tab", { name: "Deleted Teams" })).toHaveAttribute("aria-selected", "true");
expect(requestedUrls().filter((url) => url.includes("/audit"))).toEqual([]);

await user.click(screen.getByRole("tab", { name: "Audit Logs" }));

await waitFor(() => expect(requestedUrls().some((url) => url.includes("/audit"))).toBe(true));
});
});
79 changes: 75 additions & 4 deletions ui/litellm-dashboard/src/components/view_logs/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import SpendLogsTable from "./index";
import { renderWithProviders } from "../../../tests/test-utils";

const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));

vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: useAuthorizedMock,
}));

vi.mock("./RequestLogsPanel", () => ({
default: function RequestLogsPanelMock({ isActive }: { isActive: boolean }) {
return <div data-testid="request-logs-panel">{isActive ? "active" : "inactive"}</div>;
Expand Down Expand Up @@ -36,9 +42,18 @@ const defaultProps = {
premiumUser: false,
};

const renderAs = (sessionRole: string) => {
useAuthorizedMock.mockReturnValue({ userRole: sessionRole });
return renderWithProviders(<SpendLogsTable {...defaultProps} userRole={sessionRole} />);
};

describe("SpendLogsTable", () => {
beforeEach(() => {
useAuthorizedMock.mockReturnValue({ userRole: "Admin" });
});

it("renders the four log tabs", () => {
renderWithProviders(<SpendLogsTable {...defaultProps} />);
renderAs("Admin");

for (const label of ["Request Logs", "Audit Logs", "Deleted Keys", "Deleted Teams"]) {
expect(screen.getByRole("tab", { name: label })).toBeInTheDocument();
Expand All @@ -47,7 +62,7 @@ describe("SpendLogsTable", () => {

it("marks only the visible tab's panel active so background tabs do not query", async () => {
const user = userEvent.setup();
renderWithProviders(<SpendLogsTable {...defaultProps} />);
renderAs("Admin");

expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("active");

Expand All @@ -57,16 +72,72 @@ describe("SpendLogsTable", () => {
expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("inactive");
});

describe("admin-only tabs", () => {
it.each(["Internal User", "Internal Viewer"])("hides Audit Logs and Deleted Teams from %s", (role) => {
renderAs(role);

expect(screen.getByRole("tab", { name: "Request Logs" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Deleted Keys" })).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: "Audit Logs" })).not.toBeInTheDocument();
expect(screen.queryByRole("tab", { name: "Deleted Teams" })).not.toBeInTheDocument();
});

it("never mounts the panels that call the admin-only endpoints for an internal user", () => {
renderAs("Internal User");

expect(screen.queryByTestId("audit-logs-panel")).not.toBeInTheDocument();
expect(screen.queryByTestId("deleted-teams-page")).not.toBeInTheDocument();
expect(screen.getByTestId("deleted-keys-page")).toBeInTheDocument();
});
});

describe("tab index mapping", () => {
it("activates the panel the admin selected, not the one at the old hardcoded index", async () => {
const user = userEvent.setup();
renderAs("Admin");

await user.click(screen.getByRole("tab", { name: "Deleted Keys" }));

expect(screen.getByTestId("audit-logs-panel")).toHaveTextContent("inactive");
expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("inactive");
});

it("keeps the audit panel inert when an admin selects the last tab", async () => {
const user = userEvent.setup();
renderAs("Admin");

await user.click(screen.getByRole("tab", { name: "Deleted Teams" }));

expect(screen.getByTestId("audit-logs-panel")).toHaveTextContent("inactive");
expect(screen.getByTestId("deleted-teams-page")).toBeInTheDocument();
});

it("selects the last visible tab for an internal user and returns to Request Logs", async () => {
const user = userEvent.setup();
renderAs("Internal User");

await user.click(screen.getByRole("tab", { name: "Deleted Keys" }));

expect(screen.getByTestId("deleted-keys-page")).toBeInTheDocument();
expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("inactive");

await user.click(screen.getByRole("tab", { name: "Request Logs" }));

expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("active");
});
});

describe("auth-not-ready guard", () => {
it("shows a loading spinner when credentials are not yet resolved", () => {
useAuthorizedMock.mockReturnValue({ userRole: "Admin" });
renderWithProviders(<SpendLogsTable {...defaultProps} accessToken={null} />);

expect(document.querySelector(".ant-spin")).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: "Request Logs" })).not.toBeInTheDocument();
});

it("renders the tabs (no spinner) once all credentials are present", () => {
renderWithProviders(<SpendLogsTable {...defaultProps} />);
renderAs("Admin");

expect(document.querySelector(".ant-spin")).not.toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Request Logs" })).toBeInTheDocument();
Expand Down
91 changes: 60 additions & 31 deletions ui/litellm-dashboard/src/components/view_logs/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState } from "react";
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
import useCan from "@/app/(dashboard)/hooks/useCan";
import DeletedKeysPage from "../DeletedKeysPage/DeletedKeysPage";
import DeletedTeamsPage from "../DeletedTeamsPage/DeletedTeamsPage";
import AuditLogsPanel from "./AuditLogsPanel";
Expand All @@ -14,10 +15,24 @@
premiumUser: boolean;
}

type LogsTabId = "request logs" | "audit logs" | "deleted keys" | "deleted teams";

interface LogsTab {
id: LogsTabId;
label: string;
}

const REQUEST_LOGS_TAB: LogsTab = { id: "request logs", label: "Request Logs" };
const AUDIT_LOGS_TAB: LogsTab = { id: "audit logs", label: "Audit Logs" };
const DELETED_KEYS_TAB: LogsTab = { id: "deleted keys", label: "Deleted Keys" };
const DELETED_TEAMS_TAB: LogsTab = { id: "deleted teams", label: "Deleted Teams" };

export default function SpendLogsTable({ accessToken, token, userRole, userID, premiumUser }: SpendLogsTableProps) {
const [activeTab, setActiveTab] = useState("request logs");
const [activeTab, setActiveTab] = useState<LogsTabId>(REQUEST_LOGS_TAB.id);
const canViewAuditLogs = useCan("viewAuditLogs");
const canViewDeletedTeams = useCan("viewDeletedTeams");

if (!accessToken || !token || !userRole || !userID) {

Check warning on line 35 in ui/litellm-dashboard/src/components/view_logs/index.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Boolean expression combines 4 conditions; extract it into a named variable
return (
<div className="flex items-center justify-center h-64">
<AntDLoadingSpinner size="large" />
Expand All @@ -25,41 +40,55 @@
);
}

const tabs: LogsTab[] = [
REQUEST_LOGS_TAB,
...(canViewAuditLogs ? [AUDIT_LOGS_TAB] : []),
DELETED_KEYS_TAB,
...(canViewDeletedTeams ? [DELETED_TEAMS_TAB] : []),
];

const renderPanel = (tabId: LogsTabId) => {
switch (tabId) {
case "request logs":
return (
<RequestLogsPanel
accessToken={accessToken}
token={token}
userRole={userRole}
userID={userID}
isActive={activeTab === "request logs"}
/>
);
case "audit logs":
return (
<AuditLogsPanel
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
isActive={activeTab === "audit logs"}
premiumUser={premiumUser}
/>
);
case "deleted keys":
return <DeletedKeysPage />;
case "deleted teams":
return <DeletedTeamsPage />;
}
};

return (
<div className="w-full p-6 overflow-x-hidden box-border">
<TabGroup defaultIndex={0} onIndexChange={(index) => setActiveTab(index === 0 ? "request logs" : "audit logs")}>
<TabGroup defaultIndex={0} onIndexChange={(index) => setActiveTab(tabs[index].id)}>
<TabList>
<Tab>Request Logs</Tab>
<Tab>Audit Logs</Tab>
<Tab>Deleted Keys</Tab>
<Tab>Deleted Teams</Tab>
{tabs.map((tab) => (
<Tab key={tab.id}>{tab.label}</Tab>
))}
</TabList>
<TabPanels>
<TabPanel>
<RequestLogsPanel
accessToken={accessToken}
token={token}
userRole={userRole}
userID={userID}
isActive={activeTab === "request logs"}
/>
</TabPanel>
<TabPanel>
<AuditLogsPanel
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
isActive={activeTab === "audit logs"}
premiumUser={premiumUser}
/>
</TabPanel>
<TabPanel>
<DeletedKeysPage />
</TabPanel>
<TabPanel>
<DeletedTeamsPage />
</TabPanel>
{tabs.map((tab) => (
<TabPanel key={tab.id}>{renderPanel(tab.id)}</TabPanel>
))}
</TabPanels>
</TabGroup>
</div>
Expand Down
13 changes: 13 additions & 0 deletions ui/litellm-dashboard/src/utils/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ describe("hasCapability", () => {
);
});

describe.each(["viewAuditLogs", "viewDeletedTeams"] as const)("hasCapability - %s", (capability) => {
it.each(["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer"])("should grant it to %s", (role) => {
expect(hasCapability(role, capability)).toBe(true);
});

it.each(["Internal User", "Internal Viewer", "App User", "Org Admin", "Unknown Role", "", null, undefined])(
"should deny it to %s",
(role) => {
expect(hasCapability(role, capability)).toBe(false);
},
);
});

describe("rolesWithCapability", () => {
it("should return a copy so callers cannot mutate the capability map", () => {
const roles = rolesWithCapability("viewToolPolicies");
Expand Down
2 changes: 2 additions & 0 deletions ui/litellm-dashboard/src/utils/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { all_admin_roles } from "./roles";

const CAPABILITY_ROLES = {
viewToolPolicies: all_admin_roles,
viewAuditLogs: all_admin_roles,
viewDeletedTeams: all_admin_roles,
} as const satisfies Record<string, readonly string[]>;

export type Capability = keyof typeof CAPABILITY_ROLES;
Expand Down
Loading