Skip to content
Open
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
1 change: 1 addition & 0 deletions src/authz/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const COURSE_PERMISSIONS = {
DELETE_FILES: 'courses.delete_files',
EDIT_FILES: 'courses.edit_files',

VIEW_LIBRARY_UPDATES: 'courses.view_library_updates',
MANAGE_LIBRARY_UPDATES: 'courses.manage_library_updates',

VIEW_COURSE_TEAM: 'courses.view_course_team',
Expand Down
17 changes: 17 additions & 0 deletions src/authz/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useWaffleFlags } from '@src/data/apiHooks';
import { useUserPermissions } from '@src/authz/data/apiHooks';
import { PermissionValidationAnswer, PermissionValidationQuery } from '@src/authz/types';
import { CONTENT_LIBRARY_PERMISSIONS } from '@src/authz/constants';

type UseCourseUserPermissionsReturn<Query extends PermissionValidationQuery> = {
isLoading: boolean;
Expand Down Expand Up @@ -76,3 +77,19 @@ export const useCourseUserPermissions = <Query extends PermissionValidationQuery
...permissionResults as PermissionValidationAnswer<Query>,
};
};

/**
* Validates the `view_library` permission for one or more libraries in a single request.
*
* @param libraryKeys - Library key, or list of library keys, to validate.
* @returns React Query result whose `data` is keyed by library key, e.g. `data?.['lib:OpenedX:CSPROB']`.
*/
export const useLibraryViewPermissions = (libraryKeys: string | string[]) => {
const keys = Array.isArray(libraryKeys) ? libraryKeys : [libraryKeys];
return useUserPermissions(
Object.fromEntries(
keys.map((key) => [key, { action: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY, scope: key }]),
),
keys.length > 0,
);
};
6 changes: 5 additions & 1 deletion src/authz/permissionHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,14 @@ describe('permissionHelpers', () => {
});

