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,63 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import { all_admin_roles } from "@/utils/roles";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { AccessGroupResponse, accessGroupKeys } from "./useAccessGroups";

// ── Fetch function ───────────────────────────────────────────────────────────

const fetchAccessGroupDetails = async (
accessToken: string,
accessGroupId: string,
): Promise<AccessGroupResponse> => {
const baseUrl = getProxyBaseUrl();
const url = `${baseUrl}/v1/access_group/${encodeURIComponent(accessGroupId)}`;

const response = await fetch(url, {
method: "GET",
headers: {
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});

if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}

return response.json();
};

// ── Hook ─────────────────────────────────────────────────────────────────────

export const useAccessGroupDetails = (accessGroupId?: string) => {
const { accessToken, userRole } = useAuthorized();
const queryClient = useQueryClient();

return useQuery<AccessGroupResponse>({
queryKey: accessGroupKeys.detail(accessGroupId!),
queryFn: async () => fetchAccessGroupDetails(accessToken!, accessGroupId!),
enabled:
Boolean(accessToken && accessGroupId) &&
all_admin_roles.includes(userRole || ""),

// Seed from the list cache when available
initialData: () => {
if (!accessGroupId) return undefined;

const groups = queryClient.getQueryData<AccessGroupResponse[]>(
accessGroupKeys.list({}),
);

return groups?.find((g) => g.access_group_id === accessGroupId);
},
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
/* @vitest-environment jsdom */
import React from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useAccessGroups, AccessGroupResponse } from "./useAccessGroups";
import * as networking from "@/components/networking";

vi.mock("@/components/networking", () => ({
getProxyBaseUrl: vi.fn(() => "http://proxy.example"),
getGlobalLitellmHeaderName: vi.fn(() => "Authorization"),
deriveErrorMessage: vi.fn((data: unknown) => (data as { detail?: string })?.detail ?? "Unknown error"),
handleError: vi.fn(),
}));

vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: vi.fn(() => ({
accessToken: "test-token-123",
userRole: "Admin",
})),
}));

const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
});

const wrapper = ({ children }: { children: React.ReactNode }) => {
const queryClient = createQueryClient();
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};

const mockAccessToken = "test-token-123";
const mockAccessGroups: AccessGroupResponse[] = [
{
access_group_id: "ag-1",
access_group_name: "Group One",
description: "First group",
access_model_ids: [],
access_mcp_server_ids: [],
access_agent_ids: [],
assigned_team_ids: [],
assigned_key_ids: [],
created_at: "2025-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2025-01-01T00:00:00Z",
updated_by: "user-1",
},
];

const fetchMock = vi.fn();

describe("useAccessGroups", () => {
beforeEach(async () => {
vi.clearAllMocks();
vi.mocked(networking.getProxyBaseUrl).mockReturnValue("http://proxy.example");
vi.mocked(networking.getGlobalLitellmHeaderName).mockReturnValue("Authorization");

const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized");
vi.mocked(useAuthorizedModule.default).mockReturnValue({
accessToken: mockAccessToken,
userRole: "Admin",
} as any);

global.fetch = fetchMock;
});

it("should return hook result without errors", () => {
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
} as Response);

const { result } = renderHook(() => useAccessGroups(), { wrapper });

expect(result.current).toBeDefined();
expect(result.current).toHaveProperty("data");
expect(result.current).toHaveProperty("isSuccess");
expect(result.current).toHaveProperty("isError");
expect(result.current).toHaveProperty("status");
});

it("should return access groups when access token and admin role are present", async () => {
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockAccessGroups),
} as Response);

const { result } = renderHook(() => useAccessGroups(), { wrapper });

await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});

expect(fetchMock).toHaveBeenCalledWith(
"http://proxy.example/v1/access_group",
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({
Authorization: `Bearer ${mockAccessToken}`,
"Content-Type": "application/json",
}),
}),
);
expect(result.current.data).toEqual(mockAccessGroups);
});

it("should not fetch when access token is null", async () => {
const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized");
vi.mocked(useAuthorizedModule.default).mockReturnValue({
accessToken: null,
userRole: "Admin",
} as any);

const { result } = renderHook(() => useAccessGroups(), { wrapper });

expect(result.current.isFetching).toBe(false);
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
});

