diff --git a/src/authz-module/libraries-manager/context.test.tsx b/src/authz-module/libraries-manager/context.test.tsx new file mode 100644 index 0000000..6bfc719 --- /dev/null +++ b/src/authz-module/libraries-manager/context.test.tsx @@ -0,0 +1,125 @@ +import { screen } from '@testing-library/react'; +import { useParams } from 'react-router-dom'; +import { useValidateUserPermissions } from '@src/helpers/useValidateUserPermissions'; +import { renderWrapper } from '@src/setupTest'; +import { useLibrary } from './data/hooks'; +import { LibraryAuthZProvider, useLibraryAuthZ } from './context'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn(), +})); + +jest.mock('@src/helpers/useValidateUserPermissions', () => ({ + useValidateUserPermissions: jest.fn(), +})); + +jest.mock('./data/hooks', () => ({ + useLibrary: jest.fn(), +})); + +const TestComponent = () => { + const context = useLibraryAuthZ(); + return ( +
+
{context.username}
+
{context.libraryId}
+
{context.canManageTeam ? 'true' : 'false'}
+
{context.libraryName}
+
{context.libraryOrg}
+
+ ); +}; + +describe('LibraryAuthZProvider', () => { + + beforeEach(() => { + jest.clearAllMocks(); + (useParams as jest.Mock).mockReturnValue({ libraryId: 'lib123' }); + + (useLibrary as jest.Mock).mockReturnValue({ + data: { + title: 'Test Library', + org: 'Test Org', + }, + }); + }); + + it('provides the correct context values to consumers', () => { + (useValidateUserPermissions as jest.Mock).mockReturnValue({ + data: [ + { allowed: true }, // canViewTeam + { allowed: true }, // canManageTeam + ], + }); + + renderWrapper( + + + + ); + + expect(screen.getByTestId('username')).toHaveTextContent('testuser'); + expect(screen.getByTestId('libraryId')).toHaveTextContent('lib123'); + expect(screen.getByTestId('canManageTeam')).toHaveTextContent('true'); + expect(screen.getByTestId('libraryName')).toHaveTextContent('Test Library'); + expect(screen.getByTestId('libraryOrg')).toHaveTextContent('Test Org'); + }); + + it('throws error when user lacks both view and manage permissions', () => { + (useValidateUserPermissions as jest.Mock).mockReturnValue({ + data: [ + { allowed: false }, // canViewTeam + { allowed: false }, // canManageTeam + ], + }); + + expect(() => { + renderWrapper( + + + + ); + }).toThrow('NoAccess'); + }); + + it('provides context when user can view but not manage team', () => { + (useValidateUserPermissions as jest.Mock).mockReturnValue({ + data: [ + { allowed: true }, // canViewTeam + { allowed: false }, // canManageTeam + ], + }); + + renderWrapper( + + + + ); + + expect(screen.getByTestId('canManageTeam')).toHaveTextContent('false'); + }); + + it('throws error when libraryId is missing', () => { + (useParams as jest.Mock).mockReturnValue({}); // No libraryId + + expect(() => { + renderWrapper( + + + + );; + }).toThrow('MissingLibrary'); + }); + + it('throws error when useLibraryAuthZ is used outside provider', () => { + const BrokenComponent = () => { + useLibraryAuthZ(); + return null; + }; + + expect(() => { + renderWrapper(); + }).toThrow('useLibraryAuthZ must be used within an LibraryAuthZProvider'); + }); +}); diff --git a/src/authz-module/libraries-manager/context.tsx b/src/authz-module/libraries-manager/context.tsx new file mode 100644 index 0000000..9fada0d --- /dev/null +++ b/src/authz-module/libraries-manager/context.tsx @@ -0,0 +1,76 @@ +import { + createContext, useContext, useMemo, ReactNode, +} from 'react'; +import { useParams } from 'react-router-dom'; +import { AppContext } from '@edx/frontend-platform/react'; +import { useValidateUserPermissions } from '@src/helpers/useValidateUserPermissions'; +import { useLibrary } from './data/hooks'; + +const LIBRARY_TEAM_PERMISSIONS = ['act:view_library_team', 'act:manage_library_team']; + +export type AppContextType = { + authenticatedUser: { + username: string; + email: string; + }; +}; + +type LibraryAuthZContextType = { + canManageTeam: boolean; + username: string; + libraryId: string; + roles: string[]; + permissions: string[]; + libraryName: string; + libraryOrg: string; +}; + +const LibraryAuthZContext = createContext(undefined); + +type AuthZProviderProps = { + children: ReactNode; +}; + +export const LibraryAuthZProvider: React.FC = ({ children }) => { + const { libraryId } = useParams<{ libraryId: string }>(); + const { authenticatedUser } = useContext(AppContext) as AppContextType; + + // TODO: Implement a custom error view + if (!libraryId) { + throw new Error('MissingLibrary'); + } + const permissions = LIBRARY_TEAM_PERMISSIONS.map(action => ({ action, object: libraryId })); + + const { data: userPermissions } = useValidateUserPermissions(permissions); + const [{ allowed: canViewTeam }, { allowed: canManageTeam }] = userPermissions; + + if (!canViewTeam && !canManageTeam) { + throw new Error('NoAccess'); + } + + const { data: libraryMetadata } = useLibrary(libraryId); + + const value = useMemo((): LibraryAuthZContextType => ({ + username: authenticatedUser.username, + libraryId, + libraryName: libraryMetadata.title, + libraryOrg: libraryMetadata.org, + roles: [], + permissions: [], + canManageTeam, + }), [libraryId, authenticatedUser.username, canManageTeam]); + + return ( + + {children} + + ); +}; + +export const useLibraryAuthZ = (): LibraryAuthZContextType => { + const context = useContext(LibraryAuthZContext); + if (context === undefined) { + throw new Error('useLibraryAuthZ must be used within an LibraryAuthZProvider'); + } + return context; +}; diff --git a/src/authz-module/libraries-manager/data/api.ts b/src/authz-module/libraries-manager/data/api.ts new file mode 100644 index 0000000..b0fd811 --- /dev/null +++ b/src/authz-module/libraries-manager/data/api.ts @@ -0,0 +1,26 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { LibraryMetadata, TeamMember } from '@src/authz-module/constants'; +import { getApiUrl, getStudioApiUrl } from '@src/helpers/utils'; + +export interface GetTeamMembersResponse { + members: TeamMember[]; + totalCount: number; +} + +// TODO: replece api path once is created +export const getTeamMembers = async (libraryId: string): Promise => { + const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/users?scope=${libraryId}`)); + return data.results; +}; + + +// TODO: this should be replaced in the future with Console API +export const getLibrary = async (libraryId: string): Promise => { + const { data } = await getAuthenticatedHttpClient().get(getStudioApiUrl(`/api/libraries/v2/${libraryId}/`)); + return { + id: data.id, + org: data.org, + title: data.title, + slug: data.slug, + }; +}; diff --git a/src/authz-module/libraries-manager/data/hooks.test.tsx b/src/authz-module/libraries-manager/data/hooks.test.tsx new file mode 100644 index 0000000..a5b7734 --- /dev/null +++ b/src/authz-module/libraries-manager/data/hooks.test.tsx @@ -0,0 +1,115 @@ +import { ReactNode } from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useLibrary, useTeamMembers } from './hooks'; +import * as api from './api'; + +const mockMembers = [ + { + displayName: 'Alice', + username: 'user1', + email: 'alice@example.com', + roles: ['admin', 'author'], + }, + { + displayName: 'Bob', + username: 'user2', + email: 'bob@example.com', + roles: ['collaborator'], + }, +]; + +const mockLibrary = { + id: 'lib:123', + org: 'demo-org', + title: 'Test Library', + slug: 'test-library', +}; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + + ); + + return wrapper; +}; + +describe('useTeamMembers', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns data when API call succeeds', async () => { + jest.spyOn(api, 'getTeamMembers').mockResolvedValue(mockMembers); + + const { result } = renderHook(() => useTeamMembers('lib:123'), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(api.getTeamMembers).toHaveBeenCalledWith('lib:123'); + expect(result.current.data).toEqual(mockMembers); + }); + + it('handles error when API call fails', async () => { + jest + .spyOn(api, 'getTeamMembers') + .mockRejectedValue(new Error('API failure')); + + const { result } = renderHook(() => useTeamMembers('lib:123'), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(api.getTeamMembers).toHaveBeenCalledWith('lib:123'); + expect(result.current.error).toBeDefined(); + expect(result.current.data).toBeUndefined(); + }); +}); + +describe('useLibrary', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns metadata on success', async () => { + jest.spyOn(api, 'getLibrary').mockResolvedValue(mockLibrary); + + const { result } = renderHook( + () => useLibrary('lib123'), + { wrapper: createWrapper() }, + ); + await waitFor(() => { + expect(result.current.data).toEqual(mockLibrary); + expect(api.getLibrary).toHaveBeenCalledWith('lib123'); + }); + }); + + it('throws on error', () => { + jest + .spyOn(api, 'getLibrary') + .mockRejectedValue(new Error('Not found')); + + const wrapper = createWrapper(); + try { + renderHook(() => useLibrary('lib123'), { wrapper }); + } catch (e) { + expect(e).toEqual(new Error('Not found')); + } + + expect(api.getLibrary).toHaveBeenCalledWith('lib123'); + }); +}); diff --git a/src/authz-module/libraries-manager/data/hooks.ts b/src/authz-module/libraries-manager/data/hooks.ts new file mode 100644 index 0000000..9289b63 --- /dev/null +++ b/src/authz-module/libraries-manager/data/hooks.ts @@ -0,0 +1,37 @@ +import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; +import { LibraryMetadata, TeamMember } from '@src/authz-module/constants'; +import { getLibrary, getTeamMembers } from './api'; + +/** + * React Query hook to fetch all team members for a specific library. + * It retrieves the full list of members who have access to the given library. + * + * @param libraryId - The unique identifier of the library + * + * @example + * ```tsx + * const { data: teamMembers, isLoading, isError } = useTeamMembers('lib:123'); + * ``` + */ +export const useTeamMembers = (libraryId: string) => useQuery({ + queryKey: ['team-members', libraryId], + queryFn: () => getTeamMembers(libraryId), + staleTime: 1000 * 60 * 30, // refetch after 30 minutes +}); + +/** + * React Query hook to retrive the inforation of the current library. + * + * @param libraryId - The unique ID of the library. + * + * @example + * const { data, isLoading, isError } = useLibrary('lib:123',); + * + */ +export function useLibrary(libraryId: string) { + return useSuspenseQuery({ + queryKey: ['library-metadata', libraryId], + queryFn: () => getLibrary(libraryId), + retry: false, + }); +}