diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 386db8031..cc06d4323 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -113,6 +113,7 @@ jobs: - tests/mock_vws/test_update_target.py::TestInactiveProject - tests/mock_vws/test_requests_mock_usage.py - tests/mock_vws/test_flask_app_usage.py + - tests/mock_vws/test_vumark_generation_api.py - tests/mock_vws/test_docker.py - README.rst - docs/source/basic-example.rst diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst index b93e5f20b..09e1f4ca9 100644 --- a/docs/source/contributing.rst +++ b/docs/source/contributing.rst @@ -84,10 +84,14 @@ Then, add a database from the `Vuforia Target Manager`_. To find the environment variables to set in the :file:`vuforia_secrets.env` file, visit the Target Database in the `Vuforia Target Manager`_ and view the "Database Access Keys". -Two databases are necessary in order to run all the tests. +Two Cloud databases are necessary in order to run all the Cloud Target tests. One of those must be an inactive project. To create an inactive project, delete the license key associated with a database. +VuMark tests require one VuMark database. +When creating multiple credentials files, the same inactive database and the +same VuMark database can be reused across all files. + Targets sometimes get stuck at the "Processing" stage meaning that they cannot be deleted. When this happens, create a new target database to use for testing. @@ -101,6 +105,8 @@ To create databases without using the browser, use :file:`admin/create_secrets_f $ export EXISTING_SECRETS_FILE=/existing/file/with/inactive/db/creds # You may have to run this a few times, but it is idempotent. $ python admin/create_secrets_files.py + # Each generated file gets its own Cloud database credentials and shares + # one VuMark database credential set. # After creating the secrets, update the encrypted archive: $ tar cvf secrets.tar "${NEW_SECRETS_DIR}" $ gpg \ diff --git a/pyproject.toml b/pyproject.toml index 83b732734..620ab8d6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -448,6 +448,7 @@ ignore_names = [ exclude = [ ".venv" ] ignore_decorators = [ "@pytest.fixture", + "@route", # Flask "@*APP.route", "@*APP.after_request", diff --git a/src/mock_vws/_constants.py b/src/mock_vws/_constants.py index 1f832af1f..d9af993b5 100644 --- a/src/mock_vws/_constants.py +++ b/src/mock_vws/_constants.py @@ -4,6 +4,13 @@ from beartype import beartype +VUMARK_PNG = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00" + b"\x01\x08\x04\x00\x00\x00\xb5\x1c\x0c\x02\x00\x00\x00\x0bIDATx\xdac" + b"\xfc\xff\x1f\x00\x03\x03\x02\x00\xee\xd9\x97\xa9\x00\x00\x00\x00IEND" + b"\xaeB`\x82" +) + @beartype @unique diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py index 00ae09a12..99b8c3b8e 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -18,7 +18,7 @@ from flask import Flask, Response, request from pydantic_settings import BaseSettings -from mock_vws._constants import ResultCodes, TargetStatuses +from mock_vws._constants import VUMARK_PNG, ResultCodes, TargetStatuses from mock_vws._database_matchers import get_database_matching_server_keys from mock_vws._mock_common import json_dump from mock_vws._services_validators import run_services_validators @@ -338,6 +338,37 @@ def delete_target(target_id: str) -> Response: ) +@VWS_FLASK_APP.route( + rule="/targets//instances", + methods=[HTTPMethod.POST], +) +@beartype +def generate_vumark_instance(target_id: str) -> Response: + """Generate a VuMark instance. + + Fake implementation of + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#generate-instance + """ + # ``target_id`` is validated by request validators. + del target_id + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) + headers = { + "Connection": "keep-alive", + "Content-Type": "image/png", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + return Response( + status=HTTPStatus.OK, + response=VUMARK_PNG, + headers=headers, + ) + + @VWS_FLASK_APP.route(rule="/summary", methods=[HTTPMethod.GET]) @beartype def database_summary() -> Response: diff --git a/src/mock_vws/_requests_mock_server/decorators.py b/src/mock_vws/_requests_mock_server/decorators.py index a613fb53f..e8832b726 100644 --- a/src/mock_vws/_requests_mock_server/decorators.py +++ b/src/mock_vws/_requests_mock_server/decorators.py @@ -26,7 +26,7 @@ from .mock_web_query_api import MockVuforiaWebQueryAPI from .mock_web_services_api import MockVuforiaWebServicesAPI -_ResponseType = tuple[int, Mapping[str, str], str] +_ResponseType = tuple[int, Mapping[str, str], str | bytes] _Callback = Callable[[PreparedRequest], _ResponseType] _STRUCTURAL_SIMILARITY_MATCHER = StructuralSimilarityMatcher() diff --git a/src/mock_vws/_requests_mock_server/mock_web_services_api.py b/src/mock_vws/_requests_mock_server/mock_web_services_api.py index ddee6ef03..303d4a2b4 100644 --- a/src/mock_vws/_requests_mock_server/mock_web_services_api.py +++ b/src/mock_vws/_requests_mock_server/mock_web_services_api.py @@ -18,7 +18,7 @@ from beartype import BeartypeConf, beartype from requests.models import PreparedRequest -from mock_vws._constants import ResultCodes, TargetStatuses +from mock_vws._constants import VUMARK_PNG, ResultCodes, TargetStatuses from mock_vws._database_matchers import get_database_matching_server_keys from mock_vws._mock_common import Route, json_dump from mock_vws._services_validators import run_services_validators @@ -38,7 +38,7 @@ _ROUTES: set[Route] = set() -_ResponseType = tuple[int, Mapping[str, str], str] +_ResponseType = tuple[int, Mapping[str, str], str | bytes] _P = ParamSpec("_P") @@ -287,6 +287,39 @@ def delete_target(self, request: PreparedRequest) -> _ResponseType: } return HTTPStatus.OK, headers, body_json + @route( + path_pattern=f"/targets/{_TARGET_ID_PATTERN}/instances", + http_methods={HTTPMethod.POST}, + ) + def generate_vumark_instance( + self, request: PreparedRequest + ) -> _ResponseType: + """Generate a VuMark instance.""" + run_services_validators( + request_headers=request.headers, + request_body=_body_bytes(request=request), + request_method=request.method or "", + request_path=request.path_url, + databases=self._target_manager.databases, + ) + + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + headers = { + "Connection": "keep-alive", + "Content-Type": "image/png", + "Date": date, + "server": "envoy", + "x-envoy-upstream-service-time": "5", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + return HTTPStatus.OK, headers, VUMARK_PNG + @route(path_pattern="/summary", http_methods={HTTPMethod.GET}) def database_summary(self, request: PreparedRequest) -> _ResponseType: """Get a database summary report. diff --git a/src/mock_vws/_services_validators/key_validators.py b/src/mock_vws/_services_validators/key_validators.py index b07533fe0..cf4d32fa4 100644 --- a/src/mock_vws/_services_validators/key_validators.py +++ b/src/mock_vws/_services_validators/key_validators.py @@ -114,6 +114,13 @@ def validate_keys( }, ) + generate_instance = _Route( + path_pattern=f"/targets/{target_id_pattern}/instances", + http_methods={HTTPMethod.POST}, + mandatory_keys={"instance_id"}, + optional_keys=set(), + ) + target_summary = _Route( path_pattern=f"/summary/{target_id_pattern}", http_methods={HTTPMethod.GET}, @@ -129,6 +136,7 @@ def validate_keys( get_target, get_duplicates, update_target, + generate_instance, target_summary, ) diff --git a/src/mock_vws/_services_validators/target_validators.py b/src/mock_vws/_services_validators/target_validators.py index 4dbee04b1..aedfa511e 100644 --- a/src/mock_vws/_services_validators/target_validators.py +++ b/src/mock_vws/_services_validators/target_validators.py @@ -10,6 +10,7 @@ from mock_vws.database import VuforiaDatabase _LOGGER = logging.getLogger(name=__name__) +_TARGETS_WITH_INSTANCE_PATH_LENGTH = 4 @beartype @@ -42,6 +43,12 @@ def validate_target_id_exists( return target_id = split_path[-1] + if ( + len(split_path) == _TARGETS_WITH_INSTANCE_PATH_LENGTH + and split_path[-3] == "targets" + and split_path[-1] == "instances" + ): + target_id = split_path[-2] database = get_database_matching_server_keys( request_headers=request_headers, request_body=request_body, diff --git a/tests/mock_vws/fixtures/credentials.py b/tests/mock_vws/fixtures/credentials.py index 90fe125b4..91069d473 100644 --- a/tests/mock_vws/fixtures/credentials.py +++ b/tests/mock_vws/fixtures/credentials.py @@ -1,5 +1,6 @@ """Fixtures for credentials for Vuforia databases.""" +from dataclasses import dataclass, field from pathlib import Path import pytest @@ -35,6 +36,31 @@ class _InactiveVuforiaDatabaseSettings(_VuforiaDatabaseSettings): ) +class _VuMarkVuforiaDatabaseSettings(BaseSettings): + """Settings for a VuMark Vuforia database.""" + + target_manager_database_name: str + server_access_key: str + server_secret_key: str + target_id: str = "MockVuMarkTargetID00" + + model_config = SettingsConfigDict( + env_prefix="VUMARK_VUFORIA_", + env_file=Path("vuforia_secrets.env"), + extra="allow", + ) + + +@dataclass(frozen=True) +class VuMarkVuforiaDatabase: + """Credentials for the VuMark generation API.""" + + target_manager_database_name: str = field(repr=False) + server_access_key: str = field(repr=False) + server_secret_key: str = field(repr=False) + target_id: str = field(repr=False) + + @pytest.fixture def vuforia_database() -> VuforiaDatabase: """Return VWS credentials from environment variables.""" @@ -64,3 +90,16 @@ def inactive_database() -> VuforiaDatabase: client_secret_key=settings.client_secret_key, state=States.PROJECT_INACTIVE, ) + + +@pytest.fixture +def vumark_vuforia_database() -> VuMarkVuforiaDatabase: + """Return VuMark VWS credentials from environment variables.""" + settings = _VuMarkVuforiaDatabaseSettings.model_validate(obj={}) + + return VuMarkVuforiaDatabase( + target_manager_database_name=settings.target_manager_database_name, + server_access_key=settings.server_access_key, + server_secret_key=settings.server_secret_key, + target_id=settings.target_id, + ) diff --git a/tests/mock_vws/fixtures/vuforia_backends.py b/tests/mock_vws/fixtures/vuforia_backends.py index 39013b5b9..c0e95b08c 100644 --- a/tests/mock_vws/fixtures/vuforia_backends.py +++ b/tests/mock_vws/fixtures/vuforia_backends.py @@ -21,6 +21,10 @@ from mock_vws._flask_server.vws import VWS_FLASK_APP from mock_vws.database import VuforiaDatabase from mock_vws.states import States +from mock_vws.target import Target +from mock_vws.target_raters import HardcodedTargetTrackingRater +from tests.mock_vws.fixtures.credentials import VuMarkVuforiaDatabase +from tests.mock_vws.utils import make_image_file from tests.mock_vws.utils.retries import RETRY_ON_TOO_MANY_REQUESTS LOGGER = logging.getLogger(name=__name__) @@ -57,16 +61,47 @@ def _delete_all_targets(*, database_keys: VuforiaDatabase) -> None: vws_client.delete_target(target_id=target) +@beartype +def _vumark_database( + *, + vumark_vuforia_database: VuMarkVuforiaDatabase, +) -> VuforiaDatabase: + """Return a database with a target for VuMark instance generation.""" + vumark_target = Target( + active_flag=True, + application_metadata=None, + image_value=make_image_file( + file_format="PNG", + color_space="RGB", + width=8, + height=8, + ).getvalue(), + name="mock-vumark-target", + processing_time_seconds=0, + width=1, + target_tracking_rater=HardcodedTargetTrackingRater(rating=5), + target_id=vumark_vuforia_database.target_id, + ) + return VuforiaDatabase( + database_name=vumark_vuforia_database.target_manager_database_name, + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + targets={vumark_target}, + ) + + @beartype def _enable_use_real_vuforia( *, working_database: VuforiaDatabase, inactive_database: VuforiaDatabase, + vumark_vuforia_database: VuMarkVuforiaDatabase, monkeypatch: pytest.MonkeyPatch, ) -> Generator[None]: """Test against the real Vuforia.""" assert monkeypatch assert inactive_database + assert vumark_vuforia_database _delete_all_targets(database_keys=working_database) yield @@ -76,6 +111,7 @@ def _enable_use_mock_vuforia( *, working_database: VuforiaDatabase, inactive_database: VuforiaDatabase, + vumark_vuforia_database: VuMarkVuforiaDatabase, monkeypatch: pytest.MonkeyPatch, ) -> Generator[None]: """Test against the in-memory mock Vuforia.""" @@ -96,10 +132,14 @@ def _enable_use_mock_vuforia( client_access_key=inactive_database.client_access_key, client_secret_key=inactive_database.client_secret_key, ) + vumark_database = _vumark_database( + vumark_vuforia_database=vumark_vuforia_database, + ) with MockVWS() as mock: mock.add_database(database=working_database) mock.add_database(database=inactive_database) + mock.add_database(database=vumark_database) yield @@ -108,6 +148,7 @@ def _enable_use_docker_in_memory( *, working_database: VuforiaDatabase, inactive_database: VuforiaDatabase, + vumark_vuforia_database: VuMarkVuforiaDatabase, monkeypatch: pytest.MonkeyPatch, ) -> Generator[None]: """Test against mock Vuforia created to be run in a container.""" @@ -131,6 +172,10 @@ def _enable_use_docker_in_memory( name="TARGET_MANAGER_BASE_URL", value=target_manager_base_url, ) + vumark_database = _vumark_database( + vumark_vuforia_database=vumark_vuforia_database, + ) + (vumark_target,) = vumark_database.targets with responses.RequestsMock(assert_all_requests_are_fired=False) as mock: add_flask_app_to_mock( @@ -170,6 +215,16 @@ def _enable_use_docker_in_memory( json=inactive_database.to_dict(), timeout=30, ) + requests.post( + url=databases_url, + json=vumark_database.to_dict(), + timeout=30, + ) + requests.post( + url=(f"{databases_url}/{vumark_database.database_name}/targets"), + json=vumark_target.to_dict(), + timeout=30, + ) yield @@ -233,6 +288,7 @@ def fixture_verify_mock_vuforia( request: pytest.FixtureRequest, vuforia_database: VuforiaDatabase, inactive_database: VuforiaDatabase, + vumark_vuforia_database: VuMarkVuforiaDatabase, monkeypatch: pytest.MonkeyPatch, ) -> Generator[None]: """Test functions which use this fixture are run multiple times. Once @@ -260,6 +316,7 @@ def fixture_verify_mock_vuforia( yield from enable_function( working_database=vuforia_database, inactive_database=inactive_database, + vumark_vuforia_database=vumark_vuforia_database, monkeypatch=monkeypatch, ) @@ -277,6 +334,7 @@ def mock_only_vuforia( request: pytest.FixtureRequest, vuforia_database: VuforiaDatabase, inactive_database: VuforiaDatabase, + vumark_vuforia_database: VuMarkVuforiaDatabase, monkeypatch: pytest.MonkeyPatch, ) -> Generator[None]: """Test functions which use this fixture are run multiple times. Once @@ -304,5 +362,6 @@ def mock_only_vuforia( yield from enable_function( working_database=vuforia_database, inactive_database=inactive_database, + vumark_vuforia_database=vumark_vuforia_database, monkeypatch=monkeypatch, ) diff --git a/tests/mock_vws/test_target_validators.py b/tests/mock_vws/test_target_validators.py new file mode 100644 index 000000000..04b147422 --- /dev/null +++ b/tests/mock_vws/test_target_validators.py @@ -0,0 +1,86 @@ +"""Tests for target ID validators.""" + +from collections.abc import Iterable, Mapping +from functools import partial + +import pytest + +from mock_vws._services_validators import target_validators +from mock_vws._services_validators.target_validators import ( + validate_target_id_exists, +) +from mock_vws.database import VuforiaDatabase +from mock_vws.target import Target +from mock_vws.target_raters import HardcodedTargetTrackingRater +from tests.mock_vws.utils import make_image_file + + +def _database_with_target(*, target_id: str) -> VuforiaDatabase: + """Create a database containing one target with the given ID.""" + target = Target( + active_flag=True, + application_metadata=None, + image_value=make_image_file( + file_format="PNG", + color_space="RGB", + width=8, + height=8, + ).getvalue(), + name="example", + processing_time_seconds=0, + target_id=target_id, + target_tracking_rater=HardcodedTargetTrackingRater(rating=5), + width=1, + ) + return VuforiaDatabase(targets={target}) + + +def _always_match_database( + *, + database: VuforiaDatabase, + request_headers: Mapping[str, str], + request_body: bytes | None, + request_method: str, + request_path: str, + databases: Iterable[VuforiaDatabase], +) -> VuforiaDatabase: + """Return the given database regardless of request details.""" + del request_headers + del request_body + del request_method + del request_path + del databases + return database + + +@pytest.mark.parametrize( + argnames=("request_path", "target_id"), + argvalues=[ + ("/targets/instances", "instances"), + ("/targets/target123/instances", "target123"), + ], +) +def test_validate_target_id_exists_uses_correct_path_segment( + *, + request_path: str, + target_id: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Validation uses the right target segment for both endpoint + shapes. + """ + database = _database_with_target(target_id=target_id) + + monkeypatch.setattr( + target=target_validators, + name="get_database_matching_server_keys", + value=partial(_always_match_database, database=database), + ) + + validate_target_id_exists( + request_path=request_path, + request_headers={}, + request_body=b"", + request_method="GET", + databases={database}, + ) diff --git a/tests/mock_vws/test_vumark_generation_api.py b/tests/mock_vws/test_vumark_generation_api.py new file mode 100644 index 000000000..ac7b2634a --- /dev/null +++ b/tests/mock_vws/test_vumark_generation_api.py @@ -0,0 +1,55 @@ +"""Tests for the VuMark generation web API.""" + +import json +from http import HTTPMethod, HTTPStatus +from uuid import uuid4 + +import pytest +import requests +from vws_auth_tools import authorization_header, rfc_1123_date + +from tests.mock_vws.fixtures.credentials import VuMarkVuforiaDatabase + +_VWS_HOST = "https://vws.vuforia.com" +_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + + +@pytest.mark.usefixtures("verify_mock_vuforia") +def test_generate_instance_success( + vumark_vuforia_database: VuMarkVuforiaDatabase, +) -> None: + """A VuMark instance can be generated with valid template settings.""" + request_path = f"/targets/{vumark_vuforia_database.target_id}/instances" + content_type = "application/json" + generated_instance_id = uuid4().hex + content = json.dumps(obj={"instance_id": generated_instance_id}).encode( + encoding="utf-8" + ) + date = rfc_1123_date() + authorization_string = authorization_header( + access_key=vumark_vuforia_database.server_access_key, + secret_key=vumark_vuforia_database.server_secret_key, + method=HTTPMethod.POST, + content=content, + content_type=content_type, + date=date, + request_path=request_path, + ) + + response = requests.post( + url=_VWS_HOST + request_path, + headers={ + "Accept": "image/png", + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Content-Type": content_type, + "Date": date, + }, + data=content, + timeout=30, + ) + + assert response.status_code == HTTPStatus.OK + assert response.headers["Content-Type"].split(sep=";")[0] == "image/png" + assert response.content.startswith(_PNG_SIGNATURE) + assert len(response.content) > len(_PNG_SIGNATURE) diff --git a/vuforia_secrets.env.example b/vuforia_secrets.env.example index ea7273354..43847306c 100644 --- a/vuforia_secrets.env.example +++ b/vuforia_secrets.env.example @@ -15,7 +15,7 @@ INACTIVE_VUFORIA_CLIENT_ACCESS_KEY= INACTIVE_VUFORIA_CLIENT_SECRET_KEY= VUMARK_VUFORIA_TARGET_MANAGER_DATABASE_NAME= -VUMARK_VUFORIA_TARGET_ID= +VUMARK_VUFORIA_TARGET_ID=MockVuMarkTargetID00 VUMARK_VUFORIA_SERVER_ACCESS_KEY= VUMARK_VUFORIA_SERVER_SECRET_KEY=