it("should not fetch when access token is empty string", async () => {
const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized");
vi.mocked(useAuthorizedModule.default).mockReturnValue({
accessToken: "",
userRole: "Admin",
} as any);

const { result } = renderHook(() => useAccessGroups(), { wrapper });

expect(result.current.isFetching).toBe(false);
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
});

it("should not fetch when user role is not an admin role", async () => {
const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized");
vi.mocked(useAuthorizedModule.default).mockReturnValue({
accessToken: mockAccessToken,
userRole: "Viewer",
} as any);

const { result } = renderHook(() => useAccessGroups(), { wrapper });

expect(result.current.isFetching).toBe(false);
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
});

it("should not fetch when user role is null", async () => {
const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized");
vi.mocked(useAuthorizedModule.default).mockReturnValue({
accessToken: mockAccessToken,
userRole: null,
} as any);

const { result } = renderHook(() => useAccessGroups(), { wrapper });

expect(result.current.isFetching).toBe(false);
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
});

it("should fetch when user role is proxy_admin", async () => {
const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized");
vi.mocked(useAuthorizedModule.default).mockReturnValue({
accessToken: mockAccessToken,
userRole: "proxy_admin",
} as any);

fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockAccessGroups),
} as Response);

const { result } = renderHook(() => useAccessGroups(), { wrapper });

await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});

expect(fetchMock).toHaveBeenCalled();
expect(result.current.data).toEqual(mockAccessGroups);
});

it("should expose error state when fetch fails", async () => {
fetchMock.mockResolvedValue({
ok: false,
json: () => Promise.resolve({ detail: "Forbidden" }),
} as Response);
vi.mocked(networking.deriveErrorMessage).mockReturnValue("Forbidden");

const { result } = renderHook(() => useAccessGroups(), { wrapper });

await waitFor(() => {
expect(result.current.isError).toBe(true);
});

expect(result.current.error).toBeInstanceOf(Error);
expect((result.current.error as Error).message).toBe("Forbidden");
expect(result.current.data).toBeUndefined();
expect(networking.handleError).toHaveBeenCalledWith("Forbidden");
});

it("should return empty array when API returns empty list", async () => {
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
} as Response);

const { result } = renderHook(() => useAccessGroups(), { wrapper });

await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});

expect(result.current.data).toEqual([]);
});

it("should propagate network errors", async () => {
const networkError = new Error("Network failure");
fetchMock.mockRejectedValue(networkError);

const { result } = renderHook(() => useAccessGroups(), { wrapper });

await waitFor(() => {
expect(result.current.isError).toBe(true);
});

expect(result.current.error).toEqual(networkError);
expect(result.current.data).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useQuery } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import { all_admin_roles } from "@/utils/roles";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";

// ── Types ────────────────────────────────────────────────────────────────────

export interface AccessGroupResponse {
access_group_id: string;
access_group_name: string;
description: string | null;
access_model_ids: string[];
access_mcp_server_ids: string[];
access_agent_ids: string[];
assigned_team_ids: string[];
assigned_key_ids: string[];
created_at: string;
created_by: string | null;
updated_at: string;
updated_by: string | null;
}

// ── Query keys (shared across access-group hooks) ────────────────────────────

export const accessGroupKeys = createQueryKeys("accessGroups");

// ── Fetch function ───────────────────────────────────────────────────────────

const fetchAccessGroups = async (
accessToken: string,
): Promise<AccessGroupResponse[]> => {
const baseUrl = getProxyBaseUrl();
const url = `${baseUrl}/v1/access_group`;

const response = await fetch(url, {
method: "GET",
headers: {
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});

if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}

return response.json();
};

// ── Hook ─────────────────────────────────────────────────────────────────────

export const useAccessGroups = () => {
const { accessToken, userRole } = useAuthorized();

return useQuery<AccessGroupResponse[]>({
queryKey: accessGroupKeys.list({}),
queryFn: async () => fetchAccessGroups(accessToken!),
enabled:
Boolean(accessToken) && all_admin_roles.includes(userRole || ""),
});
};
Loading
Loading