describe('getLibraryUpdatesPermissions', () => {
it('returns MANAGE_LIBRARY_UPDATES permission with the correct action and scope', () => {
it('returns library updates permissions with the correct actions and scope', () => {
const result = getLibraryUpdatesPermissions(courseId);

expect(result).toEqual({
canViewLibraryUpdates: {
action: COURSE_PERMISSIONS.VIEW_LIBRARY_UPDATES,
scope: courseId,
},
canManageLibraryUpdates: {
action: COURSE_PERMISSIONS.MANAGE_LIBRARY_UPDATES,
scope: courseId,
Expand Down
4 changes: 4 additions & 0 deletions src/authz/permissionHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ export const getCourseOutlinePermissions = (courseId: string) => ({
});

export const getLibraryUpdatesPermissions = (courseId: string) => ({
canViewLibraryUpdates: {
action: COURSE_PERMISSIONS.VIEW_LIBRARY_UPDATES,
scope: courseId,
},
canManageLibraryUpdates: {
action: COURSE_PERMISSIONS.MANAGE_LIBRARY_UPDATES,
scope: courseId,
Expand Down
118 changes: 105 additions & 13 deletions src/course-libraries/CourseLibraries.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { mockContentSearchConfig } from '@src/search-manager/data/api.mock';
import { type ToastActionData } from '@src/generic/toast-context';
import { libraryBlockChangesUrl } from '@src/course-unit/data/api';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { useCourseUserPermissions } from '@src/authz/hooks';
import { validateUserPermissions } from '@src/authz/data/api';
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
import { CourseLibraries } from './CourseLibraries';
import {
mockGetEntityLinks,
Expand All @@ -32,6 +33,7 @@ const searchParamsGetMock = jest.fn();
let axiosMock: MockAdapter;
let mockShowToast: (message: string, action?: ToastActionData) => void;
let queryClient: QueryClient;
let validateUserPermissionsMock: jest.SpiedFunction<typeof validateUserPermissions>;

jest.mock('../studio-home/hooks', () => ({
useStudioHome: () => ({
Expand All @@ -49,25 +51,25 @@ jest.mock('react-router-dom', () => ({
}],
}));

jest.mock('@src/authz/hooks', () => ({
useCourseUserPermissions: jest.fn(),
}));

const mockPermissions = (overrides = {}) =>
jest.mocked(useCourseUserPermissions).mockReturnValue({
isLoading: false,
isAuthzEnabled: true,
validateUserPermissionsMock.mockResolvedValue({
canViewLibraryUpdates: true,
canManageLibraryUpdates: true,
'lib:OpenedX:CSPROB': true,
'lib:OpenedX:CSPROB2': true,
'lib:OpenedX:CSPROB3': true,
...overrides,
} as ReturnType<typeof useCourseUserPermissions>);
});

describe('<CourseLibraries />', () => {
beforeEach(() => {
initializeMocks();
const mocks = initializeMocks();
validateUserPermissionsMock = mocks.validateUserPermissionsMock;
fetchMock.mockReset();
mockFetchIndexDocuments.applyMock();
localStorage.clear();
searchParamsGetMock.mockReturnValue('all');
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
mockPermissions();
});

Expand All @@ -93,15 +95,16 @@ describe('<CourseLibraries />', () => {
expect(emptyMsg).toBeInTheDocument();
});

it('shows PermissionDeniedAlert when user lacks manage library updates permission', async () => {
mockPermissions({ canManageLibraryUpdates: false });
it('shows PermissionDeniedAlert when user lacks view library updates permission', async () => {
mockPermissions({ canViewLibraryUpdates: false });
await renderCourseLibrariesPage();
expect(await screen.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
expect(screen.queryByText('Libraries')).not.toBeInTheDocument();
});

it('shows a loading spinner while permissions are loading', async () => {
mockPermissions({ isLoading: true, canManageLibraryUpdates: false });
// The permissions request never resolves, so they stay loading.
validateUserPermissionsMock.mockReturnValue(new Promise(() => {}));
await renderCourseLibrariesPage();
expect(await screen.findByRole('status')).toBeInTheDocument();
expect(screen.queryByTestId('permissionDeniedAlert')).not.toBeInTheDocument();
Expand Down Expand Up @@ -206,6 +209,59 @@ describe('<CourseLibraries />', () => {

expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});

it('shows the read only out of sync alert when user lacks manage permission', async () => {
const user = userEvent.setup();
mockPermissions({ canViewLibraryUpdates: true, canManageLibraryUpdates: false });
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
await user.click(allTab);
const alert = await screen.findByRole('alert');
expect(
await within(alert).findByText(
'7 library components are out of sync. Review updates to see what changed',
),
).toBeInTheDocument();
expect(
within(alert).queryByText(
'7 library components are out of sync. Review updates to accept or ignore changes',
),
).not.toBeInTheDocument();
});

it('does not show Review Updates button when user lacks manage permission', async () => {
const user = userEvent.setup();
mockPermissions({ canViewLibraryUpdates: true, canManageLibraryUpdates: false });
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
await user.click(allTab);
expect(screen.queryByRole('button', { name: 'Review Updates' })).not.toBeInTheDocument();
});

it('shows a View Library link on each card when the user can view the library', async () => {
const user = userEvent.setup();
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
await user.click(await screen.findByRole('tab', { name: 'Libraries' }));

const links = await screen.findAllByRole('link', { name: 'View Library' });
expect(links.length).toEqual(3);
expect(links[0]).toHaveAttribute('href', expect.stringContaining('library/lib:OpenedX:CSPROB3'));
});

it('does not show the View Library link when user lacks view library permission', async () => {
const user = userEvent.setup();
mockPermissions({
'lib:OpenedX:CSPROB': false,
'lib:OpenedX:CSPROB2': false,
'lib:OpenedX:CSPROB3': false,
});
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
await user.click(await screen.findByRole('tab', { name: 'Libraries' }));

// The libraries are still listed, they just don't link to the library.
expect(await screen.findByText('CS problems 3')).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'View Library' })).not.toBeInTheDocument();
});
});

describe('<CourseLibraries ReviewTab />', () => {
Expand All @@ -218,6 +274,9 @@ describe('<CourseLibraries ReviewTab />', () => {
localStorage.clear();
searchParamsGetMock.mockReturnValue('review');
queryClient = mocks.queryClient;
validateUserPermissionsMock = mocks.validateUserPermissionsMock;
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
mockPermissions();
});

const renderCourseLibrariesReviewPage = async (courseKey?: string) => {
Expand Down Expand Up @@ -250,6 +309,39 @@ describe('<CourseLibraries ReviewTab />', () => {
expect(ignoreBtns.length).toEqual(7);
});

it('hides update and ignore buttons when user lacks manage permission', async () => {
mockPermissions({ canViewLibraryUpdates: true, canManageLibraryUpdates: false });
await renderCourseLibrariesReviewPage();

const updateBtns = screen.queryAllByRole('button', { name: 'Update' });
expect(updateBtns.length).toEqual(0);
const ignoreBtns = screen.queryAllByRole('button', { name: 'Ignore' });
expect(ignoreBtns.length).toEqual(0);

const reviewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
expect(reviewBtns.length).toEqual(7);
});

it('disables accept and ignore changes buttons in preview modal for read-only users', async () => {
const user = userEvent.setup();
mockPermissions({ canViewLibraryUpdates: true, canManageLibraryUpdates: false });
await renderCourseLibrariesReviewPage();
const readOnlyMessage =
'Your role doesn\'t include permission to do this. Contact your org admin to request access';

const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
expect(previewBtns.length).toEqual(7);
await user.click(previewBtns[0]);
const dialog = await screen.findByRole('dialog');
const acceptBtn = await within(dialog).findByRole('button', { name: 'Accept changes' });
expect(acceptBtn).toHaveAttribute('aria-disabled', 'true');
const ignoreBtn = await within(dialog).findByRole('button', { name: 'Ignore changes' });
expect(ignoreBtn).toBeDisabled();

await user.hover(acceptBtn.closest('span') ?? acceptBtn);
expect(await screen.findByText(readOnlyMessage)).toBeInTheDocument();
});

test.each([
{
label: 'update changes works with components',
Expand Down
Loading
Loading