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..0c29be62e 100644 --- a/docs/source/differences-to-vws.rst +++ b/docs/source/differences-to-vws.rst @@ -259,33 +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 presigned URL for cloud storage, and the report takes -between a few seconds and one hour to generate. +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. 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. - -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. +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/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/secrets.tar.gpg b/secrets.tar.gpg index 41bcc48da..3dd97f72b 100644 Binary files a/secrets.tar.gpg and b/secrets.tar.gpg differ 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 0b187daf0..b9afe299c 100644 --- a/tests/mock_vws/fixtures/credentials.py +++ b/tests/mock_vws/fixtures/credentials.py @@ -28,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.""" @@ -137,6 +147,15 @@ 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 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..a6c3de3ad 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,25 @@ 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 + + return vuforia_database_id + + +class TestRecoCountsReport: + """Tests for requesting a reco counts report.""" @staticmethod @pytest.mark.parametrize( @@ -88,7 +97,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 +107,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 +134,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 +161,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 +195,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"] @@ -179,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: 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