Skip to content

Commit 2bfe88f

Browse files
feat: adding permission validations from authz for files page for view, create, edit and delete
1 parent 929ce15 commit 2bfe88f

17 files changed

Lines changed: 750 additions & 80 deletions

src/authz/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,8 @@ export const COURSE_PERMISSIONS = {
2020

2121
VIEW_GRADING_SETTINGS: 'courses.view_grading_settings',
2222
EDIT_GRADING_SETTINGS: 'courses.edit_grading_settings',
23+
VIEW_FILES: 'courses.view_files',
24+
CREATE_FILES: 'courses.create_files',
25+
DELETE_FILES: 'courses.delete_files',
26+
EDIT_FILES: 'courses.edit_files',
2327
};

src/authz/hooks.test.tsx

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import React from 'react';
2+
import { renderHook, waitFor } from '@testing-library/react';
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4+
5+
import * as authzApi from '@src/authz/data/api';
6+
import { PermissionValidationQuery } from '@src/authz/types';
7+
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
8+
import { useUserPermissionsWithAuthzCourse } from './hooks';
9+
10+
jest.mock('@src/data/api');
11+
jest.mock('@src/authz/data/api');
12+
13+
const mockedAuthzApi = jest.mocked(authzApi);
14+
15+
describe('useUserPermissionsWithAuthzCourse', () => {
16+
let queryClient: QueryClient;
17+
18+
const createWrapper = () => function TestWrapper({ children }: { children: React.ReactNode }) {
19+
return (
20+
<QueryClientProvider client={queryClient}>
21+
{children}
22+
</QueryClientProvider>
23+
);
24+
};
25+
26+
const mockPermissions: PermissionValidationQuery = {
27+
canViewFiles: {
28+
action: 'course.view_files',
29+
scope: 'course-v1:Test+101+2023',
30+
},
31+
canManageFiles: {
32+
action: 'course.manage_files',
33+
scope: 'course-v1:Test+101+2023',
34+
},
35+
};
36+
37+
beforeEach(() => {
38+
queryClient = new QueryClient({
39+
defaultOptions: {
40+
queries: { retry: false },
41+
},
42+
});
43+
jest.clearAllMocks();
44+
});
45+
46+
it('returns all permissions as true when authz is disabled', async () => {
47+
mockWaffleFlags({
48+
enableAuthzCourseAuthoring: false,
49+
});
50+
51+
const { result } = renderHook(
52+
() => useUserPermissionsWithAuthzCourse('course-v1:Test+101+2023', mockPermissions),
53+
{ wrapper: createWrapper() },
54+
);
55+
56+
await waitFor(() => {
57+
expect(result.current.isLoading).toBe(false);
58+
});
59+
60+
expect(result.current.isAuthzEnabled).toBe(false);
61+
expect(result.current.permissions.canViewFiles).toBe(true);
62+
expect(result.current.permissions.canManageFiles).toBe(true);
63+
});
64+
65+
it('returns loading state when authz is enabled and permissions are loading', async () => {
66+
mockWaffleFlags({
67+
enableAuthzCourseAuthoring: true,
68+
});
69+
70+
mockedAuthzApi.validateUserPermissions.mockImplementation(
71+
() => new Promise(() => {}),
72+
);
73+
74+
const { result } = renderHook(
75+
() => useUserPermissionsWithAuthzCourse('course-v1:Test+101+2023', mockPermissions),
76+
{ wrapper: createWrapper() },
77+
);
78+
79+
await waitFor(() => {
80+
expect(result.current.isAuthzEnabled).toBe(true);
81+
});
82+
83+
expect(result.current.isLoading).toBe(true);
84+
});
85+
86+
it('returns actual permission values when authz is enabled and permissions loaded', async () => {
87+
mockWaffleFlags({
88+
enableAuthzCourseAuthoring: true,
89+
});
90+
91+
mockedAuthzApi.validateUserPermissions.mockResolvedValue({
92+
canViewFiles: true,
93+
canManageFiles: false,
94+
});
95+
96+
const { result } = renderHook(
97+
() => useUserPermissionsWithAuthzCourse('course-v1:Test+101+2023', mockPermissions),
98+
{ wrapper: createWrapper() },
99+
);
100+
101+
await waitFor(() => {
102+
expect(result.current.isLoading).toBe(false);
103+
});
104+
105+
expect(result.current.isAuthzEnabled).toBe(true);
106+
expect(result.current.permissions.canViewFiles).toBe(true);
107+
expect(result.current.permissions.canManageFiles).toBe(false);
108+
});
109+
110+
it('falls back to false for undefined permissions when authz is enabled', async () => {
111+
mockWaffleFlags({
112+
enableAuthzCourseAuthoring: true,
113+
});
114+
115+
mockedAuthzApi.validateUserPermissions.mockResolvedValue({
116+
canViewFiles: true,
117+
});
118+
119+
const { result } = renderHook(
120+
() => useUserPermissionsWithAuthzCourse('course-v1:Test+101+2023', mockPermissions),
121+
{ wrapper: createWrapper() },
122+
);
123+
124+
await waitFor(() => {
125+
expect(result.current.isLoading).toBe(false);
126+
});
127+
128+
expect(result.current.isAuthzEnabled).toBe(true);
129+
expect(result.current.permissions.canViewFiles).toBe(true);
130+
expect(result.current.permissions.canManageFiles).toBe(false);
131+
});
132+
});

