55including permission validation, user-role management, and role listing capabilities.
66"""
77
8- import operator
9- from functools import reduce
108from unittest .mock import patch
119from urllib .parse import urlencode
1210
1311from ddt import data , ddt , unpack
1412from django .contrib .auth import get_user_model
15- from django .db .models import CharField , Q , Value
16- from django .db .models .functions import Cast
1713from django .urls import reverse
1814from organizations .models import Organization
1915from rest_framework import status
3127from openedx_authz .rest_api .v1 .permissions import AnyScopePermission , DynamicScopePermission
3228from openedx_authz .rest_api .v1 .views import UserValidationAPIView
3329from openedx_authz .tests .api .test_roles import BaseRolesTestCase
30+ from openedx_authz .tests .stubs .models import LearningPackage
3431
3532ContentLibrary = get_content_library_model ()
3633CourseOverview = get_course_overview_model ()
@@ -924,14 +921,24 @@ def setUpTestData(cls):
924921 id = cls .COURSE_ORG2 , defaults = {"org" : "Org2" , "display_name" : "Course Org2" }
925922 )
926923
924+ lp1 , _ = LearningPackage .objects .get_or_create (title = "Library Org1" )
925+ lp2 , _ = LearningPackage .objects .get_or_create (title = "Library Org2" )
926+ lp3 , _ = LearningPackage .objects .get_or_create (title = "Library Org3" )
927+
927928 ContentLibrary .objects .get_or_create (
928- slug = "LIB1" , org = org1 , defaults = {"locator" : "lib:Org1:LIB1" , "title" : "Library Org1" }
929+ slug = "LIB1" ,
930+ org = org1 ,
931+ defaults = {"locator" : "lib:Org1:LIB1" , "title" : "Library Org1" , "learning_package" : lp1 },
929932 )
930933 ContentLibrary .objects .get_or_create (
931- slug = "LIB2" , org = org2 , defaults = {"locator" : "lib:Org2:LIB2" , "title" : "Library Org2" }
934+ slug = "LIB2" ,
935+ org = org2 ,
936+ defaults = {"locator" : "lib:Org2:LIB2" , "title" : "Library Org2" , "learning_package" : lp2 },
932937 )
933938 ContentLibrary .objects .get_or_create (
934- slug = "LIB3" , org = org3 , defaults = {"locator" : "lib:Org3:LIB3" , "title" : "Library Org3" }
939+ slug = "LIB3" ,
940+ org = org3 ,
941+ defaults = {"locator" : "lib:Org3:LIB3" , "title" : "Library Org3" , "learning_package" : lp3 },
935942 )
936943
937944 def setUp (self ):
@@ -966,38 +973,6 @@ def setUp(self):
966973 self .build_qs_patcher .start ()
967974 self .addCleanup (self .build_qs_patcher .stop )
968975
969- # The stub ContentLibrary uses 'title' directly instead of 'learning_package__title'.
970- # Patch _get_libraries_queryset to use the stub-compatible field, aliased as 'display_name'
971- # to match the column name the union and serializer expect.
972- def stub_get_libraries_queryset (_ , allowed_pairs = None , allowed_orgs = None , search = "" ): # pylint: disable=unused-argument
973- qs = ContentLibrary .objects
974- if allowed_pairs is not None or allowed_orgs is not None :
975- org_filter = Q (org__short_name__in = allowed_orgs ) if allowed_orgs else Q ()
976- pair_filter = (
977- reduce (operator .or_ , (Q (org__short_name = org , slug = slug ) for org , slug in allowed_pairs ))
978- if allowed_pairs
979- else Q ()
980- )
981- combined = org_filter | pair_filter
982- if not combined :
983- qs = qs .none ()
984- else :
985- qs = qs .filter (combined )
986- return qs .annotate (
987- scope_id = Cast ("slug" , output_field = CharField ()),
988- display_name_col = Cast ("title" , output_field = CharField ()),
989- org_name = Cast ("org__short_name" , output_field = CharField ()),
990- scope_type = Value ("library" , output_field = CharField ()),
991- ).values ("scope_id" , "display_name_col" , "org_name" , "scope_type" )
992-
993- self .libraries_qs_patcher = patch .object (
994- views .AdminConsoleScopesAPIView ,
995- "_get_libraries_queryset" ,
996- stub_get_libraries_queryset ,
997- )
998- self .libraries_qs_patcher .start ()
999- self .addCleanup (self .libraries_qs_patcher .stop )
1000-
1001976 # ------------------------------------------------------------------ #
1002977 # Authentication #
1003978 # ------------------------------------------------------------------ #
@@ -1175,11 +1150,9 @@ def test_view_permission_filters_courses_for_non_staff(self):
11751150 user = User .objects .get (username = "regular_9" )
11761151 self .client .force_authenticate (user = user )
11771152 self .build_qs_patcher .stop ()
1178- self .libraries_qs_patcher .stop ()
11791153
11801154 response = self .client .get (self .url , {"type" : "course" })
11811155
1182- self .libraries_qs_patcher .start ()
11831156 self .build_qs_patcher .start ()
11841157 self .assertEqual (response .status_code , status .HTTP_200_OK )
11851158 external_keys = [item ["external_key" ] for item in response .data ["results" ]]
@@ -1210,21 +1183,13 @@ def test_library_display_name_populated_in_standalone_path(self):
12101183 Regression test: without aliasing learning_package__title as display_name,
12111184 the standalone library queryset returns 'title' as the key and the serializer
12121185 silently produces empty strings since it only reads 'display_name'.
1213-
1214- Skipped when the stub ContentLibrary model is in use (no learning_package relation).
12151186 """
1216- if not hasattr (ContentLibrary , "learning_package" ):
1217- self .skipTest ("Stub ContentLibrary has no learning_package relation" )
1218-
12191187 user = User .objects .get (username = "regular_1" )
12201188 self .client .force_authenticate (user = user )
1221- # Stop both patchers so the real _get_libraries_queryset runs without union.
12221189 self .build_qs_patcher .stop ()
1223- self .libraries_qs_patcher .stop ()
12241190
12251191 response = self .client .get (self .url , {"type" : "library" })
12261192
1227- self .libraries_qs_patcher .start ()
12281193 self .build_qs_patcher .start ()
12291194 self .assertEqual (response .status_code , status .HTTP_200_OK )
12301195 self .assertGreater (response .data ["count" ], 0 )
@@ -1241,11 +1206,9 @@ def test_manage_permission_filters_courses_for_non_staff(self):
12411206 user = User .objects .get (username = "regular_10" )
12421207 self .client .force_authenticate (user = user )
12431208 self .build_qs_patcher .stop ()
1244- self .libraries_qs_patcher .stop ()
12451209
12461210 response = self .client .get (self .url , {"type" : "course" , "management_permission_only" : "true" })
12471211
1248- self .libraries_qs_patcher .start ()
12491212 self .build_qs_patcher .start ()
12501213 self .assertEqual (response .status_code , status .HTTP_200_OK )
12511214 external_keys = [item ["external_key" ] for item in response .data ["results" ]]
@@ -1285,6 +1248,42 @@ def test_empty_allowed_library_pairs_returns_no_libraries(self):
12851248 self .assertEqual (response .status_code , status .HTTP_200_OK )
12861249 self .assertEqual (response .data ["count" ], 0 )
12871250
1251+ def test_empty_allowed_course_ids_returns_no_courses (self ):
1252+ """When a non-staff user has no allowed courses, no courses are returned.
1253+
1254+ Regression test: an empty allowed_ids/allowed_orgs set must not bypass the filter
1255+ and return all courses (empty Q() | empty Q() was a no-op).
1256+ """
1257+ # regular_1 has only library permissions, no course permissions.
1258+ user = User .objects .get (username = "regular_1" )
1259+ self .client .force_authenticate (user = user )
1260+ self .build_qs_patcher .stop ()
1261+
1262+ response = self .client .get (self .url , {"type" : "course" })
1263+
1264+ self .build_qs_patcher .start ()
1265+ self .assertEqual (response .status_code , status .HTTP_200_OK )
1266+ self .assertEqual (response .data ["count" ], 0 )
1267+
1268+ def test_library_only_user_sees_no_courses_in_mixed_listing (self ):
1269+ """A user with only library permissions sees no courses in the default mixed listing.
1270+
1271+ Regression test: without the empty-set guard, a user with library access but no
1272+ course permissions would see all courses in the combined results.
1273+ """
1274+ # regular_1 has only library permissions, no course permissions.
1275+ user = User .objects .get (username = "regular_1" )
1276+ self .client .force_authenticate (user = user )
1277+ self .build_qs_patcher .stop ()
1278+
1279+ response = self .client .get (self .url )
1280+
1281+ self .build_qs_patcher .start ()
1282+ self .assertEqual (response .status_code , status .HTTP_200_OK )
1283+ scope_types = {item ["external_key" ].split (":" )[0 ] for item in response .data ["results" ]}
1284+ self .assertNotIn ("course-v1" , scope_types )
1285+ self .assertIn ("lib" , scope_types )
1286+
12881287 def test_org_glob_scope_returns_all_org_libraries (self ):
12891288 """A user with an org-level glob permission (lib:ORG:*) sees all libraries in that org."""
12901289 user = User .objects .get (username = "regular_1" )
@@ -1310,7 +1309,6 @@ def test_org_glob_scope_returns_all_org_courses(self):
13101309 user = User .objects .get (username = "regular_9" )
13111310 self .client .force_authenticate (user = user )
13121311 self .build_qs_patcher .stop ()
1313- self .libraries_qs_patcher .stop ()
13141312
13151313 # Simulate get_scopes_for_user_and_permission returning an org-level glob.
13161314 glob_scope = OrgCourseOverviewGlobData (external_key = "course-v1:Org1+*" )
@@ -1320,7 +1318,6 @@ def test_org_glob_scope_returns_all_org_courses(self):
13201318 ):
13211319 response = self .client .get (self .url , {"type" : "course" })
13221320
1323- self .libraries_qs_patcher .start ()
13241321 self .build_qs_patcher .start ()
13251322 self .assertEqual (response .status_code , status .HTTP_200_OK )
13261323 external_keys = [item ["external_key" ] for item in response .data ["results" ]]
0 commit comments