From 88e993e398fb7b77dbc7f0cfa1b322cfb885f70c Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 9 Aug 2026 09:10:27 +0100 Subject: [PATCH 1/2] Validate the reco counts report database ID Closes #3362. Co-Authored-By: Claude Opus 5 (1M context) --- docs/source/differences-to-vws.rst | 15 ++-- newsfragments/cloud-database-id.change | 1 + src/mock_vws/_flask_server/target_manager.py | 5 ++ src/mock_vws/_flask_server/vws.py | 4 +- src/mock_vws/_services_validators/__init__.py | 8 ++ .../database_id_validators.py | 76 +++++++++++++++++ src/mock_vws/database.py | 8 ++ tests/mock_vws/fixtures/credentials.py | 12 +-- tests/mock_vws/fixtures/vuforia_backends.py | 1 + tests/mock_vws/test_reco_counts_report.py | 85 ++++++++++--------- 10 files changed, 157 insertions(+), 58 deletions(-) create mode 100644 newsfragments/cloud-database-id.change create mode 100644 src/mock_vws/_services_validators/database_id_validators.py diff --git a/docs/source/differences-to-vws.rst b/docs/source/differences-to-vws.rst index 0c29be62e..00e7ee651 100644 --- a/docs/source/differences-to-vws.rst +++ b/docs/source/differences-to-vws.rst @@ -266,15 +266,14 @@ The mock returns the same report for the current month and the previous month. As with real Vuforia, the report is served with a ``text/plain`` content type rather than a CSV one. -The mock does not use the database ID in the request path. -It uses the database which matches the request's server keys, and it accepts -any database ID. -Real Vuforia returns a 401 response with the ``AuthenticationFailure`` result -code for a request which is signed with valid server keys but which names a -database ID that those keys do not belong to. +As real Vuforia does, the mock returns a 401 response with the +``AuthenticationFailure`` result code for a request which is signed with valid +server keys but which names a database ID in its path that those keys do not +belong to. That includes naming the database by its name rather than by its ID. -:class:`mock_vws.database.CloudDatabase` has no database ID, so the mock -cannot make the same check. +The ID of a database is +:paramref:`mock_vws.database.CloudDatabase.database_id`, which defaults to a +random string. Real Vuforia returns a presigned URL for cloud storage. The mock returns a URL served by the mock itself, without the query diff --git a/newsfragments/cloud-database-id.change b/newsfragments/cloud-database-id.change new file mode 100644 index 000000000..87c6da4bd --- /dev/null +++ b/newsfragments/cloud-database-id.change @@ -0,0 +1 @@ +Give ``CloudDatabase`` a ``database_id``, and reject a reco counts report request whose path names a database which the request's server keys do not belong to, as real Vuforia does. diff --git a/src/mock_vws/_flask_server/target_manager.py b/src/mock_vws/_flask_server/target_manager.py index 6583c0fef..651a8a83b 100644 --- a/src/mock_vws/_flask_server/target_manager.py +++ b/src/mock_vws/_flask_server/target_manager.py @@ -232,6 +232,10 @@ def create_cloud_database() -> Response: "client_secret_key", random_database.client_secret_key, ) + database_id = request_json.get( + "database_id", + random_database.database_id, + ) database_name = request_json.get( "database_name", random_database.database_name, @@ -271,6 +275,7 @@ def create_cloud_database() -> Response: server_secret_key=server_secret_key, client_access_key=client_access_key, client_secret_key=client_secret_key, + database_id=database_id, database_name=database_name, state=state, database_type=database_type, diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py index 0a1e84026..588139921 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -408,8 +408,8 @@ def reco_counts_report(database_id: str) -> Response: Fake implementation of https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api """ - # The mock authenticates with the request's server keys, so the database - # ID in the path is not used. + # The database ID in the path is validated against the request's server + # keys before the request reaches this route. del database_id settings = VWSSettings.model_validate(obj={}) return _to_flask_response( diff --git a/src/mock_vws/_services_validators/__init__.py b/src/mock_vws/_services_validators/__init__.py index eaeb09765..d08c1d80b 100644 --- a/src/mock_vws/_services_validators/__init__.py +++ b/src/mock_vws/_services_validators/__init__.py @@ -19,6 +19,7 @@ validate_content_length_header_not_too_small, ) from .content_type_validators import validate_content_type_header_given +from .database_id_validators import validate_database_id_matches_keys from .date_validators import ( validate_date_format, validate_date_header_given, @@ -91,6 +92,13 @@ def run_services_validators( request_path=request_path, databases=databases, ) + validate_database_id_matches_keys( + request_headers=request_headers, + request_body=request_body, + request_method=request_method, + request_path=request_path, + databases=databases, + ) validate_request_quota( request_headers=request_headers, request_body=request_body, diff --git a/src/mock_vws/_services_validators/database_id_validators.py b/src/mock_vws/_services_validators/database_id_validators.py new file mode 100644 index 000000000..d19dfbb2b --- /dev/null +++ b/src/mock_vws/_services_validators/database_id_validators.py @@ -0,0 +1,76 @@ +"""Validators for database IDs given in request paths.""" + +import logging +import re +from collections.abc import Iterable, Mapping + +from beartype import beartype + +from mock_vws._database_matchers import ( + AnyDatabase, + get_database_matching_server_keys, +) +from mock_vws._mock_common import RECO_COUNTS_REPORT_PATH_PATTERN +from mock_vws._services_validators.exceptions import ( + AuthenticationFailureError, +) +from mock_vws.database import CloudDatabase + +_LOGGER = logging.getLogger(name=__name__) +# The index of the database ID in +# ``/imagetargets/databases/{database_id}/reports/recoCounts``, split on "/". +_DATABASE_ID_PATH_INDEX = 3 + + +@beartype +def validate_database_id_matches_keys( + *, + request_path: str, + request_headers: Mapping[str, str], + request_body: bytes, + request_method: str, + databases: Iterable[AnyDatabase], +) -> None: + """Validate a database ID given in the request path. + + The ID must be the ID of the database which the request's server keys + belong to. + + Args: + request_path: The path of the request. + request_headers: The headers sent with the request. + request_body: The body of the request. + request_method: The HTTP method of the request. + databases: All Vuforia databases. + + Raises: + AuthenticationFailureError: The request path names a database other + than the one which the request's server keys belong to. + """ + if not re.fullmatch( + pattern=RECO_COUNTS_REPORT_PATH_PATTERN, + string=request_path, + ): + return + + database = get_database_matching_server_keys( + request_headers=request_headers, + request_body=request_body, + request_method=request_method, + request_path=request_path, + databases=databases, + ) + + given_database_id = request_path.split(sep="/")[_DATABASE_ID_PATH_INDEX] + if ( + isinstance(database, CloudDatabase) + and database.database_id == given_database_id + ): + return + + _LOGGER.warning( + 'The database ID "%s" is not the ID of the database which the ' + "request's server keys belong to.", + given_database_id, + ) + raise AuthenticationFailureError diff --git a/src/mock_vws/database.py b/src/mock_vws/database.py index 0b00d0909..f86acc454 100644 --- a/src/mock_vws/database.py +++ b/src/mock_vws/database.py @@ -26,6 +26,7 @@ class CloudDatabaseDict(TypedDict): """A dictionary type which represents a cloud database.""" + database_id: str database_name: str server_access_key: str server_secret_key: str @@ -63,6 +64,10 @@ class CloudDatabase: """Credentials for VWS APIs. Args: + database_id: The identifier of a VWS target manager database. Defaults + to a random string. Endpoints which name a database in their path, + such as the reco counts report endpoint, accept only the identifier + of the database which the request's server keys belong to. database_name: The name of a VWS target manager database name. Defaults to a random string. server_access_key: A VWS server access key. Defaults to a random @@ -92,6 +97,7 @@ class CloudDatabase: # We hide a few things in the ``repr`` with ``repr=False`` so that they do # not show up in CI logs. + database_id: str = field(default_factory=_random_hex, repr=False) database_name: str = field(default_factory=_random_hex, repr=False) server_access_key: str = field(default_factory=_random_hex, repr=False) server_secret_key: str = field(default_factory=_random_hex, repr=False) @@ -128,6 +134,7 @@ def to_dict(self) -> CloudDatabaseDict: else self.request_rate_limits.to_dict() ) return { + "database_id": self.database_id, "database_name": self.database_name, "server_access_key": self.server_access_key, "server_secret_key": self.server_secret_key, @@ -166,6 +173,7 @@ def from_dict(cls, database_dict: CloudDatabaseDict) -> Self: ) return cls( + database_id=database_dict["database_id"], database_name=database_dict["database_name"], server_access_key=database_dict["server_access_key"], server_secret_key=database_dict["server_secret_key"], diff --git a/tests/mock_vws/fixtures/credentials.py b/tests/mock_vws/fixtures/credentials.py index b9afe299c..dfedf8a2a 100644 --- a/tests/mock_vws/fixtures/credentials.py +++ b/tests/mock_vws/fixtures/credentials.py @@ -136,8 +136,9 @@ def get_model_target_credentials() -> ModelTargetCredentials: @pytest.fixture def vuforia_database() -> CloudDatabase: """Return VWS credentials from environment variables.""" - settings = _CloudDatabaseSettings.model_validate(obj={}) + settings = _WorkingCloudDatabaseSettings.model_validate(obj={}) return CloudDatabase( + database_id=settings.database_id, database_name=settings.target_manager_database_name, server_access_key=settings.server_access_key, server_secret_key=settings.server_secret_key, @@ -147,15 +148,6 @@ def vuforia_database() -> CloudDatabase: ) -@pytest.fixture -def vuforia_database_id() -> str: - """Return the ID of the working database from environment - variables. - """ - settings = _WorkingCloudDatabaseSettings.model_validate(obj={}) - return settings.database_id - - @pytest.fixture def inactive_cloud_database() -> CloudDatabase: """ diff --git a/tests/mock_vws/fixtures/vuforia_backends.py b/tests/mock_vws/fixtures/vuforia_backends.py index d612bebd0..b99afdf42 100644 --- a/tests/mock_vws/fixtures/vuforia_backends.py +++ b/tests/mock_vws/fixtures/vuforia_backends.py @@ -118,6 +118,7 @@ def _enable_use_mock_vuforia( """Test against the in-memory mock Vuforia.""" assert monkeypatch working_database = CloudDatabase( + database_id=working_database.database_id, database_name=working_database.database_name, server_access_key=working_database.server_access_key, server_secret_key=working_database.server_secret_key, diff --git a/tests/mock_vws/test_reco_counts_report.py b/tests/mock_vws/test_reco_counts_report.py index a6c3de3ad..f02ccb2f1 100644 --- a/tests/mock_vws/test_reco_counts_report.py +++ b/tests/mock_vws/test_reco_counts_report.py @@ -15,7 +15,6 @@ from mock_vws._constants import ResultCodes from mock_vws.database import CloudDatabase -from tests.mock_vws.fixtures.vuforia_backends import VuforiaBackend _VWS_HOST = "https://vws.vuforia.com" # The number of seconds which the mocks take to generate a report. @@ -41,7 +40,11 @@ def _request_reco_counts_report( database_id: str, month: str | int, ) -> requests.Response: - """Request a reco counts report and return the response.""" + """Request a reco counts report and return the response. + + The report is requested for the database named by the given ID, and the + request is signed with the given database's server keys. + """ request_path = f"/imagetargets/databases/{database_id}/reports/recoCounts" content_type = "application/json" content = json.dumps(obj={"month": month}).encode(encoding="utf-8") @@ -69,23 +72,7 @@ def _request_reco_counts_report( ) -@beartype -def _database_id_for_backend( - *, - backend: VuforiaBackend, - vuforia_database_id: str, -) -> str: - """Return the database ID to name in the request path. - - Real Vuforia requires the ID to be the ID of the database which the - request's server keys belong to. The mocks accept any ID. - """ - if backend != VuforiaBackend.REAL: - return uuid.uuid4().hex - - return vuforia_database_id - - +@pytest.mark.usefixtures("verify_mock_vuforia") class TestRecoCountsReport: """Tests for requesting a reco counts report.""" @@ -97,9 +84,7 @@ class TestRecoCountsReport: ) def test_reco_counts_report( *, - verify_mock_vuforia: VuforiaBackend, vuforia_database: CloudDatabase, - vuforia_database_id: str, months_ago: int, ) -> None: """A report can be requested for the current and previous @@ -107,10 +92,7 @@ def test_reco_counts_report( """ response = _request_reco_counts_report( vuforia_database=vuforia_database, - database_id=_database_id_for_backend( - backend=verify_mock_vuforia, - vuforia_database_id=vuforia_database_id, - ), + database_id=vuforia_database.database_id, month=_month_offset_from_now(months=-months_ago), ) @@ -134,18 +116,13 @@ def test_reco_counts_report( ) def test_month_out_of_range( *, - verify_mock_vuforia: VuforiaBackend, vuforia_database: CloudDatabase, - vuforia_database_id: str, months_ago: int, ) -> None: """Only the current and the previous month can be requested.""" response = _request_reco_counts_report( vuforia_database=vuforia_database, - database_id=_database_id_for_backend( - backend=verify_mock_vuforia, - vuforia_database_id=vuforia_database_id, - ), + database_id=vuforia_database.database_id, month=_month_offset_from_now(months=-months_ago), ) @@ -161,18 +138,13 @@ def test_month_out_of_range( ) def test_malformed_month( *, - verify_mock_vuforia: VuforiaBackend, vuforia_database: CloudDatabase, - vuforia_database_id: str, month: str | int, ) -> None: """The month must be given in the ``YYYY-mm`` form.""" response = _request_reco_counts_report( vuforia_database=vuforia_database, - database_id=_database_id_for_backend( - backend=verify_mock_vuforia, - vuforia_database_id=vuforia_database_id, - ), + database_id=vuforia_database.database_id, month=month, ) @@ -180,6 +152,43 @@ def test_malformed_month( response_json = json.loads(s=response.text) assert response_json["result_code"] == ResultCodes.FAIL.value + @staticmethod + def test_unknown_database_id(*, vuforia_database: CloudDatabase) -> None: + """The path must name the database which the request's server + keys belong to. + """ + response = _request_reco_counts_report( + vuforia_database=vuforia_database, + database_id=uuid.uuid4().hex, + month=_month_offset_from_now(months=0), + ) + + assert response.status_code == HTTPStatus.UNAUTHORIZED + response_json = json.loads(s=response.text) + assert ( + response_json["result_code"] + == ResultCodes.AUTHENTICATION_FAILURE.value + ) + + @staticmethod + def test_database_name_in_path( + *, + vuforia_database: CloudDatabase, + ) -> None: + """A database is named in the path by its ID, not by its name.""" + response = _request_reco_counts_report( + vuforia_database=vuforia_database, + database_id=vuforia_database.database_name, + month=_month_offset_from_now(months=0), + ) + + assert response.status_code == HTTPStatus.UNAUTHORIZED + response_json = json.loads(s=response.text) + assert ( + response_json["result_code"] + == ResultCodes.AUTHENTICATION_FAILURE.value + ) + @pytest.mark.usefixtures("mock_only_vuforia") class TestDownloadReport: @@ -195,7 +204,7 @@ def test_download_report(*, vuforia_database: CloudDatabase) -> None: """The report is available from the given URL once it is ready.""" response = _request_reco_counts_report( vuforia_database=vuforia_database, - database_id=uuid.uuid4().hex, + database_id=vuforia_database.database_id, month=_month_offset_from_now(months=0), ) presigned_url = json.loads(s=response.text)["presigned_url"] From 866616ab67a53e0710ede07c24b2941d935f2e42 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 9 Aug 2026 09:19:57 +0100 Subject: [PATCH 2/2] Document what actually differs about the database ID The previous wording described only behaviour which matches real Vuforia, which does not belong in this document. Co-Authored-By: Claude Opus 5 (1M context) --- docs/source/differences-to-vws.rst | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/source/differences-to-vws.rst b/docs/source/differences-to-vws.rst index 00e7ee651..4ba11998b 100644 --- a/docs/source/differences-to-vws.rst +++ b/docs/source/differences-to-vws.rst @@ -266,14 +266,15 @@ The mock returns the same report for the current month and the previous month. As with real Vuforia, the report is served with a ``text/plain`` content type rather than a CSV one. +Real Vuforia assigns a database an ID, which the target manager shows. +The ID of a database in the mock is +:paramref:`mock_vws.database.CloudDatabase.database_id`, which defaults to a +random string, so the path of a request to this endpoint is built by reading +that attribute rather than by looking the ID up. As real Vuforia does, the mock returns a 401 response with the ``AuthenticationFailure`` result code for a request which is signed with valid -server keys but which names a database ID in its path that those keys do not -belong to. -That includes naming the database by its name rather than by its ID. -The ID of a database is -:paramref:`mock_vws.database.CloudDatabase.database_id`, which defaults to a -random string. +server keys but which names any other database, including one named by its +name rather than by its ID. Real Vuforia returns a presigned URL for cloud storage. The mock returns a URL served by the mock itself, without the query