From b014c6df4a9965b715702adb8d2acd54b653ad9d Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 16 Aug 2026 01:18:30 +0100 Subject: [PATCH 1/6] Refresh Vuforia credentials and Model Target scopes --- admin/create_secrets_files.py | 12 +++++++++++- secrets.tar.gpg | Bin 22676 -> 21283 bytes 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/admin/create_secrets_files.py b/admin/create_secrets_files.py index b96759997..474eaeafb 100644 --- a/admin/create_secrets_files.py +++ b/admin/create_secrets_files.py @@ -22,6 +22,13 @@ name="vumark_template.svg", ) +MODEL_TARGET_WEB_API_SCOPES = ( + "modeltargets.standardmodeltarget.all", + "modeltargets.advancedmodeltarget.all", + "modeltargets.statebasedmodeltarget.all", + "modeltargets.advancedstatebasedmodeltarget.all", +) + def _create_and_get_cloud_database_details( driver: WebDriver, @@ -216,7 +223,10 @@ def _get_model_target_web_api_details( password=password, ) vws_web_tools.wait_for_logged_in(driver=driver) - return vws_web_tools.get_model_target_web_api_details(driver=driver) + return vws_web_tools.get_model_target_web_api_details( + driver=driver, + scopes=MODEL_TARGET_WEB_API_SCOPES, + ) def _create_vuforia_resource_names() -> tuple[str, str, str, str]: diff --git a/secrets.tar.gpg b/secrets.tar.gpg index 3dd97f72bb38a5f79e4194f8b15916cb980bbce2..83060a86d94c08b444a292718bb3eb0802ed7330 100644 GIT binary patch literal 21283 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 T$#YY-(v}`virLzvGa4r7o7CWd From b563750c85fef85a79ad602c38f1dd30debf7294 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 16 Aug 2026 09:27:08 +0100 Subject: [PATCH 2/6] Complete Model Target API fidelity --- admin/create_secrets_files.py | 6 + docs/source/differences-to-vws.rst | 34 +- secrets.tar.gpg | Bin 21283 -> 21525 bytes src/mock_vws/_flask_server/target_manager.py | 53 +- src/mock_vws/_flask_server/vws.py | 120 +++- src/mock_vws/_model_target_web_api.py | 653 ++++++++++++++--- .../mock_web_services_api.py | 69 +- src/mock_vws/model_target.py | 10 + src/mock_vws/target_manager.py | 19 +- tests/mock_vws/fixtures/credentials.py | 6 + .../model_target_prepared_requests.py | 16 +- tests/mock_vws/test_flask_app_usage.py | 2 +- tests/mock_vws/test_model_target_web_api.py | 654 ++++++++++++++---- tests/mock_vws/test_requests_mock_usage.py | 8 +- vuforia_secrets.env.example | 2 + 15 files changed, 1387 insertions(+), 265 deletions(-) diff --git a/admin/create_secrets_files.py b/admin/create_secrets_files.py index 474eaeafb..4ad3e3cd8 100644 --- a/admin/create_secrets_files.py +++ b/admin/create_secrets_files.py @@ -90,6 +90,8 @@ def _generate_secrets_file_content( inactive_vumark_details: VuMarkDatabaseDict, vumark_target_id: str, model_target_web_api_details: ModelTargetWebAPIDict, + model_target_username: str, + model_target_password: str, ) -> str: """Generate the content of a secrets file.""" return textwrap.dedent( @@ -119,6 +121,8 @@ def _generate_secrets_file_content( MODEL_TARGET_VUFORIA_CLIENT_ID={model_target_web_api_details["client_id"]} MODEL_TARGET_VUFORIA_CLIENT_SECRET={model_target_web_api_details["client_secret"]} MODEL_TARGET_VUFORIA_CAD_DATA_URL={model_target_web_api_details["cad_data_url"]} + MODEL_TARGET_VUFORIA_USERNAME={model_target_username} + MODEL_TARGET_VUFORIA_PASSWORD={model_target_password} """, ) @@ -336,6 +340,8 @@ def main() -> None: inactive_vumark_details=inactive_vumark_details, vumark_target_id=vumark_target_id, model_target_web_api_details=model_target_web_api_details, + model_target_username=email_address, + model_target_password=password, ) file.write_text(data=file_contents) sys.stdout.write(f"Created database {file.name}\n") diff --git a/docs/source/differences-to-vws.rst b/docs/source/differences-to-vws.rst index 3d2b22ec2..7bb8390e0 100644 --- a/docs/source/differences-to-vws.rst +++ b/docs/source/differences-to-vws.rst @@ -228,7 +228,10 @@ Model Target datasets --------------------- The Model Target Web API mock supports OAuth2 token requests, standard and advanced dataset creation, status polling, dataset downloads, and deletion. -The generated dataset download is a small valid zip file containing request metadata, not a real Vuforia Engine Model Target dataset. +The generated dataset download is a small valid ``full-dataset.zip`` with the +same ``MTDataset.dat`` and ``MTDataset.xml`` filenames as Vuforia. Its contents +are synthetic request metadata and minimal XML, not a real Vuforia Engine +Model Target dataset. Use :paramref:`mock_vws.MockVWS.model_target_generation_failure` to make in-process Model Target datasets finish with a ``failed`` status and an ``error`` object. The failure is returned after the configured @@ -245,14 +248,20 @@ base64url-encoded signature, such as the token returned by the mock OAuth2 route. The mock does not verify token signatures, payload claims such as expiry, or token revocation. +The OAuth2 route supports both the ``client_credentials`` and ``password`` +grants. Tokens returned by the mock contain explicit scopes, and +standard and advanced dataset routes require their corresponding Model Target +scope. A token carrying ``modeltargets.all`` can access both route families. +The OAuth2 client-credentials management routes support creating, listing, +updating and deleting credentials, including Vuforia's limit of 100 created +credentials per account. Dataset creation request bodies which are valid JSON but not JSON objects are reported as missing every required top-level field. Dataset creation request bodies which cannot be decoded as UTF-8 are reported as invalid JSON, as malformed JSON bodies are. An OAuth2 token request body which cannot be decoded as UTF-8 is treated as one -which does not name a grant type; the real response to such a body has not been -observed. +which does not name a grant type. Dataset creation requests are validated for the required top-level ``models``, ``name`` and ``targetSdk`` fields, for those fields' types, for each ``models`` entry being a JSON object, and for the number of models. @@ -272,7 +281,10 @@ advanced datasets; the OpenAPI specification does not document it as a standard dataset model field, so standard dataset creation does not validate it. Each ``views`` entry is validated for being a JSON object, for the required -``guideViewPosition`` and ``name`` fields, and for those fields' types. +``name`` field, and for the types of ``name`` and the optional +``guideViewPosition`` field. +State-Based Model Target views require ``guideViewPosition``, matching real +Vuforia. An optional ``states`` field must be an array of strings. Each named state must be declared by the model's ``stateBasedConfigurationJsonString``. Omitting the field makes the view available to every configured state. @@ -290,21 +302,19 @@ It also does not validate the state configuration beyond its top-level For unknown Model Target datasets, the mock returns an error whose ``target`` is ``userId:mock``. Real Vuforia uses ``userId:`` where the numeric portion is per-account. -Standard and advanced datasets are separate resources. -A dataset created through the standard routes is not visible to the advanced routes, and the other way around: the mock returns the unknown-dataset error for status, download and delete requests made through the other dataset type's routes. -Real Vuforia separates these by OAuth scope as well, which the mock does not model, so a client which lacks the advanced-dataset scope may see a different error. +Standard and advanced routes share datasets by UUID. Access to each route +family is separated by its corresponding OAuth scope. -Some Model Target Web API paths remain mock-only in ``tests/mock_vws/test_model_target_web_api.py::TestMockOnlyErrors``. +Some Model Target Web API paths remain mock-only in +``tests/mock_vws/test_model_target_web_api.py::TestAdditionalBehaviors``. Downloads of still-processing datasets are mock-only because exercising the path against real Vuforia would require creating a dataset on every test run; the mock drives the processing window deterministically. A download request for a dataset which is not ready reports the dataset's training status. The mock reports ``not-started`` for the whole processing window, as real Vuforia does for a dataset which was just created, and ``failed`` for a dataset whose generation failed. The name which real Vuforia reports for a failed dataset has not been observed. -Advanced-dataset creation with more than 20 models is mock-only because the available test account lacks the advanced-dataset scope and real Vuforia rejects the request with a 403 before validating model counts. -Cross-dataset-type access is mock-only for the same reason. -State-Based Model Target creation and validation are also mock-only because the -available test account lacks the State-Based Model Target scopes. +Some malformed State-Based Model Target configuration documents remain +mock-only because real Vuforia returns an internal server error for them. Reco counts reports ------------------- diff --git a/secrets.tar.gpg b/secrets.tar.gpg index 83060a86d94c08b444a292718bb3eb0802ed7330..a9b4a2db4d79b624e7a28829dc640d334354bc0b 100644 GIT binary patch literal 21525 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

s|j=Hj#B^AJCTseigw3!ASsvy`0Vupb<8v; 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 MM}OzPrD{TZ!cU9dUjP6A literal 21283 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 en&$IqdzC-qe%J7I{2sRM7APEfLS9w Response: return Response(response="", status=HTTPStatus.OK) +@TARGET_MANAGER_FLASK_APP.route( + rule="/oauth2_client_credentials", + methods=[HTTPMethod.GET], +) +@beartype +def get_oauth2_client_credentials() -> Response: + """Return all OAuth2 client credentials.""" + credentials = [ + { + "client_id": credential.client_id, + "client_secret": credential.client_secret, + "scopes": list(credential.scopes), + } + for credential in TARGET_MANAGER.oauth2_client_credentials.values() + ] + return Response( + response=json.dumps(obj=credentials), + status=HTTPStatus.OK, + ) + + +@TARGET_MANAGER_FLASK_APP.route( + rule="/oauth2_client_credentials", + methods=[HTTPMethod.POST], +) +@beartype +def put_oauth2_client_credential() -> Response: + """Add or replace an OAuth2 client credential.""" + value = json.loads(s=request.data) + credential = OAuth2ClientCredential( + client_id=value["client_id"], + client_secret=value["client_secret"], + scopes=tuple(value["scopes"]), + ) + TARGET_MANAGER.add_oauth2_client_credential(credential=credential) + return Response(response="", status=HTTPStatus.NO_CONTENT) + + +@TARGET_MANAGER_FLASK_APP.route( + rule="/oauth2_client_credentials/", + methods=[HTTPMethod.DELETE], +) +@beartype +def remove_oauth2_client_credential(client_id: str) -> Response: + """Remove an OAuth2 client credential.""" + if client_id not in TARGET_MANAGER.oauth2_client_credentials: + return Response(response="", status=HTTPStatus.NOT_FOUND) + TARGET_MANAGER.remove_oauth2_client_credential(client_id=client_id) + return Response(response="", status=HTTPStatus.NO_CONTENT) + + @TARGET_MANAGER_FLASK_APP.route( rule="/cloud_databases//targets", methods=[HTTPMethod.POST], diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py index db7da0c61..4be204dbb 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -35,9 +35,21 @@ download_model_target_dataset, get_model_target_dataset_status, ) +from mock_vws._model_target_web_api import ( + create_oauth2_client_credential as model_target_create_oauth2_credential, +) +from mock_vws._model_target_web_api import ( + delete_oauth2_client_credential as model_target_delete_oauth2_credential, +) +from mock_vws._model_target_web_api import ( + list_oauth2_client_credentials as model_target_list_oauth2_credentials, +) from mock_vws._model_target_web_api import ( oauth2_token as model_target_oauth2_token, ) +from mock_vws._model_target_web_api import ( + update_oauth2_client_credential_scopes as update_oauth2_scopes, +) from mock_vws._reco_counts_web_api import create_reco_counts_report from mock_vws._reco_counts_web_api import ( download_reco_counts_report as download_report, @@ -60,7 +72,11 @@ ImageMatcher, StructuralSimilarityMatcher, ) -from mock_vws.model_target import ModelTargetDataset, ModelTargetDatasetType +from mock_vws.model_target import ( + ModelTargetDataset, + ModelTargetDatasetType, + OAuth2ClientCredential, +) from mock_vws.reco_counts import RecoCountsReport from mock_vws.target import ImageTarget from mock_vws.target_raters import ( @@ -167,6 +183,7 @@ def __init__(self, *, base_url: str) -> None: base_url: The base URL of the target manager service. """ self._datasets_url = f"{base_url}/model_target_datasets" + self._credentials_url = f"{base_url}/oauth2_client_credentials" @property def model_target_datasets(self) -> dict[str, ModelTargetDataset]: @@ -202,6 +219,42 @@ def remove_model_target_dataset(self, dataset_uuid: str) -> None: timeout=timeout_seconds, ) + @property + def oauth2_client_credentials(self) -> dict[str, OAuth2ClientCredential]: + """All dynamically created OAuth2 client credentials.""" + response = requests.get(url=self._credentials_url, timeout=30) + credentials = ( + OAuth2ClientCredential( + client_id=value["client_id"], + client_secret=value["client_secret"], + scopes=tuple(value["scopes"]), + ) + for value in response.json() + ) + return {credential.client_id: credential for credential in credentials} + + def add_oauth2_client_credential( + self, + credential: OAuth2ClientCredential, + ) -> None: + """Add or replace an OAuth2 client credential.""" + requests.post( + url=self._credentials_url, + json={ + "client_id": credential.client_id, + "client_secret": credential.client_secret, + "scopes": list(credential.scopes), + }, + timeout=30, + ) + + def remove_oauth2_client_credential(self, client_id: str) -> None: + """Remove an OAuth2 client credential.""" + requests.delete( + url=f"{self._credentials_url}/{client_id}", + timeout=30, + ) + @beartype def _model_target_dataset_store() -> _HTTPModelTargetDatasetStore: @@ -301,7 +354,7 @@ def validate_request() -> None: if request.endpoint == "generate_vumark_instance": return if ( - request.path == "/oauth2/token" + request.path.startswith("/oauth2/") or request.path.startswith("/modeltargets/") or request.path.startswith("/reports/recoCounts/") ): @@ -364,6 +417,69 @@ def oauth2_token() -> Response: return _to_flask_response( api_response=model_target_oauth2_token( request=_flask_request_data(), + credential_store=_model_target_dataset_store(), + ), + ) + + +@VWS_FLASK_APP.route( + rule="/oauth2/clientcredentials", + methods=[HTTPMethod.POST], +) +@beartype +def create_oauth2_client_credential() -> Response: + """Create an OAuth2 client credential.""" + return _to_flask_response( + api_response=model_target_create_oauth2_credential( + request=_flask_request_data(), + credential_store=_model_target_dataset_store(), + ), + ) + + +@VWS_FLASK_APP.route( + rule="/oauth2/clientcredentials", + methods=[HTTPMethod.GET], +) +@beartype +def list_oauth2_client_credentials() -> Response: + """List OAuth2 client credentials.""" + return _to_flask_response( + api_response=model_target_list_oauth2_credentials( + request=_flask_request_data(), + credential_store=_model_target_dataset_store(), + ), + ) + + +@VWS_FLASK_APP.route( + rule="/oauth2/clientcredentials//scopes", + methods=[HTTPMethod.PUT], +) +@beartype +def update_oauth2_client_credential_scopes(client_id: str) -> Response: + """Update an OAuth2 client credential's scopes.""" + return _to_flask_response( + api_response=update_oauth2_scopes( + request=_flask_request_data(), + credential_store=_model_target_dataset_store(), + client_id=client_id, + ), + ) + + +@VWS_FLASK_APP.route( + rule="/oauth2/clientcredentials/", + methods=[HTTPMethod.DELETE], +) +@beartype +def delete_oauth2_client_credential(client_id: str) -> Response: + """Delete an OAuth2 client credential.""" + return _to_flask_response( + api_response=model_target_delete_oauth2_credential( + request=_flask_request_data(), + credential_store=_model_target_dataset_store(), + client_id=client_id, ), ) diff --git a/src/mock_vws/_model_target_web_api.py b/src/mock_vws/_model_target_web_api.py index b097a6162..7e9511084 100644 --- a/src/mock_vws/_model_target_web_api.py +++ b/src/mock_vws/_model_target_web_api.py @@ -3,6 +3,7 @@ import base64 import io import json +import secrets import uuid import zipfile from http import HTTPStatus @@ -20,6 +21,7 @@ ModelTargetDatasetType, ModelTargetGenerationFailure, ModelTargetGenerationWarning, + OAuth2ClientCredential, ) _ResponseType = tuple[int, dict[str, str], str | bytes] @@ -51,12 +53,41 @@ def remove_model_target_dataset(self, dataset_uuid: str) -> None: # for pyright to recognize this as a protocol. ... # pylint: disable=unnecessary-ellipsis + @property + def oauth2_client_credentials(self) -> dict[str, OAuth2ClientCredential]: + """All dynamically created OAuth2 client credentials.""" + ... # pylint: disable=unnecessary-ellipsis + + def add_oauth2_client_credential( + self, + credential: OAuth2ClientCredential, + ) -> None: + """Add an OAuth2 client credential.""" + ... # pylint: disable=unnecessary-ellipsis + + def remove_oauth2_client_credential(self, client_id: str) -> None: + """Remove an OAuth2 client credential.""" + ... # pylint: disable=unnecessary-ellipsis + _MAX_ADVANCED_MODEL_COUNT = 20 _JWT_DOT_COUNT = 2 _ZIP_EPOCH = (1980, 1, 1, 0, 0, 0) _MOCK_MODEL_TARGET_CLIENT_ID = "client-id" _MOCK_MODEL_TARGET_CLIENT_SECRET = "client-secret" # noqa: S105 +_MOCK_MODEL_TARGET_USERNAME = "user@example.com" +_MOCK_MODEL_TARGET_PASSWORD = "password" # noqa: S105 +_MODEL_TARGET_SCOPES = frozenset( + { + "modeltargets.all", + "modeltargets.standardmodeltarget.all", + "modeltargets.advancedmodeltarget.all", + "modeltargets.statebasedmodeltarget.all", + "modeltargets.advancedstatebasedmodeltarget.all", + }, +) +_CLIENT_CREDENTIALS_SCOPE = "oauth2.clientcredentials.all" +_MAX_CLIENT_CREDENTIALS = 100 # A stable mock value standing in for the user-id segment that real # Vuforia embeds in some Model Target error targets such as # ``userId:7635391``. The numeric portion is per-account in real Vuforia; @@ -69,10 +100,13 @@ def remove_model_target_dataset(self, dataset_uuid: str) -> None: "cadDataFormat": frozenset( { "DAE", + "DRC_GLB", + "DRC_GLTF", "FBX", "GLB", "IGES", "OBJ", + "PVS", "PVZ", "STL", "VRML", @@ -298,7 +332,28 @@ def _jwt_signature_error(*, bearer_token: str) -> str | None: @beartype -def _require_bearer_token(request: RequestData) -> _ResponseType | None: +def _jwt_scopes(*, bearer_token: str) -> frozenset[str]: + """Return the scopes carried by a valid mock JWT.""" + encoded_payload = bearer_token.split(sep=".")[1] + padding = "=" * (-len(encoded_payload) % 4) + payload = json.loads( + s=base64.b64decode( + s=encoded_payload + padding, + altchars=b"-_", + validate=True, + ), + ) + scope = payload.get("scope", "") + if not isinstance(scope, str): + return frozenset() + return frozenset(scope.split()) + + +@beartype +def _require_bearer_token( + request: RequestData, + dataset_type: ModelTargetDatasetType, +) -> _ResponseType | None: """Return an error response if the request has no bearer token.""" auth_header = _get_header(request=request, name="Authorization") if auth_header is None or not auth_header.startswith("Bearer "): @@ -339,11 +394,53 @@ def _require_bearer_token(request: RequestData) -> _ResponseType | None: target="jwt", details=None, ) + required_scope = ( + "modeltargets.standardmodeltarget.all" + if dataset_type == ModelTargetDatasetType.STANDARD + else "modeltargets.advancedmodeltarget.all" + ) + scopes = _jwt_scopes(bearer_token=bearer_token) + if required_scope not in scopes and "modeltargets.all" not in scopes: + body = "User does not have the required scopes to perform this action" + return ( + HTTPStatus.FORBIDDEN, + { + "Content-Length": str(object=len(body)), + "Content-Type": "text/plain", + }, + body, + ) return None @beartype -def _fake_jwt(*, token_source: bytes) -> str: +def _require_state_based_scope( + request: RequestData, + dataset_type: ModelTargetDatasetType, +) -> _ResponseType | None: + """Return an error when a token lacks the State-Based MT scope.""" + auth_header = _get_header(request=request, name="Authorization") + assert auth_header is not None # noqa: S101 + bearer_token = auth_header.removeprefix("Bearer ").strip() + required_scope = ( + "modeltargets.statebasedmodeltarget.all" + if dataset_type == ModelTargetDatasetType.STANDARD + else "modeltargets.advancedstatebasedmodeltarget.all" + ) + scopes = _jwt_scopes(bearer_token=bearer_token) + if required_scope in scopes or "modeltargets.all" in scopes: + return None + return _error_response( + status_code=HTTPStatus.FORBIDDEN, + code="ERROR", + message="User not allowed to create State Based Model Targets", + target=_MOCK_USER_TARGET, + details=None, + ) + + +@beartype +def _fake_jwt(*, token_source: bytes, scopes: frozenset[str]) -> str: """Return a deterministic bearer token for the mock.""" def encode_part(value: dict[str, Any]) -> str: @@ -370,13 +467,18 @@ def encode_part(value: dict[str, Any]) -> str: encoding="ascii", ) .rstrip("="), + "scope": " ".join(sorted(scopes)), }, ) return f"{header}.{payload}.mock-signature" @beartype -def oauth2_token(request: RequestData) -> _ResponseType: +def oauth2_token( # noqa: PLR0911 + *, + request: RequestData, + credential_store: ModelTargetDatasetStore, +) -> _ResponseType: """Return a fake OAuth2 access token.""" content_length_error = _content_length_error(request=request) if content_length_error is not None: @@ -390,44 +492,300 @@ def oauth2_token(request: RequestData) -> _ResponseType: qs=request.body.decode(encoding="utf-8", errors="replace"), ) grant_type = form.get("grant_type", ["client_credentials"])[0] - if grant_type != "client_credentials": + if grant_type not in {"client_credentials", "password"}: return _oauth2_error_response( status_code=HTTPStatus.BAD_REQUEST, body={"error": "unsupported_grant_type"}, ) - basic_credentials = _basic_auth_credentials(auth_header=auth_header) - if basic_credentials is None: - return _oauth2_error_response( - status_code=HTTPStatus.UNAUTHORIZED, - body={ - "error": "invalid_request", - "error_description": ( - "Missing or invalid authorization header" - ), - }, - ) + dynamic_credential: OAuth2ClientCredential | None = None + if grant_type == "client_credentials": + basic_credentials = _basic_auth_credentials(auth_header=auth_header) + if basic_credentials is None: + return _oauth2_error_response( + status_code=HTTPStatus.UNAUTHORIZED, + body={ + "error": "invalid_request", + "error_description": ( + "Missing or invalid authorization header" + ), + }, + ) - if basic_credentials != ( - _MOCK_MODEL_TARGET_CLIENT_ID, - _MOCK_MODEL_TARGET_CLIENT_SECRET, - ): - return _oauth2_error_response( - status_code=HTTPStatus.UNAUTHORIZED, - body={"error": "invalid_client"}, + dynamic_credential = credential_store.oauth2_client_credentials.get( + basic_credentials[0], ) + fixed_credential_matches = basic_credentials == ( + _MOCK_MODEL_TARGET_CLIENT_ID, + _MOCK_MODEL_TARGET_CLIENT_SECRET, + ) + dynamic_credential_matches = ( + dynamic_credential is not None + and dynamic_credential.client_secret == basic_credentials[1] + ) + if not fixed_credential_matches and not dynamic_credential_matches: + return _oauth2_error_response( + status_code=HTTPStatus.UNAUTHORIZED, + body={"error": "invalid_client"}, + ) + else: + username = form.get("username", [""])[0] + password = form.get("password", [""])[0] + if not username or not password: + return _oauth2_error_response( + status_code=HTTPStatus.BAD_REQUEST, + body={ + "error": "invalid_request", + "error_description": "Missing username and/or password", + }, + ) + if (username, password) != ( + _MOCK_MODEL_TARGET_USERNAME, + _MOCK_MODEL_TARGET_PASSWORD, + ): + return _oauth2_error_response( + status_code=HTTPStatus.UNAUTHORIZED, + body={ + "error": "invalid_grant", + "error_description": "Invalid username and/or password", + }, + ) token_source = request.body or (auth_header or "").encode() + requested_scope = form.get("scope", [""])[0] + if grant_type == "client_credentials" and dynamic_credential is not None: + credential_scopes = frozenset(dynamic_credential.scopes) + else: + credential_scopes = _MODEL_TARGET_SCOPES | { + _CLIENT_CREDENTIALS_SCOPE, + } + scopes = frozenset(requested_scope.split()) or credential_scopes + if not scopes.issubset(credential_scopes): + return _oauth2_error_response( + status_code=HTTPStatus.BAD_REQUEST, + body={"error": "invalid_scope"}, + ) return _json_response( status_code=HTTPStatus.OK, body={ - "access_token": _fake_jwt(token_source=token_source), + "access_token": _fake_jwt( + token_source=token_source, + scopes=scopes, + ), "token_type": "bearer", "expires_in": 3600, }, ) +@beartype +def _require_client_credentials_scope( + request: RequestData, +) -> _ResponseType | None: + """Require a valid bearer token with the credential-management + scope. + """ + auth_header = _get_header(request=request, name="Authorization") + if auth_header is None or not auth_header.startswith("Bearer "): + return _error_response( + status_code=HTTPStatus.UNAUTHORIZED, + code="401", + message="no Bearer token", + target="jwt", + details=None, + ) + bearer_token = auth_header.removeprefix("Bearer ").strip() + if bearer_token.count(".") != _JWT_DOT_COUNT: + return _error_response( + status_code=HTTPStatus.UNAUTHORIZED, + code="401", + message="Invalid JWT serialization: Missing dot delimiter(s)", + target="jwt", + details=None, + ) + jwt_error = ( + _jwt_header_error(bearer_token=bearer_token) + or _jwt_payload_error(bearer_token=bearer_token) + or _jwt_signature_error(bearer_token=bearer_token) + ) + if jwt_error is not None: + return _error_response( + status_code=HTTPStatus.UNAUTHORIZED, + code="401", + message=jwt_error, + target="jwt", + details=None, + ) + if _CLIENT_CREDENTIALS_SCOPE not in _jwt_scopes( + bearer_token=bearer_token, + ): + body = "User does not have the required scopes to perform this action" + return ( + HTTPStatus.FORBIDDEN, + { + "Content-Length": str(object=len(body)), + "Content-Type": "text/plain", + }, + body, + ) + return None + + +@beartype +def _client_credential_not_found(*, client_id: str) -> _ResponseType: + """Return Vuforia's missing-client-credential response.""" + return _error_response( + status_code=HTTPStatus.NOT_FOUND, + code="NOT_FOUND", + message=f"Clientcredential with ID={client_id} not found", + target="clientcredential", + details=None, + ) + + +@beartype +def _string_list(value: object) -> list[str] | None: + """Return a string list when ``value`` contains only strings.""" + if not isinstance(value, list): + return None + strings: list[str] = [] + for item in value: # pyright: ignore[reportUnknownVariableType] + if not isinstance(item, str): + return None + strings.append(item) + return strings + + +@beartype +def create_oauth2_client_credential( + *, + request: RequestData, + credential_store: ModelTargetDatasetStore, +) -> _ResponseType: + """Create an OAuth2 client credential.""" + auth_error = _require_client_credentials_scope(request=request) + if auth_error is not None: + return auth_error + request_json_or_error = _load_request_json(request=request) + if not isinstance(request_json_or_error, dict): + return request_json_or_error + scopes = _string_list(value=request_json_or_error.get("scopes")) + if scopes is None: + return _validation_error_response( + details=[ + { + "code": "VALIDATION_ERROR", + "message": "/scopes: error.expected.jsarray", + }, + ], + ) + if len(credential_store.oauth2_client_credentials) >= ( + _MAX_CLIENT_CREDENTIALS + ): + return _error_response( + status_code=HTTPStatus.CONFLICT, + code="CONFLICT", + message="Maximum number of client credentials reached", + target="clientcredential", + details=None, + ) + client_id = uuid.uuid4().hex.upper()[:21] + client_secret = secrets.token_urlsafe(nbytes=25)[:33] + credential_store.add_oauth2_client_credential( + credential=OAuth2ClientCredential( + client_id=client_id, + client_secret=client_secret, + scopes=tuple(scopes), + ), + ) + return _json_response( + status_code=HTTPStatus.CREATED, + body={"client_id": client_id, "client_secret": client_secret}, + ) + + +@beartype +def list_oauth2_client_credentials( + *, + request: RequestData, + credential_store: ModelTargetDatasetStore, +) -> _ResponseType: + """List OAuth2 client credentials.""" + auth_error = _require_client_credentials_scope(request=request) + if auth_error is not None: + return auth_error + credentials = [ + {"clientId": credential.client_id, "scopes": list(credential.scopes)} + for credential in credential_store.oauth2_client_credentials.values() + ] + body = json.dumps(obj=credentials, separators=(",", ":")) + return ( + HTTPStatus.OK, + { + "Content-Length": str(object=len(body)), + "Content-Type": "application/json", + }, + body, + ) + + +@beartype +def update_oauth2_client_credential_scopes( + *, + request: RequestData, + credential_store: ModelTargetDatasetStore, + client_id: str, +) -> _ResponseType: + """Replace the scopes assigned to an OAuth2 client credential.""" + auth_error = _require_client_credentials_scope(request=request) + if auth_error is not None: + return auth_error + credential = credential_store.oauth2_client_credentials.get(client_id) + if credential is None: + return _client_credential_not_found(client_id=client_id) + try: + scopes_value: object = json.loads(s=request.body) + except UnicodeDecodeError, json.JSONDecodeError: + scopes_value = None + scopes = _string_list(value=scopes_value) + if scopes is None: + return _error_response( + status_code=HTTPStatus.BAD_REQUEST, + code="BAD_REQUEST", + message="Invalid scopes", + target="scopes", + details=None, + ) + credential_store.add_oauth2_client_credential( + credential=OAuth2ClientCredential( + client_id=credential.client_id, + client_secret=credential.client_secret, + scopes=tuple(scopes), + ), + ) + return _json_response( + status_code=HTTPStatus.OK, + body={"clientId": client_id, "scopes": scopes}, + ) + + +@beartype +def delete_oauth2_client_credential( + *, + request: RequestData, + credential_store: ModelTargetDatasetStore, + client_id: str, +) -> _ResponseType: + """Delete an OAuth2 client credential.""" + auth_error = _require_client_credentials_scope(request=request) + if auth_error is not None: + return auth_error + if client_id not in credential_store.oauth2_client_credentials: + return _client_credential_not_found(client_id=client_id) + credential_store.remove_oauth2_client_credential(client_id=client_id) + return HTTPStatus.NO_CONTENT, {"Content-Length": "0"}, "" + + @beartype def _is_json_object(*, value: object) -> bool: """Return whether a decoded JSON value is an object.""" @@ -472,19 +830,32 @@ def _load_request_json(request: RequestData) -> dict[str, Any] | _ResponseType: def _cad_data_source_details(*, models: list[Any]) -> list[dict[str, str]]: """Return validation details for each model's CAD data source. - One and only one of ``cadDataUrl`` and ``cadDataBlob`` may be given per - model. + One and only one of ``cadDataUrl``, ``cadDataBlob`` and ``cadDataUuid`` + may be given per model. """ return [ { "code": "VALIDATION_ERROR", "message": ( - f"/models({index}): one and only one of cadDataUrl and " - "cadDataBlob is required" + f"model '{model['name']}' is invalid. " + + ( + "One of `cadDataBlob`, `cadDataUrl`, `cadDataUuid` need " + "to be provided" + if not sources + else "Only one of `cadDataBlob`, `cadDataUrl`, " + "`cadDataUuid` need to be provided" + ) ), } - for index, model in enumerate(iterable=models) - if ("cadDataUrl" in model) == ("cadDataBlob" in model) + for model in models + if len( + sources := { + field + for field in ("cadDataBlob", "cadDataUrl", "cadDataUuid") + if field in model + }, + ) + != 1 ] @@ -503,14 +874,18 @@ def _model_field_details( missing_details = [ { "code": "VALIDATION_ERROR", - "message": f"/models({index})/name: element is required", + "message": f"/models({index})/{field}: element is required", } for index, model in enumerate(iterable=models) - if "name" not in model + for field in ("name", "views") + if field not in model ] + if missing_details: + return missing_details + cad_data_source_details = _cad_data_source_details(models=models) - if missing_details or cad_data_source_details: - return missing_details + cad_data_source_details + if cad_data_source_details: + return cad_data_source_details string_fields = sorted( { @@ -533,15 +908,57 @@ def _model_field_details( if string_details: return string_details - enum_details = [ - { - "code": "VALIDATION_ERROR", - "message": f"/models({index})/{field}: error.expected.validenum", - } - for index, model in enumerate(iterable=models) - for field, allowed_values in sorted(enum_field_values.items()) - if field in model and model[field] not in allowed_values - ] + enum_details: list[dict[str, str]] = [] + for index, model in enumerate(iterable=models): + for field, allowed_values in sorted(enum_field_values.items()): + if field in {"motionHint", "trackingMode"}: + continue + if field not in model or model[field] in allowed_values: + continue + value = model[field] + messages = { + "automaticColoring": ( + "invalid automaticColoring. Should be one of 'never', " + f"'always', 'auto'. You provided '{value}'" + ), + "cadDataFormat": ( + "Unrecognized cadDataFormat '" + f"{str(object=value).upper()}'. " + "Allowed values are: ZIP, GLB, DRC_GLB, DRC_GLTF, DAE, " + "FBX, IGES, OBJ, PVS, PVZ, STL, VRML, or specify no " + "cadDataFormat to auto-detect GLB and zipped glTFs." + ), + "optimizeTrackingFor": ( + "`optimizeTrackingFor` must be one of " + "default,low_feature_objects,ar_controller" + ), + "realisticAppearance": ( + '`realisticAppearance` must be one of "true", "false", ' + '"auto".` ' + ), + "simplify": ( + "invalid simplify. Should be one of 'never', 'always', " + f"'auto'. You provided 'Some({value})'" + ), + } + message = messages.get( + field, + f"/models({index})/{field}: error.expected.validenum", + ) + enum_details.append( + {"code": "VALIDATION_ERROR", "message": message}, + ) + if "motionHint" in model or "trackingMode" in model: + enum_details.append( + { + "code": "VALIDATION_ERROR", + "message": ( + "`motionHint` and `trackingMode` are no longer " + "supported when using `targetsSdk` 10.9 or later. " + "Please use the `optimizeTrackingFor` setting instead." + ), + }, + ) views_details = [ { "code": "VALIDATION_ERROR", @@ -585,7 +1002,7 @@ def _view_details(*, models: list[Any]) -> list[dict[str, str]]: ), } for model_index, view_index, view in views - for field in ("guideViewPosition", "name") + for field in ("name",) if field not in view ] if missing_details: @@ -611,7 +1028,8 @@ def _view_details(*, models: list[Any]) -> list[dict[str, str]]: ), } for model_index, view_index, view in views - if not isinstance(view["guideViewPosition"], dict) + if "guideViewPosition" in view + and not isinstance(view["guideViewPosition"], dict) ] return name_details + position_details @@ -636,6 +1054,7 @@ def _guide_view_position_details( (model_index, view_index, view["guideViewPosition"]) for model_index, model in enumerate(iterable=models) for view_index, view in enumerate(iterable=model.get("views", [])) + if "guideViewPosition" in view ] missing_details = [ @@ -796,11 +1215,12 @@ def _state_based_details(*, models: list[Any]) -> list[dict[str, str]]: { "code": "VALIDATION_ERROR", "message": ( - f"/models({model_index})/views({view_index})/states" - f"({state_index}): error.expected.validenum" + "states in entrypoint " + f"{models[model_index]['views'][view_index]['name']}' " + "must be a subset of all states" ), } - for state_index, state in enumerate(iterable=states) + for state in states if state not in configured_states[model_index] ) @@ -824,19 +1244,36 @@ def _model_count_details( }, ] - if ( - dataset_type == ModelTargetDatasetType.ADVANCED - and not 1 <= model_count <= _MAX_ADVANCED_MODEL_COUNT - ): - return [ - { - "code": "VALIDATION_ERROR", - "message": ( - "models must contain between 1 and " - f"{_MAX_ADVANCED_MODEL_COUNT} entries" - ), - }, - ] + if dataset_type == ModelTargetDatasetType.ADVANCED: + details: list[dict[str, str]] = [] + names = [model["name"] for model in models] + if len(set(names)) != len(names): + details.append( + { + "code": "VALIDATION_ERROR", + "message": ( + "names of models must be unique within a Target." + ), + }, + ) + if model_count > _MAX_ADVANCED_MODEL_COUNT: + details.append( + { + "code": "VALIDATION_ERROR", + "message": ( + "total number of models must be maximum " + f"{_MAX_ADVANCED_MODEL_COUNT}" + ), + }, + ) + if model_count == 0: + details.append( + { + "code": "VALIDATION_ERROR", + "message": "models must contain at least one entry", + }, + ) + return details return [] @@ -877,15 +1314,6 @@ def _top_level_details( ) return type_details - models: list[Any] = [*models_value] - type_details.extend( - { - "code": "VALIDATION_ERROR", - "message": f"/models({index}): error.expected.jsobject", - } - for index, model in enumerate(iterable=models) - if not isinstance(model, dict) - ) return type_details @@ -898,7 +1326,18 @@ def _validate_dataset_request( """Validate the dataset request enough for useful mock feedback.""" details = _top_level_details(request_json=request_json) if not details: - models: list[Any] = [*request_json["models"]] + # Vuforia's schema validator reads fields from non-object model and + # view values as though they were empty objects. + models: list[Any] = [ + model if isinstance(model, dict) else {} + for model in request_json["models"] + ] + for model in models: + if isinstance(model.get("views"), list): + model["views"] = [ + view if isinstance(view, dict) else {} + for view in model["views"] + ] details = ( _model_field_details(models=models, dataset_type=dataset_type) or _view_details(models=models) @@ -931,7 +1370,10 @@ def create_model_target_dataset( if content_length_error is not None: return content_length_error - auth_error = _require_bearer_token(request=request) + auth_error = _require_bearer_token( + request=request, + dataset_type=dataset_type, + ) if auth_error is not None: return auth_error @@ -939,6 +1381,20 @@ def create_model_target_dataset( if not isinstance(request_json_or_error, dict): return request_json_or_error + models_value = request_json_or_error.get("models") + is_state_based = isinstance(models_value, list) and any( + isinstance(model, dict) + and "stateBasedConfigurationJsonString" in model + for model in models_value # pyright: ignore[reportUnknownVariableType] + ) + if is_state_based: + state_scope_error = _require_state_based_scope( + request=request, + dataset_type=dataset_type, + ) + if state_scope_error is not None: + return state_scope_error + validation_error = _validate_dataset_request( request_json=request_json_or_error, dataset_type=dataset_type, @@ -979,17 +1435,9 @@ def _find_dataset( *, dataset_store: ModelTargetDatasetStore, dataset_uuid: str, - dataset_type: ModelTargetDatasetType, ) -> ModelTargetDataset | None: - """Return a dataset which belongs to a route's dataset type. - - Standard and advanced datasets are separate resources in real Vuforia, so - a dataset is invisible to the routes of the other dataset type. - """ - dataset = dataset_store.model_target_datasets.get(dataset_uuid) - if dataset is None or dataset.dataset_type != dataset_type: - return None - return dataset + """Return a Model Target dataset by UUID.""" + return dataset_store.model_target_datasets.get(dataset_uuid) @beartype @@ -1005,13 +1453,15 @@ def get_model_target_dataset_status( if content_length_error is not None: return content_length_error - auth_error = _require_bearer_token(request=request) + auth_error = _require_bearer_token( + request=request, + dataset_type=dataset_type, + ) if auth_error is not None: return auth_error dataset = _find_dataset( dataset_store=dataset_store, dataset_uuid=dataset_uuid, - dataset_type=dataset_type, ) if dataset is None: return _unknown_dataset_response(dataset_uuid=dataset_uuid) @@ -1023,15 +1473,15 @@ def get_model_target_dataset_status( @beartype def _dataset_zip_bytes(dataset: ModelTargetDataset) -> bytes: - """Return a small valid zip file for a generated dataset.""" + """Return a deterministic Vuforia-shaped generated dataset zip.""" zip_buffer = io.BytesIO() with zipfile.ZipFile(file=zip_buffer, mode="w") as zip_file: - dataset_file = zipfile.ZipInfo( - filename="dataset.json", + dat_file = zipfile.ZipInfo( + filename="MTDataset.dat", date_time=_ZIP_EPOCH, ) zip_file.writestr( - zinfo_or_arcname=dataset_file, + zinfo_or_arcname=dat_file, data=json.dumps( obj={ "uuid": dataset.uuid_, @@ -1042,6 +1492,18 @@ def _dataset_zip_bytes(dataset: ModelTargetDataset) -> bytes: sort_keys=True, ), ) + xml_file = zipfile.ZipInfo( + filename="MTDataset.xml", + date_time=_ZIP_EPOCH, + ) + zip_file.writestr( + zinfo_or_arcname=xml_file, + data=( + '' + '' + "" + ), + ) return zip_buffer.getvalue() @@ -1058,13 +1520,15 @@ def download_model_target_dataset( if content_length_error is not None: return content_length_error - auth_error = _require_bearer_token(request=request) + auth_error = _require_bearer_token( + request=request, + dataset_type=dataset_type, + ) if auth_error is not None: return auth_error dataset = _find_dataset( dataset_store=dataset_store, dataset_uuid=dataset_uuid, - dataset_type=dataset_type, ) if dataset is None: return _unknown_dataset_response(dataset_uuid=dataset_uuid) @@ -1086,6 +1550,7 @@ def download_model_target_dataset( HTTPStatus.OK, { "Content-Length": str(object=len(body)), + "Content-Disposition": "attachment; filename=full-dataset.zip", "Content-Type": "application/zip", }, body, @@ -1105,15 +1570,21 @@ def delete_model_target_dataset( if content_length_error is not None: return content_length_error - auth_error = _require_bearer_token(request=request) + auth_error = _require_bearer_token( + request=request, + dataset_type=dataset_type, + ) if auth_error is not None: return auth_error dataset = _find_dataset( dataset_store=dataset_store, dataset_uuid=dataset_uuid, - dataset_type=dataset_type, ) if dataset is None: return _unknown_dataset_response(dataset_uuid=dataset_uuid) + if dataset.dataset_type != dataset_type: + # Real Vuforia returns success when deleting through the other route, + # but leaves the dataset available through its creation route. + return HTTPStatus.OK, {"Content-Length": "0"}, "" dataset_store.remove_model_target_dataset(dataset_uuid=dataset_uuid) return HTTPStatus.OK, {"Content-Length": "0"}, "" 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 0fdc7f5d5..66f102a90 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 @@ -35,10 +35,14 @@ ) from mock_vws._model_target_web_api import ( create_model_target_dataset, + create_oauth2_client_credential, delete_model_target_dataset, + delete_oauth2_client_credential, download_model_target_dataset, get_model_target_dataset_status, + list_oauth2_client_credentials, oauth2_token, + update_oauth2_client_credential_scopes, ) from mock_vws._reco_counts_web_api import ( create_reco_counts_report, @@ -185,7 +189,70 @@ def oauth2_token( # pylint: disable=no-self-use request: RequestData, ) -> _ResponseType: """Obtain an OAuth2 token for the Model Target Web API.""" - return oauth2_token(request=request) + return oauth2_token( + request=request, + credential_store=self._target_manager, + ) + + @route( + path_pattern="/oauth2/clientcredentials", + http_methods={HTTPMethod.POST}, + ) + def create_oauth2_client_credential( + self, + request: RequestData, + ) -> _ResponseType: + """Create an OAuth2 client credential.""" + return create_oauth2_client_credential( + request=request, + credential_store=self._target_manager, + ) + + @route( + path_pattern="/oauth2/clientcredentials", + http_methods={HTTPMethod.GET}, + ) + def list_oauth2_client_credentials( + self, + request: RequestData, + ) -> _ResponseType: + """List OAuth2 client credentials.""" + return list_oauth2_client_credentials( + request=request, + credential_store=self._target_manager, + ) + + @route( + path_pattern=("/oauth2/clientcredentials/(?P[^/]+)/scopes"), + http_methods={HTTPMethod.PUT}, + ) + def update_oauth2_client_credential_scopes( + self, + request: RequestData, + ) -> _ResponseType: + """Update an OAuth2 client credential's scopes.""" + client_id = request.path.split(sep="/")[-2] + return update_oauth2_client_credential_scopes( + request=request, + credential_store=self._target_manager, + client_id=client_id, + ) + + @route( + path_pattern="/oauth2/clientcredentials/(?P[^/]+)", + http_methods={HTTPMethod.DELETE}, + ) + def delete_oauth2_client_credential( + self, + request: RequestData, + ) -> _ResponseType: + """Delete an OAuth2 client credential.""" + client_id = request.path.rsplit(sep="/", maxsplit=1)[-1] + return delete_oauth2_client_credential( + request=request, + credential_store=self._target_manager, + client_id=client_id, + ) @route( path_pattern="/modeltargets/datasets", diff --git a/src/mock_vws/model_target.py b/src/mock_vws/model_target.py index 98e796695..b63cf35d7 100644 --- a/src/mock_vws/model_target.py +++ b/src/mock_vws/model_target.py @@ -31,6 +31,16 @@ class ModelTargetDatasetType(StrEnum): ADVANCED = "advanced" +@beartype +@dataclass(frozen=True, kw_only=True) +class OAuth2ClientCredential: + """An OAuth2 client credential managed through the Vuforia Web API.""" + + client_id: str + client_secret: str + scopes: tuple[str, ...] + + @beartype @dataclass(frozen=True, kw_only=True) class ModelTargetGenerationFailure: diff --git a/src/mock_vws/target_manager.py b/src/mock_vws/target_manager.py index 365a2242c..81e155c37 100644 --- a/src/mock_vws/target_manager.py +++ b/src/mock_vws/target_manager.py @@ -9,7 +9,7 @@ RequestRateLimiter, ) from mock_vws.database import CloudDatabase, VuMarkDatabase -from mock_vws.model_target import ModelTargetDataset +from mock_vws.model_target import ModelTargetDataset, OAuth2ClientCredential from mock_vws.reco_counts import RecoCountsReport if TYPE_CHECKING: @@ -29,6 +29,7 @@ def __init__(self) -> None: self._cloud_databases: set[CloudDatabase] = set() self._vumark_databases: set[VuMarkDatabase] = set() self._model_target_datasets: dict[str, ModelTargetDataset] = {} + self._oauth2_client_credentials: dict[str, OAuth2ClientCredential] = {} self._reco_counts_reports: dict[str, RecoCountsReport] = {} self._request_rate_limiter = RequestRateLimiter( time_function=time.monotonic, @@ -59,6 +60,22 @@ def reco_counts_reports(self) -> dict[str, RecoCountsReport]: """All reco counts reports, keyed by report identifier.""" return dict(self._reco_counts_reports) + @property + def oauth2_client_credentials(self) -> dict[str, OAuth2ClientCredential]: + """All dynamically created OAuth2 client credentials.""" + return dict(self._oauth2_client_credentials) + + def add_oauth2_client_credential( + self, + credential: OAuth2ClientCredential, + ) -> None: + """Add an OAuth2 client credential.""" + self._oauth2_client_credentials[credential.client_id] = credential + + def remove_oauth2_client_credential(self, client_id: str) -> None: + """Remove an OAuth2 client credential.""" + del self._oauth2_client_credentials[client_id] + def add_reco_counts_report( self, reco_counts_report: RecoCountsReport, diff --git a/tests/mock_vws/fixtures/credentials.py b/tests/mock_vws/fixtures/credentials.py index dfedf8a2a..81d4d59f4 100644 --- a/tests/mock_vws/fixtures/credentials.py +++ b/tests/mock_vws/fixtures/credentials.py @@ -84,6 +84,8 @@ class _ModelTargetSettings(BaseSettings): client_id: str client_secret: str cad_data_url: str + username: str + password: str model_config = SettingsConfigDict( env_prefix="MODEL_TARGET_VUFORIA_", @@ -119,6 +121,8 @@ class ModelTargetCredentials: client_id: str = field(repr=False) client_secret: str = field(repr=False) cad_data_url: str = field(repr=False) + username: str = field(repr=False) + password: str = field(repr=False) def get_model_target_credentials() -> ModelTargetCredentials: @@ -130,6 +134,8 @@ def get_model_target_credentials() -> ModelTargetCredentials: client_id=settings.client_id, client_secret=settings.client_secret, cad_data_url=settings.cad_data_url, + username=settings.username, + password=settings.password, ) diff --git a/tests/mock_vws/fixtures/model_target_prepared_requests.py b/tests/mock_vws/fixtures/model_target_prepared_requests.py index 11741d835..a6cc68a6c 100644 --- a/tests/mock_vws/fixtures/model_target_prepared_requests.py +++ b/tests/mock_vws/fixtures/model_target_prepared_requests.py @@ -52,6 +52,8 @@ def credentials_for_backend( client_id="client-id", client_secret="client-secret", cad_data_url="https://example.com/model.glb", + username="user@example.com", + password="password", ) @@ -62,6 +64,7 @@ def get_access_token( backend: VuforiaBackend, ) -> str: """Return an OAuth2 access token.""" + del backend response = requests.post( url=f"{MODEL_TARGET_VWS_HOST}/oauth2/token", auth=(credentials.client_id, credentials.client_secret), @@ -69,19 +72,6 @@ def get_access_token( timeout=30, ) - if ( - backend == VuforiaBackend.REAL - and response.status_code == HTTPStatus.UNAUTHORIZED - and response.json() == {"error": "invalid_client"} - ): - pytest.xfail( - reason=( - "Real Model Target Web API credentials are not accepted; " - "authenticated behavior is verified against the mock " - "backends only until the credentials are rotated." - ), - ) - assert response.status_code == HTTPStatus.OK response_json: dict[str, Any] = json.loads(s=response.text) access_token = response_json["access_token"] diff --git a/tests/mock_vws/test_flask_app_usage.py b/tests/mock_vws/test_flask_app_usage.py index f69e97c6e..a267b0fcb 100644 --- a/tests/mock_vws/test_flask_app_usage.py +++ b/tests/mock_vws/test_flask_app_usage.py @@ -993,7 +993,7 @@ def test_standard_dataset_workflow( with zipfile.ZipFile( file=io.BytesIO(initial_bytes=dataset_response.content), ) as dataset_zip: - assert dataset_zip.namelist() == ["dataset.json"] + assert dataset_zip.namelist() == ["MTDataset.dat", "MTDataset.xml"] @staticmethod def _dataset_status(dataset_uuid: str) -> dict[str, Any]: diff --git a/tests/mock_vws/test_model_target_web_api.py b/tests/mock_vws/test_model_target_web_api.py index c8357ebc0..24b00c1ca 100644 --- a/tests/mock_vws/test_model_target_web_api.py +++ b/tests/mock_vws/test_model_target_web_api.py @@ -5,6 +5,7 @@ import io import json import textwrap +import time import zipfile from http import HTTPMethod, HTTPStatus from typing import Any @@ -27,7 +28,11 @@ from tests.mock_vws.utils.assertions import assert_valid_date_header _VWS_HOST = "https://vws.vuforia.com" -_MOCK_BEARER_TOKEN = "eyJhbGciOiJtb2NrIn0.e30.c2lnbmF0dXJl" +_MOCK_BEARER_TOKEN = ( + "eyJhbGciOiJtb2NrIn0." + "eyJzY29wZSI6Im1vZGVsdGFyZ2V0cy5zdGFuZGFyZG1vZGVsdGFyZ2V0LmFsbCJ9." + "c2lnbmF0dXJl" +) _VIEW: dict[str, Any] = { @@ -251,6 +256,29 @@ class TestAuthentication: {"error": "unsupported_grant_type"}, id="unsupported-grant-type", ), + pytest.param( + None, + {"grant_type": "password", "password": "password"}, + HTTPStatus.BAD_REQUEST, + { + "error": "invalid_request", + "error_description": "Missing username and/or password", + }, + id="password-grant-missing-username", + ), + pytest.param( + None, + { + "grant_type": "password", + "username": "user@example.com", + }, + HTTPStatus.BAD_REQUEST, + { + "error": "invalid_request", + "error_description": "Missing username and/or password", + }, + id="password-grant-missing-password", + ), ], ) def test_invalid_oauth2_token_request( @@ -274,6 +302,206 @@ def test_invalid_oauth2_token_request( body=body, ) + @staticmethod + def test_password_grant( + *, + verify_model_target_mock_vuforia: VuforiaBackend, + ) -> None: + """A username and password can be exchanged for a scoped token.""" + credentials = credentials_for_backend( + backend=verify_model_target_mock_vuforia, + ) + response = requests.post( + url=f"{_VWS_HOST}/oauth2/token", + data={ + "grant_type": "password", + "username": credentials.username, + "password": credentials.password, + "scope": "modeltargets.standardmodeltarget.all", + }, + timeout=30, + ) + + assert response.status_code == HTTPStatus.OK + assert isinstance(response.json()["access_token"], str) + assert response.json()["token_type"] == "bearer" + + @staticmethod + def test_scoped_client_credentials_grant( + *, + verify_model_target_mock_vuforia: VuforiaBackend, + ) -> None: + """A client credentials request accepts an explicit scope.""" + credentials = credentials_for_backend( + backend=verify_model_target_mock_vuforia, + ) + response = requests.post( + url=f"{_VWS_HOST}/oauth2/token", + auth=(credentials.client_id, credentials.client_secret), + data={ + "grant_type": "client_credentials", + "scope": "modeltargets.standardmodeltarget.all", + }, + timeout=30, + ) + + assert response.status_code == HTTPStatus.OK + assert isinstance(response.json()["access_token"], str) + assert response.json()["token_type"] == "bearer" + + @staticmethod + def test_insufficient_scope( + *, + verify_model_target_mock_vuforia: VuforiaBackend, + ) -> None: + """A route rejects a token carrying only another route's scope.""" + credentials = credentials_for_backend( + backend=verify_model_target_mock_vuforia, + ) + token_response = requests.post( + url=f"{_VWS_HOST}/oauth2/token", + auth=(credentials.client_id, credentials.client_secret), + data={ + "grant_type": "client_credentials", + "scope": "modeltargets.standardmodeltarget.all", + }, + timeout=30, + ) + assert token_response.status_code == HTTPStatus.OK + + response = requests.post( + url=f"{_VWS_HOST}/modeltargets/advancedDatasets", + headers={ + "Authorization": ( + f"Bearer {token_response.json()['access_token']}" + ), + }, + json={}, + timeout=30, + ) + + assert response.status_code == HTTPStatus.FORBIDDEN + assert response.text == ( + "User does not have the required scopes to perform this action" + ) + + @staticmethod + def test_client_credentials_management( + *, + verify_model_target_mock_vuforia: VuforiaBackend, + ) -> None: + """Client credentials can be created, listed, updated and + deleted. + """ + credentials = credentials_for_backend( + backend=verify_model_target_mock_vuforia, + ) + password_token_response = requests.post( + url=f"{_VWS_HOST}/oauth2/token", + data={ + "grant_type": "password", + "username": credentials.username, + "password": credentials.password, + "scope": "oauth2.clientcredentials.all", + }, + timeout=30, + ) + assert password_token_response.status_code == HTTPStatus.OK + headers = { + "Authorization": ( + f"Bearer {password_token_response.json()['access_token']}" + ), + } + client_id: str | None = None + + try: + create_response = requests.post( + url=f"{_VWS_HOST}/oauth2/clientcredentials", + headers=headers, + json={ + "scopes": ["modeltargets.standardmodeltarget.all"], + }, + timeout=30, + ) + assert create_response.status_code == HTTPStatus.CREATED + client_id = create_response.json()["client_id"] + client_secret = create_response.json()["client_secret"] + + list_response = requests.get( + url=f"{_VWS_HOST}/oauth2/clientcredentials", + headers=headers, + timeout=30, + ) + assert list_response.status_code == HTTPStatus.OK + created_entries = [ + entry + for entry in list_response.json() + if entry["clientId"] == client_id + ] + assert len(created_entries) == 1 + assert created_entries[0]["scopes"] == [ + "modeltargets.standardmodeltarget.all", + ] + + update_response = requests.put( + url=( + f"{_VWS_HOST}/oauth2/clientcredentials/{client_id}/scopes" + ), + headers=headers, + json=["modeltargets.advancedmodeltarget.all"], + timeout=30, + ) + assert update_response.status_code == HTTPStatus.OK + assert update_response.json() == { + "clientId": client_id, + "scopes": ["modeltargets.advancedmodeltarget.all"], + } + + assert client_id is not None + assert isinstance(client_secret, str) + client_token_response = requests.post( + url=f"{_VWS_HOST}/oauth2/token", + auth=(client_id, client_secret), + data={"grant_type": "client_credentials"}, + timeout=30, + ) + assert client_token_response.status_code == HTTPStatus.OK + client_access_token = client_token_response.json()["access_token"] + insufficient_response = requests.post( + url=f"{_VWS_HOST}/modeltargets/datasets", + headers={ + "Authorization": f"Bearer {client_access_token}", + }, + json={}, + timeout=30, + ) + assert insufficient_response.status_code == HTTPStatus.FORBIDDEN + finally: + if client_id is not None: + delete_response = requests.delete( + url=(f"{_VWS_HOST}/oauth2/clientcredentials/{client_id}"), + headers=headers, + timeout=30, + ) + assert delete_response.status_code == HTTPStatus.NO_CONTENT + + missing_client_id = "000000000000000000000" + missing_response = requests.delete( + url=(f"{_VWS_HOST}/oauth2/clientcredentials/{missing_client_id}"), + headers=headers, + timeout=30, + ) + assert missing_response.status_code == HTTPStatus.NOT_FOUND + assert missing_response.json() == { + "error": { + "code": "NOT_FOUND", + "message": ( + f"Clientcredential with ID={missing_client_id} not found" + ), + "target": "clientcredential", + }, + } + @pytest.mark.usefixtures("verify_model_target_mock_vuforia") class TestAuthorizationHeader: @@ -490,10 +718,18 @@ class TestInvalidJson: """ @staticmethod + @pytest.mark.parametrize( + argnames="content_type", + argvalues=[ + pytest.param(None, id="missing"), + pytest.param("", id="empty"), + ], + ) def test_wrong_content_type( *, verify_model_target_mock_vuforia: VuforiaBackend, model_target_endpoint: ModelTargetEndpoint, + content_type: str | None, ) -> None: """Requests without a JSON content type are rejected with 415 by endpoints which read a body, and are unaffected elsewhere. @@ -505,7 +741,10 @@ def test_wrong_content_type( **model_target_endpoint.headers, "Authorization": f"Bearer {access_token}", } - new_headers.pop("Content-Type", None) + if content_type is None: + new_headers.pop("Content-Type", None) + else: + new_headers["Content-Type"] = content_type new_endpoint = dataclasses.replace( model_target_endpoint, headers=new_headers, @@ -738,7 +977,10 @@ def test_invalid_basic_auth_header(*, authorization: str) -> None: ), pytest.param( {**_UNAUTHENTICATED_DATASET_REQUEST, "models": ["model"]}, - {"/models(0): error.expected.jsobject"}, + { + "/models(0)/name: element is required", + "/models(0)/views: element is required", + }, id="model-not-object", ), pytest.param( @@ -749,7 +991,10 @@ def test_invalid_basic_auth_header(*, authorization: str) -> None: "model", ], }, - {"/models(1): error.expected.jsobject"}, + { + "/models(1)/name: element is required", + "/models(1)/views: element is required", + }, id="second-model-not-object", ), pytest.param( @@ -758,11 +1003,8 @@ def test_invalid_basic_auth_header(*, authorization: str) -> None: "models": [_EMPTY_MODEL], }, { - ( - "/models(0): one and only one of cadDataUrl and " - "cadDataBlob is required" - ), "/models(0)/name: element is required", + "/models(0)/views: element is required", }, id="model-missing-fields", ), @@ -773,8 +1015,8 @@ def test_invalid_basic_auth_header(*, authorization: str) -> None: }, { ( - "/models(0): one and only one of cadDataUrl and " - "cadDataBlob is required" + "model 'model-name' is invalid. One of `cadDataBlob`, " + "`cadDataUrl`, `cadDataUuid` need to be provided" ), }, id="model-without-cad-data", @@ -792,8 +1034,9 @@ def test_invalid_basic_auth_header(*, authorization: str) -> None: }, { ( - "/models(0): one and only one of cadDataUrl and " - "cadDataBlob is required" + "model 'model-name' is invalid. Only one of " + "`cadDataBlob`, `cadDataUrl`, `cadDataUuid` need to " + "be provided" ), }, id="model-with-both-cad-data-sources", @@ -838,7 +1081,14 @@ def test_invalid_basic_auth_header(*, authorization: str) -> None: **_UNAUTHENTICATED_DATASET_REQUEST, "models": [{**_MODEL, "cadDataFormat": "gltf"}], }, - {"/models(0)/cadDataFormat: error.expected.validenum"}, + { + ( + "Unrecognized cadDataFormat 'GLTF'. Allowed values " + "are: ZIP, GLB, DRC_GLB, DRC_GLTF, DAE, FBX, IGES, " + "OBJ, PVS, PVZ, STL, VRML, or specify no " + "cadDataFormat to auto-detect GLB and zipped glTFs." + ), + }, id="model-cad-data-format-not-in-enum", ), pytest.param( @@ -862,7 +1112,12 @@ def test_invalid_basic_auth_header(*, authorization: str) -> None: **_UNAUTHENTICATED_DATASET_REQUEST, "models": [{**_MODEL, "simplify": "sometimes"}], }, - {"/models(0)/simplify: error.expected.validenum"}, + { + ( + "invalid simplify. Should be one of 'never', " + "'always', 'auto'. You provided 'Some(sometimes)'" + ), + }, id="model-simplify-not-in-enum", ), pytest.param( @@ -870,7 +1125,12 @@ def test_invalid_basic_auth_header(*, authorization: str) -> None: **_UNAUTHENTICATED_DATASET_REQUEST, "models": [{**_MODEL, "automaticColoring": "sometimes"}], }, - {"/models(0)/automaticColoring: error.expected.validenum"}, + { + ( + "invalid automaticColoring. Should be one of 'never', " + "'always', 'auto'. You provided 'sometimes'" + ), + }, id="model-automatic-coloring-not-in-enum", ), pytest.param( @@ -878,7 +1138,13 @@ def test_invalid_basic_auth_header(*, authorization: str) -> None: **_UNAUTHENTICATED_DATASET_REQUEST, "models": [{**_MODEL, "motionHint": "still"}], }, - {"/models(0)/motionHint: error.expected.validenum"}, + { + ( + "`motionHint` and `trackingMode` are no longer " + "supported when using `targetsSdk` 10.9 or later. " + "Please use the `optimizeTrackingFor` setting instead." + ), + }, id="model-motion-hint-not-in-enum", ), pytest.param( @@ -886,7 +1152,12 @@ def test_invalid_basic_auth_header(*, authorization: str) -> None: **_UNAUTHENTICATED_DATASET_REQUEST, "models": [{**_MODEL, "optimizeTrackingFor": "cars"}], }, - {"/models(0)/optimizeTrackingFor: error.expected.validenum"}, + { + ( + "`optimizeTrackingFor` must be one of " + "default,low_feature_objects,ar_controller" + ), + }, id="model-optimize-tracking-for-not-in-enum", ), pytest.param( @@ -894,7 +1165,13 @@ def test_invalid_basic_auth_header(*, authorization: str) -> None: **_UNAUTHENTICATED_DATASET_REQUEST, "models": [{**_MODEL, "trackingMode": "boat"}], }, - {"/models(0)/trackingMode: error.expected.validenum"}, + { + ( + "`motionHint` and `trackingMode` are no longer " + "supported when using `targetsSdk` 10.9 or later. " + "Please use the `optimizeTrackingFor` setting instead." + ), + }, id="model-tracking-mode-not-in-enum", ), pytest.param( @@ -909,8 +1186,15 @@ def test_invalid_basic_auth_header(*, authorization: str) -> None: ], }, { - "/models(0)/motionHint: error.expected.validenum", - "/models(0)/simplify: error.expected.validenum", + ( + "`motionHint` and `trackingMode` are no longer " + "supported when using `targetsSdk` 10.9 or later. " + "Please use the `optimizeTrackingFor` setting instead." + ), + ( + "invalid simplify. Should be one of 'never', " + "'always', 'auto'. You provided 'Some(sometimes)'" + ), }, id="model-multiple-enum-errors", ), @@ -927,7 +1211,7 @@ def test_invalid_basic_auth_header(*, authorization: str) -> None: **_UNAUTHENTICATED_DATASET_REQUEST, "models": [{**_MODEL, "views": ["view-name"]}], }, - {"/models(0)/views(0): error.expected.jsobject"}, + {"/models(0)/views(0)/name: element is required"}, id="view-not-object", ), pytest.param( @@ -935,13 +1219,7 @@ def test_invalid_basic_auth_header(*, authorization: str) -> None: **_UNAUTHENTICATED_DATASET_REQUEST, "models": [{**_MODEL, "views": [_EMPTY_VIEW]}], }, - { - ( - "/models(0)/views(0)/guideViewPosition: " - "element is required" - ), - "/models(0)/views(0)/name: element is required", - }, + {"/models(0)/views(0)/name: element is required"}, id="view-missing-fields", ), pytest.param( @@ -1147,6 +1425,42 @@ def test_invalid_dataset_request( for detail in error["details"]: assert detail["code"] == "VALIDATION_ERROR" + @staticmethod + def test_advanced_model_count_exceeds_limit( + *, + verify_model_target_mock_vuforia: VuforiaBackend, + ) -> None: + """Advanced datasets reject more than 20 uniquely named models.""" + models = [{**_MODEL, "name": f"model-{index}"} for index in range(21)] + # Include a duplicate to verify the two validation details which real + # Vuforia returns together for this request. + models[-1]["name"] = models[0]["name"] + body = {**_UNAUTHENTICATED_DATASET_REQUEST, "models": models} + credentials = credentials_for_backend( + backend=verify_model_target_mock_vuforia, + ) + access_token = get_access_token( + credentials=credentials, + backend=verify_model_target_mock_vuforia, + ) + response = requests.post( + url=f"{_VWS_HOST}/modeltargets/advancedDatasets", + headers={"Authorization": f"Bearer {access_token}"}, + json=body, + timeout=30, + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + error = response.json()["error"] + assert error["code"] == "BAD_REQUEST" + assert {detail["message"] for detail in error["details"]} == { + "names of models must be unique within a Target.", + "total number of models must be maximum 20", + } + assert all( + detail["code"] == "VALIDATION_ERROR" for detail in error["details"] + ) + @staticmethod @pytest.mark.parametrize( argnames=("method", "path"), @@ -1201,12 +1515,8 @@ def test_unknown_dataset( assert error["target"].startswith("userId:") -class TestMockOnlyErrors: - """Mock-only Model Target Web API error paths. - - These cases cannot easily be verified against real Vuforia with the - currently available test account and are kept mock-only by design. - """ +class TestStateBasedDatasets: + """Verified fake tests for State-Based Model Targets.""" @staticmethod @pytest.mark.parametrize( @@ -1231,7 +1541,7 @@ class TestMockOnlyErrors: ) def test_state_based_dataset( *, - model_target_mock_only_vuforia: VuforiaBackend, + verify_model_target_mock_vuforia: VuforiaBackend, dataset_path: str, view_updates: dict[str, object], ) -> None: @@ -1251,7 +1561,7 @@ def test_state_based_dataset( ], } access_token = _access_token_for_backend( - backend=model_target_mock_only_vuforia, + backend=verify_model_target_mock_vuforia, ) headers = {"Authorization": f"Bearer {access_token}"} create_response = requests.post( @@ -1270,6 +1580,46 @@ def test_state_based_dataset( ) assert delete_response.status_code == HTTPStatus.OK + @staticmethod + def test_view_states_are_a_subset( + *, + verify_model_target_mock_vuforia: VuforiaBackend, + ) -> None: + """A view cannot select a state absent from the configuration.""" + body = { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **_MODEL, + "stateBasedConfigurationJsonString": ( + _STATE_CONFIGURATION + ), + "views": [{**_VIEW, "states": ["unknown"]}], + }, + ], + } + access_token = _access_token_for_backend( + backend=verify_model_target_mock_vuforia, + ) + response = requests.post( + url=f"{_VWS_HOST}/modeltargets/datasets", + headers={"Authorization": f"Bearer {access_token}"}, + json=body, + timeout=30, + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + error = response.json()["error"] + assert error["code"] == "BAD_REQUEST" + assert [detail["message"] for detail in error["details"]] == [ + "states in entrypoint view-name' must be a subset of all states", + ] + assert error["details"][0]["code"] == "VALIDATION_ERROR" + + +class TestAdditionalBehaviors: + """Additional verified and mock-only Model Target behaviors.""" + @staticmethod @pytest.mark.parametrize( argnames=("model_updates", "view_updates", "expected_message"), @@ -1322,12 +1672,6 @@ def test_state_based_dataset( ("/models(0)/views(0)/states(1): error.expected.jsstring"), id="view-state-not-string", ), - pytest.param( - {"stateBasedConfigurationJsonString": _STATE_CONFIGURATION}, - {"states": ["unknown"]}, - ("/models(0)/views(0)/states(0): error.expected.validenum"), - id="view-state-not-declared", - ), pytest.param( {}, {"states": ["assembled"]}, @@ -1376,86 +1720,61 @@ def test_invalid_state_based_dataset( assert error["details"][0]["code"] == "VALIDATION_ERROR" @staticmethod - def test_advanced_model_count_exceeds_limit() -> None: - """Advanced dataset requests with too many models are rejected. - - Real Vuforia returns a 403 for the currently available test account - because the account lacks the advanced-dataset scope, so the - validation-error shape cannot be observed end-to-end. The mock - therefore enforces the documented advanced-dataset model count - limit on its own. - """ - body = { - **_UNAUTHENTICATED_DATASET_REQUEST, - "models": [*_UNAUTHENTICATED_DATASET_REQUEST["models"]] * 21, - } - with MockVWS(): - response = requests.post( - url=f"{_VWS_HOST}/modeltargets/advancedDatasets", - headers={"Authorization": f"Bearer {_MOCK_BEARER_TOKEN}"}, - json=body, - timeout=30, - ) - - assert response.status_code == HTTPStatus.BAD_REQUEST - error = response.json()["error"] - assert error["code"] == "BAD_REQUEST" - assert error["details"][0]["code"] == "VALIDATION_ERROR" - - @staticmethod - def test_advanced_realistic_appearance_not_in_enum() -> None: + def test_advanced_realistic_appearance_not_in_enum( + *, + verify_model_target_mock_vuforia: VuforiaBackend, + ) -> None: """Advanced dataset requests with a ``realisticAppearance`` value outside the documented enumeration are rejected. The Model Target OpenAPI specification documents ``realisticAppearance`` as a model field for advanced datasets - only, so standard dataset creation does not validate it. This is - mock-only because the available test account lacks the - advanced-dataset scope, so real Vuforia rejects the request with a - 403 before validating the body. + only. """ + credentials = credentials_for_backend( + backend=verify_model_target_mock_vuforia, + ) body = { **_UNAUTHENTICATED_DATASET_REQUEST, - "models": [{**_MODEL, "realisticAppearance": "yes"}], + "models": [ + { + **_MODEL, + "cadDataUrl": credentials.cad_data_url, + "realisticAppearance": "yes", + }, + ], } - headers = {"Authorization": f"Bearer {_MOCK_BEARER_TOKEN}"} - with MockVWS(): - advanced_response = requests.post( - url=f"{_VWS_HOST}/modeltargets/advancedDatasets", - headers=headers, - json=body, - timeout=30, - ) - standard_response = requests.post( - url=f"{_VWS_HOST}/modeltargets/datasets", - headers=headers, - json=body, - timeout=30, - ) + access_token = get_access_token( + credentials=credentials, + backend=verify_model_target_mock_vuforia, + ) + advanced_response = requests.post( + url=f"{_VWS_HOST}/modeltargets/advancedDatasets", + headers={"Authorization": f"Bearer {access_token}"}, + json=body, + timeout=30, + ) assert advanced_response.status_code == HTTPStatus.BAD_REQUEST error = advanced_response.json()["error"] assert error["code"] == "BAD_REQUEST" assert [detail["message"] for detail in error["details"]] == [ - "/models(0)/realisticAppearance: error.expected.validenum", + '`realisticAppearance` must be one of "true", "false", "auto".` ', ] assert error["details"][0]["code"] == "VALIDATION_ERROR" - assert standard_response.status_code == HTTPStatus.CREATED - @staticmethod def test_oauth2_token_body_not_utf_8( *, - model_target_mock_only_vuforia: VuforiaBackend, + verify_model_target_mock_vuforia: VuforiaBackend, ) -> None: """An OAuth2 token request with a body which is not valid UTF-8 is treated as one which does not name a grant type. - Mock-only because the real response to a form body which cannot be - decoded has not been observed. + Real Vuforia also treats the undecodable body as an empty form. """ credentials = credentials_for_backend( - backend=model_target_mock_only_vuforia, + backend=verify_model_target_mock_vuforia, ) response = requests.post( @@ -1561,66 +1880,67 @@ def test_failed_dataset_cannot_be_downloaded() -> None: ), ], ) - def test_dataset_is_not_visible_to_the_other_dataset_type( + def test_dataset_is_visible_to_the_other_dataset_type( *, + verify_model_target_mock_vuforia: VuforiaBackend, created_path: str, other_path: str, ) -> None: - """A dataset is not reachable through the other type's routes. - - Standard and advanced datasets are separate resources in real - Vuforia, with separate OAuth scopes. This is mock-only because the - available test account lacks the advanced-dataset scope, so real - Vuforia rejects advanced routes with a 403 before looking a dataset - up. - """ - headers = {"Authorization": f"Bearer {_MOCK_BEARER_TOKEN}"} - with MockVWS(): + """Standard and advanced routes share datasets by UUID.""" + access_token = _access_token_for_backend( + backend=verify_model_target_mock_vuforia, + ) + headers = {"Authorization": f"Bearer {access_token}"} + dataset_uuid: str | None = None + try: create_response = requests.post( url=f"{_VWS_HOST}{created_path}", headers=headers, - json=_UNAUTHENTICATED_DATASET_REQUEST, + json=_dataset_request( + cad_data_url=credentials_for_backend( + backend=verify_model_target_mock_vuforia, + ).cad_data_url, + ), timeout=30, ) assert create_response.status_code == HTTPStatus.CREATED dataset_uuid = create_response.json()["uuid"] - other_responses = [ - requests.get( - url=f"{_VWS_HOST}{other_path}/{dataset_uuid}/status", - headers=headers, - timeout=30, - ), - requests.get( - url=f"{_VWS_HOST}{other_path}/{dataset_uuid}/dataset", - headers=headers, - timeout=30, - ), - requests.delete( - url=f"{_VWS_HOST}{other_path}/{dataset_uuid}", - headers=headers, - timeout=30, - ), - ] - - # The dataset survives the delete attempt made through the other - # type's routes. - own_status_response = requests.get( - url=f"{_VWS_HOST}{created_path}/{dataset_uuid}/status", + other_status_response = requests.get( + url=f"{_VWS_HOST}{other_path}/{dataset_uuid}/status", headers=headers, timeout=30, ) - - for response in other_responses: - assert response.status_code == HTTPStatus.NOT_FOUND - error = response.json()["error"] - assert error["code"] == "NOT_FOUND" - assert error["message"] == ( - "Could not find a model-view database with uuid " - f"{dataset_uuid}" + other_delete_response = requests.delete( + url=f"{_VWS_HOST}{other_path}/{dataset_uuid}", + headers=headers, + timeout=30, ) - assert error["target"].startswith("userId:") + own_status_response = requests.get( + url=( + f"{_VWS_HOST}{created_path}/" + f"{create_response.json()['uuid']}/status" + ), + headers=headers, + timeout=30, + ) + finally: + if dataset_uuid is not None: + delete_response = requests.delete( + url=f"{_VWS_HOST}{created_path}/{dataset_uuid}", + headers=headers, + timeout=30, + ) + assert delete_response.status_code in { + HTTPStatus.OK, + HTTPStatus.NO_CONTENT, + } + assert other_status_response.status_code == HTTPStatus.OK + assert other_delete_response.status_code in { + HTTPStatus.OK, + HTTPStatus.NO_CONTENT, + } assert own_status_response.status_code == HTTPStatus.OK @@ -1632,7 +1952,12 @@ def test_create_status_and_delete( *, verify_model_target_mock_vuforia: VuforiaBackend, ) -> None: - """A standard Model Target dataset can be created and deleted.""" + """A standard dataset works through the shared advanced routes. + + Standard generation is fast enough for verified CI coverage. Real + Vuforia exposes its completed artifact through both route families, + so this also verifies the advanced status and download endpoints. + """ credentials = credentials_for_backend( backend=verify_model_target_mock_vuforia, ) @@ -1661,7 +1986,8 @@ def test_create_status_and_delete( status_response = requests.get( url=( - f"{_VWS_HOST}/modeltargets/datasets/{dataset_uuid}/status" + f"{_VWS_HOST}/modeltargets/advancedDatasets/" + f"{dataset_uuid}/status" ), headers=headers, timeout=30, @@ -1677,6 +2003,52 @@ def test_create_status_and_delete( "failed", } assert isinstance(status_response_json["createdAt"], str) + + deadline = time.monotonic() + 60 + while ( + status_response_json["status"] == "processing" + and time.monotonic() < deadline + ): + time.sleep(1) + status_response = requests.get( + url=( + f"{_VWS_HOST}/modeltargets/advancedDatasets/" + f"{dataset_uuid}/status" + ), + headers=headers, + timeout=30, + ) + assert status_response.status_code == HTTPStatus.OK + status_response_json = status_response.json() + + assert status_response_json["status"] == "done" + assert isinstance(status_response_json["completedAt"], str) + assert set(status_response_json) == { + "completedAt", + "createdAt", + "status", + "uuid", + } + + download_response = requests.get( + url=( + f"{_VWS_HOST}/modeltargets/advancedDatasets/" + f"{dataset_uuid}/dataset" + ), + headers=headers, + timeout=30, + ) + assert download_response.status_code == HTTPStatus.OK + assert download_response.headers["Content-Type"] == ( + "application/zip" + ) + assert download_response.headers["Content-Disposition"] == ( + "attachment; filename=full-dataset.zip" + ) + with zipfile.ZipFile( + file=io.BytesIO(initial_bytes=download_response.content), + ) as archive: + assert archive.namelist() == ["MTDataset.dat", "MTDataset.xml"] finally: if dataset_uuid is not None: # pragma: no branch delete_response = requests.delete( diff --git a/tests/mock_vws/test_requests_mock_usage.py b/tests/mock_vws/test_requests_mock_usage.py index cc1586163..489b3a02d 100644 --- a/tests/mock_vws/test_requests_mock_usage.py +++ b/tests/mock_vws/test_requests_mock_usage.py @@ -52,7 +52,11 @@ processing_time_seconds, ) -_MODEL_TARGET_AUTHORIZATION = "Bearer eyJhbGciOiJtb2NrIn0.e30.c2lnbmF0dXJl" +_MODEL_TARGET_AUTHORIZATION = ( + "Bearer eyJhbGciOiJtb2NrIn0." + "eyJzY29wZSI6Im1vZGVsdGFyZ2V0cy5hbGwifQ." + "c2lnbmF0dXJl" +) _MODEL_TARGET_DATASET_REQUEST = { "name": "dataset-name", "targetSdk": "10.18", @@ -1756,7 +1760,7 @@ def test_standard_dataset_workflow() -> None: with zipfile.ZipFile( file=io.BytesIO(initial_bytes=dataset_response.content), ) as dataset_zip: - assert dataset_zip.namelist() == ["dataset.json"] + assert dataset_zip.namelist() == ["MTDataset.dat", "MTDataset.xml"] @staticmethod def test_advanced_dataset_workflow() -> None: diff --git a/vuforia_secrets.env.example b/vuforia_secrets.env.example index 4b4284a2c..2fb384ab0 100644 --- a/vuforia_secrets.env.example +++ b/vuforia_secrets.env.example @@ -29,3 +29,5 @@ INACTIVE_VUMARK_VUFORIA_SERVER_SECRET_KEY=example_inactive_vumark_server_secret_ MODEL_TARGET_VUFORIA_CLIENT_ID=example_model_target_client_id MODEL_TARGET_VUFORIA_CLIENT_SECRET=example_model_target_client_secret MODEL_TARGET_VUFORIA_CAD_DATA_URL=https://example.com/model.glb +MODEL_TARGET_VUFORIA_USERNAME=user@example.com +MODEL_TARGET_VUFORIA_PASSWORD=example_model_target_password From 4674ecd824472269e1a7ca87706ff45350417f73 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 16 Aug 2026 09:39:29 +0100 Subject: [PATCH 3/6] Fix Model Target CI checks --- src/mock_vws/_model_target_web_api.py | 4 ++-- src/mock_vws/_requests_mock_server/mock_web_services_api.py | 4 ++-- tests/mock_vws/test_model_target_generation_failure.py | 6 +++++- tests/mock_vws/test_model_target_generation_warning.py | 6 +++++- tests/mock_vws/test_model_target_web_api.py | 3 ++- tests/mock_vws/test_respx_mock_usage.py | 6 +++++- 6 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/mock_vws/_model_target_web_api.py b/src/mock_vws/_model_target_web_api.py index 7e9511084..c869fef82 100644 --- a/src/mock_vws/_model_target_web_api.py +++ b/src/mock_vws/_model_target_web_api.py @@ -333,7 +333,7 @@ def _jwt_signature_error(*, bearer_token: str) -> str | None: @beartype def _jwt_scopes(*, bearer_token: str) -> frozenset[str]: - """Return the scopes carried by a valid mock JWT.""" + """Return scopes from a valid mock JSON Web Token.""" encoded_payload = bearer_token.split(sep=".")[1] padding = "=" * (-len(encoded_payload) % 4) payload = json.loads( @@ -474,7 +474,7 @@ def encode_part(value: dict[str, Any]) -> str: @beartype -def oauth2_token( # noqa: PLR0911 +def oauth2_token( # noqa: PLR0911 # pylint: disable=too-many-return-statements *, request: RequestData, credential_store: ModelTargetDatasetStore, 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 66f102a90..fe9de4fd4 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 @@ -134,7 +134,7 @@ def decorator( @beartype(conf=BeartypeConf(is_pep484_tower=True)) -class MockVuforiaWebServicesAPI: +class MockVuforiaWebServicesAPI: # pylint: disable=too-many-public-methods """A fake implementation of the Vuforia Web Services API.""" def __init__( @@ -184,7 +184,7 @@ def __init__( self._vumark_generation_failure = vumark_generation_failure @route(path_pattern="/oauth2/token", http_methods={HTTPMethod.POST}) - def oauth2_token( # pylint: disable=no-self-use + def oauth2_token( self, request: RequestData, ) -> _ResponseType: diff --git a/tests/mock_vws/test_model_target_generation_failure.py b/tests/mock_vws/test_model_target_generation_failure.py index 266850dfb..5e315159e 100644 --- a/tests/mock_vws/test_model_target_generation_failure.py +++ b/tests/mock_vws/test_model_target_generation_failure.py @@ -10,7 +10,11 @@ from mock_vws import MockVWS, ModelTargetGenerationFailure -_AUTHORIZATION = "Bearer eyJhbGciOiJtb2NrIn0.e30.c2lnbmF0dXJl" +_AUTHORIZATION = ( + "Bearer eyJhbGciOiJtb2NrIn0." + "eyJzY29wZSI6Im1vZGVsdGFyZ2V0cy5zdGFuZGFyZG1vZGVsdGFyZ2V0LmFsbCJ9." + "c2lnbmF0dXJl" +) _CREATE_URL = "https://vws.vuforia.com/modeltargets/datasets" _REQUEST_BODY: dict[str, Any] = { "name": "dataset-name", diff --git a/tests/mock_vws/test_model_target_generation_warning.py b/tests/mock_vws/test_model_target_generation_warning.py index 51b8d216c..8170471b4 100644 --- a/tests/mock_vws/test_model_target_generation_warning.py +++ b/tests/mock_vws/test_model_target_generation_warning.py @@ -14,7 +14,11 @@ ModelTargetGenerationWarning, ) -_AUTHORIZATION = "Bearer eyJhbGciOiJtb2NrIn0.e30.c2lnbmF0dXJl" +_AUTHORIZATION = ( + "Bearer eyJhbGciOiJtb2NrIn0." + "eyJzY29wZSI6Im1vZGVsdGFyZ2V0cy5zdGFuZGFyZG1vZGVsdGFyZ2V0LmFsbCJ9." + "c2lnbmF0dXJl" +) _CREATE_URL = "https://vws.vuforia.com/modeltargets/datasets" _REQUEST_BODY: dict[str, Any] = { "name": "dataset-name", diff --git a/tests/mock_vws/test_model_target_web_api.py b/tests/mock_vws/test_model_target_web_api.py index 24b00c1ca..93eac7a6a 100644 --- a/tests/mock_vws/test_model_target_web_api.py +++ b/tests/mock_vws/test_model_target_web_api.py @@ -1771,7 +1771,8 @@ def test_oauth2_token_body_not_utf_8( """An OAuth2 token request with a body which is not valid UTF-8 is treated as one which does not name a grant type. - Real Vuforia also treats the undecodable body as an empty form. + Real Vuforia also treats a body that cannot be decoded as an empty + form. """ credentials = credentials_for_backend( backend=verify_model_target_mock_vuforia, diff --git a/tests/mock_vws/test_respx_mock_usage.py b/tests/mock_vws/test_respx_mock_usage.py index 3b3c5d5a9..9d5bc9d2f 100644 --- a/tests/mock_vws/test_respx_mock_usage.py +++ b/tests/mock_vws/test_respx_mock_usage.py @@ -19,7 +19,11 @@ from mock_vws.image_matchers import ExactMatcher from mock_vws.target import VuMarkTarget -_MODEL_TARGET_AUTHORIZATION = "Bearer eyJhbGciOiJtb2NrIn0.e30.c2lnbmF0dXJl" +_MODEL_TARGET_AUTHORIZATION = ( + "Bearer eyJhbGciOiJtb2NrIn0." + "eyJzY29wZSI6Im1vZGVsdGFyZ2V0cy5zdGFuZGFyZG1vZGVsdGFyZ2V0LmFsbCJ9." + "c2lnbmF0dXJl" +) _MODEL_TARGET_DATASET_REQUEST = { "name": "dataset-name", "targetSdk": "10.18", From 1a80144ee9b2eb7e6d7cda2d658cce8434641b29 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 16 Aug 2026 09:51:12 +0100 Subject: [PATCH 4/6] Update Docker dataset archive assertion --- tests/mock_vws/test_docker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/mock_vws/test_docker.py b/tests/mock_vws/test_docker.py index 8b05e939c..9c79e8f32 100644 --- a/tests/mock_vws/test_docker.py +++ b/tests/mock_vws/test_docker.py @@ -391,4 +391,4 @@ def _assert_model_target_round_trip(*, vws_container: Container) -> None: with zipfile.ZipFile( file=io.BytesIO(initial_bytes=download_response.content), ) as downloaded_zip: - assert downloaded_zip.namelist() == ["dataset.json"] + assert downloaded_zip.namelist() == ["MTDataset.dat", "MTDataset.xml"] From a97f4249220e362a12a28ef00dbc458a996b37f4 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 16 Aug 2026 10:28:18 +0100 Subject: [PATCH 5/6] Cover Model Target OAuth edge cases --- tests/mock_vws/test_model_target_web_api.py | 275 +++++++++++++++++++- 1 file changed, 274 insertions(+), 1 deletion(-) diff --git a/tests/mock_vws/test_model_target_web_api.py b/tests/mock_vws/test_model_target_web_api.py index 93eac7a6a..47853fde9 100644 --- a/tests/mock_vws/test_model_target_web_api.py +++ b/tests/mock_vws/test_model_target_web_api.py @@ -1,5 +1,7 @@ """Verified fake tests for the Model Target Web API.""" +# pyright: reportPrivateUsage=false + import base64 import dataclasses import io @@ -16,7 +18,12 @@ from beartype import beartype from vws.response import Response -from mock_vws import MockVWS, ModelTargetGenerationFailure +from mock_vws import ( + MockVWS, + ModelTargetGenerationFailure, + _model_target_web_api, +) +from mock_vws._flask_server import target_manager as flask_target_manager from mock_vws.model_target import ModelTargetDataset, ModelTargetDatasetType from tests.mock_vws.fixtures.model_target_prepared_requests import ( MODEL_TARGET_DATASET_UUID, @@ -2137,3 +2144,269 @@ def test_status_uses_matching_time_field( assert body["status"] == status assert body["uuid"] == "dataset-uuid" assert {"eta", "completedAt"} & body.keys() == {time_field} + + +class TestMockOnlyOAuth2EdgeCases: + """Cover mock-only OAuth2 and validation error paths.""" + + @staticmethod + def _management_token() -> str: + """Return a token which can manage client credentials.""" + response = requests.post( + url=f"{_VWS_HOST}/oauth2/token", + data={ + "grant_type": "password", + "username": "user@example.com", + "password": "password", + "scope": "oauth2.clientcredentials.all", + }, + timeout=30, + ) + assert response.status_code == HTTPStatus.OK + access_token: object = response.json()["access_token"] + assert isinstance(access_token, str) + return access_token + + @staticmethod + def test_oauth2_grant_errors() -> None: + """Invalid passwords and scopes are rejected.""" + with MockVWS(): + invalid_password = requests.post( + url=f"{_VWS_HOST}/oauth2/token", + data={ + "grant_type": "password", + "username": "user@example.com", + "password": "wrong", + }, + timeout=30, + ) + assert invalid_password.status_code == HTTPStatus.UNAUTHORIZED + assert invalid_password.json()["error"] == "invalid_grant" + + invalid_scope = requests.post( + url=f"{_VWS_HOST}/oauth2/token", + auth=("client-id", "client-secret"), + data={"scope": "not.a.scope"}, + timeout=30, + ) + assert invalid_scope.status_code == HTTPStatus.BAD_REQUEST + assert invalid_scope.json()["error"] == "invalid_scope" + + @staticmethod + def test_client_credential_authentication_errors() -> None: + """Credential-management routes enforce bearer-token validity and + scope. + """ + headers = [ + {}, + {"Authorization": "Bearer malformed"}, + {"Authorization": "Bearer e30.e30.signature"}, + {"Authorization": f"Bearer {_MOCK_BEARER_TOKEN}"}, + ] + with MockVWS(): + for request_headers in headers: + response = requests.get( + url=f"{_VWS_HOST}/oauth2/clientcredentials", + headers=request_headers, + timeout=30, + ) + assert response.status_code in { + HTTPStatus.UNAUTHORIZED, + HTTPStatus.FORBIDDEN, + } + + delete_response = requests.delete( + url=f"{_VWS_HOST}/oauth2/clientcredentials/client-id", + timeout=30, + ) + assert delete_response.status_code == HTTPStatus.UNAUTHORIZED + + create_response = requests.post( + url=f"{_VWS_HOST}/oauth2/clientcredentials", + json={"scopes": []}, + timeout=30, + ) + assert create_response.status_code == HTTPStatus.UNAUTHORIZED + + update_response = requests.put( + url=f"{_VWS_HOST}/oauth2/clientcredentials/client-id/scopes", + json=[], + timeout=30, + ) + assert update_response.status_code == HTTPStatus.UNAUTHORIZED + + @staticmethod + def test_non_string_token_scope() -> None: + """A token with a non-string scope has no usable scopes.""" + encoded_header = ( + base64.urlsafe_b64encode( + s=b'{"alg":"mock"}', + ) + .decode(encoding="ascii") + .rstrip("=") + ) + encoded_payload = ( + base64.urlsafe_b64encode( + s=b'{"scope":[]}', + ) + .decode(encoding="ascii") + .rstrip("=") + ) + token = f"{encoded_header}.{encoded_payload}.c2lnbmF0dXJl" + with MockVWS(): + response = requests.get( + url=f"{_VWS_HOST}/oauth2/clientcredentials", + headers={"Authorization": f"Bearer {token}"}, + timeout=30, + ) + assert response.status_code == HTTPStatus.FORBIDDEN + + @staticmethod + def test_client_credential_validation_errors() -> None: + """Credential creation and updates reject invalid request + bodies. + """ + with MockVWS(): + headers = { + "Authorization": ( + f"Bearer {TestMockOnlyOAuth2EdgeCases._management_token()}" + ), + } + for content in (b"{", b'{"scopes":"scope"}', b'{"scopes":[1]}'): + response = requests.post( + url=f"{_VWS_HOST}/oauth2/clientcredentials", + headers={**headers, "Content-Type": "application/json"}, + data=content, + timeout=30, + ) + assert response.status_code == HTTPStatus.BAD_REQUEST + + missing = requests.put( + url=f"{_VWS_HOST}/oauth2/clientcredentials/missing/scopes", + headers=headers, + json=[], + timeout=30, + ) + assert missing.status_code == HTTPStatus.NOT_FOUND + + created = requests.post( + url=f"{_VWS_HOST}/oauth2/clientcredentials", + headers=headers, + json={"scopes": []}, + timeout=30, + ) + client_id = created.json()["client_id"] + for content in (b"{", b'"scope"', b"[1]"): + response = requests.put( + url=( + f"{_VWS_HOST}/oauth2/clientcredentials/" + f"{client_id}/scopes" + ), + headers={**headers, "Content-Type": "application/json"}, + data=content, + timeout=30, + ) + assert response.status_code == HTTPStatus.BAD_REQUEST + + @staticmethod + def test_client_credential_limit( + *, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Credential creation rejects stores at their configured + limit. + """ + monkeypatch.setattr( + target=_model_target_web_api, + name="_MAX_CLIENT_CREDENTIALS", + value=0, + ) + with MockVWS(): + headers = { + "Authorization": ( + f"Bearer {TestMockOnlyOAuth2EdgeCases._management_token()}" + ), + } + response = requests.post( + url=f"{_VWS_HOST}/oauth2/clientcredentials", + headers=headers, + json={"scopes": []}, + timeout=30, + ) + assert response.status_code == HTTPStatus.CONFLICT + + @staticmethod + def test_dataset_scope_and_shape_errors() -> None: + """State-based scope and less common model shapes are + validated. + """ + with MockVWS(): + state_based = { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **_MODEL, + "stateBasedConfigurationJsonString": ( + _STATE_CONFIGURATION + ), + }, + ], + } + forbidden = requests.post( + url=f"{_VWS_HOST}/modeltargets/datasets", + headers={"Authorization": f"Bearer {_MOCK_BEARER_TOKEN}"}, + json=state_based, + timeout=30, + ) + assert forbidden.status_code == HTTPStatus.FORBIDDEN + + invalid_view = requests.post( + url=f"{_VWS_HOST}/modeltargets/datasets", + headers={"Authorization": f"Bearer {_MOCK_BEARER_TOKEN}"}, + json={ + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**_MODEL, "views": [1]}], + }, + timeout=30, + ) + assert invalid_view.status_code == HTTPStatus.BAD_REQUEST + + advanced_empty = requests.post( + url=f"{_VWS_HOST}/modeltargets/advancedDatasets", + headers={ + "Authorization": ( + "Bearer eyJhbGciOiJtb2NrIn0." + "eyJzY29wZSI6Im1vZGVsdGFyZ2V0cy5hZHZhbmNlZG1vZGVs" + "dGFyZ2V0LmFsbCJ9.c2lnbmF0dXJl" + ), + }, + json={"name": "name", "targetSdk": "10.18", "models": []}, + timeout=30, + ) + assert advanced_empty.status_code == HTTPStatus.BAD_REQUEST + + @staticmethod + def test_view_helper_rejects_non_objects() -> None: + """The view validator reports view values which are not + objects. + """ + # pylint: disable=protected-access + details = _model_target_web_api._view_details( # noqa: SLF001 + models=[{"views": [1]}], + ) + assert details == [ + { + "code": "VALIDATION_ERROR", + "message": "/models(0)/views(0): error.expected.jsobject", + }, + ] + + @staticmethod + def test_target_manager_missing_credential_delete() -> None: + """The internal target manager returns 404 for an unknown + credential. + """ + response = flask_target_manager.remove_oauth2_client_credential( + client_id="missing", + ) + assert response.status_code == HTTPStatus.NOT_FOUND From 9b86eddd25b6720d081f3dee658d83af4d7da604 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 16 Aug 2026 10:54:34 +0100 Subject: [PATCH 6/6] Exclude defensive cleanup branches from coverage --- tests/mock_vws/test_model_target_web_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/mock_vws/test_model_target_web_api.py b/tests/mock_vws/test_model_target_web_api.py index 47853fde9..957e3c7e3 100644 --- a/tests/mock_vws/test_model_target_web_api.py +++ b/tests/mock_vws/test_model_target_web_api.py @@ -484,7 +484,7 @@ def test_client_credentials_management( ) assert insufficient_response.status_code == HTTPStatus.FORBIDDEN finally: - if client_id is not None: + if client_id is not None: # pragma: no branch delete_response = requests.delete( url=(f"{_VWS_HOST}/oauth2/clientcredentials/{client_id}"), headers=headers, @@ -1933,7 +1933,7 @@ def test_dataset_is_visible_to_the_other_dataset_type( timeout=30, ) finally: - if dataset_uuid is not None: + if dataset_uuid is not None: # pragma: no branch delete_response = requests.delete( url=f"{_VWS_HOST}{created_path}/{dataset_uuid}", headers=headers,