From a652e20aef60bc8060778391bcfd55a02fdbcd73 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 6 Sep 2026 18:47:51 +0100 Subject: [PATCH] Reject an empty body on VWS endpoints which take JSON An empty body given to POST /targets, PUT /targets/{id}, POST /targets/{id}/instances or the reco counts report endpoint raised an uncaught JSONDecodeError from validate_keys, because validate_json returned early for an empty body. Match real Vuforia instead: 500 Fail for the target endpoints, 400 Fail for the reco counts report and 400 BadRequest for instance generation. Add a test which runs against real Vuforia, since an empty body is answered promptly unlike other malformed bodies. Closes #3550 Co-Authored-By: Claude Fable 5.1 --- .github/workflows/test.yml | 1 + newsfragments/3550.change | 2 + src/mock_vws/_services_validators/__init__.py | 6 +- .../_services_validators/json_validators.py | 30 +++++-- tests/mock_vws/test_invalid_json.py | 79 +++++++++++++++++++ 5 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 newsfragments/3550.change diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ab3fb9b84..ac82a90bf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -89,6 +89,7 @@ jobs: - tests/mock_vws/test_invalid_json.py::TestInvalidJSON::test_not_json - tests/mock_vws/test_invalid_json.py::TestInvalidJSON::test_not_an_object - tests/mock_vws/test_invalid_json.py::TestInvalidJSON::test_not_utf_8 + - tests/mock_vws/test_invalid_json.py::TestInvalidJSON::test_empty_body - tests/mock_vws/test_invalid_json.py::TestInvalidJSON::test_invalid_json_with_skewed_time - tests/mock_vws/test_target_list.py - tests/mock_vws/test_reco_counts_report.py diff --git a/newsfragments/3550.change b/newsfragments/3550.change new file mode 100644 index 000000000..e4053cc0d --- /dev/null +++ b/newsfragments/3550.change @@ -0,0 +1,2 @@ +Return a response, rather than raising an uncaught ``JSONDecodeError``, when an empty body is given to a VWS endpoint which takes a JSON body. +As real Vuforia does, ``POST /targets`` and ``PUT /targets/`` now return a 500 ``Fail`` response, the reco counts report endpoint returns a 400 ``Fail`` response, and the VuMark instance generation endpoint returns a 400 ``BadRequest`` response. diff --git a/src/mock_vws/_services_validators/__init__.py b/src/mock_vws/_services_validators/__init__.py index f26d95e54..c12222b89 100644 --- a/src/mock_vws/_services_validators/__init__.py +++ b/src/mock_vws/_services_validators/__init__.py @@ -135,7 +135,11 @@ def run_services_validators[DatabaseT: AnyDatabase]( validate_date_format(request_headers=request_headers) validate_date_in_range(request_headers=request_headers) - validate_json(request_body=request_body, request_path=request_path) + validate_json( + request_body=request_body, + request_path=request_path, + request_method=request_method, + ) validate_keys( request_body=request_body, diff --git a/src/mock_vws/_services_validators/json_validators.py b/src/mock_vws/_services_validators/json_validators.py index 5477eb52e..39363282c 100644 --- a/src/mock_vws/_services_validators/json_validators.py +++ b/src/mock_vws/_services_validators/json_validators.py @@ -44,21 +44,39 @@ def validate_body_given(*, request_body: bytes, request_method: str) -> None: @beartype -def validate_json(*, request_body: bytes, request_path: str) -> None: +def validate_json( + *, + request_body: bytes, + request_path: str, + request_method: str, +) -> None: """Validate that any given body is valid JSON. Args: request_body: The body of the request. request_path: The path of the request. + request_method: The HTTP method of the request. Raises: - BadRequestError: The request body is not valid UTF-8, or includes - invalid JSON, for the VuMark instance generation endpoint. - FailError: The request body is not valid UTF-8, or includes invalid - JSON, for other endpoints. + BadRequestError: The request body is empty, is not valid UTF-8, or + includes invalid JSON, for the VuMark instance generation + endpoint. + FailError: The request body is empty, is not valid UTF-8, or includes + invalid JSON, for other endpoints. """ if not request_body: - return + if request_method not in {HTTPMethod.POST, HTTPMethod.PUT}: + return + + _LOGGER.warning(msg="The request body is empty.") + if request_path.endswith("/instances"): + raise BadRequestError + # Vuforia reports a server error for an empty body given to the + # target endpoints, but a bad request for one given to the reco + # counts report endpoint. + if request_path.endswith("/reports/recoCounts"): + raise FailError(status_code=HTTPStatus.BAD_REQUEST) + raise FailError(status_code=HTTPStatus.INTERNAL_SERVER_ERROR) try: # Vuforia gives the same response for a body which is not UTF-8, such diff --git a/tests/mock_vws/test_invalid_json.py b/tests/mock_vws/test_invalid_json.py index a35addaa2..77e23638f 100644 --- a/tests/mock_vws/test_invalid_json.py +++ b/tests/mock_vws/test_invalid_json.py @@ -141,6 +141,85 @@ def test_not_utf_8(endpoint: Endpoint) -> None: content = '{"name": "café"}'.encode(encoding="latin-1") _assert_body_rejected(endpoint=endpoint, content=content) + @staticmethod + def test_empty_body(endpoint: Endpoint) -> None: + """Giving an empty body to an endpoint which takes JSON returns an + error response. + + Real Vuforia gives a server error for an empty body given to the + target endpoints, but a bad request for one given to the reco counts + report endpoint or to the VuMark instance generation endpoint. + + Unlike the other malformed bodies which this class sends, an empty + body is answered promptly by real Vuforia, so this test runs against + it. + """ + takes_json_data = ( + endpoint.auth_header_content_type == "application/json" + ) + if not takes_json_data: + pytest.skip(reason="This endpoint does not take a JSON body.") + + if endpoint.path_url.endswith("/instances"): + expected_status_code = HTTPStatus.BAD_REQUEST + expected_result_code = ResultCodes.BAD_REQUEST + elif endpoint.path_url.endswith("/reports/recoCounts"): + expected_status_code = HTTPStatus.BAD_REQUEST + expected_result_code = ResultCodes.FAIL + else: + expected_status_code = HTTPStatus.INTERNAL_SERVER_ERROR + expected_result_code = ResultCodes.FAIL + + content = b"" + gmt = ZoneInfo(key="GMT") + now = datetime.now(tz=gmt) + time_to_freeze = now + with freeze_time(time_to_freeze=time_to_freeze): + date = rfc_1123_date() + + authorization_string = authorization_header( + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + method=endpoint.method, + content=content, + content_type=endpoint.auth_header_content_type, + date=date, + request_path=endpoint.path_url, + ) + + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + "Content-Length": str(object=len(content)), + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=content, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + # A server error is the expected response for some endpoints, so + # only treat one as transient where it is not expected. + if expected_status_code != HTTPStatus.INTERNAL_SERVER_ERROR: + handle_server_errors(response=response) + + assert_valid_date_header(response=response) + assert_vws_failure( + response=response, + status_code=expected_status_code, + result_code=expected_result_code, + ) + @staticmethod def test_invalid_json_with_skewed_time(endpoint: Endpoint) -> None: """Giving invalid JSON to endpoints returns error responses."""