237 lines
9.2 KiB
Python
237 lines
9.2 KiB
Python
"""Unit tests for the SDK's scope / session-allowlist option handling.
|
|
|
|
Pure logic — no server, no database. These pin the wire translation the server
|
|
expects, so a rename or a shape change fails here rather than as a 422 at runtime.
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
# Add the SDK src to the path to allow imports
|
|
sdk_src_path = Path(__file__).parent.parent.parent / "sdks" / "python" / "src"
|
|
sys.path.insert(0, str(sdk_src_path))
|
|
|
|
from sdks.python.src.honcho.utils.scopes import ( # noqa: E402
|
|
MAX_SCOPES_PER_OPTION,
|
|
MAX_SESSION_ALLOWLIST_ENTRIES,
|
|
MAX_SESSIONS_PER_ADD,
|
|
resolve_scope_membership,
|
|
resolve_scope_option,
|
|
resolve_scope_session,
|
|
scope_context_fields,
|
|
scope_recall_fields,
|
|
validate_scope_id,
|
|
)
|
|
|
|
|
|
def context_fields(**overrides: object) -> dict[str, object]:
|
|
"""Call scope_context_fields with the neutral defaults filled in."""
|
|
kwargs: dict[str, object] = {
|
|
"scope": None,
|
|
"sessions": None,
|
|
"peer_target": "user",
|
|
"peer_perspective": None,
|
|
"limit_to_session": False,
|
|
}
|
|
kwargs.update(overrides)
|
|
return scope_context_fields(**kwargs) # pyright: ignore[reportArgumentType]
|
|
|
|
|
|
class TestValidateScopeId:
|
|
def test_accepts_a_plain_name(self):
|
|
assert validate_scope_id("therapy") == "therapy"
|
|
|
|
def test_rejects_the_reserved_prefix_by_name(self):
|
|
# 'scope.therapy' violates both the prefix rule and the charset. The
|
|
# prefix message is the actionable one, so it must be the one raised.
|
|
with pytest.raises(ValueError, match="reserved prefix"):
|
|
validate_scope_id("scope.therapy")
|
|
|
|
def test_rejects_characters_outside_the_charset(self):
|
|
with pytest.raises(ValueError, match="must match pattern"):
|
|
validate_scope_id("my scope")
|
|
|
|
def test_rejects_empty(self):
|
|
with pytest.raises(ValueError, match="between 1 and"):
|
|
validate_scope_id("")
|
|
|
|
def test_rejects_a_name_that_leaves_no_room_for_the_prefix(self):
|
|
# 512 - len("scope.") is the ceiling: the server stores the name prefixed
|
|
# into a 512-character peer name.
|
|
with pytest.raises(ValueError, match="between 1 and"):
|
|
validate_scope_id("a" * 507)
|
|
|
|
|
|
class TestResolveScopeOption:
|
|
def test_a_single_scope_stays_a_string(self):
|
|
# The shapes are not interchangeable to the server: one scope reads that
|
|
# scope's own view, a list restricts to the union of member sessions.
|
|
assert resolve_scope_option("therapy") == "therapy"
|
|
|
|
def test_a_sequence_becomes_a_list(self):
|
|
assert resolve_scope_option(["therapy", "work"]) == ["therapy", "work"]
|
|
|
|
def test_rejects_an_empty_sequence(self):
|
|
with pytest.raises(ValueError, match="at least one scope"):
|
|
resolve_scope_option([])
|
|
|
|
def test_rejects_an_over_cap_sequence(self):
|
|
with pytest.raises(ValueError, match="at most"):
|
|
resolve_scope_option([f"s{i}" for i in range(MAX_SCOPES_PER_OPTION + 1)])
|
|
|
|
|
|
class TestScopeRecallFields:
|
|
def test_neither_option_contributes_nothing(self):
|
|
assert scope_recall_fields(scope=None, sessions=None) == {}
|
|
|
|
def test_sessions_becomes_a_session_id_filter(self):
|
|
# `sessions` is sugar. It must never reach the wire as its own key —
|
|
# the server rejects unknown keys with a 422.
|
|
fields = scope_recall_fields(scope=None, sessions=["a", "b"])
|
|
assert fields == {"filters": {"session_id": ["a", "b"]}}
|
|
assert "sessions" not in fields
|
|
|
|
def test_scope_passes_through_under_its_own_key(self):
|
|
assert scope_recall_fields(scope="therapy", sessions=None) == {
|
|
"scope": "therapy"
|
|
}
|
|
|
|
def test_scope_and_sessions_are_mutually_exclusive(self):
|
|
with pytest.raises(ValueError, match="mutually exclusive"):
|
|
scope_recall_fields(scope="therapy", sessions=["a"])
|
|
|
|
def test_scope_and_a_single_session_are_mutually_exclusive(self):
|
|
with pytest.raises(ValueError, match="mutually exclusive"):
|
|
scope_recall_fields(scope="therapy", sessions=None, session_id="a")
|
|
|
|
def test_sessions_composes_with_a_single_session(self):
|
|
# Unlike `scope`, an allowlist may accompany a session_id — the server
|
|
# only requires that the session be inside the allowlist.
|
|
assert scope_recall_fields(scope=None, sessions=["a", "b"], session_id="a") == {
|
|
"filters": {"session_id": ["a", "b"]}
|
|
}
|
|
|
|
def test_rejects_an_empty_allowlist(self):
|
|
# An empty allowlist is fail-closed server-side (recalls nothing), which
|
|
# is never what `sessions=[]` intends.
|
|
with pytest.raises(ValueError, match="at least one session"):
|
|
scope_recall_fields(scope=None, sessions=[])
|
|
|
|
def test_rejects_an_over_cap_allowlist(self):
|
|
with pytest.raises(ValueError, match="at most"):
|
|
scope_recall_fields(
|
|
scope=None,
|
|
sessions=[f"s{i}" for i in range(MAX_SESSION_ALLOWLIST_ENTRIES + 1)],
|
|
)
|
|
|
|
def test_resolves_objects_with_an_id(self):
|
|
class FakeSession:
|
|
id: str = "session-a"
|
|
|
|
fields = scope_recall_fields(scope=None, sessions=[FakeSession()]) # pyright: ignore[reportArgumentType]
|
|
assert fields == {"filters": {"session_id": ["session-a"]}}
|
|
|
|
|
|
class TestScopeContextFields:
|
|
"""The context route takes these as query params, not as a `filters` body."""
|
|
|
|
def test_neither_option_contributes_nothing(self):
|
|
assert context_fields() == {}
|
|
|
|
def test_scope_passes_through(self):
|
|
assert context_fields(scope="therapy") == {"scope": "therapy"}
|
|
|
|
def test_sessions_stays_a_plain_list(self):
|
|
# Not wrapped in `filters` — this route reads a repeated query parameter.
|
|
assert context_fields(sessions=["a", "b"]) == {"sessions": ["a", "b"]}
|
|
|
|
def test_scope_and_peer_perspective_are_mutually_exclusive(self):
|
|
with pytest.raises(ValueError, match="mutually exclusive"):
|
|
context_fields(scope="therapy", peer_perspective="assistant")
|
|
|
|
def test_scope_and_sessions_are_mutually_exclusive(self):
|
|
with pytest.raises(ValueError, match="mutually exclusive"):
|
|
context_fields(scope="therapy", sessions=["a"])
|
|
|
|
def test_sessions_and_limit_to_session_are_mutually_exclusive(self):
|
|
with pytest.raises(ValueError, match="mutually exclusive"):
|
|
context_fields(sessions=["a"], limit_to_session=True)
|
|
|
|
@pytest.mark.parametrize("option", [{"scope": "therapy"}, {"sessions": ["a"]}])
|
|
def test_either_option_requires_a_peer_target(self, option: dict[str, object]):
|
|
# Both only reach the representation, and there is none without a target.
|
|
# Refused rather than accepted and silently ignored.
|
|
with pytest.raises(ValueError, match="peer_target"):
|
|
context_fields(peer_target=None, **option)
|
|
|
|
def test_limit_to_session_alone_is_untouched(self):
|
|
# The neutral case must not start emitting a scope/sessions key.
|
|
assert context_fields(limit_to_session=True) == {}
|
|
|
|
|
|
class TestResolveScopeMembership:
|
|
def test_resolves_ids_and_objects_in_order(self):
|
|
class FakeSession:
|
|
id: str = "session-b"
|
|
|
|
assert resolve_scope_membership(["session-a", FakeSession()]) == [ # pyright: ignore[reportArgumentType]
|
|
"session-a",
|
|
"session-b",
|
|
]
|
|
|
|
def test_rejects_empty(self):
|
|
with pytest.raises(ValueError, match="At least one session"):
|
|
resolve_scope_membership([])
|
|
|
|
def test_rejects_over_the_per_call_cap_rather_than_chunking(self):
|
|
with pytest.raises(ValueError, match="At most"):
|
|
resolve_scope_membership([f"s{i}" for i in range(MAX_SESSIONS_PER_ADD + 1)])
|
|
|
|
def test_rejects_a_malformed_id(self):
|
|
with pytest.raises(ValueError, match="must match pattern"):
|
|
resolve_scope_membership(["ok-session", "valid-session?typo"])
|
|
|
|
|
|
class TestResolveScopeSession:
|
|
"""Guards the ID that gets interpolated into a scope membership URL path."""
|
|
|
|
def test_resolves_a_plain_id(self):
|
|
assert resolve_scope_session("session-a") == "session-a"
|
|
|
|
def test_resolves_an_object(self):
|
|
class FakeSession:
|
|
id: str = "session-a"
|
|
|
|
assert resolve_scope_session(FakeSession()) == "session-a" # pyright: ignore[reportArgumentType]
|
|
|
|
@pytest.mark.parametrize(
|
|
"malformed",
|
|
[
|
|
"valid-session?typo", # would address `valid-session` + a query string
|
|
"valid-session/../other", # would climb the path
|
|
"valid session",
|
|
"",
|
|
],
|
|
)
|
|
def test_rejects_ids_that_would_alter_the_request_path(self, malformed: str):
|
|
# This value lands in a DELETE path. An unvalidated id silently changes
|
|
# which session is removed, and removal triggers reconciliation against
|
|
# whatever it hits.
|
|
with pytest.raises(ValueError, match="Session ID"):
|
|
resolve_scope_session(malformed)
|
|
|
|
|
|
def test_deprecated_conclusion_scope_aliases_still_resolve():
|
|
"""The rename keeps working for callers on the old name."""
|
|
from sdks.python.src.honcho import (
|
|
ConclusionScope,
|
|
ConclusionScopeAio,
|
|
ConclusionsView,
|
|
ConclusionsViewAio,
|
|
)
|
|
|
|
assert ConclusionScope is ConclusionsView
|
|
assert ConclusionScopeAio is ConclusionsViewAio
|