From 0c6222334cbe82dab3c478a4530b9c65bfd7efe3 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sat, 8 Aug 2026 21:35:12 +0100 Subject: [PATCH 1/3] Verify the reco counts report request against real Vuforia vws-web-tools 2026.8.7 exposes the database ID which `POST /imagetargets/databases/{database_id}/reports/recoCounts` names in its path, so write it into the secrets files as `VUFORIA_DATABASE_ID` and send it on the real backend. The mocks accept any ID, so they keep getting a random one. `verify_mock_vuforia` now yields its backend, as the Model Target equivalent already does, so that a test can tell which one it is running against. Secrets files created before this change have no database ID, so the field defaults to empty and the real-Vuforia tests skip when it is. A green run therefore does not mean the endpoint has been verified until the secrets files are regenerated, which the differences documentation now says. Towards #3359. Co-Authored-By: Claude Opus 5 (1M context) --- admin/create_secrets_files.py | 1 + docs/source/differences-to-vws.rst | 23 ++++++--- pyproject.toml | 2 +- tests/mock_vws/fixtures/credentials.py | 13 +++++ tests/mock_vws/fixtures/vuforia_backends.py | 9 ++-- tests/mock_vws/test_reco_counts_report.py | 57 +++++++++++++++++---- vuforia_secrets.env.example | 1 + 7 files changed, 83 insertions(+), 23 deletions(-) diff --git a/admin/create_secrets_files.py b/admin/create_secrets_files.py index 1cf5daf1d..b96759997 100644 --- a/admin/create_secrets_files.py +++ b/admin/create_secrets_files.py @@ -88,6 +88,7 @@ def _generate_secrets_file_content( return textwrap.dedent( text=f"""\ VUFORIA_TARGET_MANAGER_DATABASE_NAME={cloud_database_details["database_name"]} + VUFORIA_DATABASE_ID={cloud_database_details["database_id"]} VUFORIA_SERVER_ACCESS_KEY={cloud_database_details["server_access_key"]} VUFORIA_SERVER_SECRET_KEY={cloud_database_details["server_secret_key"]} VUFORIA_CLIENT_ACCESS_KEY={cloud_database_details["client_access_key"]} diff --git a/docs/source/differences-to-vws.rst b/docs/source/differences-to-vws.rst index 8981f3cb7..0cf61bd6e 100644 --- a/docs/source/differences-to-vws.rst +++ b/docs/source/differences-to-vws.rst @@ -267,6 +267,8 @@ It uses the database which matches the request's server keys, and it accepts any database ID. Real Vuforia returns a 401 response for a request which is signed with valid server keys but which names a database ID that those keys do not belong to. +:class:`mock_vws.database.CloudDatabase` has no database ID, so the mock +cannot make the same check. Real Vuforia returns a presigned URL for cloud storage, and the report takes between a few seconds and one hour to generate. @@ -278,14 +280,19 @@ The URL returned by the Flask and Docker mock is built from the As with real Vuforia, the URL returns a 404 response until the report is ready, and it requires no authorization. -The whole endpoint is mock-only in -``tests/mock_vws/test_reco_counts_report.py``, because the test credentials do -not include a database ID and so a request cannot be made which real Vuforia -authenticates. -Nothing about it has been verified against real Vuforia: not the ``Fail`` -result code returned for a ``month`` which is not in the ``YYYY-mm`` form or -which is neither the current month nor the previous month, not the columns of -the CSV report, and not the headers of either response. +Requesting a report is verified against real Vuforia when the secrets file +contains ``VUFORIA_DATABASE_ID``. +Those tests skip when it does not, so a green test run does not by itself mean +that the endpoint has been verified. +Until they have run, the ``Fail`` result code which the mock returns for a +``month`` which is not in the ``YYYY-mm`` form, and for a ``month`` which is +neither the current month nor the previous month, remains a guess. +The documentation does not give a result code for this endpoint. + +Downloading a report is tested against the mocks only, because real Vuforia +takes between a few seconds and one hour to generate a report. +The columns of the CSV report, the headers of the download response, and the +404-until-ready behaviour have therefore not been verified. Header cases ------------ diff --git a/pyproject.toml b/pyproject.toml index dab7a7eaf..7d65ba015 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,7 +111,7 @@ optional-dependencies.dev = [ "vulture==2.16", "vws-python==2026.2.25.1", "vws-test-fixtures==2023.3.5", - "vws-web-tools==2026.5.21", + "vws-web-tools==2026.8.7", "yamlfix==1.19.1", "zizmor==1.29.0", ] diff --git a/tests/mock_vws/fixtures/credentials.py b/tests/mock_vws/fixtures/credentials.py index 0b187daf0..99d3bac61 100644 --- a/tests/mock_vws/fixtures/credentials.py +++ b/tests/mock_vws/fixtures/credentials.py @@ -20,6 +20,9 @@ class _CloudDatabaseSettings(BaseSettings): server_secret_key: str client_access_key: str client_secret_key: str + # Secrets files created before ``VUFORIA_DATABASE_ID`` was added do not + # contain it. Tests which need it skip when it is empty. + database_id: str = "" model_config = SettingsConfigDict( env_prefix="VUFORIA_", @@ -137,6 +140,16 @@ def vuforia_database() -> CloudDatabase: ) +@pytest.fixture +def vuforia_database_id() -> str: + """Return the ID of the working database from environment variables. + + This is empty when the secrets file predates ``VUFORIA_DATABASE_ID``. + """ + settings = _CloudDatabaseSettings.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 0648c6adc..d612bebd0 100644 --- a/tests/mock_vws/fixtures/vuforia_backends.py +++ b/tests/mock_vws/fixtures/vuforia_backends.py @@ -367,7 +367,7 @@ def fixture_verify_mock_vuforia( vumark_vuforia_database: VuMarkCloudDatabase, inactive_vumark_database: InactiveVuMarkCloudDatabase, monkeypatch: pytest.MonkeyPatch, -) -> Generator[None]: +) -> Generator[VuforiaBackend]: """Test functions which use this fixture are run multiple times. Once with the real Vuforia, and once with each mock. @@ -375,7 +375,7 @@ def fixture_verify_mock_vuforia( This is useful for verifying the mocks. Yields: - ``None``. + The backend which the test is running against. """ backend: VuforiaBackend = request.param should_skip = request.config.getoption( @@ -390,13 +390,14 @@ def fixture_verify_mock_vuforia( VuforiaBackend.DOCKER_IN_MEMORY: _enable_use_docker_in_memory, }[backend] - yield from enable_function( + with contextlib.contextmanager(func=enable_function)( working_database=vuforia_database, inactive_cloud_database=inactive_cloud_database, vumark_vuforia_database=vumark_vuforia_database, inactive_vumark_database=inactive_vumark_database, monkeypatch=monkeypatch, - ) + ): + yield backend @pytest.fixture( diff --git a/tests/mock_vws/test_reco_counts_report.py b/tests/mock_vws/test_reco_counts_report.py index b903e8f73..3ec8d022e 100644 --- a/tests/mock_vws/test_reco_counts_report.py +++ b/tests/mock_vws/test_reco_counts_report.py @@ -15,6 +15,7 @@ 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. @@ -37,12 +38,10 @@ def _month_offset_from_now(*, months: int) -> str: def _request_reco_counts_report( *, vuforia_database: CloudDatabase, + database_id: str, month: str | int, ) -> requests.Response: """Request a reco counts report and return the response.""" - # The mocks accept any database ID, and the test credentials do not - # include the ID of the real database. - database_id = uuid.uuid4().hex request_path = f"/imagetargets/databases/{database_id}/reports/recoCounts" content_type = "application/json" content = json.dumps(obj={"month": month}).encode(encoding="utf-8") @@ -70,15 +69,34 @@ def _request_reco_counts_report( ) -@pytest.mark.usefixtures("mock_only_vuforia") -class TestRecoCountsReport: - """Tests for requesting a 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. - These are tested against the mocks only. - Real Vuforia returns a 401 response for a request which is signed with - valid server keys but which names a database ID that the keys do not - belong to, and the test credentials do not include a database ID. + 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 + + if not vuforia_database_id: + pytest.skip( + reason=( + "VUFORIA_DATABASE_ID is not set. Regenerate the secrets " + "files with admin/create_secrets_files.py to run this " + "against real Vuforia." + ), + ) + + return vuforia_database_id + + +class TestRecoCountsReport: + """Tests for requesting a reco counts report.""" @staticmethod @pytest.mark.parametrize( @@ -88,7 +106,9 @@ 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 @@ -96,6 +116,10 @@ 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, + ), month=_month_offset_from_now(months=-months_ago), ) @@ -119,12 +143,18 @@ 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, + ), month=_month_offset_from_now(months=-months_ago), ) @@ -140,12 +170,18 @@ 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, + ), month=month, ) @@ -168,6 +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, month=_month_offset_from_now(months=0), ) presigned_url = json.loads(s=response.text)["presigned_url"] diff --git a/vuforia_secrets.env.example b/vuforia_secrets.env.example index ae1990e24..4b4284a2c 100644 --- a/vuforia_secrets.env.example +++ b/vuforia_secrets.env.example @@ -1,4 +1,5 @@ VUFORIA_TARGET_MANAGER_DATABASE_NAME=example_database_name +VUFORIA_DATABASE_ID=example_database_id VUFORIA_SERVER_ACCESS_KEY=example_server_access_key VUFORIA_SERVER_SECRET_KEY=example_server_secret_key From c9d56de3e27e40a115386779ef1e696c88b1370f Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sat, 8 Aug 2026 21:56:02 +0100 Subject: [PATCH 2/3] Exclude the missing database ID guard from coverage The guard runs only when the secrets file has no `VUFORIA_DATABASE_ID`, so whichever way the secrets files are, one branch is unreachable in a given run and coverage cannot reach the required 100%. Exclude it, and say in a comment that it should be deleted once the secrets files are regenerated, after which a missing ID should be a failure. Co-Authored-By: Claude Opus 5 (1M context) --- tests/mock_vws/test_reco_counts_report.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/mock_vws/test_reco_counts_report.py b/tests/mock_vws/test_reco_counts_report.py index 3ec8d022e..62cb965c2 100644 --- a/tests/mock_vws/test_reco_counts_report.py +++ b/tests/mock_vws/test_reco_counts_report.py @@ -83,7 +83,11 @@ def _database_id_for_backend( if backend != VuforiaBackend.REAL: return uuid.uuid4().hex - if not vuforia_database_id: + # Secrets files created before ``VUFORIA_DATABASE_ID`` was added do not + # contain it, and cannot make a request which real Vuforia authenticates. + # Delete this guard once the secrets files have been regenerated with + # ``admin/create_secrets_files.py``, so that a missing ID is a failure. + if not vuforia_database_id: # pragma: no cover pytest.skip( reason=( "VUFORIA_DATABASE_ID is not set. Regenerate the secrets " From 3156bea1a247852179e2f8567cabe14e2159ddb6 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 9 Aug 2026 00:16:18 +0100 Subject: [PATCH 3/3] Verify the reco counts report against real Vuforia Probing the endpoint with a real database found two mock bugs. Real Vuforia serves the report from S3 with a `text/plain` content type, not `text/csv`, and ends the header row with a carriage return and a line feed, not a line feed alone. The probe also confirmed the guesses made when the endpoint was added: every rejected `month` returns 400 `Fail`, the columns are `target_id,reco_count`, an empty report is header-only, and the 401 for a mismatched database ID carries the `AuthenticationFailure` result code. Add `VUFORIA_DATABASE_ID` to all 100 encrypted secrets files, so the tests run against real Vuforia rather than skipping. Only the working database has an ID, because only endpoints which name a database in their path need one, so the setting lives on a subclass rather than on the shared one. Record the URL differences which remain: real report file names are derived from the requested month, and real URLs expire. Closes #3363. Towards #3359. Co-Authored-By: Claude Opus 5 (1M context) --- docs/source/differences-to-vws.rst | 56 +++++++++++++--------- secrets.tar.gpg | Bin 19230 -> 22676 bytes src/mock_vws/_reco_counts_web_api.py | 4 +- src/mock_vws/reco_counts.py | 3 +- tests/mock_vws/fixtures/credentials.py | 20 +++++--- tests/mock_vws/test_reco_counts_report.py | 17 +------ 6 files changed, 53 insertions(+), 47 deletions(-) diff --git a/docs/source/differences-to-vws.rst b/docs/source/differences-to-vws.rst index 0cf61bd6e..0c29be62e 100644 --- a/docs/source/differences-to-vws.rst +++ b/docs/source/differences-to-vws.rst @@ -259,40 +259,50 @@ Reco counts reports ------------------- The mock does not count recognitions, so a generated reco counts report -contains only the ``target_id,reco_count`` header row. +contains only the ``target_id,reco_count`` header row, ending with a carriage +return and a line feed. +That is what real Vuforia returns for a database with no recognitions. 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 for a request which is signed with valid -server keys but which names a database ID that those keys do not belong to. +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. +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. -Real Vuforia returns a presigned URL for cloud storage, and the report takes -between a few seconds and one hour to generate. +Real Vuforia returns a presigned URL for cloud storage. The mock returns a URL served by the mock itself, without the query -parameters of a presigned URL, and the report takes -:paramref:`~mock_vws.MockVWS.processing_time_seconds` seconds to generate. +parameters of a presigned URL, so the mock's URL never expires where a real +one expires after just under seven days. The URL returned by the Flask and Docker mock is built from the :envvar:`VWS_BASE_URL` environment variable. -As with real Vuforia, the URL returns a 404 response until the report is -ready, and it requires no authorization. - -Requesting a report is verified against real Vuforia when the secrets file -contains ``VUFORIA_DATABASE_ID``. -Those tests skip when it does not, so a green test run does not by itself mean -that the endpoint has been verified. -Until they have run, the ``Fail`` result code which the mock returns for a -``month`` which is not in the ``YYYY-mm`` form, and for a ``month`` which is -neither the current month nor the previous month, remains a guess. -The documentation does not give a result code for this endpoint. - -Downloading a report is tested against the mocks only, because real Vuforia -takes between a few seconds and one hour to generate a report. -The columns of the CSV report, the headers of the download response, and the -404-until-ready behaviour have therefore not been verified. +The report takes :paramref:`~mock_vws.MockVWS.processing_time_seconds` +seconds to generate in the mock. +The documentation says a real report takes between a few seconds and one +hour, but a report for a database with no recognitions has been observed +ready within seconds. + +Real Vuforia names the report file after the requested month, and does so +differently for each of the two months it accepts. +A report for the current month is named for the date and the hour, such as +``2026-08-08-21.csv``, and a report for the previous month is named for the +month, such as ``2026-07.csv``. +The mock names every report after an opaque report identifier, so the +requested month cannot be recovered from the mock's URL, and two requests for +the same month never give the same URL. + +The mock's URL returns a 404 response until the report is ready, and requires +no authorization. +The lack of authorization matches real Vuforia, whose URL carries its own +signature. +The 404 has not been verified, because no request for a real report has caught +one before it was generated. Header cases ------------ diff --git a/secrets.tar.gpg b/secrets.tar.gpg index 41bcc48daa77620fa5e9b3a9d2d3ede230ecd945..3dd97f72bb38a5f79e4194f8b15916cb980bbce2 100644 GIT binary patch literal 22676 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 T$#YY-(v}`virLzvGa4r7o7CWd literal 19230 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 Fl~IG=hNA!g diff --git a/src/mock_vws/_reco_counts_web_api.py b/src/mock_vws/_reco_counts_web_api.py index acb62e933..6e6fe6ccb 100644 --- a/src/mock_vws/_reco_counts_web_api.py +++ b/src/mock_vws/_reco_counts_web_api.py @@ -156,8 +156,10 @@ def download_reco_counts_report( ) body = report.csv_content + # Real Vuforia serves the report from S3 with a ``text/plain`` content + # type, not ``text/csv``. headers = _download_headers( - content_type="text/csv", + content_type="text/plain", content_length=len(body), ) return HTTPStatus.OK, headers, body diff --git a/src/mock_vws/reco_counts.py b/src/mock_vws/reco_counts.py index fd67ad1e9..fa04631eb 100644 --- a/src/mock_vws/reco_counts.py +++ b/src/mock_vws/reco_counts.py @@ -9,7 +9,8 @@ # The mock does not count recognitions, so a generated report never has any # rows for targets. -_CSV_CONTENT = "target_id,reco_count\n" +# Real Vuforia ends the header row with a carriage return and a line feed. +_CSV_CONTENT = "target_id,reco_count\r\n" @beartype diff --git a/tests/mock_vws/fixtures/credentials.py b/tests/mock_vws/fixtures/credentials.py index 99d3bac61..b9afe299c 100644 --- a/tests/mock_vws/fixtures/credentials.py +++ b/tests/mock_vws/fixtures/credentials.py @@ -20,9 +20,6 @@ class _CloudDatabaseSettings(BaseSettings): server_secret_key: str client_access_key: str client_secret_key: str - # Secrets files created before ``VUFORIA_DATABASE_ID`` was added do not - # contain it. Tests which need it skip when it is empty. - database_id: str = "" model_config = SettingsConfigDict( env_prefix="VUFORIA_", @@ -31,6 +28,16 @@ class _CloudDatabaseSettings(BaseSettings): ) +class _WorkingCloudDatabaseSettings(_CloudDatabaseSettings): + """Settings for the working Vuforia database. + + Only the working database has an ID, because only endpoints which name + a database in their path need one. + """ + + database_id: str + + class _InactiveCloudDatabaseSettings(_CloudDatabaseSettings): """Settings for an inactive Vuforia database.""" @@ -142,11 +149,10 @@ def vuforia_database() -> CloudDatabase: @pytest.fixture def vuforia_database_id() -> str: - """Return the ID of the working database from environment variables. - - This is empty when the secrets file predates ``VUFORIA_DATABASE_ID``. + """Return the ID of the working database from environment + variables. """ - settings = _CloudDatabaseSettings.model_validate(obj={}) + settings = _WorkingCloudDatabaseSettings.model_validate(obj={}) return settings.database_id diff --git a/tests/mock_vws/test_reco_counts_report.py b/tests/mock_vws/test_reco_counts_report.py index 62cb965c2..a6c3de3ad 100644 --- a/tests/mock_vws/test_reco_counts_report.py +++ b/tests/mock_vws/test_reco_counts_report.py @@ -83,19 +83,6 @@ def _database_id_for_backend( if backend != VuforiaBackend.REAL: return uuid.uuid4().hex - # Secrets files created before ``VUFORIA_DATABASE_ID`` was added do not - # contain it, and cannot make a request which real Vuforia authenticates. - # Delete this guard once the secrets files have been regenerated with - # ``admin/create_secrets_files.py``, so that a missing ID is a failure. - if not vuforia_database_id: # pragma: no cover - pytest.skip( - reason=( - "VUFORIA_DATABASE_ID is not set. Regenerate the secrets " - "files with admin/create_secrets_files.py to run this " - "against real Vuforia." - ), - ) - return vuforia_database_id @@ -220,8 +207,8 @@ def test_download_report(*, vuforia_database: CloudDatabase) -> None: ready_response = requests.get(url=presigned_url, timeout=30) assert ready_response.status_code == HTTPStatus.OK - assert ready_response.headers["Content-Type"] == "text/csv" - assert ready_response.text == "target_id,reco_count\n" + assert ready_response.headers["Content-Type"] == "text/plain" + assert ready_response.text == "target_id,reco_count\r\n" @staticmethod def test_unknown_report() -> None: