Skip to content

Commit 4113f9d

Browse files
authored
Merge pull request lightspeed-core#650 from omertuc/resolversmiddleware
Improve middleware and resolvers coverage
2 parents 5da4f31 + 394c8a3 commit 4113f9d

3 files changed

Lines changed: 452 additions & 79 deletions

File tree

src/authorization/resolvers.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,6 @@ def _get_claims(auth: AuthTuple) -> dict[str, Any]:
8787
return {}
8888

8989
jwt_claims = unsafe_get_claims(token)
90-
91-
if not jwt_claims:
92-
raise RoleResolutionError(
93-
"Invalid authentication token: no JWT claims found"
94-
)
95-
9690
return jwt_claims
9791

9892
@staticmethod
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
"""Unit tests for the authorization middleware."""
2+
3+
import pytest
4+
from fastapi import HTTPException, status
5+
from starlette.requests import Request
6+
7+
from authorization.middleware import (
8+
get_authorization_resolvers,
9+
_perform_authorization_check,
10+
authorize,
11+
)
12+
from authorization.resolvers import (
13+
NoopRolesResolver,
14+
NoopAccessResolver,
15+
JwtRolesResolver,
16+
GenericAccessResolver,
17+
)
18+
from models.config import Action, JwtRoleRule, AccessRule, JsonPathOperator
19+
import constants
20+
21+
22+
@pytest.fixture(name="dummy_auth_tuple")
23+
def fixture_dummy_auth_tuple():
24+
"""Standard auth tuple for testing."""
25+
return ("user_id", "username", False, "mock_token")
26+
27+
28+
class TestGetAuthorizationResolvers:
29+
"""Test cases for the get_authorization_resolvers function."""
30+
31+
@pytest.fixture
32+
def mock_configuration(self, mocker):
33+
"""Mock configuration object."""
34+
config = mocker.MagicMock()
35+
config.authorization_configuration.access_rules = []
36+
config.authentication_configuration.jwk_configuration.jwt_configuration.role_rules = (
37+
[]
38+
)
39+
return config
40+
41+
@pytest.fixture
42+
def sample_access_rule(self):
43+
"""Sample access rule for testing."""
44+
return AccessRule(role="test", actions=[Action.QUERY])
45+
46+
@pytest.fixture
47+
def sample_role_rule(self):
48+
"""Sample role rule for testing."""
49+
return JwtRoleRule(
50+
jsonpath="$.test",
51+
operator=JsonPathOperator.EQUALS,
52+
value="test",
53+
roles=["test"],
54+
)
55+
56+
@pytest.mark.parametrize(
57+
"auth_module,expected_types",
58+
[
59+
(constants.AUTH_MOD_NOOP, (NoopRolesResolver, NoopAccessResolver)),
60+
(constants.AUTH_MOD_K8S, (NoopRolesResolver, NoopAccessResolver)),
61+
(
62+
constants.AUTH_MOD_NOOP_WITH_TOKEN,
63+
(NoopRolesResolver, NoopAccessResolver),
64+
),
65+
],
66+
)
67+
def test_noop_auth_modules(
68+
self, mocker, mock_configuration, auth_module, expected_types
69+
):
70+
"""Test resolver selection for noop-style authentication modules."""
71+
mock_configuration.authentication_configuration.module = auth_module
72+
mocker.patch("authorization.middleware.configuration", mock_configuration)
73+
74+
roles_resolver, access_resolver = get_authorization_resolvers()
75+
76+
assert isinstance(roles_resolver, expected_types[0])
77+
assert isinstance(access_resolver, expected_types[1])
78+
79+
@pytest.mark.parametrize(
80+
"empty_rules", ["role_rules", "access_rules", "both_rules"]
81+
)
82+
def test_jwk_token_with_empty_rules(
83+
self,
84+
mocker,
85+
mock_configuration,
86+
sample_access_rule,
87+
sample_role_rule,
88+
empty_rules,
89+
): # pylint: disable=too-many-arguments,too-many-positional-arguments
90+
"""Test JWK token auth falls back to noop when rules are missing."""
91+
get_authorization_resolvers.cache_clear()
92+
93+
mock_configuration.authentication_configuration.module = (
94+
constants.AUTH_MOD_JWK_TOKEN
95+
)
96+
97+
# Create a real rule for the non-empty case
98+
if empty_rules == "role_rules":
99+
mock_configuration.authorization_configuration.access_rules = [
100+
sample_access_rule
101+
]
102+
elif empty_rules == "access_rules":
103+
jwt_config = (
104+
mock_configuration.authentication_configuration.jwk_configuration.jwt_configuration
105+
)
106+
jwt_config.role_rules = [sample_role_rule]
107+
elif empty_rules == "both_rules":
108+
# For "both_rules", both lists remain empty (default in fixture)
109+
pass
110+
111+
mocker.patch("authorization.middleware.configuration", mock_configuration)
112+
113+
roles_resolver, access_resolver = get_authorization_resolvers()
114+
assert isinstance(roles_resolver, NoopRolesResolver)
115+
assert isinstance(access_resolver, NoopAccessResolver)
116+
117+
def test_jwk_token_with_rules(
118+
self, mocker, mock_configuration, sample_access_rule, sample_role_rule
119+
):
120+
"""Test JWK token auth with configured rules returns proper resolvers."""
121+
get_authorization_resolvers.cache_clear()
122+
123+
mock_configuration.authentication_configuration.module = (
124+
constants.AUTH_MOD_JWK_TOKEN
125+
)
126+
mock_configuration.authorization_configuration.access_rules = [
127+
sample_access_rule
128+
]
129+
jwt_config = (
130+
mock_configuration.authentication_configuration.jwk_configuration.jwt_configuration
131+
)
132+
jwt_config.role_rules = [sample_role_rule]
133+
mocker.patch("authorization.middleware.configuration", mock_configuration)
134+
135+
roles_resolver, access_resolver = get_authorization_resolvers()
136+
assert isinstance(roles_resolver, JwtRolesResolver)
137+
assert isinstance(access_resolver, GenericAccessResolver)
138+
139+
def test_unknown_auth_module(self, mocker, mock_configuration):
140+
"""Test unknown authentication module raises HTTPException."""
141+
# Clear the cache to avoid cached results
142+
get_authorization_resolvers.cache_clear()
143+
144+
mock_configuration.authentication_configuration.module = "unknown"
145+
mocker.patch("authorization.middleware.configuration", mock_configuration)
146+
147+
with pytest.raises(HTTPException) as exc_info:
148+
get_authorization_resolvers()
149+
150+
assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
151+
152+
153+
class TestPerformAuthorizationCheck:
154+
"""Test cases for _perform_authorization_check function."""
155+
156+
@pytest.fixture
157+
def mock_resolvers(self, mocker):
158+
"""Mock role and access resolvers."""
159+
role_resolver = mocker.AsyncMock()
160+
access_resolver = mocker.MagicMock()
161+
role_resolver.resolve_roles.return_value = {"employee"}
162+
access_resolver.check_access.return_value = True
163+
access_resolver.get_actions.return_value = {Action.QUERY}
164+
return role_resolver, access_resolver
165+
166+
async def test_missing_auth_kwarg(self):
167+
"""Test KeyError when auth dependency is missing."""
168+
with pytest.raises(HTTPException) as exc_info:
169+
await _perform_authorization_check(Action.QUERY, (), {})
170+
171+
assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
172+
173+
async def test_access_denied(self, mocker, dummy_auth_tuple, mock_resolvers):
174+
"""Test HTTPException when access is denied."""
175+
role_resolver, access_resolver = mock_resolvers
176+
access_resolver.check_access.return_value = False # Override to deny access
177+
178+
mocker.patch(
179+
"authorization.middleware.get_authorization_resolvers",
180+
return_value=(role_resolver, access_resolver),
181+
)
182+
183+
with pytest.raises(HTTPException) as exc_info:
184+
await _perform_authorization_check(
185+
Action.ADMIN, (), {"auth": dummy_auth_tuple}
186+
)
187+
188+
assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN
189+
assert (
190+
"Insufficient permissions for action: Action.ADMIN" in exc_info.value.detail
191+
)
192+
193+
@pytest.mark.parametrize("request_location", ["kwargs", "args", "none"])
194+
async def test_request_state_handling(
195+
self, mocker, dummy_auth_tuple, mock_resolvers, request_location
196+
):
197+
"""Test that authorized_actions are set on request state when present."""
198+
mocker.patch(
199+
"authorization.middleware.get_authorization_resolvers",
200+
return_value=mock_resolvers,
201+
)
202+
203+
mock_request = mocker.MagicMock(spec=Request)
204+
mock_request.state = mocker.MagicMock()
205+
206+
kwargs = {"auth": dummy_auth_tuple}
207+
args = ()
208+
209+
if request_location == "kwargs":
210+
kwargs["request"] = mock_request
211+
elif request_location == "args":
212+
args = (mock_request,)
213+
214+
await _perform_authorization_check(Action.QUERY, args, kwargs)
215+
216+
if request_location != "none":
217+
assert mock_request.state.authorized_actions == {Action.QUERY}
218+
219+
async def test_everyone_role_added(self, mocker, dummy_auth_tuple, mock_resolvers):
220+
"""Test that everyone (*) role is always added to user roles."""
221+
role_resolver, access_resolver = mock_resolvers
222+
mocker.patch(
223+
"authorization.middleware.get_authorization_resolvers",
224+
return_value=(role_resolver, access_resolver),
225+
)
226+
227+
await _perform_authorization_check(Action.QUERY, (), {"auth": dummy_auth_tuple})
228+
229+
# Verify check_access was called with both user roles and everyone role
230+
access_resolver.check_access.assert_called_once_with(
231+
Action.QUERY, {"employee", "*"}
232+
)
233+
234+
235+
class TestAuthorizeDecorator:
236+
"""Test cases for authorize decorator."""
237+
238+
async def test_decorator_success(self, mocker, dummy_auth_tuple):
239+
"""Test successful authorization through decorator."""
240+
241+
@authorize(Action.QUERY)
242+
async def mock_endpoint(**_):
243+
return "success"
244+
245+
mocker.patch(
246+
"authorization.middleware._perform_authorization_check", return_value=None
247+
)
248+
249+
result = await mock_endpoint(auth=dummy_auth_tuple)
250+
assert result == "success"
251+
252+
async def test_decorator_failure(self, mocker, dummy_auth_tuple):
253+
"""Test authorization failure through decorator."""
254+
255+
@authorize(Action.ADMIN)
256+
async def mock_endpoint(**_):
257+
return "success"
258+
259+
mocker.patch(
260+
"authorization.middleware._perform_authorization_check",
261+
side_effect=HTTPException(
262+
status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions"
263+
),
264+
)
265+
266+
with pytest.raises(HTTPException) as exc_info:
267+
await mock_endpoint(auth=dummy_auth_tuple)
268+
269+
assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN

0 commit comments

Comments
 (0)