Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions src/authz-module/constants.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { buildWizardPath, getOrgAggregateScopeKey, ROUTES } from './constants';
import {
buildWizardPath, getOrgAggregateScopeKey, getPlatformAggregateScopeKey, ROUTES,
} from './constants';
import type { ContextType } from './constants';

const BASE = `${ROUTES.HOME_PATH}${ROUTES.ASSIGN_ROLE_WIZARD_PATH}`;

Expand Down Expand Up @@ -43,6 +46,20 @@ describe('getOrgAggregateScopeKey', () => {
});

it('throws for an unknown contextType', () => {
expect(() => getOrgAggregateScopeKey('unknown', 'MIT')).toThrow('Unknown contextType: "unknown"');
expect(() => getOrgAggregateScopeKey('unknown' as ContextType, 'MIT')).toThrow('Unknown contextType: "unknown"');
});
});

describe('getPlatformAggregateScopeKey', () => {
it('returns the platform-wide course wildcard scope for course context', () => {
expect(getPlatformAggregateScopeKey('course')).toBe('course-v1:*');
});

it('returns the platform-wide library wildcard scope for library context', () => {
expect(getPlatformAggregateScopeKey('library')).toBe('lib:*');
});

it('throws for an unknown contextType', () => {
expect(() => getPlatformAggregateScopeKey('unknown' as ContextType)).toThrow('Unknown contextType: "unknown"');
});
});
33 changes: 22 additions & 11 deletions src/authz-module/constants.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,33 @@
// Resource Type Definitions
export const CONTEXT_TYPES = {
LIBRARY: 'library',
COURSE: 'course',
} as const;

export type ContextType = typeof CONTEXT_TYPES[keyof typeof CONTEXT_TYPES];

const ORG_AGGREGATE_SCOPE_BUILDERS = {
course: (orgSlug: string) => `course-v1:${orgSlug}+*`,
library: (orgSlug: string) => `lib:${orgSlug}:*`,
[CONTEXT_TYPES.COURSE]: (orgSlug: string) => `course-v1:${orgSlug}+*`,
[CONTEXT_TYPES.LIBRARY]: (orgSlug: string) => `lib:${orgSlug}:*`,
};

