From 729a6d1c59506118aa998e48146f0509bb3a8666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20M=C3=A9ndez?= Date: Tue, 10 Feb 2026 12:30:37 -0600 Subject: [PATCH 1/6] feat: Add validation for Advanced Settings permissions using openedx-authz --- src/advanced-settings/AdvancedSettings.jsx | 23 ++++- src/authz/constants.ts | 4 + src/data/api.ts | 1 + src/header/{hooks.test.ts => hooks.test.tsx} | 92 ++++++++++++++++---- src/header/hooks.tsx | 30 ++++++- 5 files changed, 130 insertions(+), 20 deletions(-) rename src/header/{hooks.test.ts => hooks.test.tsx} (64%) diff --git a/src/advanced-settings/AdvancedSettings.jsx b/src/advanced-settings/AdvancedSettings.jsx index 9ac41f4799..0aed49dc92 100644 --- a/src/advanced-settings/AdvancedSettings.jsx +++ b/src/advanced-settings/AdvancedSettings.jsx @@ -6,6 +6,10 @@ import { import { CheckCircle, Info, Warning } from '@openedx/paragon/icons'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import { useWaffleFlags } from '@src/data/apiHooks'; +import { useUserPermissions } from '@src/authz/data/apiHooks'; +import { COURSE_PERMISSIONS } from '@src/authz/constants'; +import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert'; import Placeholder from '../editors/Placeholder'; import AlertProctoringError from '../generic/AlertProctoringError'; @@ -41,6 +45,15 @@ const AdvancedSettings = () => { const { courseId, courseDetails } = useCourseAuthoringContext(); document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle)); + const waffleFlags = useWaffleFlags(courseId); + const isAuthzEnabled = waffleFlags.enableAuthzCourseAuthoring; + const { isLoading: isLoadingUserPermissions, data: userPermissions } = useUserPermissions({ + canManageAdvancedSettings: { + action: COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS, + scope: courseId, + }, + }, isAuthzEnabled); + useEffect(() => { dispatch(fetchCourseAppSettings(courseId)); dispatch(fetchProctoringExamErrors(courseId)); @@ -52,7 +65,7 @@ const AdvancedSettings = () => { const settingsWithSendErrors = useSelector(getSendRequestErrors) || {}; const loadingSettingsStatus = useSelector(getLoadingStatus); - const isLoading = loadingSettingsStatus === RequestStatus.IN_PROGRESS; + const isLoading = loadingSettingsStatus === RequestStatus.IN_PROGRESS || (isAuthzEnabled && isLoadingUserPermissions); const updateSettingsButtonState = { labels: { default: intl.formatMessage(messages.buttonSaveText), @@ -128,6 +141,14 @@ const AdvancedSettings = () => { showSaveSettingsPrompt(true); }; + if (isAuthzEnabled) { + if (!isLoadingUserPermissions && !userPermissions?.canManageAdvancedSettings) { + return ( + + ); + } + } + return ( <> diff --git a/src/authz/constants.ts b/src/authz/constants.ts index b9b14bbbf0..88d4b4da58 100644 --- a/src/authz/constants.ts +++ b/src/authz/constants.ts @@ -14,3 +14,7 @@ export const CONTENT_LIBRARY_PERMISSIONS = { MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team', VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team', }; + +export const COURSE_PERMISSIONS = { + MANAGE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings', +}; diff --git a/src/data/api.ts b/src/data/api.ts index cae3212d48..4f74a7196b 100644 --- a/src/data/api.ts +++ b/src/data/api.ts @@ -92,6 +92,7 @@ export const waffleFlagDefaults = { useNewGroupConfigurationsPage: true, useReactMarkdownEditor: true, useVideoGalleryFlow: false, + enableAuthzCourseAuthoring: false, } as const; export type WaffleFlagName = keyof typeof waffleFlagDefaults; diff --git a/src/header/hooks.test.ts b/src/header/hooks.test.tsx similarity index 64% rename from src/header/hooks.test.ts rename to src/header/hooks.test.tsx index c7145a7733..4ff1deffc5 100644 --- a/src/header/hooks.test.ts +++ b/src/header/hooks.test.tsx @@ -1,6 +1,9 @@ import { useSelector } from 'react-redux'; import { getConfig, setConfig } from '@edx/frontend-platform'; -import { renderHook } from '@testing-library/react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactNode } from 'react'; +import { useUserPermissions } from '@src/authz/data/apiHooks'; import messages from './messages'; import { useContentMenuItems, useToolsMenuItems, useSettingMenuItems, useLibrarySettingsMenuItems, useLibraryToolsMenuItems, @@ -27,6 +30,30 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: jest.fn(), +})); + +jest.mock('@src/authz/data/apiHooks', () => ({ + useUserPermissions: jest.fn(), +})); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + return wrapper; +}; + describe('header utils', () => { describe('getContentMenuItems', () => { it('when video upload page enabled should include Video Uploads option', () => { @@ -37,7 +64,7 @@ describe('header utils', () => { ...getConfig(), ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'true', }); - const actualItems = renderHook(() => useContentMenuItems('course-123')).result.current; + const actualItems = renderHook(() => useContentMenuItems('course-123'), { wrapper: createWrapper() }).result.current; expect(actualItems).toHaveLength(5); }); it('when video upload page disabled should not include Video Uploads option', () => { @@ -48,14 +75,14 @@ describe('header utils', () => { ...getConfig(), ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'false', }); - const actualItems = renderHook(() => useContentMenuItems('course-123')).result.current; + const actualItems = renderHook(() => useContentMenuItems('course-123'), { wrapper: createWrapper() }).result.current; expect(actualItems).toHaveLength(4); }); it('adds course libraries link to content menu when libraries v2 is enabled', () => { jest.mocked(useSelector).mockReturnValue({ librariesV2Enabled: true, }); - const actualItems = renderHook(() => useContentMenuItems('course-123')).result.current; + const actualItems = renderHook(() => useContentMenuItems('course-123'), { wrapper: createWrapper() }).result.current; expect(actualItems[1]).toEqual({ href: '/course/course-123/libraries', title: 'Library Updates' }); }); }); @@ -65,6 +92,10 @@ describe('header utils', () => { jest.mocked(useSelector).mockReturnValue({ canAccessAdvancedSettings: true, }); + jest.mocked(useUserPermissions).mockReturnValue({ + isLoading: false, + data: { canManageAdvancedSettings: true }, + } as any); }); it('when certificate page enabled should include certificates option', () => { @@ -72,7 +103,7 @@ describe('header utils', () => { ...getConfig(), ENABLE_CERTIFICATE_PAGE: 'true', }); - const actualItems = renderHook(() => useSettingMenuItems('course-123')).result.current; + const actualItems = renderHook(() => useSettingMenuItems('course-123'), { wrapper: createWrapper() }).result.current; expect(actualItems).toHaveLength(6); }); it('when certificate page disabled should not include certificates option', () => { @@ -80,18 +111,47 @@ describe('header utils', () => { ...getConfig(), ENABLE_CERTIFICATE_PAGE: 'false', }); - const actualItems = renderHook(() => useSettingMenuItems('course-123')).result.current; + const actualItems = renderHook(() => useSettingMenuItems('course-123'), { wrapper: createWrapper() }).result.current; expect(actualItems).toHaveLength(5); }); it('when user has access to advanced settings should include advanced settings option', () => { - const actualItemsTitle = renderHook(() => useSettingMenuItems('course-123')).result.current.map((item) => item.title); + const actualItemsTitle = renderHook(() => useSettingMenuItems('course-123'), { wrapper: createWrapper() }).result.current.map((item) => item.title); expect(actualItemsTitle).toContain('Advanced Settings'); }); it('when user has no access to advanced settings should not include advanced settings option', () => { jest.mocked(useSelector).mockReturnValue({ canAccessAdvancedSettings: false }); - const actualItemsTitle = renderHook(() => useSettingMenuItems('course-123')).result.current.map((item) => item.title); + const actualItemsTitle = renderHook(() => useSettingMenuItems('course-123'), { wrapper: createWrapper() }).result.current.map((item) => item.title); expect(actualItemsTitle).not.toContain('Advanced Settings'); }); + + it('when the authz.enable_course_authoring flag is enabled and user has access to advanced settings should include advanced settings option', async () => { + // Mock feature flag + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + // Mock the useUserPermissions hook to return true for the authz.enable_course_authoring permission + jest.mocked(useUserPermissions).mockReturnValue({ + isLoading: false, + data: { canManageAdvancedSettings: true }, + } as any); + const { result } = renderHook(() => useSettingMenuItems('course-123'), { wrapper: createWrapper() }); + await waitFor(() => { + const actualItemsTitle = result.current.map((item) => item.title); + expect(actualItemsTitle).toContain('Advanced Settings'); + }); + }); + it('when authz.enable_course_authoring flag is enabled and user has no access to advanced settings should not include advanced settings option', async () => { + // Mock feature flag + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + // Mock the useUserPermissions hook to return true for the authz.enable_course_authoring permission + jest.mocked(useUserPermissions).mockReturnValue({ + isLoading: false, + data: { canManageAdvancedSettings: false }, + } as any); + const { result } = renderHook(() => useSettingMenuItems('course-123'), { wrapper: createWrapper() }); + await waitFor(() => { + const actualItemsTitle = result.current.map((item) => item.title); + expect(actualItemsTitle).not.toContain('Advanced Settings'); + }); + }); }); describe('getToolsMenuItems', () => { @@ -100,7 +160,7 @@ describe('header utils', () => { ...getConfig(), ENABLE_TAGGING_TAXONOMY_PAGES: 'true', }); - const actualItemsTitle = renderHook(() => useToolsMenuItems('course-123')).result.current.map((item) => item.title); + const actualItemsTitle = renderHook(() => useToolsMenuItems('course-123'), { wrapper: createWrapper() }).result.current.map((item) => item.title); expect(actualItemsTitle).toEqual([ 'Import', 'Export Course', @@ -113,7 +173,7 @@ describe('header utils', () => { ...getConfig(), ENABLE_TAGGING_TAXONOMY_PAGES: 'false', }); - const actualItemsTitle = renderHook(() => useToolsMenuItems('course-123')).result.current.map((item) => item.title); + const actualItemsTitle = renderHook(() => useToolsMenuItems('course-123'), { wrapper: createWrapper() }).result.current.map((item) => item.title); expect(actualItemsTitle).toEqual([ 'Import', 'Export Course', @@ -125,7 +185,7 @@ describe('header utils', () => { mockWaffleFlags({ enableCourseOptimizer: true, }); - const optimizerItem = renderHook(() => useToolsMenuItems('course-123')).result.current.find( + const optimizerItem = renderHook(() => useToolsMenuItems('course-123'), { wrapper: createWrapper() }).result.current.find( item => item.href === '/course/course-123/optimizer', ); expect(optimizerItem).toBeDefined(); @@ -135,14 +195,14 @@ describe('header utils', () => { mockWaffleFlags({ enableCourseOptimizer: false, }); - const actualItemsTitle = renderHook(() => useToolsMenuItems('course-123')).result.current.map((item) => item.title); + const actualItemsTitle = renderHook(() => useToolsMenuItems('course-123'), { wrapper: createWrapper() }).result.current.map((item) => item.title); expect(actualItemsTitle).not.toContain(messages['header.links.optimizer'].defaultMessage); }); }); describe('useLibrarySettingsMenuItems', () => { it('should contain team access url', () => { - const items = renderHook(() => useLibrarySettingsMenuItems('library-123', false)).result.current; + const items = renderHook(() => useLibrarySettingsMenuItems('library-123', false), { wrapper: createWrapper() }).result.current; expect(items).toContainEqual({ title: 'Library Team', href: 'http://localhost/?sa=manage-team' }); }); it('should contain admin console url if set', () => { @@ -150,7 +210,7 @@ describe('header utils', () => { ...getConfig(), ADMIN_CONSOLE_URL: 'http://admin-console.com', }); - const items = renderHook(() => useLibrarySettingsMenuItems('library-123', false)).result.current; + const items = renderHook(() => useLibrarySettingsMenuItems('library-123', false), { wrapper: createWrapper() }).result.current; expect(items).toContainEqual({ title: 'Library Team', href: 'http://admin-console.com/authz/libraries/library-123', @@ -161,7 +221,7 @@ describe('header utils', () => { ...getConfig(), ADMIN_CONSOLE_URL: 'http://admin-console.com', }); - const items = renderHook(() => useLibrarySettingsMenuItems('library-123', true)).result.current; + const items = renderHook(() => useLibrarySettingsMenuItems('library-123', true), { wrapper: createWrapper() }).result.current; expect(items).toContainEqual({ title: 'Library Team', href: 'http://admin-console.com/authz/libraries/library-123', @@ -171,7 +231,7 @@ describe('header utils', () => { describe('useLibraryToolsMenuItems', () => { it('should contain backup and import url', () => { - const items = renderHook(() => useLibraryToolsMenuItems('course-123')).result.current; + const items = renderHook(() => useLibraryToolsMenuItems('course-123'), { wrapper: createWrapper() }).result.current; expect(items).toContainEqual({ href: '/library/course-123/backup', title: 'Back up to local archive', diff --git a/src/header/hooks.tsx b/src/header/hooks.tsx index d009e14834..5c12bcfe9f 100644 --- a/src/header/hooks.tsx +++ b/src/header/hooks.tsx @@ -9,6 +9,9 @@ import { getStudioHomeData } from '@src/studio-home/data/selectors'; import courseOptimizerMessages from '@src/optimizer-page/messages'; import { SidebarActions } from '@src/library-authoring/common/context/SidebarContext'; import { LibQueryParamKeys } from '@src/library-authoring/routes'; + +import { useUserPermissions } from '@src/authz/data/apiHooks'; +import { COURSE_PERMISSIONS } from '@src/authz/constants'; import messages from './messages'; export const useContentMenuItems = (courseId: string) => { @@ -55,8 +58,29 @@ export const useContentMenuItems = (courseId: string) => { export const useSettingMenuItems = (courseId: string) => { const intl = useIntl(); const studioBaseUrl = getConfig().STUDIO_BASE_URL; - const { canAccessAdvancedSettings } = useSelector(getStudioHomeData); - const waffleFlags = useWaffleFlags(); + const { canAccessAdvancedSettings: legacyCanAccessAdvancedSettings } = useSelector(getStudioHomeData); + const waffleFlags = useWaffleFlags(courseId); + + /* + AuthZ for Course Authoring + If authz.enable_course_authoring flag is enabled, validate permissions using AuthZ API. + Otherwise, fallback to existing logic. + */ + const isAuthzEnabled = waffleFlags.enableAuthzCourseAuthoring; + const { isLoading: isLoadingUserPermissions, data: userPermissions } = useUserPermissions({ + canManageAdvancedSettings: { + action: COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS, + scope: courseId, + }, + }, isAuthzEnabled); + + const authzCanManageAdvancedSettings = isLoadingUserPermissions + ? false + : userPermissions?.canManageAdvancedSettings || false; + + const canAccessAdvancedSettings = isAuthzEnabled + ? authzCanManageAdvancedSettings + : legacyCanAccessAdvancedSettings; const items = [ { @@ -75,7 +99,7 @@ export const useSettingMenuItems = (courseId: string) => { href: waffleFlags.useNewGroupConfigurationsPage ? `/course/${courseId}/group_configurations` : `${studioBaseUrl}/group_configurations/${courseId}`, title: intl.formatMessage(messages['header.links.groupConfigurations']), }, - ...(canAccessAdvancedSettings === true + ...(canAccessAdvancedSettings ? [{ href: waffleFlags.useNewAdvancedSettingsPage ? `/course/${courseId}/settings/advanced` : `${studioBaseUrl}/settings/advanced/${courseId}`, title: intl.formatMessage(messages['header.links.advancedSettings']), From 07044cae50391dea2295ca312604528b345c6a97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20M=C3=A9ndez?= Date: Tue, 10 Feb 2026 13:00:03 -0600 Subject: [PATCH 2/6] squash!: Increase test coverage --- .../AdvancedSettings.test.jsx | 50 ++++++++++++++++++- src/header/hooks.test.tsx | 17 ++++++- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/advanced-settings/AdvancedSettings.test.jsx b/src/advanced-settings/AdvancedSettings.test.jsx index c66f411938..431a021812 100644 --- a/src/advanced-settings/AdvancedSettings.test.jsx +++ b/src/advanced-settings/AdvancedSettings.test.jsx @@ -11,6 +11,9 @@ import { getCourseAdvancedSettingsApiUrl } from './data/api'; import { updateCourseAppSetting } from './data/thunks'; import AdvancedSettings from './AdvancedSettings'; import messages from './messages'; +import { useUserPermissions } from '@src/authz/data/apiHooks'; +import { mockWaffleFlags } from '@src/data/apiHooks.mock'; + let axiosMock; let store; @@ -21,11 +24,15 @@ const courseId = '123'; jest.mock('react-textarea-autosize', () => jest.fn((props) => (