From 5e312c599b798b0ce609511a4d0ea73a5d79701a Mon Sep 17 00:00:00 2001 From: jacobo-dominguez-wgu Date: Mon, 17 Aug 2026 09:30:23 -0600 Subject: [PATCH 1/3] feat: adding permissions checks related to course section and units --- .../course-apps/proctoring/Settings.test.jsx | 12 +- .../info-sidebar/InfoSidebar.test.tsx | 2 + .../sharedSettings/VisibilitySection.test.tsx | 52 +++- .../sharedSettings/VisibilitySection.tsx | 5 + src/course-unit/CourseUnit.test.tsx | 113 ++++++- src/course-unit/CourseUnit.tsx | 7 +- .../SequenceNavigationDropdown.jsx | 27 +- .../SequenceNavigationTabs.jsx | 6 +- .../HeaderNavigations.test.tsx | 49 ++- .../header-navigations/HeaderNavigations.tsx | 8 +- .../header-title/HeaderTitle.test.tsx | 70 ++++- src/course-unit/header-title/HeaderTitle.tsx | 17 +- .../sidebar-footer/ActionButtons.test.jsx | 76 ++++- .../sidebar-footer/ActionButtons.tsx | 9 +- .../unit-sidebar/UnitAlignSidebar.test.tsx | 15 +- .../UnitSidebarPagesContext.test.tsx | 120 +++++++ .../unit-sidebar/UnitSidebarPagesContext.tsx | 11 +- .../unit-info/ComponentInfoSidebar.test.tsx | 2 + .../GenericUnitInfoSettings.test.tsx | 45 ++- .../unit-info/GenericUnitInfoSettings.tsx | 4 + .../unit-info/UnitInfoSidebar.test.tsx | 2 + .../unit-info/UnitVisibilityInfo.test.tsx | 114 +++++++ .../unit-info/UnitVisibilityInfo.tsx | 19 +- .../configure-modal/ConfigureModal.test.tsx | 292 ++++++++++-------- .../configure-modal/ConfigureModal.tsx | 30 +- src/generic/configure-modal/UnitTab.tsx | 30 +- src/generic/sidebar/SidebarTitle.test.tsx | 118 +++++++ src/generic/sidebar/SidebarTitle.tsx | 4 +- 28 files changed, 1007 insertions(+), 252 deletions(-) create mode 100644 src/course-unit/unit-sidebar/UnitSidebarPagesContext.test.tsx create mode 100644 src/course-unit/unit-sidebar/unit-info/UnitVisibilityInfo.test.tsx create mode 100644 src/generic/sidebar/SidebarTitle.test.tsx diff --git a/plugins/course-apps/proctoring/Settings.test.jsx b/plugins/course-apps/proctoring/Settings.test.jsx index 4ee6953dad..4d137efc98 100644 --- a/plugins/course-apps/proctoring/Settings.test.jsx +++ b/plugins/course-apps/proctoring/Settings.test.jsx @@ -470,11 +470,13 @@ describe('ProctoredExamSettings', () => { await waitFor(() => { screen.getByDisplayValue('mockproc'); }); - // (1) for studio settings - // (2) for course details - // (3) for user course permissions - expect(axiosMock.history.get.length).toBe(3); - expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true); + // With no exam service URL configured, no request should be made to the exams + // service for provider options. (Total GET count is not asserted because the + // CourseAuthoringProvider also fetches course details and waffle flags.) + const examProvidersRequested = axiosMock.history.get.some((req) => req.url.includes('/api/v1/providers')); + expect(examProvidersRequested).toBe(false); + const studioSettingsRequested = axiosMock.history.get.some((req) => req.url.includes('proctored_exam_settings')); + expect(studioSettingsRequested).toBe(true); }); it('Selected LTI proctoring provider is shown on page load', async () => { diff --git a/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.test.tsx b/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.test.tsx index 9f79791c92..2db6461454 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.test.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.test.tsx @@ -56,6 +56,8 @@ jest.mock('@src/CourseAuthoringContext', () => ({ courseId, openUnlinkModal, getUnitUrl: jest.fn(), + canEditCourseContent: true, + canPublishCourseContent: true, }), })); diff --git a/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.test.tsx b/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.test.tsx index 3ec946a0cc..9607073fef 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.test.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.test.tsx @@ -7,7 +7,9 @@ import { import userEvent from '@testing-library/user-event'; import { useCourseItemData } from '@src/course-outline/data/apiHooks'; import { VisibilityTypes } from '@src/data/constants'; +import { mockWaffleFlags } from '@src/data/apiHooks.mock'; import { VisibilitySection } from './VisibilitySection'; +import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; jest.mock('@src/course-outline/data/apiHooks', () => ({ ...jest.requireActual('@src/course-outline/data/apiHooks'), @@ -22,14 +24,30 @@ const defaultProps = { onChange: jest.fn(), }; +const WrapperProvider = ({ children }) => ( + {children} +); +const renderWithWrapper = (children) => { + render(children, { + extraWrapper: WrapperProvider, + }); +}; + +let validateUserPermissionsMock; + describe('VisibilitySection component', () => { beforeEach(() => { - initializeMocks(); + const mocks = initializeMocks(); mockUseCourseItemData.mockReturnValue({ data: undefined }); + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + validateUserPermissionsMock = mocks.validateUserPermissionsMock; + validateUserPermissionsMock.mockResolvedValue({ + canEditCourseContent: true, + }); }); it('renders title and buttons', async () => { - render(); + renderWithWrapper(); expect(await screen.findByText('Visibility')).toBeInTheDocument(); expect(await screen.findByRole('button', { name: 'Student Visible' })).toBeInTheDocument(); expect(await screen.findByRole('button', { name: 'Staff Only' })).toBeInTheDocument(); @@ -38,7 +56,7 @@ describe('VisibilitySection component', () => { it('clicking staff only calls onChange with staff and hideAfterDue false', async () => { const user = userEvent.setup(); const onChange = jest.fn(); - render(); + renderWithWrapper(); await user.click(await screen.findByRole('button', { name: 'Staff Only' })); await waitFor(async () => { @@ -50,7 +68,7 @@ describe('VisibilitySection component', () => { const user = userEvent.setup(); const onChange = jest.fn(); mockUseCourseItemData.mockReturnValue({ data: { visibilityState: VisibilityTypes.STAFF_ONLY } }); - render(); + renderWithWrapper(); await user.click(await screen.findByRole('button', { name: 'Student Visible' })); await waitFor(async () => { @@ -63,7 +81,7 @@ describe('VisibilitySection component', () => { const onChange = jest.fn(); // initial data not staff only mockUseCourseItemData.mockReturnValue({ data: { visibilityState: undefined, hideAfterDue: false } }); - render(); + renderWithWrapper(); const checkbox = await screen.findByRole('checkbox'); await user.click(checkbox); @@ -76,7 +94,7 @@ describe('VisibilitySection component', () => { const user = userEvent.setup(); const onChange = jest.fn(); mockUseCourseItemData.mockReturnValue({ data: { visibilityState: undefined, hideAfterDue: true } }); - render(); + renderWithWrapper(); await user.click(await screen.findByRole('button', { name: 'Staff Only' })); await waitFor(async () => { @@ -88,7 +106,27 @@ describe('VisibilitySection component', () => { const onChange = jest.fn(); // when item is staff only, checkbox should not be present mockUseCourseItemData.mockReturnValue({ data: { visibilityState: VisibilityTypes.STAFF_ONLY } }); - render(); + renderWithWrapper(); expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); }); + + it('disables both visibility buttons when the user cannot edit course content', async () => { + validateUserPermissionsMock.mockResolvedValue({ + canEditCourseContent: false, + }); + renderWithWrapper(); + + expect(await screen.findByRole('button', { name: 'Student Visible' })).toBeDisabled(); + expect(await screen.findByRole('button', { name: 'Staff Only' })).toBeDisabled(); + }); + + it('disables the hide-after-due checkbox when the user cannot edit course content', async () => { + validateUserPermissionsMock.mockResolvedValue({ + canEditCourseContent: false, + }); + mockUseCourseItemData.mockReturnValue({ data: { visibilityState: undefined, hideAfterDue: false } }); + renderWithWrapper(); + + expect(await screen.findByRole('checkbox')).toBeDisabled(); + }); }); diff --git a/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.tsx b/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.tsx index 75793104fb..fc69365bd8 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.tsx @@ -7,6 +7,7 @@ import { SidebarSection } from '@src/generic/sidebar'; import { useFieldDraft } from '@src/hooks/useFieldDraft'; import { useMemo } from 'react'; import messages from '../messages'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; interface Props> { itemId: string; @@ -22,6 +23,7 @@ interface State { export const VisibilitySection = ({ itemId, isSubsection, onChange }: Props) => { const intl = useIntl(); const { data: itemData } = useCourseItemData(itemId); + const { canEditCourseContent } = useCourseAuthoringContext(); const serverState = useMemo(() => ({ isVisibleToStaffOnly: itemData?.visibilityState === VisibilityTypes.STAFF_ONLY, @@ -42,12 +44,14 @@ export const VisibilitySection = ({ itemId, isSubsection, onChange }: Props) => > - {showPasteUnit && ( + {canEditCourseContent && + ( + + )} + {canEditCourseContent && showPasteUnit && ( )} diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx index 0f4ca66bff..b57deff504 100644 --- a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx @@ -23,7 +23,7 @@ const SequenceNavigationTabs = ({ const dispatch = useDispatch(); const navigate = useNavigate(); const sequenceId = useSelector(getSequenceId); - const { courseId } = useCourseAuthoringContext(); + const { courseId, canEditCourseContent } = useCourseAuthoringContext(); const courseUnit = useSelector(getCourseUnitData); const sequenceChildAddable = courseUnit?.ancestorInfo?.ancestors?.[0]?.actions?.childAddable; @@ -67,7 +67,7 @@ const SequenceNavigationTabs = ({ isActive={unitId === buttonUnitId} /> ))} - {sequenceChildAddable && ( + {canEditCourseContent && sequenceChildAddable && ( - {!readOnly && ( + {canEditCourseContent && !readOnly && ( - - + {canEditCourseContent && ( + + + + {intl.formatMessage(messages.cancelButton)} + + + + + )} )} diff --git a/src/generic/configure-modal/UnitTab.tsx b/src/generic/configure-modal/UnitTab.tsx index 852e9c9b99..83b01004ea 100644 --- a/src/generic/configure-modal/UnitTab.tsx +++ b/src/generic/configure-modal/UnitTab.tsx @@ -8,6 +8,7 @@ import classNames from 'classnames'; import { COURSE_BLOCK_NAMES } from '../../constants'; import messages from './messages'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; export type UserPartitionInfo = { selectablePartitions: { @@ -45,16 +46,19 @@ export const DiscussionEditComponent = ({ }: { discussionEnabled: boolean; handleDiscussionChange: (e: any) => void; -}) => ( - <> - - - -

- -

- -); +}) => { + const { canEditCourseContent } = useCourseAuthoringContext(); + return ( + <> + + + +

+ +

+ + ); +}; export interface AccessEditComponentProps { selectedPartitionIndex?: number; @@ -70,6 +74,7 @@ export const AccessEditComponent = ({ selectedGroups, }: AccessEditComponentProps) => { const intl = useIntl(); + const { canEditCourseContent } = useCourseAuthoringContext(); const checkIsDeletedGroup = (group) => { const isGroupSelected = selectedGroups.includes(group.id.toString()); @@ -92,6 +97,7 @@ export const AccessEditComponent = ({ value={selectedPartitionIndex} onChange={handleSelect} data-testid="group-type-select" + disabled={!canEditCourseContent} >