Files

605 lines
25 KiB
Python

"""
Tests of student.roles
"""
import ddt
from django.contrib.auth.models import Permission
from django.test import TestCase
from edx_toggles.toggles.testutils import override_waffle_flag
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import LibraryLocator
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from openedx_authz.engine.enforcer import AuthzEnforcer
from common.djangoapps.student.admin import CourseAccessRoleHistoryAdmin
from common.djangoapps.student.models import CourseAccessRoleHistory, User
from common.djangoapps.student.roles import (
AuthzCompatCourseAccessRole,
CourseAccessRole,
CourseBetaTesterRole,
CourseInstructorRole,
CourseRole,
CourseLimitedStaffRole,
CourseStaffRole,
CourseFinanceAdminRole,
CourseSalesAdminRole,
LibraryUserRole,
CourseDataResearcherRole,
GlobalStaff,
OrgContentCreatorRole,
OrgInstructorRole,
OrgStaffRole,
RoleCache,
get_role_cache_key_for_course,
ROLE_CACHE_UNGROUPED_ROLES__KEY
)
from common.djangoapps.student.role_helpers import get_course_roles, has_staff_roles
from common.djangoapps.student.tests.factories import AnonymousUserFactory, InstructorFactory, StaffFactory, UserFactory
from openedx.core.toggles import AUTHZ_COURSE_AUTHORING_FLAG
@ddt.ddt
class RolesTestCase(TestCase):
"""
Tests of student.roles
"""
def setUp(self):
super().setUp()
self._seed_database_with_policies()
self.course_key = CourseKey.from_string('course-v1:course-v1:edX+toy+2012_Fall')
self.course_loc = self.course_key.make_usage_key('course', '2012_Fall')
self.course = CourseOverviewFactory.create(id=self.course_key)
self.anonymous_user = AnonymousUserFactory()
self.student = UserFactory()
self.global_staff = UserFactory(is_staff=True)
self.course_staff = StaffFactory(course_key=self.course_key)
self.course_instructor = InstructorFactory(course_key=self.course_key)
self.orgs = ["Marvel", "DC"]
@classmethod
def _seed_database_with_policies(cls):
"""Seed the database with policies from the policy file for openedx_authz tests.
This simulates the one-time database seeding that would happen
during application deployment, separate from the runtime policy loading.
"""
import pkg_resources
from openedx_authz.engine.utils import migrate_policy_between_enforcers
import casbin
global_enforcer = AuthzEnforcer.get_enforcer()
global_enforcer.load_policy()
model_path = pkg_resources.resource_filename("openedx_authz.engine", "config/model.conf")
policy_path = pkg_resources.resource_filename("openedx_authz.engine", "config/authz.policy")
migrate_policy_between_enforcers(
source_enforcer=casbin.Enforcer(model_path, policy_path),
target_enforcer=global_enforcer,
)
global_enforcer.clear_policy() # Clear to simulate fresh start for each test
@ddt.data(True, False)
def test_global_staff(self, authz_enabled):
with override_waffle_flag(AUTHZ_COURSE_AUTHORING_FLAG, active=authz_enabled):
assert not GlobalStaff().has_user(self.student)
assert not GlobalStaff().has_user(self.course_staff)
assert not GlobalStaff().has_user(self.course_instructor)
assert GlobalStaff().has_user(self.global_staff)
@ddt.data(True, False)
def test_has_staff_roles(self, authz_enabled):
with override_waffle_flag(AUTHZ_COURSE_AUTHORING_FLAG, active=authz_enabled):
assert has_staff_roles(self.global_staff, self.course_key)
assert has_staff_roles(self.course_staff, self.course_key)
assert has_staff_roles(self.course_instructor, self.course_key)
assert not has_staff_roles(self.student, self.course_key)
@ddt.data(True, False)
def test_get_course_roles(self, authz_enabled):
with override_waffle_flag(AUTHZ_COURSE_AUTHORING_FLAG, active=authz_enabled):
assert not list(get_course_roles(self.student))
assert not list(get_course_roles(self.global_staff))
assert list(get_course_roles(self.course_staff)) == [
AuthzCompatCourseAccessRole(
user_id=self.course_staff.id,
username=self.course_staff.username,
course_id=self.course_key,
org=self.course_key.org,
role=CourseStaffRole.ROLE,
)
]
assert list(get_course_roles(self.course_instructor)) == [
AuthzCompatCourseAccessRole(
user_id=self.course_instructor.id,
username=self.course_instructor.username,
course_id=self.course_key,
org=self.course_key.org,
role=CourseInstructorRole.ROLE,
)
]
def test_group_name_case_sensitive(self):
uppercase_course_id = "ORG/COURSE/NAME"
lowercase_course_id = uppercase_course_id.lower()
uppercase_course_key = CourseKey.from_string(uppercase_course_id)
lowercase_course_key = CourseKey.from_string(lowercase_course_id)
role = "role"
lowercase_user = UserFactory()
CourseRole(role, lowercase_course_key).add_users(lowercase_user)
uppercase_user = UserFactory()
CourseRole(role, uppercase_course_key).add_users(uppercase_user)
assert CourseRole(role, lowercase_course_key).has_user(lowercase_user)
assert not CourseRole(role, uppercase_course_key).has_user(lowercase_user)
assert not CourseRole(role, lowercase_course_key).has_user(uppercase_user)
assert CourseRole(role, uppercase_course_key).has_user(uppercase_user)
@ddt.data(True, False)
def test_course_role(self, authz_enabled):
"""
Test that giving a user a course role enables access appropriately
"""
with override_waffle_flag(AUTHZ_COURSE_AUTHORING_FLAG, active=authz_enabled):
assert not CourseStaffRole(self.course_key).has_user(self.student), \
f'Student has premature access to {self.course_key}'
CourseStaffRole(self.course_key).add_users(self.student)
assert CourseStaffRole(self.course_key).has_user(self.student), \
f"Student doesn't have access to {str(self.course_key)}"
# remove access and confirm
CourseStaffRole(self.course_key).remove_users(self.student)
assert not CourseStaffRole(self.course_key).has_user(self.student), \
f'Student still has access to {self.course_key}'
def test_org_role(self):
"""
Test that giving a user an org role enables access appropriately
"""
assert not OrgStaffRole(self.course_key.org).has_user(self.student), \
f'Student has premature access to {self.course_key.org}'
OrgStaffRole(self.course_key.org).add_users(self.student)
assert OrgStaffRole(self.course_key.org).has_user(self.student), \
f"Student doesn't have access to {str(self.course_key.org)}"
# remove access and confirm
OrgStaffRole(self.course_key.org).remove_users(self.student)
if hasattr(self.student, '_roles'):
del self.student._roles
assert not OrgStaffRole(self.course_key.org).has_user(self.student), \
f'Student still has access to {self.course_key.org}'
def test_org_and_course_roles(self):
"""
Test that Org and course roles don't interfere with course roles or vice versa
"""
OrgInstructorRole(self.course_key.org).add_users(self.student)
CourseInstructorRole(self.course_key).add_users(self.student)
assert OrgInstructorRole(self.course_key.org).has_user(self.student), \
f"Student doesn't have access to {str(self.course_key.org)}"
assert CourseInstructorRole(self.course_key).has_user(self.student), \
f"Student doesn't have access to {str(self.course_key)}"
# remove access and confirm
OrgInstructorRole(self.course_key.org).remove_users(self.student)
assert not OrgInstructorRole(self.course_key.org).has_user(self.student), \
f'Student still has access to {self.course_key.org}'
assert CourseInstructorRole(self.course_key).has_user(self.student), \
f"Student doesn't have access to {str(self.course_key)}"
# ok now keep org role and get rid of course one
OrgInstructorRole(self.course_key.org).add_users(self.student)
CourseInstructorRole(self.course_key).remove_users(self.student)
assert OrgInstructorRole(self.course_key.org).has_user(self.student), \
f'Student lost has access to {self.course_key.org}'
assert not CourseInstructorRole(self.course_key).has_user(self.student), \
f"Student doesn't have access to {str(self.course_key)}"
@ddt.data(True, False)
def test_get_user_for_role(self, authz_enabled):
"""
test users_for_role
"""
with override_waffle_flag(AUTHZ_COURSE_AUTHORING_FLAG, active=authz_enabled):
role = CourseStaffRole(self.course_key)
role.add_users(self.student)
assert len(role.users_with_role()) > 0
@ddt.data(True, False)
def test_add_users_doesnt_add_duplicate_entry(self, authz_enabled):
"""
Tests that calling add_users multiple times before a single call
to remove_users does not result in the user remaining in the group.
"""
with override_waffle_flag(AUTHZ_COURSE_AUTHORING_FLAG, active=authz_enabled):
role = CourseStaffRole(self.course_key)
role.add_users(self.student)
assert role.has_user(self.student)
# Call add_users a second time, then remove just once.
role.add_users(self.student)
role.remove_users(self.student)
assert not role.has_user(self.student)
def test_get_orgs_for_user(self):
"""
Test get_orgs_for_user
"""
role = OrgContentCreatorRole(org=self.orgs[0])
assert len(role.get_orgs_for_user(self.student)) == 0
role.add_users(self.student)
assert len(role.get_orgs_for_user(self.student)) == 1
role_second_org = OrgContentCreatorRole(org=self.orgs[1])
role_second_org.add_users(self.student)
assert len(role.get_orgs_for_user(self.student)) == 2
@ddt.ddt
class RoleCacheTestCase(TestCase): # lint-amnesty, pylint: disable=missing-class-docstring
IN_KEY_STRING = 'course-v1:edX+toy+2012_Fall'
IN_KEY = CourseKey.from_string(IN_KEY_STRING)
NOT_IN_KEY = CourseKey.from_string('course-v1:edX+toy+2013_Fall')
ROLES = (
(CourseStaffRole(IN_KEY), ('staff', IN_KEY, 'edX')),
(CourseLimitedStaffRole(IN_KEY), ('limited_staff', IN_KEY, 'edX')),
(CourseInstructorRole(IN_KEY), ('instructor', IN_KEY, 'edX')),
(OrgStaffRole(IN_KEY.org), ('staff', None, 'edX')),
(CourseFinanceAdminRole(IN_KEY), ('finance_admin', IN_KEY, 'edX')),
(CourseSalesAdminRole(IN_KEY), ('sales_admin', IN_KEY, 'edX')),
(LibraryUserRole(IN_KEY), ('library_user', IN_KEY, 'edX')),
(CourseDataResearcherRole(IN_KEY), ('data_researcher', IN_KEY, 'edX')),
(OrgInstructorRole(IN_KEY.org), ('instructor', None, 'edX')),
(CourseBetaTesterRole(IN_KEY), ('beta_testers', IN_KEY, 'edX')),
)
def setUp(self):
super().setUp()
self.user = UserFactory()
@ddt.data(*ROLES)
@ddt.unpack
def test_only_in_role(self, role, target):
role.add_users(self.user)
cache = RoleCache(self.user)
assert cache.has_role(*target)
for other_role, other_target in self.ROLES:
if other_role == role:
continue
role_base_id = getattr(role, "BASE_ROLE", None)
other_role_id = getattr(other_role, "ROLE", None)
if other_role_id and role_base_id == other_role_id:
assert cache.has_role(*other_target)
else:
assert not cache.has_role(*other_target)
@ddt.data(*ROLES)
@ddt.unpack
def test_empty_cache(self, role, target): # lint-amnesty, pylint: disable=unused-argument
cache = RoleCache(self.user)
assert not cache.has_role(*target)
def test_get_role_cache_key_for_course_for_course_object_gets_string(self):
"""
Given a valid course key object, get_role_cache_key_for_course
should return the string representation of the key.
"""
course_string = 'course-v1:edX+toy+2012_Fall'
key = CourseKey.from_string(course_string)
key = get_role_cache_key_for_course(key)
assert key == course_string
def test_get_role_cache_key_for_course_for_undefined_object_returns_default(self):
"""
Given a value None, get_role_cache_key_for_course
should return the default key for ungrouped courses.
"""
key = get_role_cache_key_for_course(None)
assert key == ROLE_CACHE_UNGROUPED_ROLES__KEY
def test_role_cache_get_roles_set(self):
"""
Test that the RoleCache.all_roles_set getter method returns a flat set of all roles for a user
and that the ._roles attribute is the same as the set to avoid legacy behavior being broken.
"""
lib0 = LibraryLocator.from_string('library-v1:edX+quizzes')
course0 = CourseKey.from_string('course-v1:edX+toy+2012_Summer')
course1 = CourseKey.from_string('course-v1:edX+toy2+2013_Fall')
role_library_v1 = LibraryUserRole(lib0)
role_course_0 = CourseInstructorRole(course0)
role_course_1 = CourseInstructorRole(course1)
role_library_v1.add_users(self.user)
role_course_0.add_users(self.user)
role_course_1.add_users(self.user)
cache = RoleCache(self.user)
assert cache.has_role('library_user', lib0, 'edX')
assert cache.has_role('instructor', course0, 'edX')
assert cache.has_role('instructor', course1, 'edX')
assert len(cache.all_roles_set) == 3
roles_set = cache.all_roles_set
for role in roles_set:
assert role.course_id.course in ('quizzes', 'toy2', 'toy')
assert roles_set == cache._roles # pylint: disable=protected-access
def test_role_cache_roles_by_course_id(self):
"""
Test that the RoleCache.roles_by_course_id getter method returns a dictionary of roles for a user
that are grouped by course_id or if ungrouped by the ROLE_CACHE_UNGROUPED_ROLES__KEY.
"""
lib0 = LibraryLocator.from_string('library-v1:edX+quizzes')
course0 = CourseKey.from_string('course-v1:edX+toy+2012_Summer')
course1 = CourseKey.from_string('course-v1:edX+toy2+2013_Fall')
role_library_v1 = LibraryUserRole(lib0)
role_course_0 = CourseInstructorRole(course0)
role_course_1 = CourseInstructorRole(course1)
role_org_staff = OrgStaffRole('edX')
role_library_v1.add_users(self.user)
role_course_0.add_users(self.user)
role_course_1.add_users(self.user)
role_org_staff.add_users(self.user)
cache = RoleCache(self.user)
roles_dict = cache.roles_by_course_id
assert len(roles_dict) == 4
assert roles_dict.get(ROLE_CACHE_UNGROUPED_ROLES__KEY).pop().role == 'staff'
assert roles_dict.get('library-v1:edX+quizzes').pop().course_id.course == 'quizzes'
assert roles_dict.get('course-v1:edX+toy+2012_Summer').pop().course_id.course == 'toy'
assert roles_dict.get('course-v1:edX+toy2+2013_Fall').pop().course_id.course == 'toy2'
class CourseAccessRoleHistoryTest(TestCase):
"""
Tests for the CourseAccessRoleHistory model and associated signals/admin actions.
"""
def setUp(self):
super().setUp()
self.user = UserFactory(username="test_user", email="test@example.com")
self.admin_user = UserFactory(
username="admin_user",
email="admin@example.com",
is_staff=True,
is_superuser=True,
)
self.course_key = CourseKey.from_string("course-v1:OrgX+CourseY+2023_Fall")
self.org = "OrgX"
revert_permission = Permission.objects.get(
codename="can_revert_course_access_role", content_type__app_label="student"
)
delete_history_permission = Permission.objects.get(
codename="can_delete_course_access_role_history",
content_type__app_label="student",
)
self.admin_user.user_permissions.add(
revert_permission, delete_history_permission
)
self.admin_user = User.objects.get(pk=self.admin_user.pk)
def test_create_logs_history(self):
"""
Test that creating a CourseAccessRole logs a history entry.
"""
CourseAccessRole.objects.create(
user=self.user, org=self.org, course_id=self.course_key, role="student"
)
history = CourseAccessRoleHistory.objects.first()
self.assertIsNotNone(history)
self.assertEqual(history.user, self.user)
self.assertEqual(history.org, self.org)
self.assertEqual(history.course_id, self.course_key)
self.assertEqual(history.role, "student")
self.assertEqual(history.action_type, "created")
self.assertIsNone(history.old_values)
def test_update_logs_history(self):
"""
Test that updating a CourseAccessRole logs a history entry with old_values.
"""
role_instance = CourseAccessRole.objects.create(
user=self.user, org=self.org, course_id=self.course_key, role="student"
)
role_instance.role = "staff"
role_instance.save()
history_entries = CourseAccessRoleHistory.objects.filter(
user=self.user, course_id=self.course_key
).order_by("created")
self.assertEqual(history_entries.count(), 2)
update_history = history_entries.last()
self.assertEqual(update_history.action_type, "updated")
self.assertIsNotNone(update_history.old_values)
self.assertEqual(update_history.old_values["role"], "student")
self.assertEqual(update_history.role, "staff")
def test_delete_logs_history(self):
"""
Test that deleting a CourseAccessRole logs a history entry.
"""
role_instance = CourseAccessRole.objects.create(
user=self.user, org=self.org, course_id=self.course_key, role="student"
)
role_instance.delete()
history_entries = CourseAccessRoleHistory.objects.filter(
user=self.user, course_id=self.course_key
).order_by("created")
self.assertEqual(history_entries.count(), 2)
delete_history = history_entries.last()
self.assertEqual(delete_history.action_type, "deleted")
self.assertIsNone(delete_history.old_values)
self.assertEqual(delete_history.role, "student")
class CourseAccessRoleAdminActionsTest(TestCase):
"""
Tests for the admin actions (revert, delete) on CourseAccessRoleHistory.
"""
def setUp(self):
super().setUp()
self.user = UserFactory(
username="test_user_admin", email="test_admin@example.com"
)
self.admin_user = UserFactory(
username="admin_action_user",
email="admin_action@example.com",
is_staff=True,
is_superuser=True,
)
self.course_key = CourseKey.from_string(
"course-v1:AdminOrg+AdminCourse+2024_Spring"
)
self.org = "AdminOrg"
revert_permission = Permission.objects.get(
codename="can_revert_course_access_role", content_type__app_label="student"
)
delete_history_permission = Permission.objects.get(
codename="can_delete_course_access_role_history",
content_type__app_label="student",
)
self.admin_user.user_permissions.add(
revert_permission, delete_history_permission
)
self.admin_user = User.objects.get(pk=self.admin_user.pk)
self.messages = []
def _get_admin_action_response(self, action, queryset):
"""Helper to call admin actions and capture messages."""
from django.contrib.admin import AdminSite
model_admin = CourseAccessRoleHistoryAdmin(CourseAccessRoleHistory, AdminSite())
request = self.client.get("/")
request.user = self.admin_user
def mock_message_user(request, message, level=None):
self.messages.append(message)
model_admin.message_user = mock_message_user
response = action(model_admin, request, queryset)
return response
def test_revert_created_action(self):
"""
Test reverting a 'created' history entry should delete the CourseAccessRole.
"""
CourseAccessRole.objects.create(
user=self.user, org=self.org, course_id=self.course_key, role="beta_tester"
)
self.assertEqual(CourseAccessRole.objects.count(), 1)
created_history = CourseAccessRoleHistory.objects.filter(
action_type="created"
).first()
self.assertIsNotNone(created_history)
self._get_admin_action_response(
CourseAccessRoleHistoryAdmin.revert_selected_history,
CourseAccessRoleHistory.objects.filter(pk=created_history.pk),
)
self.assertEqual(CourseAccessRole.objects.count(), 0)
self.assertIn(
f"Successfully reverted creation of role for {self.user.username} in {self.course_key}",
self.messages[0],
)
def test_revert_updated_action(self):
"""
Test reverting an 'updated' history entry should restore the CourseAccessRole to its old_values.
"""
role_instance = CourseAccessRole.objects.create(
user=self.user, org=self.org, course_id=self.course_key, role="old_role"
)
role_instance.role = "new_role"
role_instance.save()
self.assertEqual(CourseAccessRole.objects.get().role, "new_role")
updated_history = CourseAccessRoleHistory.objects.filter(
action_type="updated"
).first()
self.assertIsNotNone(updated_history)
self.assertEqual(updated_history.old_values["role"], "old_role")
self._get_admin_action_response(
CourseAccessRoleHistoryAdmin.revert_selected_history,
CourseAccessRoleHistory.objects.filter(pk=updated_history.pk),
)
self.assertEqual(CourseAccessRole.objects.get().role, "old_role")
self.assertIn(
f"Successfully reverted update of role for {self.user.username} to old_role in {self.course_key}",
self.messages[0],
)
def test_revert_deleted_action(self):
"""
Test reverting a 'deleted' history entry should recreate the CourseAccessRole.
"""
role_instance = CourseAccessRole.objects.create(
user=self.user,
org=self.org,
course_id=self.course_key,
role="to_be_deleted",
)
self.assertEqual(CourseAccessRole.objects.count(), 1)
initial_history_count = CourseAccessRoleHistory.objects.count()
role_instance.delete()
self.assertEqual(CourseAccessRole.objects.count(), 0)
deleted_history = CourseAccessRoleHistory.objects.filter(
action_type="deleted"
).first()
self.assertIsNotNone(deleted_history)
self._get_admin_action_response(
CourseAccessRoleHistoryAdmin.revert_selected_history,
CourseAccessRoleHistory.objects.filter(pk=deleted_history.pk),
)
self.assertEqual(CourseAccessRole.objects.count(), 1)
reverted_role = CourseAccessRole.objects.first()
self.assertEqual(reverted_role.user, self.user)
self.assertEqual(reverted_role.org, self.org)
self.assertEqual(reverted_role.course_id, self.course_key)
self.assertEqual(reverted_role.role, "to_be_deleted")
self.assertIn(
f"Successfully reverted deletion of role for {self.user.username} in {self.course_key}",
self.messages[0],
)
def test_delete_history_action(self):
"""
Test the admin action to delete selected history entries.
"""
CourseAccessRole.objects.create(
user=self.user, org=self.org, course_id=self.course_key, role="some_role"
)
self.assertEqual(CourseAccessRoleHistory.objects.count(), 1)
history_entry = CourseAccessRoleHistory.objects.first()
self._get_admin_action_response(
CourseAccessRoleHistoryAdmin.delete_selected_history_entries,
CourseAccessRoleHistory.objects.filter(pk=history_entry.pk),
)
self.assertEqual(CourseAccessRoleHistory.objects.count(), 0)
self.assertIn("Successfully deleted 1 selected history entry.", self.messages[0])