diff --git a/src/adcp/decisioning/__init__.py b/src/adcp/decisioning/__init__.py index ed37f058e..ba5d796db 100644 --- a/src/adcp/decisioning/__init__.py +++ b/src/adcp/decisioning/__init__.py @@ -93,6 +93,9 @@ def create_media_buy( InMemoryMockAdServer, MockAdServer, ) +from adcp.decisioning.oauth_passthrough import ( + create_oauth_passthrough_resolver, +) from adcp.decisioning.platform import ( GOVERNANCE_SPECIALISMS, DecisioningCapabilities, @@ -313,6 +316,7 @@ def __init__(self, *args: object, **kwargs: object) -> None: "bearer_only_registry", "compose_method", "create_adcp_server_from_platform", + "create_oauth_passthrough_resolver", "create_roster_account_store", "create_translation_map", "create_upstream_http_client", diff --git a/src/adcp/decisioning/oauth_passthrough.py b/src/adcp/decisioning/oauth_passthrough.py new file mode 100644 index 000000000..cb7e0aeab --- /dev/null +++ b/src/adcp/decisioning/oauth_passthrough.py @@ -0,0 +1,276 @@ +"""OAuth pass-through ``AccountStore`` factory ("Shape B"). + +Standardises the canonical Shape B account-resolution pattern: an +adapter wraps a vendor OAuth + ad-account API (e.g. social-platform ad +management APIs that expose ``/me/adaccounts``-shaped endpoints) and +resolves the buyer's :class:`AccountReference` by hitting the +upstream's "list-my-accounts" endpoint with the buyer's bearer. + +Without this factory, every Shape B adapter rolls the same ~30 LOC: +extract bearer from ``auth_info``, GET ``/me/adaccounts``, match by +id, return the mapped :class:`Account`. This factory handles the +boilerplate; the adapter supplies the upstream specifics +(``list_endpoint``, ``to_account`` mapper) and the auth shape via +:func:`create_upstream_http_client`'s :class:`DynamicBearer.get_token`. + +Mirrors the JS ``createOAuthPassthroughResolver`` from +``@adcp/sdk@6.7`` (``src/lib/adapters/oauth-passthrough-resolver.ts``). + +Picking an :class:`AccountStore`? Three reference shapes by *who creates +the account*: + +* **Buyer self-onboards via ``sync_accounts``** — implement + :class:`AccountStoreUpsert` (Shape A). +* **Upstream OAuth API owns the roster** — + :func:`create_oauth_passthrough_resolver` (this module, Shape B, + returns an :class:`AccountStore`). +* **Publisher ops curates the roster** — your own + :class:`AccountStore` impl backed by a database (Shape C). + +**Pagination limitation.** The factory issues a single GET against +``list_endpoint`` and treats the parsed body as the full account +list. Upstreams that paginate (cursor / next-url envelopes) drop +accounts beyond page one silently. Adopters with paginated upstreams +must either aggregate pages inside ``extract_rows`` (synchronous +collection of all pages before returning the list) or compose their +own resolver. See the :class:`AccountStore` Protocol if you need +streaming pagination. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Awaitable, Callable +from typing import Any, Literal + +from adcp.decisioning.accounts import ResolveContext +from adcp.decisioning.context import AuthInfo +from adcp.decisioning.helpers import ref_account_id +from adcp.decisioning.types import Account +from adcp.decisioning.upstream import AuthContext, UpstreamHttpClient +from adcp.types import AccountReference + +__all__ = ["create_oauth_passthrough_resolver"] + + +def _default_extract_rows(body: Any) -> list[Any] | None: + """Default unwrap for the common ``/me/adaccounts``-shaped APIs. + + Accepts either a flat list (some plain-list APIs) or a + ``{"data": [...]}`` envelope. Returns ``None`` when the body + doesn't match either shape, signalling "no rows". + """ + if body is None: + return None + if isinstance(body, list): + return body + if isinstance(body, dict): + rows = body.get("data") + if isinstance(rows, list): + return rows + return None + + +def _default_auth_context(ctx: ResolveContext | None) -> AuthContext | None: + """Default ``get_auth_context``: forward ``ctx.auth_info`` verbatim. + + Works when the http client's :class:`DynamicBearer.get_token` + resolver reads the bearer off the :class:`AuthInfo` directly. The + upstream client treats this as an opaque mapping; the factory + doesn't interpret it. + """ + if ctx is None: + return None + return ctx.auth_info # type: ignore[return-value] + + +class _OAuthPassthroughAccountStore: + """:class:`AccountStore` impl backing :func:`create_oauth_passthrough_resolver`. + + Public attributes match the :class:`AccountStore` Protocol so an + instance plugs directly into :class:`DecisioningPlatform.accounts`. + The resolve method takes ``(ref, auth_info=None)`` per the + Protocol; the factory's ``to_account`` and ``get_auth_context`` + callbacks see a synthesised :class:`ResolveContext` so adopter + callbacks have a uniform shape with the rest of the + :class:`AccountStore` surface. + """ + + resolution: Literal["explicit"] = "explicit" + + def __init__( + self, + *, + http_client: UpstreamHttpClient, + list_endpoint: str, + to_account: Callable[ + [Any, ResolveContext | None], + Account[Any] | Awaitable[Account[Any]], + ], + id_field: str, + extract_rows: Callable[[Any], list[Any] | None], + get_auth_context: Callable[[ResolveContext | None], AuthContext | None], + ) -> None: + self._http_client = http_client + self._list_endpoint = list_endpoint + self._to_account = to_account + self._id_field = id_field + self._extract_rows = extract_rows + self._get_auth_context = get_auth_context + + async def resolve( + self, + ref: AccountReference | dict[str, Any] | None, + auth_info: AuthInfo | None = None, + ) -> Account[Any] | None: + account_id = ref_account_id(ref) + if account_id is None: + return None + + ctx = ResolveContext(auth_info=auth_info, tool_name="resolve") + auth_ctx = self._get_auth_context(ctx) + body = await self._http_client.get( + self._list_endpoint, + auth_context=auth_ctx, + ) + rows = self._extract_rows(body) + if rows is None: + return None + + for row in rows: + row_id = ( + row.get(self._id_field) + if isinstance(row, dict) + else getattr(row, self._id_field, None) + ) + if row_id == account_id: + result = self._to_account(row, ctx) + if inspect.isawaitable(result): + return await result + return result + return None + + +def create_oauth_passthrough_resolver( + *, + http_client: UpstreamHttpClient, + list_endpoint: str, + to_account: Callable[ + [Any, ResolveContext | None], + Account[Any] | Awaitable[Account[Any]], + ], + id_field: str = "id", + extract_rows: Callable[[Any], list[Any] | None] | None = None, + get_auth_context: Callable[[ResolveContext | None], AuthContext | None] | None = None, +) -> _OAuthPassthroughAccountStore: + """Create an :class:`AccountStore` backed by an upstream + OAuth-protected listing endpoint. + + The returned object satisfies the :class:`AccountStore` Protocol + (``resolution = 'explicit'``, ``resolve(ref, auth_info=None)``). + Adopters wire it directly into :class:`DecisioningPlatform`:: + + class SnapSeller(DecisioningPlatform): + accounts = create_oauth_passthrough_resolver(...) + + Shape B adapters typically don't manage account lifecycle on the + seller side, so the returned store implements only ``resolve`` — + not the optional :meth:`AccountStoreUpsert.upsert` / + :meth:`AccountStoreList.list` surfaces. Add those by wrapping the + returned store in a class that delegates ``resolve`` and adds the + upsert/list methods. + + :param http_client: Pre-configured upstream HTTP client (typically + from :func:`create_upstream_http_client`). Should be configured + with :class:`DynamicBearer` so the per-request auth context + flows through to bearer selection. + :param list_endpoint: Path on the upstream API that returns the + buyer's accounts. Common shapes: ``/v1/adaccounts``, + ``/me/adaccounts``, ``/customers``. + :param to_account: Map an upstream row to a framework + :class:`Account`. Receives the row and a synthesised + :class:`ResolveContext` (carrying the caller's + ``auth_info``). Sync or async — the framework awaits the + result either way. + + **Treat any embedded credential in ``Account.metadata`` as a + secret.** The framework strips ``metadata`` from the wire + response, but adopter code that throws an error containing + ``json.dumps(account)`` or logs ``ctx.account`` at info level + WILL leak it. Either don't embed the bearer (re-derive from + ``ctx.auth_info`` on each downstream method), or audit your + error projections. + :param id_field: Field on each upstream row that matches + ``AccountReference.account_id``. Defaults to ``"id"``. A typo + here silently always returns ``None`` — verify against the + upstream's documented response shape. + :param extract_rows: Optional callback receiving the raw parsed + upstream body and returning the row list. Defaults to: try the + body if it's a list, else ``body["data"]`` if it's a dict with + that key. Provide a custom callback for deeper-nested shapes + (e.g. ``{"data": {"list": [...]}}``). + :param get_auth_context: Extract the auth context to forward to the + upstream's :meth:`DynamicBearer.get_token` resolver. The return + value flows through as the per-call ``auth_context`` on + :meth:`UpstreamHttpClient.get`. Defaults to forwarding + ``ctx.auth_info`` verbatim — works when the http client's token + resolver reads from :class:`AuthInfo` directly. + + Behavior: + + * The returned store only handles the ``{account_id}`` + discriminated-union arm of :class:`AccountReference`. Other arms + (``{brand, operator}``) and ``None`` ref return ``None`` without + calling upstream. Adopters needing natural-key fallback compose + their own resolver around this one. + * Upstream errors propagate verbatim — ``http_client`` already + projects non-2xx to spec-conformant :class:`AdcpError` codes + (``AUTH_REQUIRED``, ``SERVICE_UNAVAILABLE``, etc.). Adopters + compose error mapping over the result if they want a different + shape. + * 404 from the upstream listing endpoint surfaces as ``None`` (the + http client's ``treat_404_as_none`` default), which the store + treats as "no rows found". + * **Pagination is not handled.** A single GET fetches the full + list; paginated upstreams drop accounts beyond page one. See the + module docstring for adopter workarounds. + + Example:: + + from adcp.decisioning import ( + DynamicBearer, + create_oauth_passthrough_resolver, + create_upstream_http_client, + ) + + async def get_token(ctx): + # ctx is the AuthInfo forwarded by default get_auth_context. + return ctx.credential.token + + upstream = create_upstream_http_client( + "https://upstream.example.com", + auth=DynamicBearer(get_token=get_token), + ) + + class UpstreamSeller(DecisioningPlatform): + accounts = create_oauth_passthrough_resolver( + http_client=upstream, + list_endpoint="/v1/me/adaccounts", + to_account=lambda row, ctx: Account( + id=row["id"], + name=row["name"], + status="active", + metadata={"upstream_id": row["id"]}, + ), + ) + """ + return _OAuthPassthroughAccountStore( + http_client=http_client, + list_endpoint=list_endpoint, + to_account=to_account, + id_field=id_field, + extract_rows=(extract_rows if extract_rows is not None else _default_extract_rows), + get_auth_context=( + get_auth_context if get_auth_context is not None else _default_auth_context + ), + ) diff --git a/tests/test_oauth_passthrough.py b/tests/test_oauth_passthrough.py new file mode 100644 index 000000000..353b883e3 --- /dev/null +++ b/tests/test_oauth_passthrough.py @@ -0,0 +1,499 @@ +"""Tests for ``create_oauth_passthrough_resolver``. + +Mirrors the JS-side ``test/server-adapters-oauth-passthrough-resolver.test.js`` +coverage. Uses ``respx`` for HTTP mocking against a real +:class:`UpstreamHttpClient` so the full bearer-injection / +404-translation / error-projection path runs end-to-end (matching the +posture in :mod:`tests.test_upstream_helpers`). +""" + +from __future__ import annotations + +from typing import Any + +import httpx +import pytest +import respx + +from adcp.decisioning import ( + Account, + AccountStore, + AdcpError, + AuthInfo, + DynamicBearer, + create_oauth_passthrough_resolver, + create_upstream_http_client, +) +from adcp.decisioning.oauth_passthrough import _default_extract_rows +from adcp.types import AccountReference, AccountReferenceById + +BASE = "https://upstream.example.com" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _ref_by_id(account_id: str) -> AccountReference: + return AccountReference(root=AccountReferenceById(account_id=account_id)) + + +def _ref_natural_key() -> dict[str, Any]: + """Natural-key arm — passed as a raw dict, which ``ref_account_id`` + accepts. Avoids constructing the typed natural-key model here + (test only needs the no-account_id branch).""" + return {"brand": {"domain": "acme.com"}, "operator": "pinnacle.com"} + + +async def _passthrough_token(ctx: Any) -> str: + """``DynamicBearer.get_token`` callback that reads the bearer + from the per-request auth_context (the AuthInfo dict the resolver + forwards). Mirrors how a real Shape B adapter wires it.""" + if ctx is None: + return "" + # ctx is the AuthInfo instance (default ``get_auth_context``). + token = getattr(ctx, "credential", None) + if token is None: + # Test paths that pass a plain mapping rather than AuthInfo. + return str(ctx.get("token", "")) if isinstance(ctx, dict) else "" + # AuthInfo carries the bearer on .credential.token for OAuth. + return getattr(token, "token", "") or "" + + +def _to_account(row: dict[str, Any], _ctx: Any) -> Account[Any]: + return Account( + id=str(row["id"]), + name=str(row.get("name", "")), + status="active", + metadata={"upstream_id": row["id"]}, + ) + + +def _make_client(token_factory: Any = _passthrough_token) -> Any: + return create_upstream_http_client( + BASE, + auth=DynamicBearer(get_token=token_factory), + ) + + +# --------------------------------------------------------------------------- +# AccountStore Protocol conformance +# --------------------------------------------------------------------------- + + +def test_returned_object_satisfies_account_store_protocol() -> None: + client = _make_client() + store = create_oauth_passthrough_resolver( + http_client=client, + list_endpoint="/me/adaccounts", + to_account=_to_account, + ) + assert isinstance(store, AccountStore) + assert store.resolution == "explicit" + + +# --------------------------------------------------------------------------- +# Ref-shape handling +# --------------------------------------------------------------------------- + + +@respx.mock +async def test_returns_none_for_natural_key_ref_without_calling_upstream() -> None: + route = respx.get(f"{BASE}/me/adaccounts").mock( + return_value=httpx.Response(200, json={"data": []}) + ) + client = _make_client() + store = create_oauth_passthrough_resolver( + http_client=client, + list_endpoint="/me/adaccounts", + to_account=_to_account, + ) + + result = await store.resolve(_ref_natural_key()) + assert result is None + assert not route.called + await client.aclose() + + +@respx.mock +async def test_returns_none_when_ref_is_none_without_calling_upstream() -> None: + route = respx.get(f"{BASE}/me/adaccounts").mock( + return_value=httpx.Response(200, json={"data": []}) + ) + client = _make_client() + store = create_oauth_passthrough_resolver( + http_client=client, + list_endpoint="/me/adaccounts", + to_account=_to_account, + ) + + result = await store.resolve(None) + assert result is None + assert not route.called + await client.aclose() + + +# --------------------------------------------------------------------------- +# Upstream lookup + match +# --------------------------------------------------------------------------- + + +@respx.mock +async def test_returns_mapped_account_when_account_id_matches_upstream_row() -> None: + respx.get(f"{BASE}/me/adaccounts").mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + {"id": "acc_1", "name": "Acme"}, + {"id": "acc_2", "name": "Globex"}, + ] + }, + ) + ) + client = _make_client() + store = create_oauth_passthrough_resolver( + http_client=client, + list_endpoint="/me/adaccounts", + to_account=_to_account, + ) + + auth_info = AuthInfo(kind="oauth", principal="p1") + # Inject the bearer the DynamicBearer resolver will read. + auth_info.credential = type("_C", (), {"token": "t_buyer_1"})() # type: ignore[attr-defined] + + result = await store.resolve(_ref_by_id("acc_1"), auth_info=auth_info) + assert result is not None + assert result.id == "acc_1" + assert result.name == "Acme" + assert result.metadata == {"upstream_id": "acc_1"} + + last = respx.calls.last.request + assert last.headers["Authorization"] == "Bearer t_buyer_1" + await client.aclose() + + +@respx.mock +async def test_returns_none_when_no_upstream_row_matches() -> None: + respx.get(f"{BASE}/me/adaccounts").mock( + return_value=httpx.Response(200, json={"data": [{"id": "acc_1", "name": "Acme"}]}) + ) + client = _make_client() + store = create_oauth_passthrough_resolver( + http_client=client, + list_endpoint="/me/adaccounts", + to_account=_to_account, + ) + + result = await store.resolve(_ref_by_id("acc_unknown")) + assert result is None + await client.aclose() + + +@respx.mock +async def test_returns_none_when_upstream_returns_none_body() -> None: + # 404 → http client returns None (treat_404_as_none=True default). + respx.get(f"{BASE}/me/adaccounts").mock(return_value=httpx.Response(404)) + client = _make_client() + store = create_oauth_passthrough_resolver( + http_client=client, + list_endpoint="/me/adaccounts", + to_account=_to_account, + ) + + result = await store.resolve(_ref_by_id("acc_1")) + assert result is None + await client.aclose() + + +# --------------------------------------------------------------------------- +# Error pass-through +# --------------------------------------------------------------------------- + + +@respx.mock +async def test_upstream_401_raises_adcp_error() -> None: + respx.get(f"{BASE}/me/adaccounts").mock(return_value=httpx.Response(401, text="unauthorized")) + client = _make_client() + store = create_oauth_passthrough_resolver( + http_client=client, + list_endpoint="/me/adaccounts", + to_account=_to_account, + ) + + with pytest.raises(AdcpError) as exc_info: + await store.resolve(_ref_by_id("acc_1")) + assert exc_info.value.code == "AUTH_REQUIRED" + await client.aclose() + + +@respx.mock +async def test_upstream_500_raises_service_unavailable() -> None: + respx.get(f"{BASE}/me/adaccounts").mock(return_value=httpx.Response(500)) + client = _make_client() + store = create_oauth_passthrough_resolver( + http_client=client, + list_endpoint="/me/adaccounts", + to_account=_to_account, + ) + + with pytest.raises(AdcpError) as exc_info: + await store.resolve(_ref_by_id("acc_1")) + assert exc_info.value.code == "SERVICE_UNAVAILABLE" + await client.aclose() + + +# --------------------------------------------------------------------------- +# Configurable extraction +# --------------------------------------------------------------------------- + + +@respx.mock +async def test_custom_id_field() -> None: + respx.get(f"{BASE}/v1/adaccounts").mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + {"account_uuid": "act_42", "name": "Acme"}, + {"account_uuid": "act_43", "name": "Globex"}, + ] + }, + ) + ) + client = _make_client() + store = create_oauth_passthrough_resolver( + http_client=client, + list_endpoint="/v1/adaccounts", + id_field="account_uuid", + to_account=lambda row, _ctx: Account( + id=row["account_uuid"], + name=row["name"], + status="active", + ), + ) + + result = await store.resolve(_ref_by_id("act_43")) + assert result is not None + assert result.id == "act_43" + assert result.name == "Globex" + await client.aclose() + + +@respx.mock +async def test_custom_extract_rows_receives_raw_response() -> None: + # Some APIs nest deeper than the default unwrap, e.g. + # ``data.list``. The custom callback gets the raw parsed body. + respx.get(f"{BASE}/v2/me").mock( + return_value=httpx.Response( + 200, + json={"data": {"list": [{"id": "a1", "name": "Acme"}]}}, + ) + ) + seen: list[Any] = [] + + def extract(body: Any) -> list[dict[str, Any]]: + seen.append(body) + return list(body["data"]["list"]) + + client = _make_client() + store = create_oauth_passthrough_resolver( + http_client=client, + list_endpoint="/v2/me", + extract_rows=extract, + to_account=_to_account, + ) + + result = await store.resolve(_ref_by_id("a1")) + assert result is not None + assert result.id == "a1" + assert seen == [{"data": {"list": [{"id": "a1", "name": "Acme"}]}}] + await client.aclose() + + +@respx.mock +async def test_default_extract_rows_handles_flat_list() -> None: + respx.get(f"{BASE}/customers").mock( + return_value=httpx.Response( + 200, + json=[ + {"id": "a1", "name": "Acme"}, + {"id": "a2", "name": "Globex"}, + ], + ) + ) + client = _make_client() + store = create_oauth_passthrough_resolver( + http_client=client, + list_endpoint="/customers", + to_account=_to_account, + ) + + result = await store.resolve(_ref_by_id("a2")) + assert result is not None + assert result.name == "Globex" + await client.aclose() + + +# --------------------------------------------------------------------------- +# _default_extract_rows edge cases (unit-level) +# --------------------------------------------------------------------------- + + +def test_default_extract_rows_returns_none_for_none_body() -> None: + assert _default_extract_rows(None) is None + + +def test_default_extract_rows_returns_none_for_empty_dict() -> None: + assert _default_extract_rows({}) is None + + +def test_default_extract_rows_returns_none_for_data_null() -> None: + assert _default_extract_rows({"data": None}) is None + + +def test_default_extract_rows_returns_none_when_data_is_not_a_list() -> None: + assert _default_extract_rows({"data": "not_a_list"}) is None + + +def test_default_extract_rows_returns_flat_list() -> None: + rows = [{"id": "a1"}] + assert _default_extract_rows(rows) is rows + + +def test_default_extract_rows_unwraps_data_envelope() -> None: + rows = [{"id": "a1"}] + assert _default_extract_rows({"data": rows}) is rows + + +# --------------------------------------------------------------------------- +# to_account: sync + async +# --------------------------------------------------------------------------- + + +@respx.mock +async def test_async_to_account_is_awaited() -> None: + respx.get(f"{BASE}/me/adaccounts").mock( + return_value=httpx.Response(200, json={"data": [{"id": "a1", "name": "Acme"}]}) + ) + + async def to_account_async(row: dict[str, Any], _ctx: Any) -> Account[Any]: + return Account(id=row["id"], name=row["name"], status="active") + + client = _make_client() + store = create_oauth_passthrough_resolver( + http_client=client, + list_endpoint="/me/adaccounts", + to_account=to_account_async, + ) + + result = await store.resolve(_ref_by_id("a1")) + assert result is not None + assert result.id == "a1" + await client.aclose() + + +@respx.mock +async def test_sync_to_account_returns_account_directly() -> None: + respx.get(f"{BASE}/me/adaccounts").mock( + return_value=httpx.Response(200, json={"data": [{"id": "a1", "name": "Acme"}]}) + ) + + def to_account_sync(row: dict[str, Any], _ctx: Any) -> Account[Any]: + return Account(id=row["id"], name=row["name"], status="active") + + client = _make_client() + store = create_oauth_passthrough_resolver( + http_client=client, + list_endpoint="/me/adaccounts", + to_account=to_account_sync, + ) + + result = await store.resolve(_ref_by_id("a1")) + assert result is not None + assert result.id == "a1" + await client.aclose() + + +# --------------------------------------------------------------------------- +# Auth-context forwarding +# --------------------------------------------------------------------------- + + +@respx.mock +async def test_default_get_auth_context_forwards_auth_info_verbatim() -> None: + respx.get(f"{BASE}/me/adaccounts").mock( + return_value=httpx.Response(200, json={"data": [{"id": "a1", "name": "Acme"}]}) + ) + + seen_ctx: list[Any] = [] + + async def capture_token(ctx: Any) -> str: + seen_ctx.append(ctx) + return "tok_x" + + client = create_upstream_http_client(BASE, auth=DynamicBearer(get_token=capture_token)) + store = create_oauth_passthrough_resolver( + http_client=client, + list_endpoint="/me/adaccounts", + to_account=_to_account, + ) + + auth_info = AuthInfo(kind="oauth", principal="p1") + await store.resolve(_ref_by_id("a1"), auth_info=auth_info) + assert seen_ctx == [auth_info] + await client.aclose() + + +@respx.mock +async def test_custom_get_auth_context_threads_through() -> None: + respx.get(f"{BASE}/me/adaccounts").mock( + return_value=httpx.Response(200, json={"data": [{"id": "a1", "name": "Acme"}]}) + ) + + seen_ctx: list[Any] = [] + + async def capture_token(ctx: Any) -> str: + seen_ctx.append(ctx) + return "tok_y" + + client = create_upstream_http_client(BASE, auth=DynamicBearer(get_token=capture_token)) + store = create_oauth_passthrough_resolver( + http_client=client, + list_endpoint="/me/adaccounts", + get_auth_context=lambda ctx: { + "principal": ctx.auth_info.principal if ctx and ctx.auth_info else None, + }, + to_account=_to_account, + ) + + auth_info = AuthInfo(kind="oauth", principal="agent-1") + await store.resolve(_ref_by_id("a1"), auth_info=auth_info) + assert seen_ctx == [{"principal": "agent-1"}] + await client.aclose() + + +# --------------------------------------------------------------------------- +# Misc +# --------------------------------------------------------------------------- + + +@respx.mock +async def test_resolve_passes_dict_ref_through() -> None: + """Adopters that pass dicts straight from JSON deserialization + should still get a match — ``ref_account_id`` accepts both.""" + respx.get(f"{BASE}/me/adaccounts").mock( + return_value=httpx.Response(200, json={"data": [{"id": "a1", "name": "Acme"}]}) + ) + client = _make_client() + store = create_oauth_passthrough_resolver( + http_client=client, + list_endpoint="/me/adaccounts", + to_account=_to_account, + ) + + result = await store.resolve({"account_id": "a1"}) + assert result is not None + assert result.id == "a1" + await client.aclose()