Skip to content

Commit c8d3d1c

Browse files
committed
test: add unit test for authz permissions
1 parent 6b4dea3 commit c8d3d1c

1 file changed

Lines changed: 123 additions & 0 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Unit tests for openedx_authz.rest_api.v1.permissions."""
2+
3+
from unittest.mock import MagicMock, patch
4+
5+
from django.test import TestCase
6+
7+
from openedx_authz.rest_api.v1.permissions import (
8+
BaseScopePermission,
9+
CoursePermission,
10+
DynamicScopePermission,
11+
)
12+
13+
14+
def _make_user(superuser=False):
15+
"""Return a mock user. Regular user by default; pass superuser=True for a superuser."""
16+
user = MagicMock()
17+
user.is_superuser = superuser
18+
user.is_staff = False
19+
user.username = "testuser"
20+
return user
21+
22+
23+
def _make_request(data=None, query_params=None, user=None, method="GET"):
24+
"""Return a mock DRF request with the given body data, query params, user, and HTTP method."""
25+
request = MagicMock()
26+
request.data = data or {}
27+
request.query_params = query_params or {}
28+
request.method = method
29+
request.user = user or _make_user()
30+
return request
31+
32+
33+
def _make_view(method="get", required_permissions=None):
34+
"""Return a mock view whose handler carries required_permissions when provided,
35+
simulating the @authz_permissions decorator. Omit required_permissions to simulate
36+
a plain handler with no decorator."""
37+
view = MagicMock()
38+
handler = MagicMock()
39+
if required_permissions is not None:
40+
handler.required_permissions = required_permissions
41+
else:
42+
del handler.required_permissions
43+
setattr(view, method, handler)
44+
return view
45+
46+
47+
class TestGetScopeValueScopesFallback(TestCase):
48+
"""Test scopes-list fallback in BaseScopePermission.get_scope_value."""
49+
50+
def setUp(self):
51+
self.perm = BaseScopePermission()
52+
53+
def test_scopes_list_fallback_returns_first_element(self):
54+
"""When no 'scope' key is present, the first item of the 'scopes' list is used as the scope value."""
55+
request = _make_request(data={"scopes": ["lib:Org:A", "lib:Org:B"]})
56+
self.assertEqual(self.perm.get_scope_value(request), "lib:Org:A")
57+
58+
def test_scope_is_string_returns_value(self):
59+
"""When 'scope' is a plain string instead of a list, it is used as scope value."""
60+
request = _make_request(data={"scope": "lib:Org:A"})
61+
self.assertEqual(self.perm.get_scope_value(request), "lib:Org:A")
62+
63+
64+
class TestDynamicScopePermissionBulkScopes(TestCase):
65+
"""Test bulk-scopes path in DynamicScopePermission.has_permission."""
66+
67+
def setUp(self):
68+
self.perm = DynamicScopePermission()
69+
70+
def test_non_mixin_namespace_returns_false(self):
71+
"""A 'global' scope resolves to BaseScopePermission which does not implement MethodPermissionMixin.
72+
The bulk path requires MethodPermissionMixin, so the check is rejected immediately."""
73+
request = _make_request(data={"scopes": ["global:x"]})
74+
self.assertFalse(self.perm.has_permission(request, _make_view(required_permissions=["p"])))
75+
76+
def test_no_required_permissions_returns_false(self):
77+
"""When the view method has no @authz_permissions decorator, there are no required permissions
78+
to evaluate, so the bulk check is rejected."""
79+
request = _make_request(data={"scopes": ["lib:Org:A", "lib:Org:B"]})
80+
self.assertFalse(self.perm.has_permission(request, _make_view(required_permissions=None)))
81+
82+
@patch("openedx_authz.api.is_user_allowed", return_value=True)
83+
def test_all_scopes_pass_returns_true(self, _):
84+
"""When the user has the required permission on every scope in the list, access is granted
85+
(AND logic across scopes — all must pass)."""
86+
request = _make_request(data={"scopes": ["lib:Org:A", "lib:Org:B"]}, method="GET")
87+
self.assertTrue(self.perm.has_permission(request, _make_view(method="get", required_permissions=["p"])))
88+
89+
@patch("openedx_authz.api.is_user_allowed", side_effect=[True, False])
90+
def test_one_scope_fails_returns_false(self, _):
91+
"""When the user lacks the required permission on at least one scope, access is denied
92+
(AND logic across scopes — a single failure is enough to reject)."""
93+
request = _make_request(data={"scopes": ["lib:Org:A", "lib:Org:B"]}, method="GET")
94+
self.assertFalse(self.perm.has_permission(request, _make_view(method="get", required_permissions=["p"])))
95+
96+
97+
class TestCoursePermission(TestCase):
98+
"""Test CoursePermission class."""
99+
100+
def setUp(self):
101+
self.perm = CoursePermission()
102+
103+
def test_no_scope_returns_false(self):
104+
"""A request without any scope value is always rejected — there is nothing to authorize against."""
105+
self.assertFalse(self.perm.has_permission(_make_request(), _make_view(required_permissions=["p"])))
106+
107+
def test_scope_no_decorator_returns_true(self):
108+
"""When a scope is present but the view method has no @authz_permissions decorator,
109+
the endpoint is considered open and access is granted."""
110+
request = _make_request(data={"scope": "course-v1:Org1+C1+2024"})
111+
self.assertTrue(self.perm.has_permission(request, _make_view(required_permissions=None)))
112+
113+
@patch("openedx_authz.api.is_user_allowed", return_value=True)
114+
def test_scope_with_permission_allowed(self, _):
115+
"""When the user has the required permission on the given course scope, access is granted."""
116+
request = _make_request(data={"scope": "course-v1:Org1+C1+2024"}, method="GET")
117+
self.assertTrue(self.perm.has_permission(request, _make_view(method="get", required_permissions=["p"])))
118+
119+
@patch("openedx_authz.api.is_user_allowed", return_value=False)
120+
def test_scope_with_permission_denied(self, _):
121+
"""When the user lacks the required permission on the course scope, access is denied."""
122+
request = _make_request(data={"scope": "course-v1:Org1+C1+2024"}, method="GET")
123+
self.assertFalse(self.perm.has_permission(request, _make_view(method="get", required_permissions=["p"])))

0 commit comments

Comments
 (0)