export const getOrgAggregateScopeKey = (contextType: string, orgSlug: string): string => {
export const getOrgAggregateScopeKey = (contextType: ContextType, orgSlug: string): string => {
const builder = ORG_AGGREGATE_SCOPE_BUILDERS[contextType];
if (!builder) { throw new Error(`Unknown contextType: "${contextType}"`); }
return builder(orgSlug);
};

const PLATFORM_AGGREGATE_SCOPE_KEYS = {
[CONTEXT_TYPES.COURSE]: 'course-v1:*',
[CONTEXT_TYPES.LIBRARY]: 'lib:*',
};

export const getPlatformAggregateScopeKey = (contextType: ContextType): string => {
const scope = PLATFORM_AGGREGATE_SCOPE_KEYS[contextType];
if (!scope) { throw new Error(`Unknown contextType: "${contextType}"`); }
return scope;
};

export const DEFAULT_TOAST_DELAY = 5000;
export const RETRY_TOAST_DELAY = 120_000; // 2 minutes
export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({
Expand Down Expand Up @@ -65,11 +84,3 @@ export const TABLE_DEFAULT_PAGE_SIZE = 10;

export const DEFAULT_FILTER_PAGE_SIZE = 5;
export const ADMIN_ROLES = ['course_admin', 'library_admin'];

// Resource Type Definitions
export const CONTEXT_TYPES = {
LIBRARY: 'library',
COURSE: 'course',
} as const;

export type ResourceType = typeof CONTEXT_TYPES[keyof typeof CONTEXT_TYPES];
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ describe('DefineApplicationScopeStep', () => {
it('does not render platform aggregate (disabled pending backend support)', () => {
(useScopes as jest.Mock).mockReturnValue(makeScopesHook());
renderComponent({ selectedRole: 'library_admin' });
expect(screen.queryByText('All libraries in Platform')).not.toBeInTheDocument();
expect(screen.queryByText('All libraries on the platform')).not.toBeInTheDocument();
});

it('renders scopes grouped by org in OrgSection', () => {
Expand Down Expand Up @@ -318,17 +318,17 @@ describe('DefineApplicationScopeStep', () => {
it('does not show platform aggregate (disabled pending backend support)', () => {
(useScopes as jest.Mock).mockReturnValue(makeScopesHook());
renderComponent({ selectedRole: 'library_admin' });
expect(screen.queryByText('All libraries in Platform')).not.toBeInTheDocument();
expect(screen.queryByText('All libraries on the platform')).not.toBeInTheDocument();
});

it('does not show platform aggregate when selectedRole is null', () => {
renderComponent({ selectedRole: null });
expect(screen.queryByText('All libraries in Platform')).not.toBeInTheDocument();
expect(screen.queryByText('All libraries on the platform')).not.toBeInTheDocument();
});

it('does not show "All courses in Platform" (disabled pending backend support)', () => {
it('does not show "All courses on the platform" (disabled pending backend support)', () => {
renderComponent({ selectedRole: 'course_admin' });
expect(screen.queryByText('All courses in Platform')).not.toBeInTheDocument();
expect(screen.queryByText('All courses on the platform')).not.toBeInTheDocument();
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ describe('useScopeListData', () => {
});

describe('Platform aggregate scope item', () => {
it('returns null platformAggregateScopeItem for library context (disabled pending backend support)', () => {
it('returns null platformAggregateScopeItem when the user lacks platform permission', () => {
mockUseScopes.mockReturnValue(makeScopesHook());
mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } });

Expand All @@ -241,7 +241,8 @@ describe('useScopeListData', () => {
expect(result.current.platformAggregateScopeItem).toBeNull();
});

it('returns null platformAggregateScopeItem for course context (disabled pending backend support)', () => {
it('emits the platform-wide course scope (course-v1:*) when permission is granted', () => {
mockUseScopePermissions.mockReturnValue({ hasPlatformPermission: true, orgHasPermission: {} });
mockUseScopes.mockReturnValue(makeScopesHook());
mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } });

Expand All @@ -251,10 +252,31 @@ describe('useScopeListData', () => {
orgs: [],
}), { wrapper });

expect(result.current.platformAggregateScopeItem).toBeNull();
expect(result.current.platformAggregateScopeItem).toMatchObject({
externalKey: 'course-v1:*',
org: null,
});
});

it('emits the platform-wide library scope (lib:*) when permission is granted', () => {
mockUseScopePermissions.mockReturnValue({ hasPlatformPermission: true, orgHasPermission: {} });
mockUseScopes.mockReturnValue(makeScopesHook());
mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } });

const { result } = renderHook(() => useScopeListData({
contextType: 'library',
search: '',
orgs: [],
}), { wrapper });

expect(result.current.platformAggregateScopeItem).toMatchObject({
externalKey: 'lib:*',
org: null,
});
});

it('returns null platformAggregateScopeItem when contextType is undefined', () => {
mockUseScopePermissions.mockReturnValue({ hasPlatformPermission: true, orgHasPermission: {} });
mockUseScopes.mockReturnValue(makeScopesHook());
mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { Scope } from '@src/types';
import { useOrgs, useScopes } from '@src/authz-module/data/hooks';
import { useCourseAuthoringFlag } from '@src/authz-module/hooks/useCourseAuthoringFlag';
import { getOrgAggregateScopeKey } from '@src/authz-module/constants';
import { getOrgAggregateScopeKey, getPlatformAggregateScopeKey } from '@src/authz-module/constants';
import type { ContextType } from '@src/authz-module/constants';
import messages from '../messages';
import useScopePermissions from './useScopePermissions';

Expand Down Expand Up @@ -83,7 +84,7 @@ const useScopeListData = ({ contextType, search, orgs }: UseScopeListDataParams)

const platformAggregateScopeItem: Scope | null = (contextType && hasPlatformPermission)
? {
externalKey: '*',
externalKey: getPlatformAggregateScopeKey(contextType as ContextType),
displayName: platformAggregateLabel,
description: aggregateDescription,
org: null,
Expand All @@ -109,7 +110,7 @@ const useScopeListData = ({ contextType, search, orgs }: UseScopeListDataParams)
.map((orgSlug) => [
orgSlug,
{
externalKey: getOrgAggregateScopeKey(contextType, orgSlug),
externalKey: getOrgAggregateScopeKey(contextType as ContextType, orgSlug),
displayName: orgAggregateLabel,
description: aggregateDescription,
org: { id: '0', name: orgSlug, shortName: orgSlug },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,46 @@ describe('useScopePermissions', () => {
});

describe('hasPlatformPermission', () => {
it('is always false (pending backend support)', () => {
it('is true when the platform-wide scope is allowed', () => {
mockUseValidateUserPermissions.mockReturnValue({
data: [
{ scope: 'course-v1:*', allowed: true },
{ scope: 'course-v1:MIT+*', allowed: false },
],
});

const { result } = renderHook(() => useScopePermissions({
contextType: 'course',
orderedOrgs: ['MIT'],
}));

expect(result.current.hasPlatformPermission).toBe(true);
});

it('is false when the platform-wide scope is not allowed', () => {
mockUseValidateUserPermissions.mockReturnValue({
data: [
{ scope: 'course-v1:*', allowed: false },
{ scope: 'course-v1:MIT+*', allowed: true },
],
});

const { result } = renderHook(() => useScopePermissions({
contextType: 'course',
orderedOrgs: ['MIT'],
}));

expect(result.current.hasPlatformPermission).toBe(false);
});

it('is false when contextType is undefined', () => {
const { result } = renderHook(() => useScopePermissions({
contextType: undefined,
orderedOrgs: ['MIT'],
}));

expect(result.current.hasPlatformPermission).toBe(false);
});
});

describe('orgHasPermission', () => {
Expand All @@ -36,9 +68,13 @@ describe('useScopePermissions', () => {
expect(result.current.orgHasPermission).toEqual({});
});

it('maps allowed responses by org slug index for course context', () => {
it('maps allowed responses by org slug for course context', () => {
mockUseValidateUserPermissions.mockReturnValue({
data: [{ allowed: true }, { allowed: false }],
data: [
{ scope: 'course-v1:*', allowed: false },
{ scope: 'course-v1:MIT+*', allowed: true },
{ scope: 'course-v1:HarvardX+*', allowed: false },
],
});

const { result } = renderHook(() => useScopePermissions({
Expand All @@ -49,9 +85,13 @@ describe('useScopePermissions', () => {
expect(result.current.orgHasPermission).toEqual({ MIT: true, HarvardX: false });
});

it('maps allowed responses by org slug index for library context', () => {
it('maps allowed responses by org slug for library context', () => {
mockUseValidateUserPermissions.mockReturnValue({
data: [{ allowed: false }, { allowed: true }],
data: [
{ scope: 'lib:*', allowed: false },
{ scope: 'lib:MIT:*', allowed: false },
{ scope: 'lib:HarvardX:*', allowed: true },
],
});

const { result } = renderHook(() => useScopePermissions({
Expand All @@ -73,7 +113,7 @@ describe('useScopePermissions', () => {
expect(result.current.orgHasPermission).toEqual({ MIT: false });
});

it('defaults to false when orgPerms data is undefined', () => {
it('defaults to false when the response data is undefined', () => {
mockUseValidateUserPermissions.mockReturnValue({ data: undefined });

const { result } = renderHook(() => useScopePermissions({
Expand All @@ -86,37 +126,41 @@ describe('useScopePermissions', () => {
});

describe('permission request construction', () => {
it('uses MANAGE_COURSE_TEAM action with course-v1 scope for course context', () => {
it('uses MANAGE_COURSE_TEAM action with the platform-wide and per-org course scopes', () => {
renderHook(() => useScopePermissions({
contextType: 'course',
orderedOrgs: ['MIT', 'HarvardX'],
}));

expect(mockUseValidateUserPermissions).toHaveBeenCalledWith([
{ action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, scope: 'course-v1:*' },
{ action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, scope: 'course-v1:MIT+*' },
{ action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, scope: 'course-v1:HarvardX+*' },
]);
});

it('uses MANAGE_LIBRARY_TEAM action with lib scope for library context', () => {
it('uses MANAGE_LIBRARY_TEAM action with the platform-wide and per-org lib scopes', () => {
renderHook(() => useScopePermissions({
contextType: 'library',
orderedOrgs: ['MIT', 'HarvardX'],
}));

expect(mockUseValidateUserPermissions).toHaveBeenCalledWith([
{ action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, scope: 'lib:*' },
{ action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, scope: 'lib:MIT:*' },
{ action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, scope: 'lib:HarvardX:*' },
]);
});

it('passes empty array to useValidateUserPermissions when orderedOrgs is empty', () => {
it('validates only the platform-wide scope when orderedOrgs is empty', () => {
renderHook(() => useScopePermissions({
contextType: 'course',
orderedOrgs: [],
}));

expect(mockUseValidateUserPermissions).toHaveBeenCalledWith([]);
expect(mockUseValidateUserPermissions).toHaveBeenCalledWith([
{ action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, scope: 'course-v1:*' },
]);
});

it('passes empty array when contextType is undefined', () => {
Expand Down
Loading