src/authz/hooks.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ type UseCourseUserPermissionsReturn<Query extends PermissionValidationQuery> = {
77
isAuthzEnabled: boolean;
88
} & PermissionValidationAnswer<Query>;
99

10+
/**
11+
* Return type for the useUserCoursePermissionsWithAuthz hook
12+
*/
13+
interface UseUserPermissionsWithAuthzCourseReturn {
14+
/** Whether permissions are currently loading */
15+
isLoading: boolean;
16+
/** Object containing permission results with boolean values */
17+
permissions: PermissionValidationAnswer;
18+
/** Whether authorization is enabled for the course */
19+
isAuthzEnabled: boolean;
20+
}
21+
1022
/**
1123
* Custom hook to retrieve and evaluate user permissions for the current course using the openedx-authz service.
1224
*
@@ -69,3 +81,69 @@ export const useCourseUserPermissions = <Query extends PermissionValidationQuery
6981
...permissionResults as PermissionValidationAnswer<Query>,
7082
};
7183
};
84+
85+
/**
86+
* Custom hook to handle user permissions with course authorization waffle flag
87+
*
88+
* This hook abstracts the common pattern of:
89+
* 1. Checking if authz is enabled via waffle flag
90+
* 2. Fetching user permissions when authz is enabled
91+
* 3. Defaulting all permissions to true when authz is disabled
92+
* 4. Providing fallback values for undefined permissions
93+
*
94+
* @param courseId - The course ID to check permissions for
95+
* @param permissions - Object mapping permission names to their action/scope definitions
96+
* @returns Object containing loading state, permissions results, and authz status
97+
*
98+
* @example
99+
* ```tsx
100+
* const { isLoading, permissions, isAuthzEnabled } = useUserPermissionsWithAuthzCourse(
101+
* courseId,
102+
* {
103+
* canViewFiles: {
104+
* action: COURSE_PERMISSIONS.VIEW_FILES,
105+
* scope: courseId,
106+
* },
107+
* canManageFiles: {
108+
* action: COURSE_PERMISSIONS.MANAGE_FILES,
109+
* scope: courseId,
110+
* },
111+
* }
112+
* );
113+
*
114+
* const { canViewFiles, canManageFiles } = permissions;
115+
* ```
116+
*/
117+
export const useUserPermissionsWithAuthzCourse = (
118+
courseId: string,
119+
permissions: PermissionValidationQuery,
120+
): UseUserPermissionsWithAuthzCourseReturn => {
121+
const waffleFlags = useWaffleFlags(courseId);
122+
const isAuthzEnabled: boolean = waffleFlags?.enableAuthzCourseAuthoring ?? false;
123+
124+
const {
125+
isLoading: isLoadingUserPermissions,
126+
data: userPermissions,
127+
} = useUserPermissions(permissions, isAuthzEnabled);
128+
129+
// Build permission results object
130+
const permissionResults: PermissionValidationAnswer = {};
131+
132+
if (isAuthzEnabled && !isLoadingUserPermissions) {
133+
// Authz is enabled and permissions loaded, use actual permission values with fallback to false
134+
Object.keys(permissions).forEach((permissionKey: string) => {
135+
permissionResults[permissionKey] = userPermissions?.[permissionKey] ?? false;
136+
});
137+
} else if (!isLoadingUserPermissions) {
138+
// Authz is disabled or permissions still loading, default all to true
139+
Object.keys(permissions).forEach((permissionKey: string) => {
140+
permissionResults[permissionKey] = true;
141+
});
142+
}
143+
144+
return {
145+
isLoading: isAuthzEnabled ? isLoadingUserPermissions : false,
146+
permissions: permissionResults,
147+
isAuthzEnabled,
148+
};
149+
};
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { getFilesPermissions, getGradingPermissions } from './permissionHelpers';
2+
import { COURSE_PERMISSIONS } from './constants';
3+
4+
describe('permissionHelpers', () => {
5+
const mockCourseId = 'course-v1:edX+DemoX+Demo_Course';
6+
7+
describe('getFilesPermissions', () => {
8+
it('should return correct permission structure for file operations', () => {
9+
const result = getFilesPermissions(mockCourseId);
10+
11+
expect(result).toEqual({
12+
canViewFiles: {
13+
action: COURSE_PERMISSIONS.VIEW_FILES,
14+
scope: mockCourseId,
15+
},
16+
canCreateFiles: {
17+
action: COURSE_PERMISSIONS.CREATE_FILES,
18+
scope: mockCourseId,
19+
},
20+
canDeleteFiles: {
21+
action: COURSE_PERMISSIONS.DELETE_FILES,
22+
scope: mockCourseId,
23+
},
24+
canEditFiles: {
25+
action: COURSE_PERMISSIONS.EDIT_FILES,
26+
scope: mockCourseId,
27+
},
28+
});
29+
});
30+
31+
it('should use the provided courseId as scope for all permissions', () => {
32+
const customCourseId = 'course-v1:TestOrg+TestCourse+2024';
33+
const result = getFilesPermissions(customCourseId);
34+
35+
Object.values(result).forEach(permission => {
36+
expect(permission.scope).toBe(customCourseId);
37+
});
38+
});
39+
40+
it('should use correct COURSE_PERMISSIONS constants for each action', () => {
41+
const result = getFilesPermissions(mockCourseId);
42+
43+
expect(result.canViewFiles.action).toBe(COURSE_PERMISSIONS.VIEW_FILES);
44+
expect(result.canCreateFiles.action).toBe(COURSE_PERMISSIONS.CREATE_FILES);
45+
expect(result.canDeleteFiles.action).toBe(COURSE_PERMISSIONS.DELETE_FILES);
46+
expect(result.canEditFiles.action).toBe(COURSE_PERMISSIONS.EDIT_FILES);
47+
});
48+
});
49+
50+
describe('getGradingPermissions', () => {
51+
it('should return correct permission structure for grading operations', () => {
52+
const result = getGradingPermissions(mockCourseId);
53+
54+
expect(result).toEqual({
55+
canViewGradingSettings: {
56+
action: COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS,
57+
scope: mockCourseId,
58+
},
59+
canEditGradingSettings: {
60+
action: COURSE_PERMISSIONS.EDIT_GRADING_SETTINGS,
61+
scope: mockCourseId,
62+
},
63+
});
64+
});
65+
66+
it('should use the provided courseId as scope for all permissions', () => {
67+
const customCourseId = 'course-v1:TestOrg+TestCourse+2024';
68+
const result = getGradingPermissions(customCourseId);
69+
70+
Object.values(result).forEach(permission => {
71+
expect(permission.scope).toBe(customCourseId);
72+
});
73+
});
74+
75+
it('should use correct COURSE_PERMISSIONS constants for each action', () => {
76+
const result = getGradingPermissions(mockCourseId);
77+
78+
expect(result.canViewGradingSettings.action).toBe(COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS);
79+
expect(result.canEditGradingSettings.action).toBe(COURSE_PERMISSIONS.EDIT_GRADING_SETTINGS);
80+
});
81+
});
82+
});

