Skip to content

Commit 5a8c242

Browse files
committed
squash!: Add tests
1 parent 666ebb9 commit 5a8c242

5 files changed

Lines changed: 476 additions & 3 deletions

File tree

openedx_authz/rest_api/v1/views.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,6 @@ class TeamMembersAPIView(APIView):
463463
"""
464464

465465
pagination_class = AuthZAPIViewPagination
466-
permission_classes = [DynamicScopePermission] # TODO check if this is needed/works
467466

468467
@apidocs.schema(
469468
parameters=[
@@ -481,7 +480,6 @@ class TeamMembersAPIView(APIView):
481480
status.HTTP_401_UNAUTHORIZED: "The user is not authenticated or does not have the required permissions",
482481
},
483482
)
484-
@authz_permissions([permissions.VIEW_LIBRARY.identifier])
485483
def get(self, request: HttpRequest) -> Response:
486484
"""Retrieve all users that have at least one assignation according to the filtering fields."""
487485
serializer = ListTeamMembersSerializer(data=request.query_params)

openedx_authz/tests/api/test_roles.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
_get_field_index_and_values,
2828
assign_role_to_subject_in_scope,
2929
batch_assign_role_to_subjects_in_scope,
30+
get_all_subject_role_assignments,
3031
get_all_subject_role_assignments_in_scope,
3132
get_permissions_for_active_roles_in_scope,
3233
get_permissions_for_single_role,
@@ -1110,6 +1111,43 @@ def test_get_all_role_assignments_in_scope(self, scope_name, expected_assignment
11101111
for assignment in role_assignments:
11111112
self.assertIn(assignment, expected_assignments)
11121113

1114+
def test_get_all_subject_role_assignments(self):
1115+
"""Test retrieving all role assignments across all subjects and scopes.
1116+
1117+
Expected result:
1118+
- Returns all role assignments present in the system.
1119+
- Each assignment includes subject, role, and scope.
1120+
- Known assignments from the test setup are present in the result.
1121+
"""
1122+
role_assignments = get_all_subject_role_assignments()
1123+
1124+
self.assertGreater(len(role_assignments), 0)
1125+
1126+
# Verify each assignment has the expected structure
1127+
for assignment in role_assignments:
1128+
self.assertIsNotNone(assignment.subject)
1129+
self.assertIsNotNone(assignment.scope)
1130+
self.assertTrue(len(assignment.roles) > 0)
1131+
for role in assignment.roles:
1132+
self.assertIsNotNone(role.external_key)
1133+
1134+
# Verify known assignments from setup are present
1135+
subject_scope_role_triples = {
1136+
(a.subject.external_key, a.scope.external_key, a.roles[0].external_key) for a in role_assignments
1137+
}
1138+
self.assertIn(
1139+
("alice", "lib:Org1:math_101", roles.LIBRARY_ADMIN.external_key),
1140+
subject_scope_role_triples,
1141+
)
1142+
self.assertIn(
1143+
("eve", "lib:Org2:physics_401", roles.LIBRARY_ADMIN.external_key),
1144+
subject_scope_role_triples,
1145+
)
1146+
self.assertIn(
1147+
("liam", "lib:Org4:art_101", roles.LIBRARY_AUTHOR.external_key),
1148+
subject_scope_role_triples,
1149+
)
1150+
11131151
def test_assign_role_creates_extended_casbin_rule(self):
11141152
"""Test that assigning a role creates an ExtendedCasbinRule record.
11151153

openedx_authz/tests/api/test_users.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
assign_role_to_user_in_scope,
88
batch_assign_role_to_users_in_scope,
99
batch_unassign_role_from_users,
10+
get_all_user_role_assignments,
1011
get_all_user_role_assignments_in_scope,
1112
get_user_role_assignments,
1213
get_user_role_assignments_for_role_in_scope,
@@ -290,6 +291,43 @@ def test_unassign_all_roles_from_user_removes_all_assignments(self, username, sc
290291
)
291292
self.assertEqual(len(scope_assignments), 0)
292293

