Files
turnstone/tests/test_perception.py
T
metaclassing 9adde920d4 feat(models): per-alias backend auth via Entra OBO and app identity (#898)
Adds a per-alias `auth_mode` on model definitions so a model backend can
authenticate to an Entra-fronted gateway with a per-request minted token instead
of one shared static API key, letting the gateway attribute calls to the actual
user or to the app as a machine identity.

- `static` (default, unchanged) sends the stored `api_key`.
- `entra_obo` mints a per-user On-Behalf-Of token for `obo_audience` from the
  caller's captured refresh credential.
- `entra_app` mints an app-identity token via the client-credentials grant, and
  covers userless turns that OBO cannot.

Reuses the existing OBO grant legs, refresh-token rotation CAS, cluster advisory
lock and the `mcp_user_tokens` mint-cache, keyed under synthetic
`__model_obo__:<audience>` / `__model_app__:<audience>` rows. The token binds at
the call site through `client.with_options(api_key=...)` so each SDK emits it on
its own auth path rather than through header injection.

Migration 068 adds `auth_mode` and `obo_audience`. Both are additive and existing
rows default to `static`, so behaviour is unchanged unless an alias opts in.

Operator controls: `model.auth_audience_allowlist` is an exact-match allow-list
that gates which audiences may be configured and denies all by default, and
changing a mode or audience requires `admin.mcp`. `model.auth_fail_closed`
decides whether a failed mint may fall back to an explicitly configured static
key. A delegated call with no user, or a dynamic alias with no real static key,
always refuses.

Two changes here apply regardless of whether any alias opts in:

- Storage and app state are now wired into the console MCP client manager. This
  fixes per-user `oauth_user` / `oauth_obo` dispatch for coordinator-hosted
  sessions, which previously raised `RuntimeError` on first call because
  `set_app_state` was only ever called on the node.
- Unattended watch restores and `--resume` resolve the persisted workstream
  owner instead of constructing the session under an empty principal. A
  workstream with no owner is now a permanent refusal rather than an anonymous,
  auto-approved run.
2026-08-02 15:15:02 -07:00

169 lines
5.5 KiB
Python

"""Unit tests for the perception wire-fallback (turnstone/core/perception.py)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import pytest
from tests._session_helpers import as_stream, mock_completion_result
from turnstone.core import perception
if TYPE_CHECKING:
from collections.abc import Iterator
class _StubProvider:
"""Minimal LLMProvider stand-in: counts calls, can fail the first N.
``describe`` routes through ``model_turn``, so the stub carries the lane
surface (``provider_name``, ``get_capabilities``) and returns a full
``CompletionResult`` shape, and it records the ``resolve_attachments``
callback the translator would use to materialize the by-reference parts.
"""
provider_name = "openai-compatible"
def __init__(self, *, content: str = "a description", fail_times: int = 0) -> None:
self.calls = 0
self._content = content
self._fail_times = fail_times
self.last_messages: list[dict[str, Any]] | None = None
self.last_resolve: Any = None
def get_capabilities(self, model: str) -> Any:
from turnstone.core.providers._protocol import ModelCapabilities
return ModelCapabilities()
def create_streaming(
self,
*,
client: Any,
model: str,
messages: list[dict[str, Any]],
resolve_attachments: Any = None,
**_: Any,
) -> Any:
self.calls += 1
self.last_messages = messages
self.last_resolve = resolve_attachments
if self.calls <= self._fail_times:
raise RuntimeError("backend down")
# Shared field inventory: when model_turn's re-ingest reads a new
# CompletionResult field, mock_completion_result is the ONE
# definition to extend and this suite moves with it.
return as_stream(mock_completion_result(self._content))
@pytest.fixture(autouse=True)
def _clear_cache() -> Iterator[None]:
perception._clear_perception_cache_for_test()
yield
perception._clear_perception_cache_for_test()
def _parts() -> list[dict[str, Any]]:
return [{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}]
def test_describe_lowers_prompt_then_by_reference_parts() -> None:
prov = _StubProvider(content="desc")
out = perception.describe(provider=prov, client=object(), model="m", parts=_parts()) # type: ignore[arg-type]
assert out == "desc"
assert prov.last_messages is not None
content = prov.last_messages[0]["content"]
assert content[0]["type"] == "text" # prompt leads
# The attachment rides by reference; the translator materializes it via
# the threaded resolver, which must return the prebuilt parts verbatim.
assert content[1]["attachment_id"] == "perception-input"
assert prov.last_resolve is not None
assert prov.last_resolve(["perception-input"]) == {"perception-input": _parts()}
def test_describe_empty_parts_skips_backend() -> None:
prov = _StubProvider()
assert perception.describe(provider=prov, client=object(), model="m", parts=[]) == "" # type: ignore[arg-type]
assert prov.calls == 0
def test_describe_cached_memoizes_by_principal_alias_and_hash() -> None:
prov = _StubProvider(content="desc")
kw: dict[str, Any] = {
"provider": prov,
"client": object(),
"model": "m",
"principal_id": "user-a",
"alias": "omni",
"content_hash": "h1",
"parts": _parts(),
}
assert perception.describe_cached(**kw) == "desc"
assert perception.describe_cached(**kw) == "desc"
assert prov.calls == 1 # second served from cache
perception.describe_cached(**{**kw, "content_hash": "h2"})
assert prov.calls == 2 # distinct hash → fresh perceive
perception.describe_cached(**{**kw, "principal_id": "user-b"})
assert prov.calls == 3 # same content under another user's grant → fresh perceive
def test_describe_cached_does_not_cache_failures() -> None:
prov = _StubProvider(content="recovered", fail_times=1)
kw: dict[str, Any] = {
"provider": prov,
"client": object(),
"model": "m",
"principal_id": "user-a",
"alias": "omni",
"content_hash": "h",
"parts": _parts(),
}
assert perception.describe_cached(**kw) == "" # backend down → "" (uncached)
assert perception.describe_cached(**kw) == "recovered" # retried, succeeds
assert prov.calls == 2
def test_describe_peek_returns_none_when_absent() -> None:
assert (
perception.describe_peek(
principal_id="user-a",
alias="omni",
content_hash="missing",
)
is None
)
def test_describe_peek_returns_cached_without_recompute() -> None:
prov = _StubProvider(content="desc")
kw: dict[str, Any] = {
"provider": prov,
"client": object(),
"model": "m",
"principal_id": "user-a",
"alias": "omni",
"content_hash": "h",
"parts": _parts(),
}
perception.describe_cached(**kw) # populate the memo
assert prov.calls == 1
# Peek serves the memoized text and never re-invokes the backend — this is
# what lets the wire resolver skip the PDF rasterize on a cross-send hit.
assert (
perception.describe_peek(
principal_id="user-a",
alias="omni",
content_hash="h",
)
== "desc"
)
assert (
perception.describe_peek(
principal_id="user-b",
alias="omni",
content_hash="h",
)
is None
)
assert prov.calls == 1