src/authz/permissionHelpers.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,25 @@ export const getGradingPermissions = (courseId: string) => ({
77
},
88
canEditGradingSettings: {
99
action: COURSE_PERMISSIONS.EDIT_GRADING_SETTINGS,
10+
scope: courseId,
11+
},
12+
});
13+
14+
export const getFilesPermissions = (courseId: string) => ({
15+
canViewFiles: {
16+
action: COURSE_PERMISSIONS.VIEW_FILES,
17+
scope: courseId,
18+
},
19+
canCreateFiles: {
20+
action: COURSE_PERMISSIONS.CREATE_FILES,
21+
scope: courseId,
22+
},
23+
canDeleteFiles: {
24+
action: COURSE_PERMISSIONS.DELETE_FILES,
25+
scope: courseId,
26+
},
27+
canEditFiles: {
28+
action: COURSE_PERMISSIONS.EDIT_FILES,
1029
scope: courseId,
1130
},
1231
});

src/files-and-videos/files-page/CourseFilesTable.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import { getFileSizeToClosestByte } from '@src/utils';
2828
import React from 'react';
2929
import { useDispatch, useSelector } from 'react-redux';
3030
import { useParams } from 'react-router-dom';
31+
import { useUserPermissionsWithAuthzCourse } from '@src/authz/hooks';
32+
import { getFilesPermissions } from '@src/authz/permissionHelpers';
3133

