Skip to content

Commit 7d7dc3d

Browse files
Return 404 for requests to paths the Flask mock does not serve (#3384)
* Return 404 for requests to unrouted paths The Flask app's ``validate_request`` before_request hook ran for requests which match no route, because Flask runs before_request handlers before it raises the routing error. ``validate_keys`` then unpacked an empty generator and raised a ``ValueError``, so any authenticated request to an unknown path, or to a known path with a method it does not serve, crashed the Flask and Docker backends. Skip validation when Flask has matched no route, so Flask raises its own routing error: 404 for an unknown path, as real Vuforia returns, and 405 for an unserved method. Closes #3368 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Reword docstring to satisfy the pylint spelling check Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Verify the unrouted request responses against real Vuforia Real Vuforia returns a 404 response both for a request to a path which it does not serve and for a request to a served path with a method which that path does not serve; it does not return a 405. Make the Flask app return a 404 with no body in both cases, rather than Flask's 404 page or a 405. Add verified fake tests which run against real Vuforia and the mocks, and record in the differences documentation which bodies real Vuforia gives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Document only the differences for unserved paths Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Fold the unrouted request tests into an existing test file Every entry in the CI test matrix uses one of the credentials files in secrets.tar.gpg, and there are exactly as many of those files as there are entries, so a new entry has no database to use and its job fails while copying the file. Move the tests into tests/mock_vws/test_invalid_given_id.py, which already covers requests which name something the API does not serve, rather than adding an entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent eca404b commit 7d7dc3d

5 files changed

Lines changed: 209 additions & 5 deletions

File tree

docs/source/differences-to-vws.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,21 @@ signature.
318318
The 404 has not been verified, because no request for a real report has caught
319319
one before it was generated.
320320

321+
Paths which the mock does not serve
322+
-----------------------------------
323+
324+
Real Vuforia gives an empty body with a 404 response only for a request to a
325+
path which does not start with a served path, such as
326+
``/some-random-endpoint``.
327+
For any other request which it does not serve, such as ``DELETE /summary`` or
328+
``GET /targetsfoo``, it gives an HTML "Not Found" page which names the method
329+
and the path of the request.
330+
The Flask and Docker mock gives an empty body for all of these.
331+
332+
The ``requests`` and ``httpx`` backends mock only the paths which the mock
333+
serves, so a request to any other path raises a connection error rather than
334+
giving the 404 response which real Vuforia gives.
335+
321336
Header cases
322337
------------
323338

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Return a 404 response from the Flask and Docker mock for a request to a path which it does not serve, and for a request to a served path with a method which that path does not serve, as real Vuforia does, rather than raising an error.

src/mock_vws/_flask_server/vws.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from beartype import beartype
1919
from flask import Flask, Response, request
2020
from pydantic_settings import BaseSettings
21+
from werkzeug.exceptions import MethodNotAllowed, NotFound
2122

2223
from mock_vws._constants import (
2324
VUMARK_PDF,
@@ -199,7 +200,14 @@ def validate_request() -> None:
199200
200201
Reco counts report downloads stand in for presigned URLs, which are not
201202
authorized with VWS credentials.
203+
204+
Flask runs ``before_request`` handlers before it raises a routing error,
205+
so requests which match no route reach this function.
206+
Those requests are left to Flask, which raises the routing error, and
207+
``handle_unrouted_request`` turns that into a response.
202208
"""
209+
if request.url_rule is None:
210+
return
203211
if request.endpoint == "generate_vumark_instance":
204212
return
205213
if (
@@ -242,6 +250,23 @@ def handle_exceptions(exc: ValidatorError) -> Response:
242250
return response
243251

244252

253+
@VWS_FLASK_APP.errorhandler(code_or_exception=HTTPStatus.NOT_FOUND)
254+
@VWS_FLASK_APP.errorhandler(code_or_exception=HTTPStatus.METHOD_NOT_ALLOWED)
255+
@beartype
256+
def handle_unrouted_request(exc: NotFound | MethodNotAllowed) -> Response:
257+
"""Return a 404 response with no body for a request which no route
258+
serves.
259+
260+
Real Vuforia returns a 404 response for a request to a path which it does
261+
not serve, and for a request to a served path with a method which that
262+
path does not serve.
263+
"""
264+
del exc
265+
response = Response(status=HTTPStatus.NOT_FOUND, response=b"")
266+
del response.headers["Content-Type"]
267+
return response
268+
269+
245270
@VWS_FLASK_APP.route(rule="/oauth2/token", methods=[HTTPMethod.POST])
246271
@beartype
247272
def oauth2_token() -> Response:

tests/mock_vws/test_flask_app_usage.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,27 @@ def test_per_endpoint_limits() -> None:
246246
client.get_database_summary_report()
247247

248248

249+
class TestUnroutedRequests:
250+
"""Tests for requests which the Flask app does not route.
251+
252+
Signed requests are covered by
253+
``tests/mock_vws/test_invalid_given_id.py``, which verifies the
254+
responses against real Vuforia.
255+
"""
256+
257+
@staticmethod
258+
def test_unauthenticated_unknown_path() -> None:
259+
"""A request to a path which is not routed returns a 404 even
260+
without credentials.
261+
262+
The Docker health check relies on this request returning a
263+
response.
264+
"""
265+
response = VWS_FLASK_APP.test_client().get("/some-random-endpoint")
266+
267+
assert response.status_code == HTTPStatus.NOT_FOUND
268+
269+
249270
class TestAddCloudDatabase:
250271
"""Tests for adding cloud databases to the mock."""
251272

tests/mock_vws/test_invalid_given_id.py

Lines changed: 147 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,106 @@
1-
"""
2-
Tests for passing invalid target IDs to endpoints which require a target
3-
ID to
4-
be given.
1+
"""Tests for requests which name something that VWS does not serve.
2+
3+
These cover an invalid target ID given to an endpoint which requires one, a
4+
path which VWS does not serve, and a served path with a method which that
5+
path does not serve.
6+
7+
The tests for paths and methods live here, rather than in a file of their
8+
own, because every entry in the CI test matrix uses one of the credentials
9+
files in ``secrets.tar.gpg``, and there are exactly as many of those files as
10+
there are entries.
511
"""
612

7-
from http import HTTPStatus
13+
from dataclasses import dataclass
14+
from http import HTTPMethod, HTTPStatus
815

916
import pytest
17+
import requests
18+
from beartype import beartype
1019
from vws import VWS
20+
from vws_auth_tools import authorization_header, rfc_1123_date
1121

1222
from mock_vws._constants import ResultCodes
23+
from mock_vws._flask_server.vws import VWS_FLASK_APP
24+
from mock_vws.database import CloudDatabase
25+
from tests.mock_vws.fixtures.vuforia_backends import VuforiaBackend
1326
from tests.mock_vws.utils import Endpoint
1427
from tests.mock_vws.utils.assertions import assert_vws_failure
1528
from tests.mock_vws.utils.too_many_requests import handle_server_errors
1629

30+
_VWS_HOST = "https://vws.vuforia.com"
31+
32+
33+
@beartype
34+
@dataclass(frozen=True, kw_only=True)
35+
class _UnroutedResponse:
36+
"""The parts of a response to a request which no route serves."""
37+
38+
status_code: int
39+
body: bytes
40+
content_type: str | None
41+
42+
43+
@beartype
44+
def _send_unrouted_request(
45+
*,
46+
backend: VuforiaBackend,
47+
vuforia_database: CloudDatabase,
48+
method: HTTPMethod,
49+
request_path: str,
50+
) -> _UnroutedResponse | None:
51+
"""Send a signed request which no route serves and return the response.
52+
53+
``None`` is returned when the backend refuses the connection rather than
54+
returning a response.
55+
"""
56+
date = rfc_1123_date()
57+
headers = {
58+
"Authorization": authorization_header(
59+
access_key=vuforia_database.server_access_key,
60+
secret_key=vuforia_database.server_secret_key,
61+
method=method,
62+
content=b"",
63+
content_type="",
64+
date=date,
65+
request_path=request_path,
66+
),
67+
"Date": date,
68+
}
69+
70+
if backend == VuforiaBackend.DOCKER_IN_MEMORY:
71+
# The ``responses`` library intercepts only the paths and methods
72+
# which the Flask app routes, so requests to any other path never
73+
# reach the app. A running container serves every path, so we drive
74+
# the app with its own test client.
75+
test_client_response = VWS_FLASK_APP.test_client().open(
76+
request_path,
77+
method=method,
78+
headers=headers,
79+
)
80+
return _UnroutedResponse(
81+
status_code=test_client_response.status_code,
82+
body=test_client_response.data,
83+
content_type=test_client_response.headers.get(
84+
key="Content-Type",
85+
),
86+
)
87+
88+
try:
89+
response = requests.request(
90+
method=method,
91+
url=_VWS_HOST + request_path,
92+
headers=headers,
93+
timeout=30,
94+
)
95+
except requests.exceptions.ConnectionError:
96+
return None
97+
98+
return _UnroutedResponse(
99+
status_code=response.status_code,
100+
body=response.content,
101+
content_type=response.headers.get("Content-Type"),
102+
)
103+
17104

18105
@pytest.mark.usefixtures("verify_mock_vuforia")
19106
class TestInvalidGivenID:
@@ -53,3 +140,58 @@ def test_not_real_id(
53140
status_code=HTTPStatus.NOT_FOUND,
54141
result_code=ResultCodes.UNKNOWN_TARGET,
55142
)
143+
144+
145+
@pytest.mark.usefixtures("verify_mock_vuforia")
146+
class TestUnroutedRequests:
147+
"""Tests for requests which VWS does not serve."""
148+
149+
@staticmethod
150+
def test_unknown_path(
151+
*,
152+
vuforia_database: CloudDatabase,
153+
verify_mock_vuforia: VuforiaBackend,
154+
) -> None:
155+
"""A request to a path which is not served returns a 404 with no
156+
body.
157+
"""
158+
response = _send_unrouted_request(
159+
backend=verify_mock_vuforia,
160+
vuforia_database=vuforia_database,
161+
method=HTTPMethod.GET,
162+
request_path="/some-random-endpoint",
163+
)
164+
165+
if verify_mock_vuforia == VuforiaBackend.MOCK:
166+
# The ``requests`` and ``httpx`` backends mock only the paths
167+
# which they serve, so they give no response at all.
168+
assert response is None
169+
return
170+
171+
assert response is not None
172+
assert response.status_code == HTTPStatus.NOT_FOUND
173+
assert response.body == b""
174+
assert response.content_type is None
175+
176+
@staticmethod
177+
def test_unknown_method(
178+
*,
179+
vuforia_database: CloudDatabase,
180+
verify_mock_vuforia: VuforiaBackend,
181+
) -> None:
182+
"""A request to a served path with a method which that path does
183+
not serve returns a 404, rather than a 405.
184+
"""
185+
response = _send_unrouted_request(
186+
backend=verify_mock_vuforia,
187+
vuforia_database=vuforia_database,
188+
method=HTTPMethod.DELETE,
189+
request_path="/summary",
190+
)
191+
192+
if verify_mock_vuforia == VuforiaBackend.MOCK:
193+
assert response is None
194+
return
195+
196+
assert response is not None
197+
assert response.status_code == HTTPStatus.NOT_FOUND

0 commit comments

Comments
 (0)