Skip to content
This repository was archived by the owner on Oct 30, 2026. It is now read-only.

Commit 4dccb4d

Browse files
committed
Validate route handlers and JWT payloads
1 parent 7ec1f62 commit 4dccb4d

8 files changed

Lines changed: 107 additions & 38 deletions

File tree

‎.github/workflows/test.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ jobs:
132132
- tests/mock_vws/test_model_target_retries.py
133133
- tests/mock_vws/test_vumark_generation_api.py
134134
- tests/mock_vws/test_vumark_generation_failure.py
135+
- tests/mock_vws/test_mock_common.py
135136
- tests/mock_vws/test_target_validators.py
136137
- tests/mock_vws/test_requests_mock_usage.py
137138
- tests/mock_vws/test_respx_mock_usage.py

‎src/mock_vws/_httpx2_mock_server/decorators.py‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import httpx2
1818
from mock_response_delay.for_httpx2 import delayed_httpx2_handler
1919

20-
from mock_vws._mock_common import RequestData, Route
20+
from mock_vws._mock_common import RequestData, Route, route_handler
2121

2222
_ResponseType = tuple[int, Mapping[str, str], str | bytes]
2323
_Handler = Callable[[RequestData], _ResponseType]
@@ -285,10 +285,7 @@ def _mock_routes(
285285
for route in api.routes:
286286
url_pattern = base_url.rstrip("/") + route.path_pattern + "$"
287287
compiled_url_pattern = re.compile(pattern=url_pattern)
288-
handler: _Handler = getattr( # pylint: disable=bad-builtin
289-
api,
290-
route.route_name,
291-
) # ty: ignore[unsound-assignment]
288+
handler = route_handler(api=api, route=route)
292289
httpx2_handler = delayed_httpx2_handler(
293290
handler=_httpx2_handler(handler=handler, base_path=base_path),
294291
delay_seconds=response_delay_seconds,

‎src/mock_vws/_mock_common.py‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
import datetime
44
import email.utils
55
import json
6+
import operator
67
import uuid
7-
from collections.abc import Iterable, Mapping
8+
from collections.abc import Callable, Iterable, Mapping
89
from dataclasses import dataclass
9-
from typing import Any, Final, override
10+
from typing import Any, Final, TypeGuard, override
1011

1112
from beartype import beartype
1213

@@ -88,6 +89,27 @@ class Route:
8889
http_methods: Iterable[str]
8990

9091

92+
type RouteHandler = Callable[
93+
[RequestData],
94+
tuple[int, Mapping[str, str], str | bytes],
95+
]
96+
97+
98+
def _is_route_handler(value: object, /) -> TypeGuard[RouteHandler]:
99+
"""Return whether a route attribute can handle requests."""
100+
return callable(value)
101+
102+
103+
@beartype
104+
def route_handler(*, api: object, route: Route) -> RouteHandler:
105+
"""Return the callable registered for a mock API route."""
106+
candidate: object = operator.attrgetter(route.route_name)(api)
107+
if not _is_route_handler(candidate):
108+
message = f"{route.route_name} is not a route handler."
109+
raise TypeError(message)
110+
return candidate
111+
112+
91113
@beartype
92114
def _upload_order_key(target: ImageTarget) -> tuple[datetime.datetime, str]:
93115
"""Give the sort key which orders targets by upload date, then ID.

‎src/mock_vws/_model_target_web_api.py‎

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
import secrets
77
import uuid
88
import zipfile
9+
from collections.abc import Mapping
910
from http import HTTPStatus
10-
from typing import Any, Protocol, runtime_checkable
11+
from typing import Any, Protocol, TypeGuard, runtime_checkable
1112
from urllib.parse import parse_qs
1213

1314
from beartype import beartype
@@ -88,6 +89,13 @@ def remove_oauth2_client_credential(self, client_id: str) -> None:
8889
)
8990
_CLIENT_CREDENTIALS_SCOPE = "oauth2.clientcredentials.all"
9091
_MAX_CLIENT_CREDENTIALS = 100
92+
93+
94+
def _is_object_mapping(value: object, /) -> TypeGuard[Mapping[object, object]]:
95+
"""Return whether a value is a mapping with unchecked entries."""
96+
return isinstance(value, Mapping)
97+
98+
9199
# A stable mock value standing in for the user-id segment that real
92100
# Vuforia embeds in some Model Target error targets such as
93101
# ``userId:7635391``. The numeric portion is per-account in real Vuforia;
@@ -290,8 +298,10 @@ def _jwt_header_error(*, bearer_token: str) -> str | None:
290298

291299

292300
@beartype
293-
def _jwt_payload_error(*, bearer_token: str) -> str | None:
294-
"""Return the Vuforia error for an invalid JSON Web Token payload."""
301+
def _jwt_payload(*, bearer_token: str) -> tuple[Mapping[object, object], bool]:
302+
"""Decode a JSON Web Token payload and report whether it is an
303+
object.
304+
"""
295305
encoded_payload = bearer_token.split(sep=".")[1]
296306
try:
297307
padding = "=" * (-len(encoded_payload) % 4)
@@ -300,11 +310,20 @@ def _jwt_payload_error(*, bearer_token: str) -> str | None:
300310
altchars=b"-_",
301311
validate=True,
302312
)
303-
payload = json.loads(s=decoded_payload)
313+
payload: object = json.loads(s=decoded_payload)
304314
except ValueError:
305315
payload = None
306316

307-
if not isinstance(payload, dict):
317+
if not _is_object_mapping(payload):
318+
return dict[object, object](), False
319+
return payload, True
320+
321+
322+
@beartype
323+
def _jwt_payload_error(*, bearer_token: str) -> str | None:
324+
"""Return the Vuforia error for an invalid JSON Web Token payload."""
325+
_, is_object = _jwt_payload(bearer_token=bearer_token)
326+
if not is_object:
308327
return "Payload of JWS object is not a valid JSON object"
309328
return None
310329

@@ -334,19 +353,12 @@ def _jwt_signature_error(*, bearer_token: str) -> str | None:
334353
@beartype
335354
def _jwt_scopes(*, bearer_token: str) -> frozenset[str]:
336355
"""Return scopes from a valid mock JSON Web Token."""
337-
encoded_payload = bearer_token.split(sep=".")[1]
338-
padding = "=" * (-len(encoded_payload) % 4)
339-
payload = json.loads(
340-
s=base64.b64decode(
341-
s=encoded_payload + padding,
342-
altchars=b"-_",
343-
validate=True,
344-
),
345-
)
346-
scope = payload.get("scope", "") # pyrefly: ignore [unknown-variable-type]
356+
empty_scopes = frozenset[str]()
357+
payload, _ = _jwt_payload(bearer_token=bearer_token)
358+
scope: object = payload.get("scope", "")
347359
if not isinstance(scope, str):
348-
return frozenset() # ty: ignore[unsound-return-statement]
349-
return frozenset(scope.split()) # ty: ignore[unsound-return-statement]
360+
return empty_scopes
361+
return frozenset(scope.split())
350362

351363

352364
@beartype

‎src/mock_vws/_respx_mock_server/decorators.py‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from mock_response_delay.for_httpx import delayed_httpx_handler
1111
from respx.mocks import Mocker
1212

13-
from mock_vws._mock_common import RequestData, Route
13+
from mock_vws._mock_common import RequestData, Route, route_handler
1414

1515
_ResponseType = tuple[int, Mapping[str, str], str | bytes]
1616

@@ -152,10 +152,7 @@ def start_respx_router(
152152
compiled_url_pattern = re.compile(pattern=url_pattern)
153153

154154
for http_method in route.http_methods:
155-
original_callback = getattr( # pylint: disable=bad-builtin
156-
api,
157-
route.route_name,
158-
)
155+
original_callback = route_handler(api=api, route=route)
159156
_ = router.route(
160157
method=http_method,
161158
url=compiled_url_pattern,

‎src/mock_vws/decorators.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@
1919
Httpx2Router,
2020
start_httpx2_router,
2121
)
22-
from mock_vws._mock_common import MissingSchemeError, RequestData
22+
from mock_vws._mock_common import (
23+
MissingSchemeError,
24+
RequestData,
25+
route_handler,
26+
)
2327
from mock_vws._requests_mock_server.mock_web_query_api import (
2428
MockVuforiaWebQueryAPI,
2529
)
@@ -513,10 +517,7 @@ def __enter__(self) -> Self:
513517
compiled_url_pattern = re.compile(pattern=url_pattern)
514518

515519
for http_method in route.http_methods:
516-
original_callback = getattr( # pylint: disable=bad-builtin
517-
api,
518-
route.route_name,
519-
)
520+
original_callback = route_handler(api=api, route=route)
520521
_ = mock.add_callback(
521522
method=http_method,
522523
url=compiled_url_pattern,

‎tests/mock_vws/test_mock_common.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Tests for shared mock route helpers."""
2+
3+
import pytest
4+
5+
from mock_vws._mock_common import Route, route_handler
6+
7+
8+
class _InvalidAPI:
9+
"""An API whose registered route attribute is not callable."""
10+
11+
invalid_route = 1
12+
13+
14+
def test_route_handler_must_be_callable() -> None:
15+
"""A route name must resolve to a callable handler."""
16+
route = Route(
17+
route_name="invalid_route",
18+
path_pattern="/invalid",
19+
http_methods=["GET"],
20+
)
21+
api = _InvalidAPI()
22+
assert api.invalid_route == 1
23+
with pytest.raises(
24+
expected_exception=TypeError,
25+
match="invalid_route is not a route handler",
26+
):
27+
_ = route_handler(api=api, route=route)

‎tests/mock_vws/test_model_target_web_api.py‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2445,8 +2445,20 @@ def test_client_credential_authentication_errors() -> None:
24452445
)
24462446

24472447
@staticmethod
2448-
def test_non_string_token_scope() -> None:
2449-
"""A token with a non-string scope has no usable scopes."""
2448+
@pytest.mark.parametrize(
2449+
argnames=("payload", "status_code"),
2450+
argvalues=[
2451+
(b'{"scope":[]}', HTTPStatus.FORBIDDEN),
2452+
(b"[]", HTTPStatus.UNAUTHORIZED),
2453+
],
2454+
)
2455+
def test_invalid_token_scope(
2456+
payload: bytes,
2457+
status_code: HTTPStatus,
2458+
) -> None:
2459+
"""A non-string scope or non-object payload has no usable
2460+
scopes.
2461+
"""
24502462
encoded_header = (
24512463
base64.urlsafe_b64encode(
24522464
s=b'{"alg":"mock"}',
@@ -2456,7 +2468,7 @@ def test_non_string_token_scope() -> None:
24562468
)
24572469
encoded_payload = (
24582470
base64.urlsafe_b64encode(
2459-
s=b'{"scope":[]}',
2471+
s=payload,
24602472
)
24612473
.decode(encoding="ascii")
24622474
.rstrip("=")
@@ -2470,7 +2482,7 @@ def test_non_string_token_scope() -> None:
24702482
)
24712483
assert_model_target_status(
24722484
response=response,
2473-
status_codes=HTTPStatus.FORBIDDEN,
2485+
status_codes=status_code,
24742486
)
24752487

24762488
@staticmethod

0 commit comments

Comments
 (0)