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
72 changes: 62 additions & 10 deletions src/authz-module/components/AddRoleButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useNavigate } from 'react-router-dom';
import { initializeMockApp } from '@edx/frontend-platform/testing';
import { renderWrapper } from '@src/setupTest';
import { renderWithAllProviders } from '@src/setupTest';
import { useValidateUserPermissionsNonSuspense } from '@src/data/hooks';
import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from '../roles-permissions';
import AddRoleButton from './AddRoleButton';

// Mock react-router-dom navigation
Expand All @@ -11,6 +13,20 @@ jest.mock('react-router-dom', () => ({
useNavigate: jest.fn(),
}));

jest.mock('@src/data/hooks', () => ({
...jest.requireActual('@src/data/hooks'),
useValidateUserPermissionsNonSuspense: jest.fn(),
}));

const mockUseValidatePermissions = useValidateUserPermissionsNonSuspense as jest.Mock;
const allowAllPermissions = {
data: [
{ action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, allowed: true },
{ action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, allowed: true },
],
isLoading: false,
};

describe('AddRoleButton', () => {
const mockNavigate = jest.fn();

Expand All @@ -26,6 +42,7 @@ describe('AddRoleButton', () => {

beforeEach(() => {
(useNavigate as jest.Mock).mockReturnValue(mockNavigate);
mockUseValidatePermissions.mockReturnValue(allowAllPermissions);
});

afterEach(() => {
Expand All @@ -34,21 +51,21 @@ describe('AddRoleButton', () => {

describe('rendering', () => {
it('renders the assign role button with correct text', () => {
renderWrapper(<AddRoleButton />);
renderWithAllProviders(<AddRoleButton />);

const button = screen.getByRole('button', { name: /assign role/i });
expect(button).toBeInTheDocument();
});

it('displays the plus icon', () => {
renderWrapper(<AddRoleButton />);
renderWithAllProviders(<AddRoleButton />);

const button = screen.getByRole('button', { name: /assign role/i });
expect(button.querySelector('svg')).toBeInTheDocument();
});

it('renders correctly when presetUsername is provided', () => {
renderWrapper(<AddRoleButton presetUsername="testuser123" />);
renderWithAllProviders(<AddRoleButton presetUsername="testuser123" />);

const button = screen.getByRole('button', { name: /assign role/i });
expect(button).toBeInTheDocument();
Expand All @@ -58,7 +75,7 @@ describe('AddRoleButton', () => {
describe('navigation behavior', () => {
it('navigates to assign role page without username when clicked', async () => {
const user = userEvent.setup();
renderWrapper(<AddRoleButton />);
renderWithAllProviders(<AddRoleButton />);

const button = screen.getByRole('button', { name: /assign role/i });
await user.click(button);
Expand All @@ -70,7 +87,7 @@ describe('AddRoleButton', () => {
it('navigates to assign role page with username query parameter when presetUsername is provided', async () => {
const user = userEvent.setup();
const presetUsername = 'john.doe';
renderWrapper(<AddRoleButton presetUsername={presetUsername} />);
renderWithAllProviders(<AddRoleButton presetUsername={presetUsername} />);

const button = screen.getByRole('button', { name: /assign role/i });
await user.click(button);
Expand All @@ -82,7 +99,7 @@ describe('AddRoleButton', () => {
it('handles special characters in presetUsername correctly', async () => {
const user = userEvent.setup();
const presetUsername = 'user@example.com';
renderWrapper(<AddRoleButton presetUsername={presetUsername} />);
renderWithAllProviders(<AddRoleButton presetUsername={presetUsername} />);

const button = screen.getByRole('button', { name: /assign role/i });
await user.click(button);
Expand All @@ -95,7 +112,7 @@ describe('AddRoleButton', () => {
describe('user interactions', () => {
it('responds to keyboard navigation', async () => {
const user = userEvent.setup();
renderWrapper(<AddRoleButton />);
renderWithAllProviders(<AddRoleButton />);

const button = screen.getByRole('button', { name: /assign role/i });

Expand All @@ -108,7 +125,7 @@ describe('AddRoleButton', () => {

it('responds to spacebar activation', async () => {
const user = userEvent.setup();
renderWrapper(<AddRoleButton />);
renderWithAllProviders(<AddRoleButton />);

const button = screen.getByRole('button', { name: /assign role/i });
button.focus();
Expand All @@ -119,7 +136,7 @@ describe('AddRoleButton', () => {

it('handles multiple clicks gracefully', async () => {
const user = userEvent.setup();
renderWrapper(<AddRoleButton presetUsername="testuser" />);
renderWithAllProviders(<AddRoleButton presetUsername="testuser" />);

const button = screen.getByRole('button', { name: /assign role/i });

Expand All @@ -131,4 +148,39 @@ describe('AddRoleButton', () => {
expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role?users=testuser');
});
});

describe('permission gating', () => {
it('does not render the button when the user cannot manage any team', () => {
mockUseValidatePermissions.mockReturnValue({
data: [
{ action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, allowed: false },
{ action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, allowed: false },
],
isLoading: false,
});
renderWithAllProviders(<AddRoleButton />);

expect(screen.queryByRole('button', { name: /assign role/i })).not.toBeInTheDocument();
});

it('renders the button when at least one team-management permission is allowed', () => {
mockUseValidatePermissions.mockReturnValue({
data: [
{ action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, allowed: false },
{ action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, allowed: true },
],
isLoading: false,
});
renderWithAllProviders(<AddRoleButton />);

expect(screen.getByRole('button', { name: /assign role/i })).toBeInTheDocument();
});

it('does not render the button while permissions are loading', () => {
mockUseValidatePermissions.mockReturnValue({ data: undefined, isLoading: true });
renderWithAllProviders(<AddRoleButton />);

expect(screen.queryByRole('button', { name: /assign role/i })).not.toBeInTheDocument();
});
});
});
20 changes: 13 additions & 7 deletions src/authz-module/components/AddRoleButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { useNavigate } from 'react-router-dom';

import baseMessages from '@src/authz-module/messages';
import { buildWizardPath } from '@src/authz-module/constants';
import { useValidateUserPermissionsNonSuspense } from '@src/data/hooks';
import { MANAGE_TEAM_PERMISSIONS } from '../roles-permissions';

interface AddRoleButtonProps {
presetUsername?: string;
Expand All @@ -15,19 +17,23 @@ const AddRoleButton = ({ presetUsername, from }: AddRoleButtonProps) => {
const intl = useIntl();
const navigate = useNavigate();

const { data: permissionValidationResponse } = useValidateUserPermissionsNonSuspense(MANAGE_TEAM_PERMISSIONS);
const canAssignRole = permissionValidationResponse?.some((permission) => permission.allowed);

const handleClick = () => {
const path = buildWizardPath({ from, users: presetUsername });
navigate(path);
};

return (
<Button
iconBefore={Plus}
onClick={handleClick}
>
{intl.formatMessage(baseMessages['authz.management.assign.role.title'])}
</Button>
);
canAssignRole ? (
<Button
iconBefore={Plus}
onClick={handleClick}
>
{intl.formatMessage(baseMessages['authz.management.assign.role.title'])}
</Button>
) : null);
};

export default AddRoleButton;
75 changes: 69 additions & 6 deletions src/authz-module/components/TableControlBar/RolesFilter.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
import { screen } from '@testing-library/react';
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWrapper } from '@src/setupTest';
import { useValidateUserPermissionsNonSuspense } from '@src/data/hooks';
import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from '@src/authz-module/roles-permissions';
import RolesFilter from './RolesFilter';

jest.mock('@src/data/hooks', () => ({
useValidateUserPermissionsNonSuspense: jest.fn(),
}));

const mockUsePermissions = useValidateUserPermissionsNonSuspense as jest.Mock;

const permissionsData = ({ library, course }: { library?: boolean; course?: boolean }) => [
{ action: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, allowed: !!library },
{ action: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM, allowed: !!course },
];

describe('RolesFilter', () => {
const defaultProps = {
filterButtonText: 'Roles',
Expand All @@ -10,11 +24,17 @@ describe('RolesFilter', () => {
disabled: false,
};

const openDropdown = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole('button', { name: /Roles/i }));
return within(await screen.findByRole('group', { name: 'Roles' }));
};

beforeEach(() => {
jest.clearAllMocks();
mockUsePermissions.mockReturnValue({ data: permissionsData({ library: true, course: true }) });
});

it('renders without crashing', () => {
it('renders the filter toggle', () => {
renderWrapper(<RolesFilter {...defaultProps} />);
expect(screen.getByText('Roles')).toBeInTheDocument();
});
Expand All @@ -29,9 +49,52 @@ describe('RolesFilter', () => {
expect(screen.getByText('Select Roles')).toBeInTheDocument();
});

it('calls setFilter when filter changes', () => {
const mockSetFilter = jest.fn();
renderWrapper(<RolesFilter {...defaultProps} setFilter={mockSetFilter} />);
expect(screen.getByText('Roles')).toBeInTheDocument();
it('calls setFilter with the selected role when a role is checked', async () => {
const user = userEvent.setup();
const setFilter = jest.fn();
renderWrapper(<RolesFilter {...defaultProps} setFilter={setFilter} />);
const menu = await openDropdown(user);
await user.click(menu.getByLabelText('Course Admin'));
expect(setFilter).toHaveBeenCalledWith(
['course_admin'],
expect.objectContaining({ value: 'course_admin', displayName: 'Course Admin' }),
);
});

it('shows only library roles when the user can view the library scope only', async () => {
const user = userEvent.setup();
mockUsePermissions.mockReturnValue({ data: permissionsData({ library: true }) });
renderWrapper(<RolesFilter {...defaultProps} />);
const menu = await openDropdown(user);
expect(menu.getByText('Libraries')).toBeInTheDocument();
expect(menu.getByLabelText('Library Admin')).toBeInTheDocument();
expect(menu.queryByText('Courses')).not.toBeInTheDocument();
expect(menu.queryByLabelText('Course Admin')).not.toBeInTheDocument();
});

it('shows both course and library roles when the user can view both scopes', async () => {
const user = userEvent.setup();
renderWrapper(<RolesFilter {...defaultProps} />);
const menu = await openDropdown(user);
expect(menu.getByText('Courses')).toBeInTheDocument();
expect(menu.getByText('Libraries')).toBeInTheDocument();
});

it('shows no role options when the user cannot view any scope', async () => {
const user = userEvent.setup();
mockUsePermissions.mockReturnValue({ data: permissionsData({}) });
renderWrapper(<RolesFilter {...defaultProps} />);
const menu = await openDropdown(user);
expect(menu.queryByText('Courses')).not.toBeInTheDocument();
expect(menu.queryByText('Libraries')).not.toBeInTheDocument();
});

it('shows no role options while permissions are still loading', async () => {
const user = userEvent.setup();
mockUsePermissions.mockReturnValue({ data: undefined });
renderWrapper(<RolesFilter {...defaultProps} />);
const menu = await openDropdown(user);
expect(menu.queryByText('Courses')).not.toBeInTheDocument();
expect(menu.queryByText('Libraries')).not.toBeInTheDocument();
});
});
25 changes: 24 additions & 1 deletion src/authz-module/components/TableControlBar/RolesFilter.tsx
Comment thread
dcoa marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { useMemo } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Person } from '@openedx/paragon/icons';
import { useValidateUserPermissionsNonSuspense } from '@src/data/hooks';
import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from '@src/authz-module/roles-permissions';
import { CONTEXT_TYPES } from '@src/authz-module/constants';
import MultipleChoiceFilter from './MultipleChoiceFilter';
import { MultipleChoiceFilterProps } from './types';
import { getRolesFiltersOptions } from '../constants';
Expand All @@ -11,7 +14,27 @@ const RolesFilter = ({
filterButtonText, filterValue, setFilter, disabled,
}: RolesFilterProps) => {
const intl = useIntl();
const rolesOptions = useMemo(() => getRolesFiltersOptions(intl), [intl]);
const { data: permissions } = useValidateUserPermissionsNonSuspense([
{ action: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM },
{ action: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM },
]);

// Only show role groups for the domains the user can view. Global roles stay
// hidden until a platform-wide permission is available to gate them on.
const allowedContexts = useMemo(() => {
const contexts = new Set<string>();
permissions?.forEach((p) => {
if (!p.allowed) { return; }
if (p.action === CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM) { contexts.add(CONTEXT_TYPES.LIBRARY); }
if (p.action === CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM) { contexts.add(CONTEXT_TYPES.COURSE); }
});
return contexts;
}, [permissions]);

const rolesOptions = useMemo(
() => getRolesFiltersOptions(intl).filter((option) => allowedContexts.has(option.contextType)),
[intl, allowedContexts],
);
return (
<MultipleChoiceFilter
filterButtonText={filterButtonText}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@ jest.mock('@src/authz-module/data/hooks', () => ({
useScopes: () => ({ data: { results: [] } }),
}));

// RolesFilter validates view-team permissions to decide which role groups to show.
// Grant both so the full course/library role set renders for these wiring tests.
jest.mock('@src/data/hooks', () => {
const { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } = jest.requireActual('@src/authz-module/roles-permissions');
return {
useValidateUserPermissionsNonSuspense: () => ({
data: [
{ action: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, allowed: true },
{ action: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM, allowed: true },
],
}),
};
});

describe('TableControlBar', () => {
const mockDataTableContext = {
columns: mockColumns,
Expand Down Expand Up @@ -90,9 +104,9 @@ describe('TableControlBar', () => {
const rolesButton = screen.getByText('Select Roles');
expect(rolesButton).toBeInTheDocument();
await user.click(rolesButton);
const superAdminOption = screen.getByRole('checkbox', { name: /Super Admin/i });
expect(superAdminOption).toBeInTheDocument();
await user.click(superAdminOption);
const courseAdminOption = screen.getByRole('checkbox', { name: /Course Admin/i });
expect(courseAdminOption).toBeInTheDocument();
await user.click(courseAdminOption);
expect(contextWithRolesFilter.columns[0].setFilter).toHaveBeenCalled();
});

Expand Down
Loading