Skip to content

Commit 699af15

Browse files
committed
feat(pages-and-resources): add permission tests for grading and pages/resources access
1 parent bf1f32a commit 699af15

16 files changed

Lines changed: 658 additions & 38 deletions

src/CourseAuthoringPage.test.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,14 +108,20 @@ describe('Course authoring page', () => {
108108

109109
axiosMock.onGet(
110110
`${courseAppsApiUrl}/${courseId}`,
111-
).reply(403);
111+
).reply(403, { response: { status: 403 } });
112112
await executeThunk(fetchCourseApps(courseId), store.dispatch);
113113
};
114114
test('renders PermissionDeniedAlert when courseAppsApiStatus is DENIED', async () => {
115115
mockPathname = '/editor/';
116116
await mockStoreDenied();
117117

118-
const wrapper = renderComponent(<CourseAuthoringPage />);
118+
// Test PagesAndResources (which has the PermissionDeniedAlert logic),
119+
// not CourseAuthoringPage which is just the layout wrapper
120+
const wrapper = renderComponent(
121+
<CourseAuthoringPage>
122+
<PagesAndResources />
123+
</CourseAuthoringPage>,
124+
);
119125
expect(await wrapper.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
120126
});
121127
});

src/advanced-settings/AdvancedSettings.test.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,4 +191,25 @@ describe('<AdvancedSettings />', () => {
191191
render();
192192
expect(await screen.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
193193
});
194+
195+
it('should render settings in read-only mode when user has VIEW but not MANAGE permissions (auditor)', async () => {
196+
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
197+
jest.mocked(useUserPermissions).mockReturnValue({
198+
isLoading: false,
199+
data: { canViewAdvancedSettings: true, canManageAdvancedSettings: false },
200+
} as unknown as ReturnType<typeof useUserPermissions>);
201+
render();
202+
const textarea = await screen.findByLabelText(/Advanced Module List/i);
203+
expect(textarea).toBeDisabled();
204+
});
205+
206+
it('should show permission denied when user has NO permissions (null data)', async () => {
207+
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
208+
jest.mocked(useUserPermissions).mockReturnValue({
209+
isLoading: false,
210+
data: null,
211+
} as unknown as ReturnType<typeof useUserPermissions>);
212+
render();
213+
expect(await screen.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
214+
});
194215
});

src/advanced-settings/AdvancedSettings.tsx

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,18 @@ const AdvancedSettings = () => {
7676
} = updateMutation;
7777

7878
const isLoading = isPendingSettingsStatus || (isAuthzEnabled && isLoadingUserPermissions);
79+
80+
// Determine if UI should be read-only (has VIEW but not MANAGE) — auditor
81+
const isReadOnly = isAuthzEnabled
82+
&& !isLoadingUserPermissions
83+
&& !!userPermissions?.canViewAdvancedSettings
84+
&& !userPermissions?.canManageAdvancedSettings;
85+
86+
useEffect(() => {
87+
if (isReadOnly) {
88+
setIsEditableState(false);
89+
}
90+
}, [isReadOnly]);
7991
const updateSettingsButtonState = {
8092
labels: {
8193
default: intl.formatMessage(messages.buttonSaveText),
@@ -162,17 +174,6 @@ const AdvancedSettings = () => {
162174
return <PermissionDeniedAlert />;
163175
}
164176

165-
// Determine if UI should be disabled (has VIEW but not MANAGE) - auditor sees read-only view
166-
const isReadOnly = isAuthzEnabled
167-
&& !isLoadingUserPermissions
168-
&& userPermissions?.canViewAdvancedSettings
169-
&& !userPermissions?.canManageAdvancedSettings;
170-
171-
// Apply read-only mode for auditors
172-
if (isReadOnly) {
173-
setIsEditableState(false);
174-
}
175-
176177
// Show the page content (read-only or editable)
177178

178179
return (

src/advanced-settings/setting-card/SettingCard.test.jsx

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fireEvent, render, waitFor } from '@testing-library/react';
1+
import { fireEvent, render, waitFor, screen } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
33
import { IntlProvider } from '@edx/frontend-platform/i18n';
44

@@ -25,7 +25,7 @@ jest.mock('react-textarea-autosize', () =>
2525
/>
2626
)));
2727