294+
def test_get_all_user_role_assignments(self):
295+
"""Test retrieving all role assignments across all users and scopes.
296+
297+
Expected result:
298+
- Returns all role assignments present in the system.
299+
- Each assignment includes subject, role, and scope.
300+
- Known assignments from the test setup are present in the result.
301+
"""
302+
role_assignments = get_all_user_role_assignments()
303+
304+
self.assertGreater(len(role_assignments), 0)
305+
306+
# Verify each assignment has the expected structure
307+
for assignment in role_assignments:
308+
self.assertIsNotNone(assignment.subject)
309+
self.assertIsNotNone(assignment.scope)
310+
self.assertTrue(len(assignment.roles) > 0)
311+
for role in assignment.roles:
312+
self.assertIsNotNone(role.external_key)
313+
314+
# Verify known assignments from setup are present
315+
user_scope_role_triples = {
316+
(a.subject.username, a.scope.external_key, a.roles[0].external_key) for a in role_assignments
317+
}
318+
self.assertIn(
319+
("alice", "lib:Org1:math_101", roles.LIBRARY_ADMIN.external_key),
320+
user_scope_role_triples,
321+
)
322+
self.assertIn(
323+
("eve", "lib:Org2:physics_401", roles.LIBRARY_ADMIN.external_key),
324+
user_scope_role_triples,
325+
)
326+
self.assertIn(
327+
("liam", "lib:Org4:art_101", roles.LIBRARY_AUTHOR.external_key),
328+
user_scope_role_triples,
329+
)
330+
293331
def test_unassign_all_roles_from_user_with_no_roles_returns_false(self):
294332
"""Test that unassigning a user with no roles returns False.
295333

openedx_authz/tests/rest_api/test_views.py

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,261 @@ def test_put_accepts_valid_full_course_key_scope(self, _mock_exists, _mock_assig
853853
self.assertEqual(len(response.data["completed"]), 1)
854854

855855

856+
@ddt
857+
class TestTeamMembersAPIView(ViewTestMixin):
858+
"""
859+
Test suite for TeamMembersAPIView.
860+
861+
Setup summary (from ViewTestMixin.setUpClass):
862+
lib:Org1:LIB1 → admin_1 (library_admin), regular_1 (library_user), regular_2 (library_user) [3 users]
863+
lib:Org2:LIB2 → admin_2 (library_user), regular_3 (library_user), regular_4 (library_user) [3 users]
864+
lib:Org3:LIB3 → admin_3 (library_admin), regular_5 (library_admin), regular_6 (library_author),
865+
regular_7 (library_contributor), regular_8 (library_user) [5 users]
866+
867+
Total unique users with assignments: 11
868+
(admin_1..3 are staff/superuser; regular_1..8 are plain users)
869+
870+
Visibility via filter_allowed_assignments:
871+
- Staff/superuser: sees all 11 users (is_admin_or_superuser_check grants VIEW_LIBRARY on lib scopes)
872+
- regular_1 (library_user in Org1:LIB1): VIEW_LIBRARY granted → sees Org1 members (3)
873+
- regular_3 (library_user in Org2:LIB2): VIEW_LIBRARY granted → sees Org2 members (3)
874+
- regular_9 (no assignments): sees 0 users
875+
"""
876+
877+
def setUp(self):
878+
"""Set up test fixtures."""
879+
super().setUp()
880+
self.url = reverse("openedx_authz:user-list")
881+
self.get_user_map_patcher = patch(
882+
"openedx_authz.rest_api.utils.get_user_map",
883+
side_effect=get_user_map_without_profile,
884+
)
885+
self.get_user_map_patcher.start()
886+
self.addCleanup(self.get_user_map_patcher.stop)
887+
888+
# ------------------------------------------------------------------ #
889+
# Visibility: calling user only sees assignments it has view access to #
890+
# ------------------------------------------------------------------ #
891+
892+
@data(
893+
# Staff/superuser sees all users across all scopes
894+
("admin_1", 11),
895+
# regular_1 has LIBRARY_USER in lib:Org1:LIB1 (VIEW_LIBRARY granted) → sees only Org1 members
896+
("regular_1", 3),
897+
# regular_3 has LIBRARY_USER in lib:Org2:LIB2 → sees only Org2 members
898+
("regular_3", 3),
899+
# regular_9 has no assignments → sees nothing
900+
("regular_9", 0),
901+
)
902+
@unpack
903+
def test_visibility_limited_to_accessible_scopes(self, username: str, expected_count: int):
904+
"""Calling user only sees assignments for scopes it has view access to.
905+
906+
Expected result:
907+
- Staff/superuser sees all users across all scopes.
908+
- Regular users only see members of scopes they can view.
909+
- Users with no assignments see no results.
910+
"""
911+
user = User.objects.get(username=username)
912+
self.client.force_authenticate(user=user)
913+
914+
response = self.client.get(self.url)
915+
916+
self.assertEqual(response.status_code, status.HTTP_200_OK)
917+
self.assertEqual(response.data["count"], expected_count)
918+
919+
def test_unauthenticated_returns_401(self):
920+
"""Unauthenticated requests are rejected.
921+
922+
Expected result:
923+
- Returns 401 UNAUTHORIZED.
924+
"""
925+
self.client.force_authenticate(user=None)
926+
927+
response = self.client.get(self.url)
928+
929+
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
930+
931+
# ------------------------------------------------------------------ #
932+
# Filter by scopes #
933+
# ------------------------------------------------------------------ #
934+
935+
@data(
936+
# Single scope
937+
("lib:Org1:LIB1", 3),
938+
("lib:Org2:LIB2", 3),
939+
("lib:Org3:LIB3", 5),
940+
# Multiple scopes (users are unique per scope, no overlap)
941+
("lib:Org1:LIB1,lib:Org2:LIB2", 6),
942+
("lib:Org1:LIB1,lib:Org3:LIB3", 8),
943+
("lib:Org1:LIB1,lib:Org2:LIB2,lib:Org3:LIB3", 11),
944+
# Non-existent scope returns no results
945+
("lib:Org99:NOLIB", 0),
946+
)
947+
@unpack
948+
def test_filter_by_scopes(self, scopes: str, expected_count: int):
949+
"""Results are filtered to the requested scopes.
950+
951+
Expected result:
952+
- Only users with assignments in the given scope(s) are returned.
953+
- Multiple scopes are OR-combined.
954+
"""
955+
response = self.client.get(self.url, {"scopes": scopes})
956+
957+
self.assertEqual(response.status_code, status.HTTP_200_OK)
958+
self.assertEqual(response.data["count"], expected_count)
959+
960+
# ------------------------------------------------------------------ #
961+
# Filter by orgs #
962+
# ------------------------------------------------------------------ #
963+
964+
@data(
965+
# Single org
966+
("Org1", 3),
967+
("Org2", 3),
968+
("Org3", 5),
969+
# Multiple orgs
970+
("Org1,Org2", 6),
971+
("Org1,Org3", 8),
972+
("Org1,Org2,Org3", 11),
973+
# Non-existent org returns no results
974+
("OrgX", 0),
975+
)
976+
@unpack
977+
def test_filter_by_orgs(self, orgs: str, expected_count: int):
978+
"""Results are filtered to the requested orgs.
979+
980+
Expected result:
981+
- Only users with assignments in the given org(s) are returned.
982+
- Multiple orgs are OR-combined.
983+
"""
984+
response = self.client.get(self.url, {"orgs": orgs})
985+
986+
self.assertEqual(response.status_code, status.HTTP_200_OK)
987+
self.assertEqual(response.data["count"], expected_count)
988+
989+
# ------------------------------------------------------------------ #
990+
# Search (username, full_name, email) #
991+
# ------------------------------------------------------------------ #
992+
993+
@data(
994+
# Exact username match
995+
("admin_1", 1),
996+
# Partial username match
997+
("admin", 3),
998+
("regular", 8),
999+
# Email match
1000+
1001+
("@example.com", 11),
1002+
# No match
1003+
("nonexistent", 0),
1004+
)
1005+
@unpack
1006+
def test_search(self, search: str, expected_count: int):
1007+
"""Search filters by username, full_name, or email (case-insensitive).
1008+
1009+
Expected result:
1010+
- Returns only users whose username, full_name, or email contains the search term.
1011+
"""
1012+
response = self.client.get(self.url, {"search": search})
1013+
1014+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1015+
self.assertEqual(response.data["count"], expected_count)
1016+
1017+
# ------------------------------------------------------------------ #
1018+
# Sorting #
1019+
# ------------------------------------------------------------------ #
1020+
1021+
@data(
1022+
("username", "asc"),
1023+
("username", "desc"),
1024+
("email", "asc"),
1025+
("email", "desc"),
1026+
("full_name", "asc"),
1027+
("full_name", "desc"),
1028+
)
1029+
@unpack
1030+
def test_sorting(self, sort_by: str, order: str):
1031+
"""Results can be sorted by username, full_name, or email in asc/desc order.
1032+
1033+
Expected result:
1034+
- Returns 200 OK.
1035+
- Results are ordered according to the requested field and direction.
1036+
"""
1037+
response = self.client.get(self.url, {"sort_by": sort_by, "order": order})
1038+
1039+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1040+
values = [item[sort_by] for item in response.data["results"]]
1041+
expected = sorted(values, key=lambda v: (v or "").lower(), reverse=(order == "desc"))
1042+
self.assertEqual(values, expected)
1043+
1044+
@data(
1045+
{"sort_by": "invalid"},
1046+
{"order": "ascending"},
1047+
{"order": "descending"},
1048+
)
1049+
def test_sorting_invalid_params(self, query_params: dict):
1050+
"""Invalid sort_by or order values return 400.
1051+
1052+
Expected result:
1053+
- Returns 400 BAD REQUEST.
1054+
"""
1055+
response = self.client.get(self.url, query_params)
1056+
1057+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
1058+
1059+
# ------------------------------------------------------------------ #
1060+
# Pagination #
1061+
# ------------------------------------------------------------------ #
1062+
1063+
@data(
1064+
({"page": 1, "page_size": 5}, 5, True),
1065+
({"page": 2, "page_size": 5}, 5, True),
1066+
({"page": 3, "page_size": 5}, 1, False),
1067+
({"page": 1, "page_size": 11}, 11, False),
1068+
({"page": 1, "page_size": 6}, 6, True),
1069+
)
1070+
@unpack
1071+
def test_pagination(self, query_params: dict, expected_page_count: int, has_next: bool):
1072+
"""Results are paginated correctly.
1073+
1074+
Expected result:
1075+
- Returns 200 OK.
1076+
- Page contains the expected number of items.
1077+
- `next` link is present only when more pages exist.
1078+
"""
1079+
response = self.client.get(self.url, query_params)
1080+
1081+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1082+
self.assertEqual(response.data["count"], 11)
1083+
self.assertEqual(len(response.data["results"]), expected_page_count)
1084+
if has_next:
1085+
self.assertIsNotNone(response.data["next"])
1086+
else:
1087+
self.assertIsNone(response.data["next"])
1088+
1089+
# ------------------------------------------------------------------ #
1090+
# Response shape #
1091+
# ------------------------------------------------------------------ #
1092+
1093+
def test_response_shape(self):
1094+
"""Each result item contains the expected fields.
1095+
1096+
Expected result:
1097+
- Returns 200 OK.
1098+
- Each item has username, full_name, email, and assignation_count.
1099+
"""
1100+
response = self.client.get(self.url, {"scopes": "lib:Org1:LIB1"})
1101+
1102+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1103+
for item in response.data["results"]:
1104+
self.assertIn("username", item)
1105+
self.assertIn("full_name", item)
1106+
self.assertIn("email", item)
1107+
self.assertIn("assignation_count", item)
1108+
self.assertEqual(item["assignation_count"], 1)
1109+
1110+
8561111
@ddt
8571112
class TestRoleListView(ViewTestMixin):
8581113
"""Test suite for RoleListView."""

0 commit comments

Comments
 (0)