|
34 | 34 |
|
35 | 35 | User = get_user_model() |
36 | 36 |
|
| 37 | +COURSE_SCOPE_ORG1 = "course-v1:Org1+COURSE1+2024" |
| 38 | + |
37 | 39 |
|
38 | 40 | class ViewTestMixin(BaseRolesTestCase): |
39 | 41 | """Mixin providing common test utilities for view tests.""" |
@@ -141,12 +143,22 @@ def create_admin_users(cls, quantity: int): |
141 | 143 | user.is_staff = True |
142 | 144 | user.save() |
143 | 145 |
|
| 146 | + @classmethod |
| 147 | + def create_course_users(cls): |
| 148 | + """Create course users (plain, non-staff).""" |
| 149 | + users = ["course_admin", "course_editor", "course_auditor"] |
| 150 | + for username in users: |
| 151 | + User.objects.get_or_create( |
| 152 | + username=username, defaults={"email": f"{username}@example.com"} |
| 153 | + ) |
| 154 | + |
144 | 155 | @classmethod |
145 | 156 | def setUpTestData(cls): |
146 | 157 | """Set up test fixtures once for the entire test class.""" |
147 | 158 | super().setUpTestData() |
148 | 159 | cls.create_admin_users(quantity=3) |
149 | 160 | cls.create_regular_users(quantity=10) |
| 161 | + cls.create_course_users() |
150 | 162 |
|
151 | 163 | def setUp(self): |
152 | 164 | """Set up test fixtures.""" |
@@ -315,6 +327,29 @@ def test_permission_validation_exception_handling(self, exception: Exception, st |
315 | 327 | class TestRoleUserAPIView(ViewTestMixin): |
316 | 328 | """Test suite for RoleUserAPIView.""" |
317 | 329 |
|
| 330 | + _COURSE_ASSIGNMENTS = [ |
| 331 | + { |
| 332 | + "subject_name": "course_admin", |
| 333 | + "role_name": roles.COURSE_ADMIN.external_key, |
| 334 | + "scope_name": COURSE_SCOPE_ORG1, |
| 335 | + }, |
| 336 | + { |
| 337 | + "subject_name": "course_editor", |
| 338 | + "role_name": roles.COURSE_EDITOR.external_key, |
| 339 | + "scope_name": COURSE_SCOPE_ORG1, |
| 340 | + }, |
| 341 | + { |
| 342 | + "subject_name": "course_auditor", |
| 343 | + "role_name": roles.COURSE_AUDITOR.external_key, |
| 344 | + "scope_name": COURSE_SCOPE_ORG1, |
| 345 | + }, |
| 346 | + ] |
| 347 | + |
| 348 | + @classmethod |
| 349 | + def setUpClass(cls): |
| 350 | + super().setUpClass() |
| 351 | + cls._assign_roles_to_users(assignments=cls._COURSE_ASSIGNMENTS) |
| 352 | + |
318 | 353 | def setUp(self): |
319 | 354 | """Set up test fixtures.""" |
320 | 355 | super().setUp() |
@@ -421,6 +456,36 @@ def test_get_users_by_scope_permissions(self, username: str, status_code: int): |
421 | 456 |
|
422 | 457 | self.assertEqual(response.status_code, status_code) |
423 | 458 |
|
| 459 | + # --- Course scope equivalents --- |
| 460 | + |
| 461 | + @data( |
| 462 | + # Unauthenticated |
| 463 | + (None, status.HTTP_401_UNAUTHORIZED), |
| 464 | + # Django superuser always passes |
| 465 | + ("admin_1", status.HTTP_200_OK), |
| 466 | + # course_admin has COURSES_MANAGE_COURSE_TEAM ⊇ COURSES_VIEW_COURSE_TEAM |
| 467 | + ("course_admin", status.HTTP_200_OK), |
| 468 | + # course_editor has COURSES_VIEW_COURSE_TEAM |
| 469 | + ("course_editor", status.HTTP_200_OK), |
| 470 | + # course_auditor has COURSES_VIEW_COURSE_TEAM |
| 471 | + ("course_auditor", status.HTTP_200_OK), |
| 472 | + # Library-only user has no course permission |
| 473 | + ("regular_1", status.HTTP_403_FORBIDDEN), |
| 474 | + ) |
| 475 | + @unpack |
| 476 | + def test_get_users_by_scope_course_permissions(self, username: str, status_code: int): |
| 477 | + """Mirror of test_get_users_by_scope_permissions for course scopes. |
| 478 | +
|
| 479 | + Expected result: |
| 480 | + - Returns appropriate status code based on course-scope permissions. |
| 481 | + """ |
| 482 | + user = User.objects.filter(username=username).first() |
| 483 | + self.client.force_authenticate(user=user) |
| 484 | + |
| 485 | + response = self.client.get(self.url, {"scope": COURSE_SCOPE_ORG1}) |
| 486 | + |
| 487 | + self.assertEqual(response.status_code, status_code) |
| 488 | + |
424 | 489 | @data( |
425 | 490 | # With username ----------------------------- |
426 | 491 | # Single user - success (admin user) |
@@ -661,6 +726,42 @@ def test_add_users_to_role_permissions(self, username: str, status_code: int): |
661 | 726 |
|
662 | 727 | self.assertEqual(response.status_code, status_code) |
663 | 728 |
|
| 729 | + # --- Course scope equivalents --- |
| 730 | + |
| 731 | + @data( |
| 732 | + # Unauthenticated |
| 733 | + (None, status.HTTP_401_UNAUTHORIZED), |
| 734 | + # Django superuser always passes |
| 735 | + ("admin_1", status.HTTP_207_MULTI_STATUS), |
| 736 | + # course_admin has COURSES_MANAGE_COURSE_TEAM |
| 737 | + ("course_admin", status.HTTP_207_MULTI_STATUS), |
| 738 | + # course_editor has COURSES_VIEW_COURSE_TEAM only — cannot manage team |
| 739 | + ("course_editor", status.HTTP_403_FORBIDDEN), |
| 740 | + # course_auditor has COURSES_VIEW_COURSE_TEAM only — cannot manage team |
| 741 | + ("course_auditor", status.HTTP_403_FORBIDDEN), |
| 742 | + # Library-only user has no course permission |
| 743 | + ("regular_1", status.HTTP_403_FORBIDDEN), |
| 744 | + ) |
| 745 | + @unpack |
| 746 | + def test_add_users_to_role_course_permissions(self, username: str, status_code: int): |
| 747 | + """Mirror of test_add_users_to_role_permissions for course scopes. |
| 748 | +
|
| 749 | + Expected result: |
| 750 | + - Returns appropriate status code based on course-scope permissions. |
| 751 | + """ |
| 752 | + request_data = { |
| 753 | + "role": roles.COURSE_ADMIN.external_key, |
| 754 | + "scope": COURSE_SCOPE_ORG1, |
| 755 | + "users": ["regular_2"], |
| 756 | + } |
| 757 | + user = User.objects.filter(username=username).first() |
| 758 | + self.client.force_authenticate(user=user) |
| 759 | + |
| 760 | + with patch.object(api.CourseOverviewData, "exists", return_value=True): |
| 761 | + response = self.client.put(self.url, data=request_data, format="json") |
| 762 | + |
| 763 | + self.assertEqual(response.status_code, status_code) |
| 764 | + |
664 | 765 | @data( |
665 | 766 | # With username ----------------------------- |
666 | 767 | # Single user - success (admin user) |
@@ -799,6 +900,42 @@ def test_remove_users_from_role_permissions(self, username: str, status_code: in |
799 | 900 |
|
800 | 901 | self.assertEqual(response.status_code, status_code) |
801 | 902 |
|
| 903 | + # --- Course scope equivalents --- |
| 904 | + |
| 905 | + @data( |
| 906 | + # Unauthenticated |
| 907 | + (None, status.HTTP_401_UNAUTHORIZED), |
| 908 | + # Django superuser always passes |
| 909 | + ("admin_1", status.HTTP_207_MULTI_STATUS), |
| 910 | + # course_admin has COURSES_MANAGE_COURSE_TEAM |
| 911 | + ("course_admin", status.HTTP_207_MULTI_STATUS), |
| 912 | + # course_editor has COURSES_VIEW_COURSE_TEAM only — cannot manage team |
| 913 | + ("course_editor", status.HTTP_403_FORBIDDEN), |
| 914 | + # course_auditor has COURSES_VIEW_COURSE_TEAM only — cannot manage team |
| 915 | + ("course_auditor", status.HTTP_403_FORBIDDEN), |
| 916 | + # Library-only user has no course permission |
| 917 | + ("regular_1", status.HTTP_403_FORBIDDEN), |
| 918 | + ) |
| 919 | + @unpack |
| 920 | + def test_remove_users_from_role_course_permissions(self, username: str, status_code: int): |
| 921 | + """Mirror of test_remove_users_from_role_permissions for course scopes. |
| 922 | +
|
| 923 | + Expected result: |
| 924 | + - Returns appropriate status code based on course-scope permissions. |
| 925 | + """ |
| 926 | + query_params = { |
| 927 | + "role": roles.COURSE_ADMIN.external_key, |
| 928 | + "scope": COURSE_SCOPE_ORG1, |
| 929 | + "users": "regular_2", |
| 930 | + } |
| 931 | + user = User.objects.filter(username=username).first() |
| 932 | + self.client.force_authenticate(user=user) |
| 933 | + |
| 934 | + with patch.object(api.CourseOverviewData, "exists", return_value=True): |
| 935 | + response = self.client.delete(f"{self.url}?{urlencode(query_params)}") |
| 936 | + |
| 937 | + self.assertEqual(response.status_code, status_code) |
| 938 | + |
802 | 939 |
|
803 | 940 | @ddt |
804 | 941 | class TestRoleUserAPIViewScopeStringValidation(ViewTestMixin): |
@@ -952,7 +1089,7 @@ class TestScopesAPIView(ViewTestMixin): |
952 | 1089 | and the queryset helper methods, since those models live in openedx-platform. |
953 | 1090 | """ |
954 | 1091 |
|
955 | | - COURSE_ORG1 = "course-v1:Org1+COURSE1+2024" |
| 1092 | + COURSE_ORG1 = COURSE_SCOPE_ORG1 |
956 | 1093 | COURSE_ORG2 = "course-v1:Org2+COURSE2+2024" |
957 | 1094 | LIBRARY_ORG1 = "lib:Org1:LIB1" |
958 | 1095 | LIBRARY_ORG2 = "lib:Org2:LIB2" |
@@ -1764,7 +1901,7 @@ def setUpClass(cls): |
1764 | 1901 | { |
1765 | 1902 | "subject_name": "regular_9", |
1766 | 1903 | "role_name": roles.COURSE_STAFF.external_key, |
1767 | | - "scope_name": "course-v1:Org1+COURSE1+2024", |
| 1904 | + "scope_name": COURSE_SCOPE_ORG1, |
1768 | 1905 | }, |
1769 | 1906 | ] |
1770 | 1907 | ) |
@@ -1922,7 +2059,7 @@ def test_get_orgs_user_with_both_permissions_allowed(self): |
1922 | 2059 | { |
1923 | 2060 | "subject_name": "regular_1", |
1924 | 2061 | "role_name": roles.COURSE_STAFF.external_key, |
1925 | | - "scope_name": "course-v1:Org1+COURSE1+2024", |
| 2062 | + "scope_name": COURSE_SCOPE_ORG1, |
1926 | 2063 | }, |
1927 | 2064 | ] |
1928 | 2065 | ) |
@@ -2509,6 +2646,29 @@ def test_response_shape(self): |
2509 | 2646 | class TestRoleListView(ViewTestMixin): |
2510 | 2647 | """Test suite for RoleListView.""" |
2511 | 2648 |
|
| 2649 | + _COURSE_ASSIGNMENTS = [ |
| 2650 | + { |
| 2651 | + "subject_name": "course_admin", |
| 2652 | + "role_name": roles.COURSE_ADMIN.external_key, |
| 2653 | + "scope_name": COURSE_SCOPE_ORG1, |
| 2654 | + }, |
| 2655 | + { |
| 2656 | + "subject_name": "course_editor", |
| 2657 | + "role_name": roles.COURSE_EDITOR.external_key, |
| 2658 | + "scope_name": COURSE_SCOPE_ORG1, |
| 2659 | + }, |
| 2660 | + { |
| 2661 | + "subject_name": "course_auditor", |
| 2662 | + "role_name": roles.COURSE_AUDITOR.external_key, |
| 2663 | + "scope_name": COURSE_SCOPE_ORG1, |
| 2664 | + }, |
| 2665 | + ] |
| 2666 | + |
| 2667 | + @classmethod |
| 2668 | + def setUpClass(cls): |
| 2669 | + super().setUpClass() |
| 2670 | + cls._assign_roles_to_users(assignments=cls._COURSE_ASSIGNMENTS) |
| 2671 | + |
2512 | 2672 | def setUp(self): |
2513 | 2673 | """Set up test fixtures.""" |
2514 | 2674 | super().setUp() |
@@ -2644,6 +2804,34 @@ def test_get_roles_permissions(self, username: str, status_code: int): |
2644 | 2804 | self.assertIn("results", response.data) |
2645 | 2805 | self.assertIn("count", response.data) |
2646 | 2806 |
|
| 2807 | + # --- Course scope equivalents --- |
| 2808 | + |
| 2809 | + @data( |
| 2810 | + # Unauthenticated |
| 2811 | + (None, status.HTTP_401_UNAUTHORIZED), |
| 2812 | + # Django superuser always passes |
| 2813 | + ("admin_1", status.HTTP_200_OK), |
| 2814 | + # course_admin has COURSES_MANAGE_COURSE_TEAM ⊇ COURSES_VIEW_COURSE_TEAM |
| 2815 | + ("course_admin", status.HTTP_200_OK), |
| 2816 | + # course_auditor has COURSES_VIEW_COURSE_TEAM |
| 2817 | + ("course_auditor", status.HTTP_200_OK), |
| 2818 | + # Library-only user has no course permission |
| 2819 | + ("regular_9", status.HTTP_403_FORBIDDEN), |
| 2820 | + ) |
| 2821 | + @unpack |
| 2822 | + def test_get_roles_course_permissions(self, username: str, status_code: int): |
| 2823 | + """Mirror of test_get_roles_permissions for course scopes. |
| 2824 | +
|
| 2825 | + Expected result: |
| 2826 | + - Returns appropriate status code based on course-scope permissions. |
| 2827 | + """ |
| 2828 | + user = User.objects.filter(username=username).first() |
| 2829 | + self.client.force_authenticate(user=user) |
| 2830 | + |
| 2831 | + response = self.client.get(self.url, {"scope": COURSE_SCOPE_ORG1}) |
| 2832 | + |
| 2833 | + self.assertEqual(response.status_code, status_code) |
| 2834 | + |
2647 | 2835 |
|
2648 | 2836 | @ddt |
2649 | 2837 | class TestUserValidationAPIView(ViewTestMixin): |
@@ -3536,12 +3724,12 @@ def setUpClass(cls): |
3536 | 3724 | { |
3537 | 3725 | "subject_name": "regular_9", |
3538 | 3726 | "role_name": roles.COURSE_STAFF.external_key, |
3539 | | - "scope_name": "course-v1:Org1+COURSE1+2024", |
| 3727 | + "scope_name": COURSE_SCOPE_ORG1, |
3540 | 3728 | }, |
3541 | 3729 | { |
3542 | 3730 | "subject_name": "regular_10", |
3543 | 3731 | "role_name": roles.COURSE_AUDITOR.external_key, |
3544 | | - "scope_name": "course-v1:Org1+COURSE1+2024", |
| 3732 | + "scope_name": COURSE_SCOPE_ORG1, |
3545 | 3733 | }, |
3546 | 3734 | ] |
3547 | 3735 | ) |
@@ -3838,3 +4026,65 @@ def test_user_with_both_library_and_course_permissions(self): |
3838 | 4026 | scope_types = {item["scope"].split(":")[0] for item in non_superadmin_items} |
3839 | 4027 | self.assertIn("lib", scope_types) |
3840 | 4028 | self.assertIn("course-v1", scope_types) |
| 4029 | + |
| 4030 | + |
| 4031 | +class TestBulkPutScopesAllLogic(ViewTestMixin): |
| 4032 | + """Test that DynamicScopePermission enforces AND logic across scopes in bulk PUT. |
| 4033 | +
|
| 4034 | + validate_permissions uses OR logic (any permission suffices per scope), but |
| 4035 | + DynamicScopePermission wraps that with all(...for sv in scopes_list), meaning |
| 4036 | + the user must pass the permission check for EVERY scope in the list. |
| 4037 | +
|
| 4038 | + course_admin has COURSE_ADMIN on COURSE_SCOPE_ORG1 only, giving them |
| 4039 | + COURSES_MANAGE_COURSE_TEAM on that scope but no permissions elsewhere. |
| 4040 | + """ |
| 4041 | + |
| 4042 | + ANOTHER_COURSE_SCOPE = "course-v1:Org1+COURSE2+2024" |
| 4043 | + _COURSE_ASSIGNMENTS = [ |
| 4044 | + { |
| 4045 | + "subject_name": "course_admin", |
| 4046 | + "role_name": roles.COURSE_ADMIN.external_key, |
| 4047 | + "scope_name": COURSE_SCOPE_ORG1, |
| 4048 | + }, |
| 4049 | + ] |
| 4050 | + |
| 4051 | + def setUp(self): |
| 4052 | + super().setUp() |
| 4053 | + self.user = User.objects.get(username="course_admin") |
| 4054 | + self.client.force_authenticate(user=self.user) |
| 4055 | + self.url = reverse("openedx_authz:role-user-list") |
| 4056 | + self._assign_roles_to_users(assignments=self._COURSE_ASSIGNMENTS) |
| 4057 | + |
| 4058 | + def _put_course(self, scopes): |
| 4059 | + request_data = {"role": roles.COURSE_ADMIN.external_key, "scopes": scopes, "users": ["regular_2"]} |
| 4060 | + with patch.object(api.CourseOverviewData, "exists", return_value=True): |
| 4061 | + return self.client.put(self.url, data=request_data, format="json") |
| 4062 | + |
| 4063 | + def _put_lib(self, scopes): |
| 4064 | + request_data = {"role": roles.LIBRARY_ADMIN.external_key, "scopes": scopes, "users": ["regular_2"]} |
| 4065 | + with patch.object(api.ContentLibraryData, "exists", return_value=True): |
| 4066 | + return self.client.put(self.url, data=request_data, format="json") |
| 4067 | + |
| 4068 | + def test_all_scopes_permitted_succeeds(self): |
| 4069 | + """User has permission on all requested scopes → 207.""" |
| 4070 | + response = self._put_course([COURSE_SCOPE_ORG1]) |
| 4071 | + self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS) |
| 4072 | + |
| 4073 | + def test_one_scope_not_permitted_denied(self): |
| 4074 | + """User lacks permission on one of the scopes → 403. |
| 4075 | +
|
| 4076 | + course_admin has no role on ANOTHER_COURSE_SCOPE, so the all() check must |
| 4077 | + fail even though they pass for COURSE_SCOPE_ORG1. |
| 4078 | + """ |
| 4079 | + response = self._put_course([COURSE_SCOPE_ORG1, self.ANOTHER_COURSE_SCOPE]) |
| 4080 | + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) |
| 4081 | + |
| 4082 | + def test_course_user_cannot_add_library_roles(self): |
| 4083 | + """A course-only user is denied when trying to assign library roles. |
| 4084 | +
|
| 4085 | + course_admin has no library permissions at all, so a bulk PUT targeting |
| 4086 | + a library scope must be denied regardless of the OR logic inside |
| 4087 | + validate_permissions. |
| 4088 | + """ |
| 4089 | + response = self._put_lib(["lib:Org1:LIB1"]) |
| 4090 | + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) |
0 commit comments