Skip to content

Commit 9f9dd2d

Browse files
committed
test: add test
1 parent bb20c63 commit 9f9dd2d

1 file changed

Lines changed: 255 additions & 5 deletions

File tree

openedx_authz/tests/rest_api/test_views.py

Lines changed: 255 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434

3535
User = get_user_model()
3636

37+
COURSE_SCOPE_ORG1 = "course-v1:Org1+COURSE1+2024"
38+
3739

3840
class ViewTestMixin(BaseRolesTestCase):
3941
"""Mixin providing common test utilities for view tests."""
@@ -141,12 +143,22 @@ def create_admin_users(cls, quantity: int):
141143
user.is_staff = True
142144
user.save()
143145

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+
144155
@classmethod
145156
def setUpTestData(cls):
146157
"""Set up test fixtures once for the entire test class."""
147158
super().setUpTestData()
148159
cls.create_admin_users(quantity=3)
149160
cls.create_regular_users(quantity=10)
161+
cls.create_course_users()
150162

151163
def setUp(self):
152164
"""Set up test fixtures."""
@@ -315,6 +327,29 @@ def test_permission_validation_exception_handling(self, exception: Exception, st
315327
class TestRoleUserAPIView(ViewTestMixin):
316328
"""Test suite for RoleUserAPIView."""
317329

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+
318353
def setUp(self):
319354
"""Set up test fixtures."""
320355
super().setUp()
@@ -421,6 +456,36 @@ def test_get_users_by_scope_permissions(self, username: str, status_code: int):
421456

422457
self.assertEqual(response.status_code, status_code)
423458

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+
424489
@data(
425490
# With username -----------------------------
426491
# Single user - success (admin user)
@@ -661,6 +726,42 @@ def test_add_users_to_role_permissions(self, username: str, status_code: int):
661726

662727
self.assertEqual(response.status_code, status_code)
663728

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+
664765
@data(
665766
# With username -----------------------------
666767
# Single user - success (admin user)
@@ -799,6 +900,42 @@ def test_remove_users_from_role_permissions(self, username: str, status_code: in
799900

800901
self.assertEqual(response.status_code, status_code)
801902

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+
802939

803940
@ddt
804941
class TestRoleUserAPIViewScopeStringValidation(ViewTestMixin):
@@ -952,7 +1089,7 @@ class TestScopesAPIView(ViewTestMixin):
9521089
and the queryset helper methods, since those models live in openedx-platform.
9531090
"""
9541091

955-
COURSE_ORG1 = "course-v1:Org1+COURSE1+2024"
1092+
COURSE_ORG1 = COURSE_SCOPE_ORG1
9561093
COURSE_ORG2 = "course-v1:Org2+COURSE2+2024"
9571094
LIBRARY_ORG1 = "lib:Org1:LIB1"
9581095
LIBRARY_ORG2 = "lib:Org2:LIB2"
@@ -1764,7 +1901,7 @@ def setUpClass(cls):
17641901
{
17651902
"subject_name": "regular_9",
17661903
"role_name": roles.COURSE_STAFF.external_key,
1767-
"scope_name": "course-v1:Org1+COURSE1+2024",
1904+
"scope_name": COURSE_SCOPE_ORG1,
17681905
},
17691906
]
17701907
)
@@ -1922,7 +2059,7 @@ def test_get_orgs_user_with_both_permissions_allowed(self):
19222059
{
19232060
"subject_name": "regular_1",
19242061
"role_name": roles.COURSE_STAFF.external_key,
1925-
"scope_name": "course-v1:Org1+COURSE1+2024",
2062+
"scope_name": COURSE_SCOPE_ORG1,
19262063
},
19272064
]
19282065
)
@@ -2509,6 +2646,29 @@ def test_response_shape(self):
25092646
class TestRoleListView(ViewTestMixin):
25102647
"""Test suite for RoleListView."""
25112648

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+
25122672
def setUp(self):
25132673
"""Set up test fixtures."""
25142674
super().setUp()
@@ -2644,6 +2804,34 @@ def test_get_roles_permissions(self, username: str, status_code: int):
26442804
self.assertIn("results", response.data)
26452805
self.assertIn("count", response.data)
26462806

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+
26472835

26482836
@ddt
26492837
class TestUserValidationAPIView(ViewTestMixin):
@@ -3536,12 +3724,12 @@ def setUpClass(cls):
35363724
{
35373725
"subject_name": "regular_9",
35383726
"role_name": roles.COURSE_STAFF.external_key,
3539-
"scope_name": "course-v1:Org1+COURSE1+2024",
3727+
"scope_name": COURSE_SCOPE_ORG1,
35403728
},
35413729
{
35423730
"subject_name": "regular_10",
35433731
"role_name": roles.COURSE_AUDITOR.external_key,
3544-
"scope_name": "course-v1:Org1+COURSE1+2024",
3732+
"scope_name": COURSE_SCOPE_ORG1,
35453733
},
35463734
]
35473735
)
@@ -3838,3 +4026,65 @@ def test_user_with_both_library_and_course_permissions(self):
38384026
scope_types = {item["scope"].split(":")[0] for item in non_superadmin_items}
38394027
self.assertIn("lib", scope_types)
38404028
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

Comments
 (0)