Skip to content

Commit 88405df

Browse files
feat: adding permission validations from authz for course updates for view and manage
1 parent 449af65 commit 88405df

9 files changed

Lines changed: 350 additions & 30 deletions

File tree

src/authz/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,7 @@ export const CONTENT_LIBRARY_PERMISSIONS = {
1717

1818
export const COURSE_PERMISSIONS = {
1919
MANAGE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings',
20+
21+
VIEW_COURSE_UPDATES: 'courses.view_course_updates',
22+
MANAGE_COURSE_UPDATES: 'courses.manage_course_updates',
2023
};

src/course-updates/CourseUpdates.test.tsx

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import {
55
initializeMocks, render, waitFor, fireEvent, screen,
66
} from '@src/testUtils';
77

8+
import { useUserPermissions } from '@src/authz/data/apiHooks';
9+
10+
import * as apiHooks from '@src/data/apiHooks';
811
import {
912
getCourseUpdatesApiUrl,
1013
getCourseHandoutApiUrl,
@@ -25,6 +28,19 @@ let store;
2528
const mockPathname = '/foo-bar';
2629
const courseId = '123';
2730

31+
jest.mock('@src/authz/data/apiHooks', () => ({
32+
...jest.requireActual('@src/authz/data/apiHooks'),
33+
useUserPermissions: jest.fn(() => ({
34+
isLoading: false,
35+
data: { canManageCourseUpdates: false, canViewCourseUpdates: true },
36+
})),
37+
}));
38+
39+
jest.mock('@src/data/apiHooks', () => ({
40+
...jest.requireActual('@src/data/apiHooks'),
41+
useWaffleFlags: jest.fn(() => ({ enableAuthzCourseAuthoring: false })),
42+
}));
43+
2844
jest.mock('react-router-dom', () => ({
2945
...jest.requireActual('react-router-dom'),
3046
useLocation: () => ({
@@ -342,4 +358,149 @@ describe('<CourseUpdates />', () => {
342358
expect(await screen.findByText(messages.savingHandoutsErrorDescription.defaultMessage));
343359
});
344360
});
361+
362+
describe('Authorization and permissions', () => {
363+
describe('when user has permission to manage course updates', () => {
364+
beforeEach(() => {
365+
const mocks = initializeMocks();
366+
store = mocks.reduxStore;
367+
axiosMock = mocks.axiosMock;
368+
369+
(apiHooks.useWaffleFlags as jest.Mock).mockReturnValue({ enableAuthzCourseAuthoring: true });
370+
(useUserPermissions as jest.Mock).mockReturnValue({
371+
isLoading: false,
372+
data: { canManageCourseUpdates: true },
373+
});
374+
375+
axiosMock
376+
.onGet(getCourseUpdatesApiUrl(courseId))
377+
.reply(200, courseUpdatesMock);
378+
axiosMock
379+
.onGet(getCourseHandoutApiUrl(courseId))
380+
.reply(200, courseHandoutsMock);
381+
});
382+
383+
it('should render the "New update" button', async () => {
384+
render(<RootWrapper />);
385+
386+
expect(await screen.findByRole('button', {
387+
name: messages.newUpdateButton.defaultMessage,
388+
})).toBeInTheDocument();
389+
});
390+
391+
it('should render edit and delete buttons for course updates', async () => {
392+
const { container } = render(<RootWrapper />);
393+
await waitFor(() => {
394+
expect(container.querySelectorAll('.course-update')).toHaveLength(3);
395+
});
396+
397+
expect(await screen.findAllByRole('button', { name: /edit/i })).toHaveLength(4); // 3 for course updates and 1 for handouts
398+
expect(await screen.findAllByRole('button', { name: /delete/i })).toHaveLength(3);
399+
});
400+
});
401+
402+
describe('when user does NOT have permission to manage course updates and enableAuthzCourseAuthoring is enabled', () => {
403+
beforeEach(() => {
404+
const mocks = initializeMocks();
405+
store = mocks.reduxStore;
406+
axiosMock = mocks.axiosMock;
407+
408+
(apiHooks.useWaffleFlags as jest.Mock).mockReturnValue({ enableAuthzCourseAuthoring: true });
409+
(useUserPermissions as jest.Mock).mockReturnValue({
410+
isLoading: false,
411+
data: { canManageCourseUpdates: false },
412+
});
413+
414+
axiosMock
415+
.onGet(getCourseUpdatesApiUrl(courseId))
416+
.reply(200, courseUpdatesMock);
417+
axiosMock
418+
.onGet(getCourseHandoutApiUrl(courseId))
419+
.reply(200, courseHandoutsMock);
420+
});
421+
422+
it('should NOT render the "New update" button', async () => {
423+
render(<RootWrapper />);
424+
425+
await waitFor(() => {
426+
expect(screen.getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
427+
});
428+
429+
const newUpdateButton = screen.queryByRole('button', { name: /New update/ });
430+
431+
expect(newUpdateButton).not.toBeInTheDocument();
432+
});
433+
434+
it('should NOT render edit and delete buttons for course updates', async () => {
435+
const { container } = render(<RootWrapper />);
436+
437+
await waitFor(() => {
438+
expect(container.querySelectorAll('.course-update')).toHaveLength(3);
439+
expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument();
440+
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
441+
});
442+
});
443+
});
444+
445+
describe('when enableAuthzCourseAuthoring is disabled', () => {
446+
beforeEach(() => {
447+
const mocks = initializeMocks();
448+
store = mocks.reduxStore;
449+
axiosMock = mocks.axiosMock;
450+
451+
(apiHooks.useWaffleFlags as jest.Mock).mockReturnValue({ enableAuthzCourseAuthoring: false });
452+
(useUserPermissions as jest.Mock).mockReturnValue({
453+
isLoading: false,
454+
data: { canManageCourseUpdates: false },
455+
});
456+
457+
axiosMock
458+
.onGet(getCourseUpdatesApiUrl(courseId))
459+
.reply(200, courseUpdatesMock);
460+
axiosMock
461+
.onGet(getCourseHandoutApiUrl(courseId))
462+
.reply(200, courseHandoutsMock);
463+
});
464+
465+
it('should render the "New update" button (defaults to true when authz disabled)', async () => {
466+
render(<RootWrapper />);
467+
468+
expect(await screen.findByRole('button', {
469+
name: messages.newUpdateButton.defaultMessage,
470+
})).toBeInTheDocument();
471+
});
472+
});
473+
474+
describe('when user does NOT have permission to view course updates', () => {
475+
beforeEach(() => {
476+
const mocks = initializeMocks();
477+
store = mocks.reduxStore;
478+
axiosMock = mocks.axiosMock;
479+
480+
(apiHooks.useWaffleFlags as jest.Mock).mockReturnValue({ enableAuthzCourseAuthoring: true });
481+
(useUserPermissions as jest.Mock).mockReturnValue({
482+
isLoading: false,
483+
data: { canManageCourseUpdates: false, canViewCourseUpdates: false },
484+
});
485+
486+
axiosMock
487+
.onGet(getCourseUpdatesApiUrl(courseId))
488+
.reply(200, courseUpdatesMock);
489+
axiosMock
490+
.onGet(getCourseHandoutApiUrl(courseId))
491+
.reply(200, courseHandoutsMock);
492+
});
493+
494+
it('should render PermissionDeniedAlert instead of course updates content', async () => {
495+
render(<RootWrapper />);
496+
497+
expect(await screen.findByText(/You are not authorized to view this page/)).toBeInTheDocument();
498+
expect(screen.queryByText(messages.headingTitle.defaultMessage)).not.toBeInTheDocument();
499+
expect(screen.queryByText(messages.headingSubtitle.defaultMessage)).not.toBeInTheDocument();
500+
expect(screen.queryByRole('button', {
501+
name: messages.newUpdateButton.defaultMessage,
502+
})).not.toBeInTheDocument();
503+
});
504+
});
505+
});
345506
});

src/course-updates/CourseUpdates.tsx

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ import InternetConnectionAlert from '@src/generic/internet-connection-alert';
1616
import ConnectionErrorAlert from '@src/generic/ConnectionErrorAlert';
1717
import { RequestStatus } from '@src/data/constants';
1818
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
19+
import { useWaffleFlags } from '@src/data/apiHooks';
20+
import { useUserPermissions } from '@src/authz/data/apiHooks';
21+
import { COURSE_PERMISSIONS } from '@src/authz/constants';
22+
import PermissionDeniedAlert from '@src/generic/PermissionDeniedAlert';
1923
import CourseHandouts from './course-handouts/CourseHandouts';
2024
import CourseUpdate from './course-update/CourseUpdate';
2125
import DeleteModal from './delete-modal/DeleteModal';
@@ -58,6 +62,26 @@ const CourseUpdates = () => {
5862
title: processingNotificationTitle,
5963
} = useSelector(getProcessingNotification);
6064

65+
const waffleFlags = useWaffleFlags(courseId);
66+
const isAuthzEnabled = waffleFlags.enableAuthzCourseAuthoring;
67+
const { isLoading: isLoadingUserPermissions, data: userPermissions } = useUserPermissions({
68+
canManageCourseUpdates: {
69+
action: COURSE_PERMISSIONS.MANAGE_COURSE_UPDATES,
70+
scope: courseId,
71+
},
72+
canViewCourseUpdates: {
73+
action: COURSE_PERMISSIONS.VIEW_COURSE_UPDATES,
74+
scope: courseId,
75+
},
76+
}, isAuthzEnabled);
77+
78+
let canManageCourseUpdates = true;
79+
let canViewCourseUpdates = true;
80+
if (isAuthzEnabled && !isLoadingUserPermissions) {
81+
canManageCourseUpdates = userPermissions?.canManageCourseUpdates ?? false;
82+
canViewCourseUpdates = userPermissions?.canViewCourseUpdates ?? true;
83+
}
84+
6185
const loadingStatuses = useSelector(getLoadingStatuses);
6286
const savingStatuses = useSelector(getSavingStatuses);
6387
const errors = useSelector(getErrors);
@@ -67,6 +91,9 @@ const CourseUpdates = () => {
6791
const anyStatusInProgress = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.IN_PROGRESS);
6892
const anyStatusPending = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.PENDING);
6993

94+
if (!canViewCourseUpdates) {
95+
return <PermissionDeniedAlert />;
96+
}
7097
if (anyStatusDenied) {
7198
return (
7299
<Container size="xl" className="course-unit px-4 mt-4">
@@ -146,7 +173,7 @@ const CourseUpdates = () => {
146173
title={intl.formatMessage(messages.headingTitle)}
147174
subtitle={intl.formatMessage(messages.headingSubtitle)}
148175
instruction={intl.formatMessage(messages.sectionInfo)}
149-
headerActions={(
176+
headerActions={(canManageCourseUpdates ? (
150177
<Button
151178
variant="primary"
152179
iconBefore={AddIcon}
@@ -156,7 +183,7 @@ const CourseUpdates = () => {
156183
>
157184
{intl.formatMessage(messages.newUpdateButton)}
158185
</Button>
159-
)}
186+
) : null)}
160187
/>
161188
<section className="updates-section">
162189
{isMainFormOpen && (
@@ -186,6 +213,8 @@ const CourseUpdates = () => {
186213
contentForUpdate={courseUpdate.content}
187214
onEdit={() => handleOpenUpdateForm(REQUEST_TYPES.edit_update, courseUpdate)}
188215
onDelete={() => handleOpenDeleteForm(courseUpdate)}
216+
canEdit={canManageCourseUpdates}
217+
canDelete={canManageCourseUpdates}
189218
isDisabledButtons={isUpdateFormOpen}
190219
/>
191220
)
@@ -215,6 +244,7 @@ const CourseUpdates = () => {
215244
contentForHandouts={courseHandouts?.data || ''}
216245
onEdit={() => handleOpenUpdateForm(REQUEST_TYPES.edit_handouts)}
217246
isDisabledButtons={isUpdateFormOpen || errors.loadingHandouts}
247+
canEdit={canManageCourseUpdates}
218248
/>
219249
</div>
220250
<DeleteModal

src/course-updates/course-handouts/CourseHandouts.jsx

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,26 @@ import { useIntl } from '@edx/frontend-platform/i18n';
66

77
import messages from './messages';
88

9-
const CourseHandouts = ({ contentForHandouts, onEdit, isDisabledButtons }) => {
9+
const CourseHandouts = ({
10+
contentForHandouts, onEdit, isDisabledButtons, canEdit,
11+
}) => {
1012
const intl = useIntl();
1113

1214
return (
1315
<div className="course-handouts" data-testid="course-handouts">
1416
<div className="course-handouts-header">
1517
<h2 className="course-handouts-header__title lead">{intl.formatMessage(messages.handoutsTitle)}</h2>
16-
<IconButtonWithTooltip
17-
tooltipContent={intl.formatMessage(messages.editButton)}
18-
src={EditOutline}
19-
iconAs={Icon}
20-
disabled={isDisabledButtons}
21-
data-testid="course-handouts-edit-button"
22-
onClick={onEdit}
23-
/>
18+
{canEdit && (
19+
<IconButtonWithTooltip
20+
aria-label={intl.formatMessage(messages.editButton)}
21+
tooltipContent={intl.formatMessage(messages.editButton)}
22+
src={EditOutline}
23+
iconAs={Icon}
24+
disabled={isDisabledButtons}
25+
data-testid="course-handouts-edit-button"
26+
onClick={onEdit}
27+
/>
28+
)}
2429
</div>
2530
<div
2631
className="small"
@@ -35,6 +40,11 @@ CourseHandouts.propTypes = {
3540
contentForHandouts: PropTypes.string.isRequired,
3641
onEdit: PropTypes.func.isRequired,
3742
isDisabledButtons: PropTypes.bool.isRequired,
43+
canEdit: PropTypes.bool,
44+
};
45+
46+
CourseHandouts.defaultProps = {
47+
canEdit: true,
3848
};
3949

4050
export default CourseHandouts;

src/course-updates/course-handouts/CourseHandouts.test.jsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,19 @@ describe('<CourseHandouts />', () => {
4242
const editButton = getByTestId('course-handouts-edit-button');
4343
expect(editButton).toBeDisabled();
4444
});
45+
46+
it('"Edit" button is not rendered when canEdit is false', () => {
47+
const { queryByRole } = renderComponent({ canEdit: false });
48+
49+
const editButton = queryByRole('button', { name: /edit/i });
50+
expect(editButton).toBeNull();
51+
});
52+
53+
it('"Edit" button is rendered when canEdit is true', () => {
54+
const { getByRole } = renderComponent({ canEdit: true });
55+
56+
const editButton = getByRole('button', { name: /edit/i });
57+
expect(editButton).not.toBeNull();
58+
expect(editButton).toBeInTheDocument();
59+
});
4560
});

0 commit comments

Comments
 (0)