28-
const RootWrapper = () => (
28+
const RootWrapper = (props = {}) => (
2929
<IntlProvider locale="en">
3030
<SettingCard
3131
isOn
@@ -37,6 +37,7 @@ const RootWrapper = () => (
3737
handleBlur={handleBlur}
3838
isEditableState
3939
saveSettingsPrompt={false}
40+
{...props}
4041
/>
4142
</IntlProvider>
4243
);
@@ -91,4 +92,34 @@ describe('<SettingCard />', () => {
9192
expect(handleBlur).toHaveBeenCalled();
9293
});
9394
});
95+
it('renders in readOnly mode with disabled input', () => {
96+
render(<RootWrapper readOnly />);
97+
const input = screen.getByLabelText(/Setting Name/i);
98+
expect(input).toBeDisabled();
99+
});
100+
101+
it('renders enabled by default when readOnly is not specified (default false)', () => {
102+
// readOnly defaults to false - input should be enabled
103+
render(<RootWrapper />);
104+
const input = screen.getByLabelText(/Setting Name/i);
105+
expect(input).not.toBeDisabled();
106+
});
107+
108+
it('calls setIsEditableState when value changes and isEditableState is false', async () => {
109+
// Test for line 45: setIsEditableState(true) called when value changes
110+
const { getByLabelText } = render(<RootWrapper isEditableState={false} />);
111+
const input = getByLabelText(/Setting Name/i);
112+
fireEvent.change(input, { target: { value: 'new-different-value' } });
113+
await waitFor(() => {
114+
expect(setIsEditableState).toHaveBeenCalledWith(true);
115+
});
116+
});
117+
118+
it('shows help popup when clicking info button', () => {
119+
render(<RootWrapper />);
120+
const helpButton = screen.getByRole('button', { name: /show help text/i });
121+
fireEvent.click(helpButton);
122+
// The help text should be visible in the popup - verify component renders help
123+
expect(screen.queryByText(/This is a help message/i)).toBeInTheDocument();
124+
});
94125
});

src/advanced-settings/setting-card/SettingCard.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,4 @@ SettingCard.propTypes = {
138138
readOnly: PropTypes.bool,
139139
};
140140

