diff --git a/nuxeo/client.py b/nuxeo/client.py index d6248dc..8c76151 100644 --- a/nuxeo/client.py +++ b/nuxeo/client.py @@ -2,7 +2,7 @@ import atexit import json import logging -from typing import Any, Dict, Optional, Tuple, Type, Union +from typing import Any, Dict, List, Optional, Tuple, Type, Union from warnings import warn import requests @@ -88,6 +88,39 @@ class NuxeoClient(object): :param kwargs: kwargs passed to :func:`NuxeoClient.request` """ + # Cache mapping username -> generated UID. + # Populated lazily on first encounter via resolve_uid / resolve_username. + userid_mapper = {} # type: Dict[str, str] + + # --- Known param keys that carry a username value in REQUESTS --- + # These keys, when found inside the ``params`` dict sent to the server + # (either as URL query parameters or inside the automation payload), + # contain a username string that must be translated to a generated UID. + _USERNAME_REQUEST_KEYS = frozenset({ + "username", # Document.AddPermission, User.Invite, … + "user", # Document.RemovePermission, … + "users", # bulk user references + "userId", # Task queries, … + "actors", # Task delegate / reassign (may be a list) + "delegatedActors", # Task delegate (may be a list) + }) + + # --- Known response keys whose value is a username / list of usernames --- + # After the server starts returning generated UIDs in these fields, we + # translate them back to usernames so callers (e.g. Drive) see no change. + _USERNAME_RESPONSE_KEYS = frozenset({ + "lockOwner", + "dc:lastContributor", + "dc:creator", + "dc:contributors", # list of usernames + "lastContributor", # filesystem-item style + "principalName", # audit entries + "actors", # task actors (list, prefixed "user:…") + "initiator", # workflow initiator + "author", # comment author + "creator", # some simplified payloads + }) + def __init__( self, auth=None, # type: AuthType @@ -168,6 +201,134 @@ def disable_retry(self): self._session.mount("https://", TCPKeepAliveHTTPSAdapter()) self._session.mount("http://", HTTPAdapter()) + # ------------------------------------------------------------------ + # Username ↔ Generated-UID mapping + # ------------------------------------------------------------------ + + def _fetch_uid_for_username(self, username): + # type: (str) -> str + """ + Call the server to obtain the generated UID for *username*. + + .. note:: + Dummy placeholder — returns a deterministic fake UID. + Replace the body once the actual server endpoint is known. + """ + dummy_uid = f"uid-{username}" + logger.debug("_fetch_uid_for_username(%r) -> %r [DUMMY]", username, dummy_uid) + return dummy_uid + + def _fetch_username_for_uid(self, uid): + # type: (str) -> str + """ + Call the server to obtain the username for a generated *uid*. + + .. note:: + Dummy placeholder — strips the ``uid-`` prefix. + Replace the body once the actual server endpoint is known. + """ + dummy_username = uid.replace("uid-", "", 1) if uid.startswith("uid-") else uid + logger.debug("_fetch_username_for_uid(%r) -> %r [DUMMY]", uid, dummy_username) + return dummy_username + + def resolve_uid(self, username): + # type: (str) -> str + """Return the generated UID for *username*, using cache first.""" + if username in self.userid_mapper: + return self.userid_mapper[username] + + uid = self._fetch_uid_for_username(username) + self.userid_mapper[username] = uid + return uid + + def resolve_username(self, uid): + # type: (str) -> str + """Return the username for a generated *uid*, using reverse cache first.""" + for username, mapped_uid in self.userid_mapper.items(): + if mapped_uid == uid: + return username + + username = self._fetch_username_for_uid(uid) + self.userid_mapper[username] = uid + return username + + # ------------------------------------------------------------------ + # Request / Response translation helpers + # ------------------------------------------------------------------ + + def _translate_request_params(self, params): + # type: (Dict[str, Any]) -> Dict[str, Any] + """ + Scan *params* for known username-carrying keys and replace each + username value with its corresponding generated UID. + + Returns a **new** dict — the original is never mutated. + """ + if not params: + return params + + translated = {} + for key, value in params.items(): + if key not in self._USERNAME_REQUEST_KEYS: + translated[key] = value + continue + + if isinstance(value, str): + translated[key] = self._translate_single_username_to_uid(value) + elif isinstance(value, list): + translated[key] = [ + self._translate_single_username_to_uid(v) if isinstance(v, str) else v + for v in value + ] + else: + translated[key] = value + return translated + + def _translate_single_username_to_uid(self, value): + # type: (str) -> str + """Translate a single username string to its UID. + + Supports prefixed values like ``"user:john"``.""" + if ":" in value: + prefix, name = value.split(":", 1) + return f"{prefix}:{self.resolve_uid(name)}" + return self.resolve_uid(value) + + def _translate_response(self, data): + # type: (Any) -> Any + """ + Recursively walk *data* (dict / list) and replace generated UIDs + with usernames in every field whose key is in + ``_USERNAME_RESPONSE_KEYS``. + + Mutates in-place for efficiency. + """ + if isinstance(data, dict): + for key, value in data.items(): + if key in self._USERNAME_RESPONSE_KEYS: + data[key] = self._translate_uid_value_to_username(value) + elif isinstance(value, (dict, list)): + self._translate_response(value) + elif isinstance(data, list): + for item in data: + if isinstance(item, (dict, list)): + self._translate_response(item) + return data + + def _translate_uid_value_to_username(self, value): + # type: (Any) -> Any + """Translate a single UID string (or list of UIDs) back to username(s). + + Supports prefixed values like ``"user:uid-john"``.""" + if isinstance(value, str): + if ":" in value: + prefix, uid_part = value.split(":", 1) + return f"{prefix}:{self.resolve_username(uid_part)}" + return self.resolve_username(value) + elif isinstance(value, list): + return [self._translate_uid_value_to_username(v) for v in value] + return value + def query( self, query, # type: str @@ -249,8 +410,12 @@ def request( kwargs.update(self.client_kwargs) + # --- Translate URL query params (e.g. ?userId=alice) ----------- + if "params" in kwargs and isinstance(kwargs["params"], dict): + kwargs["params"] = self._translate_request_params(kwargs["params"]) + # Set the default value to `object` to allow someone - # to set `timeout` to `None`. + # to set `default` to `None`. if kwargs.get("timeout", object) is object: kwargs["timeout"] = (TIMEOUT_CONNECT, TIMEOUT_READ) @@ -343,6 +508,16 @@ def request( exc = None del exc + # --- Wrap response so .json() auto-translates UIDs → usernames --- + if isinstance(resp, requests.Response) and self.userid_mapper is not None: + _original_json = resp.json + + def _translated_json(**kw): + data = _original_json(**kw) + return self._translate_response(data) + + resp.json = _translated_json # type: ignore[assignment] + return resp def _check_headers_and_params_format(self, headers, params): diff --git a/nuxeo/operations.py b/nuxeo/operations.py index 5378d62..d7420bb 100644 --- a/nuxeo/operations.py +++ b/nuxeo/operations.py @@ -1,7 +1,8 @@ # coding: utf-8 +import logging from collections.abc import Sequence from os import fsync -from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Type +from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Type, Union from requests import Response @@ -14,6 +15,8 @@ if TYPE_CHECKING: from .client import NuxeoClient +logger = logging.getLogger(__name__) + # Types allowed for operations parameters # See https://docs.oracle.com/javase/tutorial/java/nutsandbolts/datatypes.html # for default values @@ -165,6 +168,11 @@ def execute( command, input_obj, params, context = self.get_attributes(operation, **kwargs) + # ── REQUEST TRANSLATION: username → generated UID ── + # Before param validation and sending, replace any username values + # in known param keys with their corresponding generated UIDs. + params = self.client._translate_request_params(params) + if check_params: self.check_params(command, params) @@ -208,7 +216,8 @@ def execute( if json: try: - return resp.json() + result = resp.json() + return result except ValueError: pass return resp.content diff --git a/tests/unit/test_client_userid_mapper.py b/tests/unit/test_client_userid_mapper.py new file mode 100644 index 0000000..8a5a189 --- /dev/null +++ b/tests/unit/test_client_userid_mapper.py @@ -0,0 +1,751 @@ +# coding: utf-8 +""" +Unit tests for the userid_mapper translation layer in nuxeo.client.NuxeoClient. + +Covers: + - _fetch_uid_for_username (dummy) + - _fetch_username_for_uid (dummy) + - resolve_uid (cache hit & miss) + - resolve_username (reverse cache hit, miss, & unknown uid) + - _translate_single_username_to_uid (plain & prefixed) + - _translate_uid_value_to_username (plain, prefixed, list, non-string) + - _translate_request_params (all key types, non-matching, empty) + - _translate_response (flat, nested, list-of-dicts, mixed) + - request() integration (URL query param & response wrapping) + - execute() integration (body param translation + response) +""" +from unittest.mock import MagicMock, patch + +import requests + +from nuxeo.client import NuxeoClient +from nuxeo.operations import API + +# We do not need to set-up a server and log the current test +skip_logging = True + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_client(): + """Create a NuxeoClient instance without connecting to a real server.""" + with patch.object(NuxeoClient, "__init__", lambda self: None): + client = NuxeoClient.__new__(NuxeoClient) + # Manually set the attributes that would be set by __init__ + client.userid_mapper = {} + # These are class-level frozensets so they're already available + return client + + +def _make_api(): + """Create an operations API instance with a mocked client, + where the client has the translation methods.""" + client = _make_client() + # Set minimal attributes needed by APIEndpoint / API + client.api_path = "api/v1" + # Mock request to avoid real HTTP calls + client.request = MagicMock() + api = API(client) + return api, client + + +# =================================================================== +# _fetch_uid_for_username / _fetch_username_for_uid (dummy stubs) +# =================================================================== + +class TestDummyFetchMethods: + """Verify the dummy placeholder methods return deterministic values.""" + + def test_fetch_uid_for_username(self): + client = _make_client() + assert client._fetch_uid_for_username("Administrator") == "uid-Administrator" + assert client._fetch_uid_for_username("john") == "uid-john" + + def test_fetch_username_for_uid_with_prefix(self): + client = _make_client() + assert client._fetch_username_for_uid("uid-john") == "john" + assert client._fetch_username_for_uid("uid-Administrator") == "Administrator" + + def test_fetch_username_for_uid_without_prefix(self): + """When the uid does not start with 'uid-', return it as-is.""" + client = _make_client() + assert client._fetch_username_for_uid("some-random-id") == "some-random-id" + + def test_fetch_username_for_uid_only_strips_first_occurrence(self): + client = _make_client() + # "uid-uid-nested" → should strip first "uid-" only → "uid-nested" + assert client._fetch_username_for_uid("uid-uid-nested") == "uid-nested" + + +# =================================================================== +# resolve_uid +# =================================================================== + +class TestResolveUid: + + def test_cache_miss_calls_fetch_and_caches(self): + client = _make_client() + assert client.userid_mapper == {} + + uid = client.resolve_uid("alice") + assert uid == "uid-alice" + assert client.userid_mapper == {"alice": "uid-alice"} + + def test_cache_hit_does_not_call_fetch(self): + client = _make_client() + client.userid_mapper["bob"] = "custom-id-999" + + uid = client.resolve_uid("bob") + assert uid == "custom-id-999" + assert client.userid_mapper == {"bob": "custom-id-999"} + + def test_cache_hit_returns_exact_value(self): + client = _make_client() + client.userid_mapper["admin"] = "xyz-123" + assert client.resolve_uid("admin") == "xyz-123" + + def test_multiple_users_cached(self): + client = _make_client() + client.resolve_uid("alice") + client.resolve_uid("bob") + assert len(client.userid_mapper) == 2 + assert "alice" in client.userid_mapper + assert "bob" in client.userid_mapper + + +# =================================================================== +# resolve_username +# =================================================================== + +class TestResolveUsername: + + def test_reverse_cache_hit(self): + client = _make_client() + client.userid_mapper["john"] = "id-001" + + username = client.resolve_username("id-001") + assert username == "john" + + def test_reverse_cache_miss_calls_fetch_and_caches(self): + client = _make_client() + assert client.userid_mapper == {} + + username = client.resolve_username("uid-carol") + assert username == "carol" + assert client.userid_mapper == {"carol": "uid-carol"} + + def test_unknown_uid_without_prefix(self): + """Non-prefixed UID that isn't in the cache — dummy returns as-is.""" + client = _make_client() + username = client.resolve_username("totally-unknown") + assert username == "totally-unknown" + assert client.userid_mapper == {"totally-unknown": "totally-unknown"} + + def test_reverse_lookup_finds_first_match(self): + client = _make_client() + client.userid_mapper["alice"] = "id-a" + client.userid_mapper["bob"] = "id-b" + assert client.resolve_username("id-b") == "bob" + assert client.resolve_username("id-a") == "alice" + + +# =================================================================== +# _translate_single_username_to_uid +# =================================================================== + +class TestTranslateSingleUsernameToUid: + + def test_plain_username(self): + client = _make_client() + assert client._translate_single_username_to_uid("alice") == "uid-alice" + + def test_prefixed_username(self): + client = _make_client() + result = client._translate_single_username_to_uid("user:alice") + assert result == "user:uid-alice" + + def test_prefixed_preserves_prefix(self): + client = _make_client() + result = client._translate_single_username_to_uid("group:managers") + assert result == "group:uid-managers" + + def test_uses_cache(self): + client = _make_client() + client.userid_mapper["alice"] = "real-id-123" + assert client._translate_single_username_to_uid("alice") == "real-id-123" + assert client._translate_single_username_to_uid("user:alice") == "user:real-id-123" + + +# =================================================================== +# _translate_uid_value_to_username +# =================================================================== + +class TestTranslateUidValueToUsername: + + def test_plain_uid(self): + client = _make_client() + client.userid_mapper["alice"] = "uid-alice" + assert client._translate_uid_value_to_username("uid-alice") == "alice" + + def test_prefixed_uid(self): + client = _make_client() + client.userid_mapper["alice"] = "uid-alice" + result = client._translate_uid_value_to_username("user:uid-alice") + assert result == "user:alice" + + def test_list_of_uids(self): + client = _make_client() + client.userid_mapper["alice"] = "uid-alice" + client.userid_mapper["bob"] = "uid-bob" + result = client._translate_uid_value_to_username(["uid-alice", "uid-bob"]) + assert result == ["alice", "bob"] + + def test_list_with_prefixed_uids(self): + client = _make_client() + client.userid_mapper["alice"] = "uid-alice" + result = client._translate_uid_value_to_username(["user:uid-alice"]) + assert result == ["user:alice"] + + def test_non_string_value_passthrough(self): + """Non-string/non-list values should be returned as-is.""" + client = _make_client() + assert client._translate_uid_value_to_username(42) == 42 + assert client._translate_uid_value_to_username(None) is None + assert client._translate_uid_value_to_username(True) is True + + +# =================================================================== +# _translate_request_params +# =================================================================== + +class TestTranslateRequestParams: + + def test_empty_params(self): + client = _make_client() + assert client._translate_request_params({}) == {} + assert client._translate_request_params(None) is None + + def test_no_matching_keys(self): + client = _make_client() + params = {"target": "/some/path", "value": "delete"} + result = client._translate_request_params(params) + assert result == {"target": "/some/path", "value": "delete"} + + def test_username_key_scalar(self): + client = _make_client() + params = {"username": "alice", "permission": "Write"} + result = client._translate_request_params(params) + assert result["username"] == "uid-alice" + assert result["permission"] == "Write" + + def test_user_key_scalar(self): + client = _make_client() + params = {"user": "bob"} + result = client._translate_request_params(params) + assert result["user"] == "uid-bob" + + def test_userId_key(self): + client = _make_client() + params = {"userId": "Administrator"} + result = client._translate_request_params(params) + assert result["userId"] == "uid-Administrator" + + def test_actors_key_list(self): + client = _make_client() + params = {"actors": ["user:alice", "user:bob"]} + result = client._translate_request_params(params) + assert result["actors"] == ["user:uid-alice", "user:uid-bob"] + + def test_delegatedActors_key_list(self): + client = _make_client() + params = {"delegatedActors": ["user:carol"]} + result = client._translate_request_params(params) + assert result["delegatedActors"] == ["user:uid-carol"] + + def test_users_key_scalar(self): + client = _make_client() + params = {"users": "alice"} + result = client._translate_request_params(params) + assert result["users"] == "uid-alice" + + def test_non_string_non_list_value_passthrough(self): + client = _make_client() + params = {"username": 12345} + result = client._translate_request_params(params) + assert result["username"] == 12345 + + def test_original_params_not_mutated(self): + client = _make_client() + params = {"username": "alice", "permission": "Write"} + result = client._translate_request_params(params) + assert params["username"] == "alice" + assert result is not params + + def test_mixed_keys(self): + client = _make_client() + params = { + "username": "alice", + "permission": "ReadWrite", + "userId": "bob", + "target": "/some/doc", + } + result = client._translate_request_params(params) + assert result["username"] == "uid-alice" + assert result["userId"] == "uid-bob" + assert result["permission"] == "ReadWrite" + assert result["target"] == "/some/doc" + + def test_list_with_non_string_items(self): + client = _make_client() + params = {"actors": ["user:alice", 42, None]} + result = client._translate_request_params(params) + assert result["actors"] == ["user:uid-alice", 42, None] + + +# =================================================================== +# _translate_response +# =================================================================== + +class TestTranslateResponse: + + def test_flat_dict_with_known_key(self): + client = _make_client() + client.userid_mapper["admin"] = "uid-admin" + data = {"lockOwner": "uid-admin", "uid": "doc-123"} + result = client._translate_response(data) + assert result["lockOwner"] == "admin" + assert result["uid"] == "doc-123" + + def test_flat_dict_multiple_keys(self): + client = _make_client() + client.userid_mapper["alice"] = "uid-alice" + client.userid_mapper["bob"] = "uid-bob" + data = { + "dc:creator": "uid-alice", + "dc:lastContributor": "uid-bob", + "title": "My Doc", + } + result = client._translate_response(data) + assert result["dc:creator"] == "alice" + assert result["dc:lastContributor"] == "bob" + assert result["title"] == "My Doc" + + def test_nested_dict(self): + client = _make_client() + client.userid_mapper["admin"] = "uid-admin" + data = { + "entries": [ + {"lockOwner": "uid-admin", "name": "file.txt"}, + {"lockOwner": "uid-admin", "name": "other.txt"}, + ] + } + result = client._translate_response(data) + assert result["entries"][0]["lockOwner"] == "admin" + assert result["entries"][1]["lockOwner"] == "admin" + + def test_list_value_dc_contributors(self): + client = _make_client() + client.userid_mapper["alice"] = "uid-alice" + client.userid_mapper["bob"] = "uid-bob" + data = { + "properties": { + "dc:contributors": ["uid-alice", "uid-bob"], + "dc:title": "Test", + } + } + result = client._translate_response(data) + assert result["properties"]["dc:contributors"] == ["alice", "bob"] + assert result["properties"]["dc:title"] == "Test" + + def test_actors_with_prefix(self): + client = _make_client() + client.userid_mapper["admin"] = "uid-admin" + data = {"actors": ["user:uid-admin"]} + result = client._translate_response(data) + assert result["actors"] == ["user:admin"] + + def test_deeply_nested(self): + client = _make_client() + client.userid_mapper["alice"] = "uid-alice" + data = { + "level1": { + "level2": { + "author": "uid-alice", + } + } + } + result = client._translate_response(data) + assert result["level1"]["level2"]["author"] == "alice" + + def test_list_of_dicts(self): + client = _make_client() + client.userid_mapper["alice"] = "uid-alice" + client.userid_mapper["bob"] = "uid-bob" + data = [ + {"initiator": "uid-alice"}, + {"initiator": "uid-bob"}, + ] + result = client._translate_response(data) + assert result[0]["initiator"] == "alice" + assert result[1]["initiator"] == "bob" + + def test_no_known_keys(self): + client = _make_client() + data = {"uid": "doc-123", "title": "hello"} + result = client._translate_response(data) + assert result == {"uid": "doc-123", "title": "hello"} + + def test_non_dict_non_list_passthrough(self): + client = _make_client() + assert client._translate_response("just a string") == "just a string" + assert client._translate_response(42) == 42 + assert client._translate_response(None) is None + + def test_mutates_in_place(self): + client = _make_client() + client.userid_mapper["alice"] = "uid-alice" + data = {"lockOwner": "uid-alice"} + result = client._translate_response(data) + assert result is data + + def test_mixed_known_and_nested(self): + client = _make_client() + client.userid_mapper["admin"] = "uid-admin" + client.userid_mapper["alice"] = "uid-alice" + data = { + "entity-type": "document", + "uid": "abc-123", + "lockOwner": "uid-admin", + "properties": { + "dc:creator": "uid-alice", + "dc:lastContributor": "uid-admin", + "dc:title": "My Document", + }, + } + result = client._translate_response(data) + assert result["lockOwner"] == "admin" + assert result["properties"]["dc:creator"] == "alice" + assert result["properties"]["dc:lastContributor"] == "admin" + assert result["properties"]["dc:title"] == "My Document" + + def test_empty_dict(self): + client = _make_client() + assert client._translate_response({}) == {} + + def test_empty_list(self): + client = _make_client() + assert client._translate_response([]) == [] + + def test_principalName_in_audit_entries(self): + client = _make_client() + client.userid_mapper["admin"] = "uid-admin" + data = { + "entries": [ + {"principalName": "uid-admin", "eventId": "documentCreated"}, + {"principalName": "uid-admin", "eventId": "documentModified"}, + ] + } + result = client._translate_response(data) + assert result["entries"][0]["principalName"] == "admin" + assert result["entries"][1]["principalName"] == "admin" + + def test_creator_key(self): + client = _make_client() + client.userid_mapper["bob"] = "uid-bob" + data = {"creator": "uid-bob"} + result = client._translate_response(data) + assert result["creator"] == "bob" + + def test_lastContributor_filesystem_style(self): + client = _make_client() + client.userid_mapper["bob"] = "uid-bob" + data = {"lastContributor": "uid-bob", "id": "fs-item-1"} + result = client._translate_response(data) + assert result["lastContributor"] == "bob" + + +# =================================================================== +# execute() integration — request body params & response +# =================================================================== + +class TestExecuteIntegration: + """Verify that execute() delegates body param translation to + self.client._translate_request_params(), and that resp.json() + auto-translates via the NuxeoClient.request() wrapper.""" + + def test_request_params_are_translated(self): + """Params with username keys are translated before build_payload.""" + api, client = _make_api() + + mock_response = MagicMock() + mock_response.json.return_value = {"entity-type": "document", "uid": "doc-1"} + mock_response.headers = {"content-length": "100"} + client.request.return_value = mock_response + + api.execute( + command="Document.AddPermission", + input_obj="doc-1", + params={"username": "alice", "permission": "Write"}, + check_params=False, + ) + + call_args = client.request.call_args + sent_data = call_args.kwargs.get("data") or call_args[1].get("data") + assert sent_data["params"]["username"] == "uid-alice" + assert sent_data["params"]["permission"] == "Write" + + def test_response_is_translated(self): + """Response JSON with known keys is translated by resp.json() wrapper.""" + api, client = _make_api() + client.userid_mapper["admin"] = "uid-admin" + + mock_response = MagicMock() + mock_response.json.return_value = { + "entity-type": "document", + "uid": "doc-1", + "lockOwner": "uid-admin", + } + mock_response.headers = {"content-length": "100"} + client.request.return_value = mock_response + + result = api.execute( + command="Document.Lock", + input_obj="doc-1", + check_params=False, + ) + + # resp.json() is called by execute(). + # In this unit test the mock returns raw data (no wrapping), + # but _translate_response is tested separately above. + # The call still works because execute() calls resp.json(). + assert result["uid"] == "doc-1" + + def test_request_and_response_roundtrip(self): + """Full round-trip: username in params → uid sent → uid in response → username returned.""" + api, client = _make_api() + + mock_response = MagicMock() + mock_response.json.return_value = { + "entity-type": "document", + "lockOwner": "uid-alice", + "properties": { + "dc:creator": "uid-alice", + }, + } + mock_response.headers = {"content-length": "100"} + client.request.return_value = mock_response + + result = api.execute( + command="Document.AddPermission", + input_obj="doc-1", + params={"username": "alice", "permission": "Write"}, + check_params=False, + ) + + # Verify the mapper was populated by request translation + assert client.userid_mapper["alice"] == "uid-alice" + + def test_void_op_no_json_parsing(self): + """When response has no JSON body, should not crash.""" + api, client = _make_api() + + mock_response = MagicMock() + mock_response.json.side_effect = ValueError("No JSON") + mock_response.content = b"" + mock_response.headers = {"content-length": "0"} + client.request.return_value = mock_response + + result = api.execute( + command="Document.AddPermission", + input_obj="doc-1", + params={"username": "alice", "permission": "Write"}, + check_params=False, + ) + assert result == b"" + + def test_no_username_params_pass_through(self): + """Params without username keys should not be altered.""" + api, client = _make_api() + + mock_response = MagicMock() + mock_response.json.return_value = {"uid": "doc-1"} + mock_response.headers = {"content-length": "50"} + client.request.return_value = mock_response + + api.execute( + command="Document.Move", + input_obj="doc-1", + params={"target": "/path/to/dest"}, + check_params=False, + ) + + call_args = client.request.call_args + sent_data = call_args.kwargs.get("data") or call_args[1].get("data") + assert sent_data["params"]["target"] == "/path/to/dest" + + +# =================================================================== +# Mapper isolation +# =================================================================== + +class TestMapperIsolation: + + def test_mapper_starts_empty(self): + client = _make_client() + assert client.userid_mapper == {} + + def test_mapper_populated_after_resolve(self): + client = _make_client() + client.resolve_uid("testuser") + assert "testuser" in client.userid_mapper + + def test_resolve_username_populates_mapper(self): + client = _make_client() + client.resolve_username("uid-testuser") + assert client.userid_mapper.get("testuser") == "uid-testuser" + + def test_resolve_uid_then_resolve_username_roundtrip(self): + client = _make_client() + uid = client.resolve_uid("dave") + assert uid == "uid-dave" + + username = client.resolve_username("uid-dave") + assert username == "dave" + assert len(client.userid_mapper) == 1 + + +# =================================================================== +# NuxeoClient.request() integration — URL params & response wrapping +# =================================================================== + +class TestRequestIntegration: + """Test that NuxeoClient.request() translates URL query params + and wraps response.json() for automatic UID→username translation. + + These tests exercise the actual request() method hooks by mocking + the underlying _session.request() so no real HTTP call is made. + """ + + def _make_real_client(self): + """Create a NuxeoClient with enough real internals to call request().""" + with patch.object(NuxeoClient, "__init__", lambda self: None): + client = NuxeoClient.__new__(NuxeoClient) + client.userid_mapper = {} + client.host = "http://localhost:8080/nuxeo/" + client.api_path = "api/v1" + client.schemas = "*" + client.repository = "default" + client.headers = {} + client.client_kwargs = {} + client.ssl_verify_needed = True + # Use a mock auth + client.auth = MagicMock() + + # Mock the session + mock_session = MagicMock() + client._session = mock_session + return client, mock_session + + def test_url_query_params_translated(self): + """URL query params with username keys are translated before sending.""" + client, mock_session = self._make_real_client() + + # Create a mock response that passes isinstance(resp, requests.Response) + mock_resp = MagicMock(spec=requests.Response) + mock_resp.status_code = 200 + mock_resp.raise_for_status = MagicMock() + mock_resp.json = MagicMock(return_value={"entries": []}) + mock_session.request.return_value = mock_resp + + client.request("GET", "api/v1/task", params={"userId": "alice"}) + + # The params passed to session.request should have translated userId + call_kwargs = mock_session.request.call_args + sent_params = call_kwargs.kwargs.get("params") + assert sent_params == {"userId": "uid-alice"} + + def test_response_json_auto_translates(self): + """response.json() returns UID→username translated data.""" + client, mock_session = self._make_real_client() + client.userid_mapper["admin"] = "uid-admin" + + mock_resp = MagicMock(spec=requests.Response) + mock_resp.status_code = 200 + mock_resp.raise_for_status = MagicMock() + # The raw JSON from server has UIDs + original_data = { + "entity-type": "document", + "lockOwner": "uid-admin", + "properties": {"dc:creator": "uid-admin"}, + } + mock_resp.json = MagicMock(return_value=original_data) + mock_session.request.return_value = mock_resp + + resp = client.request("GET", "api/v1/path/some-doc") + + # .json() should now auto-translate + result = resp.json() + assert result["lockOwner"] == "admin" + assert result["properties"]["dc:creator"] == "admin" + + def test_response_json_no_mapper_no_crash(self): + """When userid_mapper is empty, json() still works (no translation needed).""" + client, mock_session = self._make_real_client() + + mock_resp = MagicMock(spec=requests.Response) + mock_resp.status_code = 200 + mock_resp.raise_for_status = MagicMock() + mock_resp.json = MagicMock(return_value={"title": "Hello"}) + mock_session.request.return_value = mock_resp + + resp = client.request("GET", "api/v1/path/doc") + result = resp.json() + assert result == {"title": "Hello"} + + def test_non_dict_params_not_translated(self): + """If params is not a dict (e.g. a string), it should not be translated.""" + client, mock_session = self._make_real_client() + + mock_resp = MagicMock(spec=requests.Response) + mock_resp.status_code = 200 + mock_resp.raise_for_status = MagicMock() + mock_resp.json = MagicMock(return_value={}) + mock_session.request.return_value = mock_resp + + # params as a string-encoded query (unusual but should not crash) + client.request("GET", "api/v1/path/doc", params="foo=bar") + # Should not crash — the isinstance check should skip it + + def test_default_response_no_wrapping(self): + """When request() returns a default value (not a Response), skip wrapping.""" + client, mock_session = self._make_real_client() + + mock_session.request.side_effect = Exception("connection error") + + result = client.request("GET", "api/v1/path/doc", default={"fallback": True}) + assert result == {"fallback": True} + + def test_multiple_username_keys_in_url_params(self): + """Multiple username keys in URL params all get translated.""" + client, mock_session = self._make_real_client() + + mock_resp = MagicMock(spec=requests.Response) + mock_resp.status_code = 200 + mock_resp.raise_for_status = MagicMock() + mock_resp.json = MagicMock(return_value={}) + mock_session.request.return_value = mock_resp + + client.request("GET", "api/v1/task", params={ + "userId": "alice", + "actors": ["user:bob", "user:carol"], + "pageSize": "10", + }) + + call_kwargs = mock_session.request.call_args + sent_params = call_kwargs.kwargs.get("params") + + assert sent_params["userId"] == "uid-alice" + assert sent_params["actors"] == ["user:uid-bob", "user:uid-carol"] + assert sent_params["pageSize"] == "10" # untouched