3234
export const CourseFilesTable = () => {
3335
const intl = useIntl();
@@ -40,6 +42,10 @@ export const CourseFilesTable = () => {
4042
errors: errorMessages,
4143
} = useSelector((state: DeprecatedReduxState) => state.assets);
4244

45+
const {
46+
permissions,
47+
} = useUserPermissionsWithAuthzCourse(courseId, getFilesPermissions(courseId));
48+
4349
const handleErrorReset = (error) => dispatch(resetErrors(error));
4450
const handleDeleteFile = (id) => dispatch(deleteAssetFile(courseId, id));
4551
const handleDownloadFile = (selectedRows) =>
@@ -65,11 +71,11 @@ export const CourseFilesTable = () => {
6571
};
6672

6773
const thumbnailPreview = (props) => FileThumbnail(props);
68-
const infoModalSidebar = (asset) =>
69-
FileInfoModalSidebar({
70-
asset,
71-
handleLockedAsset: handleLockFile,
72-
});
74+
const infoModalSidebar = (asset) => FileInfoModalSidebar({
75+
asset,
76+
handleLockedAsset: handleLockFile,
77+
canLockFile: permissions.canEditFiles,
78+
});
7379

7480
const assets = useModels('assets', assetIds);
7581
const data = {
@@ -180,6 +186,11 @@ export const CourseFilesTable = () => {
180186
thumbnailPreview,
181187
infoModalSidebar,
182188
files: assets,
189+
permissions: {
190+
canCreateFiles: permissions.canCreateFiles,
191+
canDeleteFiles: permissions.canDeleteFiles,
192+
canEditFiles: permissions.canEditFiles,
193+
},
183194
}}
184195
/>
185196
<FileValidationModal {...{ handleFileOverwrite }} />

0 commit comments

Comments
 (0)