141-
SettingCard.defaultProps = {
142-
readOnly: false,
143-
};
144-
145141
export default SettingCard;
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { getGradingPermissions, getPagesAndResourcesPermissions } from './permissionHelpers';
2+
import { COURSE_PERMISSIONS } from './constants';
3+
4+
describe('permissionHelpers', () => {
5+
const courseId = 'course-v1:org+course+run';
6+
7+
describe('getGradingPermissions', () => {
8+
it('returns correct permission structure with VIEW action', () => {
9+
const result = getGradingPermissions(courseId);
10+
11+
expect(result.canViewGradingSettings).toBeDefined();
12+
expect(result.canViewGradingSettings.action).toBe(COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS);
13+
expect(result.canViewGradingSettings.scope).toBe(courseId);
14+
});
15+
16+
it('returns correct permission structure with EDIT action', () => {
17+
const result = getGradingPermissions(courseId);
18+
19+
expect(result.canEditGradingSettings).toBeDefined();
20+
expect(result.canEditGradingSettings.action).toBe(COURSE_PERMISSIONS.EDIT_GRADING_SETTINGS);
21+
expect(result.canEditGradingSettings.scope).toBe(courseId);
22+
});
23+
24+
it('returns both VIEW and EDIT permissions in single call', () => {
25+
const result = getGradingPermissions(courseId);
26+
27+
const permissions = Object.keys(result);
28+
expect(permissions).toContain('canViewGradingSettings');
29+
expect(permissions).toContain('canEditGradingSettings');
30+
});
31+
32+
it('uses correct courseId as scope for all permissions', () => {
33+
const customCourseId = 'course-v1:custom+org+custom_run';
34+
const result = getGradingPermissions(customCourseId);
35+
36+
expect(result.canViewGradingSettings.scope).toBe(customCourseId);
37+
expect(result.canEditGradingSettings.scope).toBe(customCourseId);
38+
});
39+
});
40+
41+
describe('getPagesAndResourcesPermissions', () => {
42+
it('returns correct permission structure with VIEW action', () => {
43+
const result = getPagesAndResourcesPermissions(courseId);
44+
45+
expect(result.canViewPagesAndResources).toBeDefined();
46+
expect(result.canViewPagesAndResources.action).toBe(COURSE_PERMISSIONS.VIEW_PAGES_AND_RESOURCES);
47+
expect(result.canViewPagesAndResources.scope).toBe(courseId);
48+
});
49+
50+
it('returns correct permission structure with EDIT action', () => {
51+
const result = getPagesAndResourcesPermissions(courseId);
52+
53+
expect(result.canEditPagesAndResources).toBeDefined();
54+
expect(result.canEditPagesAndResources.action).toBe(COURSE_PERMISSIONS.EDIT_PAGES_AND_RESOURCES);
55+
expect(result.canEditPagesAndResources.scope).toBe(courseId);
56+
});
57+
58+
it('returns both VIEW and EDIT permissions in single call', () => {
59+
const result = getPagesAndResourcesPermissions(courseId);
60+
61+
const permissions = Object.keys(result);
62+
expect(permissions).toContain('canViewPagesAndResources');
63+
expect(permissions).toContain('canEditPagesAndResources');
64+
});
65+
66+
it('uses correct courseId as scope for all permissions', () => {
67+
const customCourseId = 'course-v1:another+test+course';
68+
const result = getPagesAndResourcesPermissions(customCourseId);
69+
70+
expect(result.canViewPagesAndResources.scope).toBe(customCourseId);
71+
expect(result.canEditPagesAndResources.scope).toBe(customCourseId);
72+
});
73+
});
74+
75+
describe('permission constants verification', () => {
76+
it('uses correct VIEW_GRADING_SETTINGS constant', () => {
77+
const result = getGradingPermissions(courseId);
78+
expect(result.canViewGradingSettings.action).toBe('courses.view_grading_settings');
79+
});
80+
81+
it('uses correct EDIT_GRADING_SETTINGS constant', () => {
82+
const result = getGradingPermissions(courseId);
83+
expect(result.canEditGradingSettings.action).toBe('courses.edit_grading_settings');
84+
});
85+
86+
it('uses correct VIEW_PAGES_AND_RESOURCES constant', () => {
87+
const result = getPagesAndResourcesPermissions(courseId);
88+
expect(result.canViewPagesAndResources.action).toBe('courses.view_pages_and_resources');
89+
});
90+
91+
it('uses correct EDIT_PAGES_AND_RESOURCES constant', () => {
92+
const result = getPagesAndResourcesPermissions(courseId);
93+
expect(result.canEditPagesAndResources.action).toBe('courses.manage_pages_and_resources');
94+
});
95+
});
96+
97+
describe('edge cases', () => {
98+
it('handles empty courseId', () => {
99+
const result = getGradingPermissions('');
100+
101+
expect(result.canViewGradingSettings.scope).toBe('');
102+
expect(result.canEditGradingSettings.scope).toBe('');
103+
});
104+
105+
it('handles special characters in courseId', () => {
106+
const specialCourseId = 'course-v1:test+special:id';
107+
const result = getPagesAndResourcesPermissions(specialCourseId);
108+
109+
expect(result.canViewPagesAndResources.scope).toBe(specialCourseId);
110+
});
111+
112+
it('returns consistent structure across multiple calls', () => {
113+
const result1 = getGradingPermissions(courseId);
114+
const result2 = getGradingPermissions(courseId);
115+
116+
expect(Object.keys(result1)).toEqual(Object.keys(result2));
117+
expect(result1.canViewGradingSettings.action).toBe(result2.canViewGradingSettings.action);
118+
});
119+
});
120+
});

