Skip to content

Commit 1df4ca1

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

13 files changed

Lines changed: 586 additions & 30 deletions

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/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.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// src/authz/hooks/useUserPermissionsWithAuthz.ts
2+
import { useWaffleFlags } from '@src/data/apiHooks';
3+
import { useUserPermissions } from '@src/authz/data/apiHooks';
4+
import { PermissionValidationQuery, PermissionValidationAnswer } from '@src/authz/types';
5+
6+
/**
7+
* Return type for the useUserCoursePermissionsWithAuthz hook
8+
*/
9+
interface UseUserPermissionsWithAuthzCourseReturn {
10+
/** Whether permissions are currently loading */
11+
isLoading: boolean;
12+
/** Object containing permission results with boolean values */
13+
permissions: PermissionValidationAnswer;
14+
/** Whether authorization is enabled for the course */
15+
isAuthzEnabled: boolean;
16+
}
17+
18+
/**
19+
* Custom hook to handle user permissions with course authorization waffle flag
20+
*
21+
* This hook abstracts the common pattern of:
22+
* 1. Checking if authz is enabled via waffle flag
23+
* 2. Fetching user permissions when authz is enabled
24+
* 3. Defaulting all permissions to true when authz is disabled
25+
* 4. Providing fallback values for undefined permissions
26+
*
27+
* @param courseId - The course ID to check permissions for
28+
* @param permissions - Object mapping permission names to their action/scope definitions
29+
* @returns Object containing loading state, permissions results, and authz status
30+
*
31+
* @example
32+
* ```tsx
33+
* const { isLoading, permissions, isAuthzEnabled } = useUserPermissionsWithAuthzCourse(
34+
* courseId,
35+
* {
36+
* canViewFiles: {
37+
* action: COURSE_PERMISSIONS.VIEW_FILES,
38+
* scope: courseId,
39+
* },
40+
* canManageFiles: {
41+
* action: COURSE_PERMISSIONS.MANAGE_FILES,
42+
* scope: courseId,
43+
* },
44+
* }
45+
* );
46+
*
47+
* const { canViewFiles, canManageFiles } = permissions;
48+
* ```
49+
*/
50+
export const useUserPermissionsWithAuthzCourse = (
51+
courseId: string,
52+
permissions: PermissionValidationQuery,
53+
): UseUserPermissionsWithAuthzCourseReturn => {
54+
const waffleFlags = useWaffleFlags(courseId);
55+
const isAuthzEnabled: boolean = waffleFlags?.enableAuthzCourseAuthoring ?? false;
56+
57+
const {
58+
isLoading: isLoadingUserPermissions,
59+
data: userPermissions,
60+
} = useUserPermissions(permissions, isAuthzEnabled);
61+
62+
// Build permission results object
63+
const permissionResults: PermissionValidationAnswer = {};
64+
65+
if (isAuthzEnabled && !isLoadingUserPermissions) {
66+
// Authz is enabled and permissions loaded, use actual permission values with fallback to false
67+
Object.keys(permissions).forEach((permissionKey: string) => {
68+
permissionResults[permissionKey] = userPermissions?.[permissionKey] ?? false;
69+
});
70+
} else if (!isLoadingUserPermissions) {
71+
// Authz is disabled or permissions still loading, default all to true
72+
Object.keys(permissions).forEach((permissionKey: string) => {
73+
permissionResults[permissionKey] = true;
74+
});
75+
}
76+
77+
return {
78+
isLoading: isAuthzEnabled ? isLoadingUserPermissions : false,
79+
permissions: permissionResults,
80+
isAuthzEnabled,
81+
};
82+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { getCourseUpdatesPermissions } from './permissionHelpers';
2+
import { COURSE_PERMISSIONS } from './constants';
3+
4+
describe('permissionHelpers', () => {
5+
describe('getCourseUpdatesPermissions', () => {
6+
const mockCourseId = 'course-v1:edX+DemoX+Demo_Course';
7+
8+
it('should return correct permission structure for course updates operations', () => {
9+
const result = getCourseUpdatesPermissions(mockCourseId);
10+
11+
expect(result).toEqual({
12+
canViewCourseUpdates: {
13+
action: COURSE_PERMISSIONS.VIEW_COURSE_UPDATES,
14+
scope: mockCourseId,
15+
},
16+
canManageCourseUpdates: {
17+
action: COURSE_PERMISSIONS.MANAGE_COURSE_UPDATES,
18+
scope: mockCourseId,
19+
},
20+
});
21+
});
22+
23+
it('should use the provided courseId as scope for all permissions', () => {
24+
const customCourseId = 'course-v1:TestOrg+TestCourse+2024';
25+
const result = getCourseUpdatesPermissions(customCourseId);
26+
27+
Object.values(result).forEach(permission => {
28+
expect(permission.scope).toBe(customCourseId);
29+
});
30+
});
31+
});
32+
});

src/authz/permissionHelpers.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { COURSE_PERMISSIONS } from './constants';
2+
3+
export const getCourseUpdatesPermissions = (courseId: string) => ({
4+
canViewCourseUpdates: {
5+
action: COURSE_PERMISSIONS.VIEW_COURSE_UPDATES,
6+
scope: courseId,
7+
},
8+
canManageCourseUpdates: {
9+
action: COURSE_PERMISSIONS.MANAGE_COURSE_UPDATES,
10+
scope: courseId,
11+
},
12+
});

0 commit comments

Comments
 (0)