diff --git a/docs/source/differences-to-vws.rst b/docs/source/differences-to-vws.rst index 3281974b7..d6a56b9ad 100644 --- a/docs/source/differences-to-vws.rst +++ b/docs/source/differences-to-vws.rst @@ -318,6 +318,21 @@ signature. The 404 has not been verified, because no request for a real report has caught one before it was generated. +Paths which the mock does not serve +----------------------------------- + +Real Vuforia gives an empty body with a 404 response only for a request to a +path which does not start with a served path, such as +``/some-random-endpoint``. +For any other request which it does not serve, such as ``DELETE /summary`` or +``GET /targetsfoo``, it gives an HTML "Not Found" page which names the method +and the path of the request. +The Flask and Docker mock gives an empty body for all of these. + +The ``requests`` and ``httpx`` backends mock only the paths which the mock +serves, so a request to any other path raises a connection error rather than +giving the 404 response which real Vuforia gives. + Header cases ------------ diff --git a/newsfragments/unrouted-requests.change b/newsfragments/unrouted-requests.change new file mode 100644 index 000000000..28ab5d718 --- /dev/null +++ b/newsfragments/unrouted-requests.change @@ -0,0 +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. diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py index 7933be0b9..f0548de2d 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -18,6 +18,7 @@ from beartype import beartype from flask import Flask, Response, request from pydantic_settings import BaseSettings +from werkzeug.exceptions import MethodNotAllowed, NotFound from mock_vws._constants import ( VUMARK_PDF, @@ -199,7 +200,14 @@ def validate_request() -> None: Reco counts report downloads stand in for presigned URLs, which are not authorized with VWS credentials. + + Flask runs ``before_request`` handlers before it raises a routing error, + so requests which match no route reach this function. + Those requests are left to Flask, which raises the routing error, and + ``handle_unrouted_request`` turns that into a response. """ + if request.url_rule is None: + return if request.endpoint == "generate_vumark_instance": return if ( @@ -242,6 +250,23 @@ def handle_exceptions(exc: ValidatorError) -> Response: return response +@VWS_FLASK_APP.errorhandler(code_or_exception=HTTPStatus.NOT_FOUND) +@VWS_FLASK_APP.errorhandler(code_or_exception=HTTPStatus.METHOD_NOT_ALLOWED) +@beartype +def handle_unrouted_request(exc: NotFound | MethodNotAllowed) -> Response: + """Return a 404 response with no body for a request which no route + serves. + + Real Vuforia returns a 404 response 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. + """ + del exc + response = Response(status=HTTPStatus.NOT_FOUND, response=b"") + del response.headers["Content-Type"] + return response + + @VWS_FLASK_APP.route(rule="/oauth2/token", methods=[HTTPMethod.POST]) @beartype def oauth2_token() -> Response: diff --git a/tests/mock_vws/test_flask_app_usage.py b/tests/mock_vws/test_flask_app_usage.py index 60742fc46..9ff6e2a1a 100644 --- a/tests/mock_vws/test_flask_app_usage.py +++ b/tests/mock_vws/test_flask_app_usage.py @@ -246,6 +246,27 @@ def test_per_endpoint_limits() -> None: client.get_database_summary_report() +class TestUnroutedRequests: + """Tests for requests which the Flask app does not route. + + Signed requests are covered by + ``tests/mock_vws/test_invalid_given_id.py``, which verifies the + responses against real Vuforia. + """ + + @staticmethod + def test_unauthenticated_unknown_path() -> None: + """A request to a path which is not routed returns a 404 even + without credentials. + + The Docker health check relies on this request returning a + response. + """ + response = VWS_FLASK_APP.test_client().get("/some-random-endpoint") + + assert response.status_code == HTTPStatus.NOT_FOUND + + class TestAddCloudDatabase: """Tests for adding cloud databases to the mock.""" diff --git a/tests/mock_vws/test_invalid_given_id.py b/tests/mock_vws/test_invalid_given_id.py index 1d3b49e8c..1081429de 100644 --- a/tests/mock_vws/test_invalid_given_id.py +++ b/tests/mock_vws/test_invalid_given_id.py @@ -1,19 +1,106 @@ -""" -Tests for passing invalid target IDs to endpoints which require a target -ID to -be given. +"""Tests for requests which name something that VWS does not serve. + +These cover an invalid target ID given to an endpoint which requires one, a +path which VWS does not serve, and a served path with a method which that +path does not serve. + +The tests for paths and methods live here, rather than in a file of their +own, because 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. """ -from http import HTTPStatus +from dataclasses import dataclass +from http import HTTPMethod, HTTPStatus import pytest +import requests +from beartype import beartype from vws import VWS +from vws_auth_tools import authorization_header, rfc_1123_date from mock_vws._constants import ResultCodes +from mock_vws._flask_server.vws import VWS_FLASK_APP +from mock_vws.database import CloudDatabase +from tests.mock_vws.fixtures.vuforia_backends import VuforiaBackend from tests.mock_vws.utils import Endpoint from tests.mock_vws.utils.assertions import assert_vws_failure from tests.mock_vws.utils.too_many_requests import handle_server_errors +_VWS_HOST = "https://vws.vuforia.com" + + +@beartype +@dataclass(frozen=True, kw_only=True) +class _UnroutedResponse: + """The parts of a response to a request which no route serves.""" + + status_code: int + body: bytes + content_type: str | None + + +@beartype +def _send_unrouted_request( + *, + backend: VuforiaBackend, + vuforia_database: CloudDatabase, + method: HTTPMethod, + request_path: str, +) -> _UnroutedResponse | None: + """Send a signed request which no route serves and return the response. + + ``None`` is returned when the backend refuses the connection rather than + returning a response. + """ + date = rfc_1123_date() + headers = { + "Authorization": authorization_header( + access_key=vuforia_database.server_access_key, + secret_key=vuforia_database.server_secret_key, + method=method, + content=b"", + content_type="", + date=date, + request_path=request_path, + ), + "Date": date, + } + + if backend == VuforiaBackend.DOCKER_IN_MEMORY: + # The ``responses`` library intercepts only the paths and methods + # which the Flask app routes, so requests to any other path never + # reach the app. A running container serves every path, so we drive + # the app with its own test client. + test_client_response = VWS_FLASK_APP.test_client().open( + request_path, + method=method, + headers=headers, + ) + return _UnroutedResponse( + status_code=test_client_response.status_code, + body=test_client_response.data, + content_type=test_client_response.headers.get( + key="Content-Type", + ), + ) + + try: + response = requests.request( + method=method, + url=_VWS_HOST + request_path, + headers=headers, + timeout=30, + ) + except requests.exceptions.ConnectionError: + return None + + return _UnroutedResponse( + status_code=response.status_code, + body=response.content, + content_type=response.headers.get("Content-Type"), + ) + @pytest.mark.usefixtures("verify_mock_vuforia") class TestInvalidGivenID: @@ -53,3 +140,58 @@ def test_not_real_id( status_code=HTTPStatus.NOT_FOUND, result_code=ResultCodes.UNKNOWN_TARGET, ) + + +@pytest.mark.usefixtures("verify_mock_vuforia") +class TestUnroutedRequests: + """Tests for requests which VWS does not serve.""" + + @staticmethod + def test_unknown_path( + *, + vuforia_database: CloudDatabase, + verify_mock_vuforia: VuforiaBackend, + ) -> None: + """A request to a path which is not served returns a 404 with no + body. + """ + response = _send_unrouted_request( + backend=verify_mock_vuforia, + vuforia_database=vuforia_database, + method=HTTPMethod.GET, + request_path="/some-random-endpoint", + ) + + if verify_mock_vuforia == VuforiaBackend.MOCK: + # The ``requests`` and ``httpx`` backends mock only the paths + # which they serve, so they give no response at all. + assert response is None + return + + assert response is not None + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.body == b"" + assert response.content_type is None + + @staticmethod + def test_unknown_method( + *, + vuforia_database: CloudDatabase, + verify_mock_vuforia: VuforiaBackend, + ) -> None: + """A request to a served path with a method which that path does + not serve returns a 404, rather than a 405. + """ + response = _send_unrouted_request( + backend=verify_mock_vuforia, + vuforia_database=vuforia_database, + method=HTTPMethod.DELETE, + request_path="/summary", + ) + + if verify_mock_vuforia == VuforiaBackend.MOCK: + assert response is None + return + + assert response is not None + assert response.status_code == HTTPStatus.NOT_FOUND