src/pages-and-resources/PagesAndResources.test.tsx

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,16 @@ import {
88
import { getConfig, setConfig } from '@edx/frontend-platform';
99
import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework';
1010
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
11+
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
12+
import { useCourseUserPermissions } from '@src/authz/hooks';
1113
import { PagesAndResources } from '.';
1214

15+
// Mock authz hooks
16+
jest.mock('@src/authz/hooks', () => ({
17+
...jest.requireActual('@src/authz/hooks'),
18+
useCourseUserPermissions: jest.fn(),
19+
}));
20+
1321
const mockPlugin = (identifier) => ({
1422
plugins: [
1523
{
@@ -45,8 +53,30 @@ describe('PagesAndResources', () => {
4553
),
4654
},
4755
});
56+
57+
// Set up waffle flags to disable authz by default
58+
mockWaffleFlags({ enableAuthzCourseAuthoring: false });
59+
60+
// Default: authz disabled allows everything
61+
jest.mocked(useCourseUserPermissions).mockReturnValue({
62+
isLoading: false,
63+
isAuthzEnabled: false,
64+
canViewPagesAndResources: true,
65+
canEditPagesAndResources: true,
66+
} as ReturnType<typeof useCourseUserPermissions>);
4867
});
4968

69+
// Helper to set up permission mocks
70+
const mockPermissions = (canView: boolean, canEdit: boolean) => {
71+
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
72+
jest.mocked(useCourseUserPermissions).mockReturnValue({
73+
isLoading: false,
74+
isAuthzEnabled: true,
75+
canViewPagesAndResources: canView,
76+
canEditPagesAndResources: canEdit,
77+
} as ReturnType<typeof useCourseUserPermissions>);
78+
};
79+
5080
it('doesn\'t show content permissions section if relevant apps are not enabled', async () => {
5181
const initialState = {
5282
models: {
@@ -128,4 +158,60 @@ describe('PagesAndResources', () => {
128158
await waitFor(() => expect(screen.queryByTestId('additional_course_plugin')).toBeInTheDocument());
129159
await waitFor(() => expect(screen.queryByTestId('additional_course_content_plugin')).toBeInTheDocument());
130160
});
161+
162+
describe('permission integration', () => {
163+
it('shows PermissionDeniedAlert when user has no VIEW or EDIT permissions', async () => {
164+
mockPermissions(false, false);
165+
166+
const initialState = {
167+
models: {
168+
courseApps: {},
169+
},
170+
pagesAndResources: {
171+
courseAppIds: [],
172+
},
173+
};
174+
175+
initializeMocks({ initialState });
176+
renderComponent();
177+
178+
await waitFor(() => expect(screen.getByTestId('permissionDeniedAlert')).toBeInTheDocument());
179+
});
180+
181+
it('does NOT show PermissionDeniedAlert when user has VIEW permission', async () => {
182+
mockPermissions(true, false);
183+
184+
const initialState = {
185+
models: {
186+
courseApps: {},
187+
},
188+
pagesAndResources: {
189+
courseAppIds: [],
190+
},
191+
};
192+
193+
initializeMocks({ initialState });
194+
renderComponent();
195+
196+
await waitFor(() => expect(screen.queryByTestId('permissionDeniedAlert')).not.toBeInTheDocument());
197+
});
198+
199+
it('does NOT show PermissionDeniedAlert when user has EDIT permission', async () => {
200+
mockPermissions(true, true);
201+
202+
const initialState = {
203+
models: {
204+
courseApps: {},
205+
},
206+
pagesAndResources: {
207+
courseAppIds: [],
208+
},
209+
};
210+
211+
initializeMocks({ initialState });
212+
renderComponent();
213+
214+
await waitFor(() => expect(screen.queryByTestId('permissionDeniedAlert')).not.toBeInTheDocument());
215+
});
216+
});
131217
});

src/pages-and-resources/discussions/app-config-form/apps/openedx/OpenedXConfigForm.jsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,4 @@ OpenedXConfigForm.propTypes = {
181181
isEditable: PropTypes.bool,
182182
};
183183

184-
OpenedXConfigForm.defaultProps = {
185-
isEditable: true,
186-
};
187-
188184
export default OpenedXConfigForm;

0 commit comments

Comments
 (0)