honcho/tests/routes/test_scopes.py

1202 lines
45 KiB
Python

"""Tests for the scopes facade: scope-kind peers, guardrails, and CRUD routes.
A scope is a named grouping of sessions, implemented as a peer named
``scope.<name>`` carrying ``{"kind": "scope"}`` in ``internal_metadata`` and
``{"observe_me": false}`` in ``configuration``, that observes its member sessions
and never speaks. See src/utils/scopes.py.
"""
import re
from typing import Any
import pytest
from fastapi.testclient import TestClient
from nanoid import generate as generate_nanoid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, models, schemas
from src.config import settings
from src.deriver.enqueue import enqueue
from src.exceptions import ValidationException
from src.models import Peer, QueueItem, Workspace
from src.schemas.api import RESOURCE_NAME_PATTERN
from src.security import JWTParams, create_jwt
from src.utils.scopes import (
SCOPE_PEER_PREFIX,
is_scope_peer,
is_scope_peer_name,
scope_name_from_peer,
scope_peer_name,
)
def _create_scope(
client: TestClient,
workspace_name: str,
scope_name: str,
metadata: dict[str, Any] | None = None,
):
body: dict[str, Any] = {"id": scope_name}
if metadata is not None:
body["metadata"] = metadata
return client.post(f"/v3/workspaces/{workspace_name}/scopes", json=body)
def _create_session(
client: TestClient,
workspace_name: str,
session_name: str | None = None,
**extra: Any,
):
session_name = session_name or str(generate_nanoid())
response = client.post(
f"/v3/workspaces/{workspace_name}/sessions",
json={"id": session_name, **extra},
)
assert response.status_code in [200, 201]
return session_name
def _add_sessions(
client: TestClient,
workspace_name: str,
scope_name: str,
session_names: list[str],
) -> int:
"""Add sessions to a scope via the facade; returns the status code (204 on success)."""
return client.post(
f"/v3/workspaces/{workspace_name}/scopes/{scope_name}/sessions",
json={"session_ids": session_names},
).status_code
def _scope_sessions(
client: TestClient, workspace_name: str, scope_name: str
) -> list[str]:
"""Names of a scope's member sessions, oldest membership first."""
response = client.post(
f"/v3/workspaces/{workspace_name}/scopes/{scope_name}/sessions/list"
)
assert response.status_code == 200, response.text
return [item["id"] for item in response.json()["items"]]
async def _get_session_peer(
db_session: AsyncSession,
workspace_name: str,
session_name: str,
peer_name: str,
) -> models.SessionPeer | None:
return await db_session.scalar(
select(models.SessionPeer)
.where(models.SessionPeer.workspace_name == workspace_name)
.where(models.SessionPeer.session_name == session_name)
.where(models.SessionPeer.peer_name == peer_name)
)
def test_scope_namespace_helpers():
assert scope_peer_name("therapy") == "scope.therapy"
assert is_scope_peer_name("scope.therapy")
assert not is_scope_peer_name("therapy")
assert scope_name_from_peer("scope.therapy") == "therapy"
# The prefix must stay outside the peer-name charset, or an existing peer
# could occupy the scope namespace.
assert not re.fullmatch(RESOURCE_NAME_PATTERN, SCOPE_PEER_PREFIX)
async def test_create_scope_creates_flagged_peer(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Creating a scope creates a peer with the kind flag and observe_me=false."""
test_workspace, _ = sample_data
scope_name = str(generate_nanoid())
response = _create_scope(
client, test_workspace.name, scope_name, metadata={"purpose": "testing"}
)
assert response.status_code == 201
data = response.json()
# The response id is the UNPREFIXED scope name
assert data["id"] == scope_name
assert data["metadata"] == {"purpose": "testing"}
assert "created_at" in data
peer = await db_session.scalar(
select(models.Peer)
.where(models.Peer.workspace_name == test_workspace.name)
.where(models.Peer.name == scope_peer_name(scope_name))
)
assert peer is not None
# The kind flag lives in internal_metadata (not user-writable); only
# observe_me is in the user-visible configuration.
assert peer.internal_metadata == {"kind": "scope"}
assert peer.configuration == {"observe_me": False}
def test_create_scope_idempotent(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, _ = sample_data
scope_name = str(generate_nanoid())
first = _create_scope(client, test_workspace.name, scope_name)
assert first.status_code == 201
second = _create_scope(client, test_workspace.name, scope_name)
assert second.status_code == 200
assert second.json()["id"] == scope_name
def test_create_scope_rejects_invalid_names(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, _ = sample_data
# Names must match the resource name pattern
response = _create_scope(client, test_workspace.name, "bad name!")
assert response.status_code == 422
# Scope names are unprefixed: double-prefixing is rejected
response = _create_scope(client, test_workspace.name, "scope.therapy")
assert response.status_code == 422
async def test_create_scope_rejects_legacy_collision(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""A pre-existing plain peer occupying the reserved name is never adopted."""
test_workspace, _ = sample_data
scope_name = str(generate_nanoid())
legacy_peer = models.Peer(
workspace_name=test_workspace.name,
name=scope_peer_name(scope_name),
)
db_session.add(legacy_peer)
await db_session.commit()
response = _create_scope(client, test_workspace.name, scope_name)
assert response.status_code == 409
# And the collision peer is invisible to the scope read routes
response = client.get(f"/v3/workspaces/{test_workspace.name}/scopes/{scope_name}")
assert response.status_code == 404
def test_get_scope(client: TestClient, sample_data: tuple[Workspace, Peer]):
test_workspace, _ = sample_data
scope_name = str(generate_nanoid())
assert _create_scope(client, test_workspace.name, scope_name).status_code == 201
response = client.get(f"/v3/workspaces/{test_workspace.name}/scopes/{scope_name}")
assert response.status_code == 200
assert response.json()["id"] == scope_name
response = client.get(
f"/v3/workspaces/{test_workspace.name}/scopes/{generate_nanoid()}"
)
assert response.status_code == 404
def test_list_scopes(client: TestClient, sample_data: tuple[Workspace, Peer]):
"""The scopes list contains only scopes, with unprefixed ids."""
test_workspace, test_peer = sample_data
scope_names = {str(generate_nanoid()), str(generate_nanoid())}
for scope_name in scope_names:
assert _create_scope(client, test_workspace.name, scope_name).status_code == 201
response = client.post(f"/v3/workspaces/{test_workspace.name}/scopes/list")
assert response.status_code == 200
items = response.json()["items"]
listed = {item["id"] for item in items}
assert listed == scope_names
assert test_peer.name not in listed
def test_scopes_routes_require_workspace_level_key(
client: TestClient,
sample_data: tuple[Workspace, Peer],
monkeypatch: pytest.MonkeyPatch,
):
"""Scopes are an app-level admin surface: workspace keys work, peer- and
session-scoped keys are rejected."""
test_workspace, test_peer = sample_data
monkeypatch.setattr(settings.AUTH, "USE_AUTH", True)
monkeypatch.setattr(settings.AUTH, "JWT_SECRET", "test-secret")
scope_name = str(generate_nanoid())
scopes_url = f"/v3/workspaces/{test_workspace.name}/scopes"
# Workspace-scoped key: allowed
client.headers["Authorization"] = (
f"Bearer {create_jwt(JWTParams(w=test_workspace.name))}"
)
assert client.post(scopes_url, json={"id": scope_name}).status_code == 201
# Peer-scoped key: rejected
client.headers["Authorization"] = (
f"Bearer {create_jwt(JWTParams(w=test_workspace.name, p=test_peer.name))}"
)
assert client.post(scopes_url, json={"id": scope_name}).status_code == 401
assert client.post(f"{scopes_url}/list").status_code == 401
assert client.get(f"{scopes_url}/{scope_name}").status_code == 401
# Session-scoped key: rejected
client.headers["Authorization"] = (
f"Bearer {create_jwt(JWTParams(w=test_workspace.name, s='some-session'))}"
)
assert client.post(f"{scopes_url}/{scope_name}/sessions/list").status_code == 401
def test_session_create_scopes_requires_workspace_level_key(
client: TestClient,
sample_data: tuple[Workspace, Peer],
monkeypatch: pytest.MonkeyPatch,
):
"""`scopes` on session create is the scopes routes by another door.
It creates scope peers and attaches memberships, so it carries the same
workspace-level requirement. Session create is otherwise a self-authorizing
route that accepts peer- and session-scoped keys, which is exactly why this
needs its own check rather than the route's auth dependency.
"""
test_workspace, test_peer = sample_data
monkeypatch.setattr(settings.AUTH, "USE_AUTH", True)
monkeypatch.setattr(settings.AUTH, "JWT_SECRET", "test-secret")
sessions_url = f"/v3/workspaces/{test_workspace.name}/sessions"
scopes_url = f"/v3/workspaces/{test_workspace.name}/scopes"
scope_name = str(generate_nanoid())
def create_with_scope(session_name: str) -> int:
return client.post(
sessions_url, json={"id": session_name, "scopes": [scope_name]}
).status_code
def listed_scopes() -> set[str]:
response = client.post(f"{scopes_url}/list")
assert response.status_code == 200
return {item["id"] for item in response.json()["items"]}
# Peer-scoped key: rejected
client.headers["Authorization"] = (
f"Bearer {create_jwt(JWTParams(w=test_workspace.name, p=test_peer.name))}"
)
assert create_with_scope(str(generate_nanoid())) == 401
# Session-scoped key: rejected. The session name must match the token's `s`
# claim, or the handler's own session check would 401 first and this would
# pass without ever reaching the scopes gate.
own_session = str(generate_nanoid())
client.headers["Authorization"] = (
f"Bearer {create_jwt(JWTParams(w=test_workspace.name, s=own_session))}"
)
assert create_with_scope(own_session) == 401
# Workspace-scoped key: allowed. The scope is absent until this call lands,
# which proves the rejected requests did not mint it on their way out.
client.headers["Authorization"] = (
f"Bearer {create_jwt(JWTParams(w=test_workspace.name))}"
)
assert scope_name not in listed_scopes()
assert create_with_scope(str(generate_nanoid())) == 201
assert scope_name in listed_scopes()
def test_peer_create_rejects_reserved_prefix(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""User-created peers may not use the reserved scope prefix.
The prefix sits outside RESOURCE_NAME_PATTERN, so PeerCreate's own charset
validation rejects it at the schema boundary — one layer earlier than the
route's validate_no_scope_peer_names guard. Either way the caller gets 422.
"""
test_workspace, _ = sample_data
response = client.post(
f"/v3/workspaces/{test_workspace.name}/peers",
json={"name": f"{SCOPE_PEER_PREFIX}{generate_nanoid()}"},
)
assert response.status_code == 422
assert RESOURCE_NAME_PATTERN in str(response.json()["detail"])
def test_peers_list_kind_filtering(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""peers.list excludes scope peers by default; kind switches the view."""
test_workspace, test_peer = sample_data
scope_name = str(generate_nanoid())
assert _create_scope(client, test_workspace.name, scope_name).status_code == 201
backing_peer_name = scope_peer_name(scope_name)
# Default: scope peers excluded
response = client.post(f"/v3/workspaces/{test_workspace.name}/peers/list")
assert response.status_code == 200
names = {item["id"] for item in response.json()["items"]}
assert test_peer.name in names
assert backing_peer_name not in names
# kind=scope: only scope peers
response = client.post(
f"/v3/workspaces/{test_workspace.name}/peers/list",
json={"kind": "scope"},
)
assert response.status_code == 200
names = {item["id"] for item in response.json()["items"]}
assert names == {backing_peer_name}
# kind=all: everything
response = client.post(
f"/v3/workspaces/{test_workspace.name}/peers/list",
json={"kind": "all"},
)
assert response.status_code == 200
names = {item["id"] for item in response.json()["items"]}
assert {test_peer.name, backing_peer_name} <= names
async def test_scope_sessions_add_list_remove(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
test_workspace, _ = sample_data
workspace_name = test_workspace.name
scope_name = str(generate_nanoid())
assert _create_scope(client, workspace_name, scope_name).status_code == 201
session_1 = _create_session(client, workspace_name)
session_2 = _create_session(client, workspace_name)
scope_base = f"/v3/workspaces/{workspace_name}/scopes/{scope_name}"
# Add both sessions
assert (
_add_sessions(client, workspace_name, scope_name, [session_1, session_2]) == 204
)
assert set(_scope_sessions(client, workspace_name, scope_name)) == {
session_1,
session_2,
}
# Membership rows carry the observer shape: observe_others on, observe_me off
session_peer = await _get_session_peer(
db_session, workspace_name, session_1, scope_peer_name(scope_name)
)
assert session_peer is not None
assert session_peer.left_at is None
assert session_peer.configuration["observe_others"] is True
assert session_peer.configuration["observe_me"] is False
# Remove one membership (soft delete, like the generic remove-peer path)
response = client.delete(f"{scope_base}/sessions/{session_1}")
assert response.status_code == 204
assert _scope_sessions(client, workspace_name, scope_name) == [session_2]
db_session.expire_all()
session_peer = await _get_session_peer(
db_session, workspace_name, session_1, scope_peer_name(scope_name)
)
assert session_peer is not None
assert session_peer.left_at is not None
def test_scope_sessions_add_missing_session_404(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, _ = sample_data
scope_name = str(generate_nanoid())
assert _create_scope(client, test_workspace.name, scope_name).status_code == 201
existing_session = _create_session(client, test_workspace.name)
assert (
_add_sessions(
client,
test_workspace.name,
scope_name,
[existing_session, str(generate_nanoid())],
)
== 404
)
def test_scope_sessions_routes_404_on_unknown_scope(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, _ = sample_data
session_name = _create_session(client, test_workspace.name)
unknown_scope = str(generate_nanoid())
scope_base = f"/v3/workspaces/{test_workspace.name}/scopes/{unknown_scope}"
assert (
_add_sessions(client, test_workspace.name, unknown_scope, [session_name]) == 404
)
assert client.post(f"{scope_base}/sessions/list").status_code == 404
assert client.delete(f"{scope_base}/sessions/{session_name}").status_code == 404
async def test_session_create_with_scopes(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""`scopes` on session creation creates the scope peers and memberships."""
test_workspace, _ = sample_data
scope_a = str(generate_nanoid())
scope_b = str(generate_nanoid())
session_name = str(generate_nanoid())
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions",
json={"id": session_name, "scopes": [scope_a, scope_b]},
)
assert response.status_code == 201
for scope_name in (scope_a, scope_b):
peer = await db_session.scalar(
select(models.Peer)
.where(models.Peer.workspace_name == test_workspace.name)
.where(models.Peer.name == scope_peer_name(scope_name))
)
assert peer is not None
assert peer.internal_metadata == {"kind": "scope"}
assert peer.configuration == {"observe_me": False}
session_peer = await _get_session_peer(
db_session, test_workspace.name, session_name, scope_peer_name(scope_name)
)
assert session_peer is not None
assert session_peer.configuration["observe_others"] is True
assert session_peer.configuration["observe_me"] is False
# And the memberships show up through the facade
assert _scope_sessions(client, test_workspace.name, scope_a) == [session_name]
def test_session_create_rejects_prefixed_scope_names(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, _ = sample_data
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions",
json={"id": str(generate_nanoid()), "scopes": ["scope.x"]},
)
assert response.status_code == 422
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions",
json={"id": str(generate_nanoid()), "scopes": ["bad name!"]},
)
assert response.status_code == 422
async def test_scope_membership_equals_hand_built_observer(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Facade-less equivalence: a scope membership row is exactly what a
hand-built observer peer would have (name and kind flag aside)."""
test_workspace, _ = sample_data
# Hand-built observer peer added through the generic session-peer route
observer_name = str(generate_nanoid())
observer_session = _create_session(client, test_workspace.name)
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions/{observer_session}/peers",
json={observer_name: {"observe_others": True, "observe_me": False}},
)
assert response.status_code == 200
# Scope membership added through the facade
scope_name = str(generate_nanoid())
assert _create_scope(client, test_workspace.name, scope_name).status_code == 201
scope_session = _create_session(client, test_workspace.name)
assert (
_add_sessions(client, test_workspace.name, scope_name, [scope_session]) == 204
)
hand_built = await _get_session_peer(
db_session, test_workspace.name, observer_session, observer_name
)
via_facade = await _get_session_peer(
db_session, test_workspace.name, scope_session, scope_peer_name(scope_name)
)
assert hand_built is not None and via_facade is not None
assert hand_built.configuration == via_facade.configuration
assert hand_built.left_at is None and via_facade.left_at is None
async def test_scope_peer_observes_ingested_messages(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""End-to-end litmus: after adding a session to a scope, a message from a
real peer fans out a representation task with the scope peer as observer."""
test_workspace, test_peer = sample_data
scope_name = str(generate_nanoid())
assert _create_scope(client, test_workspace.name, scope_name).status_code == 201
session_name = _create_session(client, test_workspace.name)
# Add the speaking peer and the scope membership
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/peers",
json={test_peer.name: {}},
)
assert response.status_code == 200
assert _add_sessions(client, test_workspace.name, scope_name, [session_name]) == 204
# Ingest a message from the real peer and run the deriver enqueue fan-out
message = models.Message(
workspace_name=test_workspace.name,
session_name=session_name,
peer_name=test_peer.name,
content="I love hiking in the mountains",
public_id=generate_nanoid(),
seq_in_session=1,
token_count=10,
)
db_session.add(message)
await db_session.commit()
await enqueue(
[
{
"workspace_name": test_workspace.name,
"session_name": session_name,
"message_id": message.id,
"content": message.content,
"peer_name": test_peer.name,
"created_at": message.created_at,
"message_public_id": message.public_id,
"seq_in_session": message.seq_in_session,
}
]
)
result = await db_session.execute(
select(QueueItem)
.where(QueueItem.task_type == "representation")
.where(QueueItem.message_id == message.id)
)
representation_items = list(result.scalars().all())
assert len(representation_items) == 1
payload = representation_items[0].payload
assert payload.get("observed") == test_peer.name
observers = payload.get("observers")
assert observers is not None
assert test_peer.name in observers # self-observation
assert scope_peer_name(scope_name) in observers # the scope observes
# ---------------------------------------------------------------------------
# Dotted / legacy peer names must not 500 (regression for the PeerCreate-as-DTO
# chokepoint). Names were length-only validated before migration
# d429de0e5338, so pre-existing peers can contain '.' — and re-validating them
# against PeerCreate's charset pattern raised a raw pydantic error, i.e. a 500.
# ---------------------------------------------------------------------------
async def test_legacy_dotted_peer_name_is_fully_usable(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""A pre-existing dotted peer name must work end to end, not 500."""
test_workspace, _ = sample_data
legacy_name = f"alice.smith.{generate_nanoid()}"
db_session.add(models.Peer(workspace_name=test_workspace.name, name=legacy_name))
await db_session.commit()
session_name = _create_session(client, test_workspace.name)
# Can author messages
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/messages",
json={"messages": [{"peer_id": legacy_name, "content": "hello"}]},
)
assert response.status_code in [200, 201], response.text
# Can be added to a session through the generic route
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/peers",
json={legacy_name: {}},
)
assert response.status_code == 200, response.text
# Can be updated through the generic peer route
response = client.put(
f"/v3/workspaces/{test_workspace.name}/peers/{legacy_name}",
json={"metadata": {"k": "v"}},
)
assert response.status_code == 200, response.text
async def test_legacy_prefixed_peer_without_flag_is_not_a_scope(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""A peer merely occupying the reserved namespace keeps working.
Only the name half of the invariant is present, so it is not a scope: it can
still author messages and shows up in the default peers list, while the
scopes facade refuses to treat it as a scope.
"""
test_workspace, _ = sample_data
scope_name = str(generate_nanoid())
squatter = scope_peer_name(scope_name)
db_session.add(models.Peer(workspace_name=test_workspace.name, name=squatter))
await db_session.commit()
session_name = _create_session(client, test_workspace.name)
# Not a scope, so the message-author guard must not fire
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/messages",
json={"messages": [{"peer_id": squatter, "content": "hello"}]},
)
assert response.status_code in [200, 201], response.text
# Visible in the default (non-scope) peers list
response = client.post(f"/v3/workspaces/{test_workspace.name}/peers/list")
assert squatter in [p["id"] for p in response.json()["items"]]
# But the facade does not recognise it
response = client.get(f"/v3/workspaces/{test_workspace.name}/scopes/{scope_name}")
assert response.status_code == 404
response = client.post(f"/v3/workspaces/{test_workspace.name}/scopes/list")
assert scope_name not in [s["id"] for s in response.json()["items"]]
async def test_forged_configuration_kind_does_not_make_a_scope(
client: TestClient,
sample_data: tuple[Workspace, Peer],
):
"""`configuration` is user-writable, so it must not be load-bearing.
A peer that forges `{"kind": "scope"}` in configuration has neither the
reserved name nor the internal flag, so it stays an ordinary peer.
"""
test_workspace, _ = sample_data
peer_name = str(generate_nanoid())
response = client.put(
f"/v3/workspaces/{test_workspace.name}/peers/{peer_name}",
json={"configuration": {"kind": "scope"}},
)
assert response.status_code == 200, response.text
# Still an ordinary peer: present in the default list, absent from scopes
response = client.post(f"/v3/workspaces/{test_workspace.name}/peers/list")
assert peer_name in [p["id"] for p in response.json()["items"]]
response = client.post(f"/v3/workspaces/{test_workspace.name}/scopes/list")
assert peer_name not in [s["id"] for s in response.json()["items"]]
# And can still author messages
session_name = _create_session(client, test_workspace.name)
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/messages",
json={"messages": [{"peer_id": peer_name, "content": "hello"}]},
)
assert response.status_code in [200, 201], response.text
def test_internal_metadata_never_in_peer_response(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""The scope flag must not leak into any peer response body."""
test_workspace, _ = sample_data
scope_name = str(generate_nanoid())
assert _create_scope(client, test_workspace.name, scope_name).status_code == 201
response = client.post(
f"/v3/workspaces/{test_workspace.name}/peers/list", json={"kind": "all"}
)
assert response.status_code == 200
for peer in response.json()["items"]:
assert "internal_metadata" not in peer
assert "kind" not in peer.get("configuration", {})
async def test_crud_get_peer_resolves_scope_and_dotted_names(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""crud.get_peer must accept names outside RESOURCE_NAME_PATTERN.
This is the Dreamer's preflight path: DreamScheduler passes
``collection.observer`` straight through, and scope peers have
``observe_others=true``, so ``(scope.x, peer)`` collections exist and get
dreamt. While get_peer took a PeerCreate, every such dream died at preflight
on a raw pydantic ValidationError.
"""
test_workspace, _ = sample_data
scope_name = str(generate_nanoid())
assert _create_scope(client, test_workspace.name, scope_name).status_code == 201
resolved = await crud.get_peer(
db_session, test_workspace.name, scope_peer_name(scope_name)
)
assert resolved.name == scope_peer_name(scope_name)
assert is_scope_peer(resolved.name, resolved.internal_metadata)
dotted = f"legacy.dotted.{generate_nanoid()}"
db_session.add(models.Peer(workspace_name=test_workspace.name, name=dotted))
await db_session.commit()
resolved = await crud.get_peer(db_session, test_workspace.name, dotted)
assert resolved.name == dotted
# Has neither half of the invariant
assert not is_scope_peer(resolved.name, resolved.internal_metadata)
# ---------------------------------------------------------------------------
# Name validation on the create path. PeerSpec carries no charset pattern so
# existing names can be *looked up*, but anything crud is about to INSERT is a
# new peer and must obey the public contract — otherwise request-controlled
# names (message authors, session peer maps) mint squatters.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"bad_name",
["not a valid name!@#", "has spaces", "emoji-\U0001f600"],
)
def test_message_author_cannot_create_invalid_peer_name(
client: TestClient, sample_data: tuple[Workspace, Peer], bad_name: str
):
"""A message author must not be able to create a non-conforming peer."""
test_workspace, _ = sample_data
session_name = _create_session(client, test_workspace.name)
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/messages",
json={"messages": [{"peer_id": bad_name, "content": "hello"}]},
)
assert response.status_code == 422, response.text
response = client.post(
f"/v3/workspaces/{test_workspace.name}/peers/list", json={"kind": "all"}
)
assert bad_name not in [p["id"] for p in response.json()["items"]]
async def test_resolved_scope_peer_rejected_at_membership_upsert(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""The last-line guard runs on resolved rows, closing the check-then-upsert race.
Calls crud directly, bypassing the route-level name check, so the only thing
standing between the caller and a scope membership is the guard on the
resolved peer row — the guard the racing caller would hit. The unflagged →
flagged transition itself is not simulated here.
"""
test_workspace, _ = sample_data
scope_name = str(generate_nanoid())
assert _create_scope(client, test_workspace.name, scope_name).status_code == 201
backing = scope_peer_name(scope_name)
session_name = _create_session(client, test_workspace.name)
# crud-level: the resolved row is a scope, so membership must be refused even
# though the peer already exists (no create-path validation fires).
with pytest.raises(ValidationException):
await crud.get_or_create_session(
db_session,
session=schemas.SessionCreate(
name=session_name, peers={backing: schemas.SessionPeerConfig()}
),
workspace_name=test_workspace.name,
)
async def test_set_peer_config_cannot_disable_a_scope(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""A scope's membership config belongs to the facade, not the caller.
`observe_others=false` would silently stop all fan-out into the scope, and
`observe_me=true` would make Honcho form a representation *of* a scope.
Verified against the row rather than the config route, because that read is
refused for a scope too — see the policy table.
"""
test_workspace, _ = sample_data
scope_name = str(generate_nanoid())
assert _create_scope(client, test_workspace.name, scope_name).status_code == 201
backing = scope_peer_name(scope_name)
session_name = _create_session(client, test_workspace.name)
assert _add_sessions(client, test_workspace.name, scope_name, [session_name]) == 204
response = client.put(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/peers/{backing}/config",
json={"observe_others": False, "observe_me": True},
)
assert response.status_code == 422, response.text
# Observer semantics intact
membership = await _get_session_peer(
db_session, test_workspace.name, session_name, backing
)
assert membership is not None
assert membership.configuration == {"observe_me": False, "observe_others": True}
def test_session_peers_listing_excludes_scopes(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""The generic session-peer surface must not expose the facade's observer.
Without the filter this listing returns a peer literally named
`scope.<name>` carrying `observe_others=true` — the exact mechanic the
facade exists to hide. The membership-config read is refused for the same
reason; ordinary members are unaffected by both.
"""
test_workspace, test_peer = sample_data
base = f"/v3/workspaces/{test_workspace.name}"
scope_name = str(generate_nanoid())
assert _create_scope(client, test_workspace.name, scope_name).status_code == 201
backing = scope_peer_name(scope_name)
session_name = _create_session(client, test_workspace.name)
assert (
client.post(
f"{base}/sessions/{session_name}/peers", json={test_peer.name: {}}
).status_code
== 200
)
assert _add_sessions(client, test_workspace.name, scope_name, [session_name]) == 204
listed = client.get(f"{base}/sessions/{session_name}/peers")
assert listed.status_code == 200
names = {item["id"] for item in listed.json()["items"]}
assert test_peer.name in names
assert backing not in names
assert (
client.get(f"{base}/sessions/{session_name}/peers/{backing}/config").status_code
== 422
)
assert (
client.get(
f"{base}/sessions/{session_name}/peers/{test_peer.name}/config"
).status_code
== 200
)
@pytest.mark.parametrize("bad_name", ["", "a" * 513])
def test_degenerate_peer_names_are_422_not_500(
client: TestClient, sample_data: tuple[Workspace, Peer], bad_name: str
):
"""Empty and over-long author names must not reach PeerSpec and 500.
Request-bound peer names carry no length bound of their own, so these used to
raise a raw pydantic ValidationError inside crud, which the catch-all handler
turned into a 500.
"""
test_workspace, _ = sample_data
session_name = _create_session(client, test_workspace.name)
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/messages",
json={"messages": [{"peer_id": bad_name, "content": "hello"}]},
)
assert response.status_code == 422, response.text
# ---------------------------------------------------------------------------
# Detach-by-omission. The replacement routes never name the scope, so there is
# no peer position for the route-policy table to classify — the caller detaches
# by leaving it out. Preservation has to be flag-based, not request-based.
# ---------------------------------------------------------------------------
def test_generic_replacement_preserves_scope_membership(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""`PUT /sessions/{id}/peers` replaces ordinary peers, never scopes.
The guard cannot key off the request body: a caller detaches a scope by simply
*omitting* it from an otherwise valid replacement map, never naming it.
"""
test_workspace, test_peer = sample_data
scope_name = str(generate_nanoid())
assert _create_scope(client, test_workspace.name, scope_name).status_code == 201
session_name = _create_session(client, test_workspace.name)
assert _add_sessions(client, test_workspace.name, scope_name, [session_name]) == 204
# Replacement naming only an ordinary peer must succeed...
response = client.put(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/peers",
json={test_peer.name: {}},
)
assert response.status_code == 200, response.text
# ...while leaving the scope's membership intact.
assert _scope_sessions(client, test_workspace.name, scope_name) == [session_name]
async def test_empty_replacement_preserves_scope_membership(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""An empty replacement map clears ordinary peers but not scopes."""
test_workspace, test_peer = sample_data
scope_name = str(generate_nanoid())
assert _create_scope(client, test_workspace.name, scope_name).status_code == 201
session_name = _create_session(client, test_workspace.name)
client.post(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/peers",
json={test_peer.name: {}},
)
_add_sessions(client, test_workspace.name, scope_name, [session_name])
assert (
client.put(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/peers",
json={},
).status_code
== 200
)
assert _scope_sessions(client, test_workspace.name, scope_name) == [session_name]
# The other half of the docstring: without this the test passes even if the
# empty replacement became a no-op for ordinary peers too.
session_peer = await _get_session_peer(
db_session, test_workspace.name, session_name, test_peer.name
)
assert session_peer is not None
assert session_peer.left_at is not None
async def test_replacement_still_removes_unflagged_squatter(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""The preservation is flag-based: a squatter keeps ordinary semantics."""
test_workspace, test_peer = sample_data
squatter = scope_peer_name(str(generate_nanoid()))
db_session.add(models.Peer(workspace_name=test_workspace.name, name=squatter))
await db_session.commit()
session_name = _create_session(client, test_workspace.name)
assert (
client.post(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/peers",
json={squatter: {}},
).status_code
== 200
)
assert (
client.put(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/peers",
json={test_peer.name: {}},
).status_code
== 200
)
session_peer = await _get_session_peer(
db_session, test_workspace.name, session_name, squatter
)
assert session_peer is not None
assert session_peer.left_at is not None, "squatter should be replaced normally"
# ---------------------------------------------------------------------------
# Observed-position guards below the HTTP surface. A scope may be an observer
# but must never be observed — and "observed" includes a reserved name that does
# not yet exist, since nothing on these paths creates the peer and the state
# would retroactively describe the scope once created. The route-level half of
# this is enumerated in test_scope_route_policy.py; these cover the crud entry
# points that no route table reaches, plus the side effects a status code alone
# would not catch.
# ---------------------------------------------------------------------------
async def test_set_peer_card_guard_covers_internal_callers(
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""The guard is in crud, so Dreamer and agent-tool paths are covered too."""
test_workspace, test_peer = sample_data
with pytest.raises(ValidationException):
await crud.set_peer_card(
db_session,
test_workspace.name,
peer_card=["x"],
observer=test_peer.name,
observed=scope_peer_name(str(generate_nanoid())),
)
async def test_dream_cannot_be_queued_for_a_future_scope(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""A refused dream leaves no queue row behind.
The 422 itself is enumerated in test_scope_route_policy.py; what that cannot
see is the side effect. The route's own precheck cannot catch this — the peer
is not flagged yet — so the check has to sit in the transaction that inserts
the queue item, and a check placed after the insert would still 422.
"""
test_workspace, test_peer = sample_data
future = scope_peer_name(str(generate_nanoid()))
response = client.post(
f"/v3/workspaces/{test_workspace.name}/schedule_dream",
json={"observer": test_peer.name, "observed": future, "dream_type": "omni"},
)
assert response.status_code == 422, response.text
# No queue row was inserted for it
items = (
await db_session.execute(
select(QueueItem).where(QueueItem.work_unit_key.contains(future))
)
).all()
assert not items
async def test_enqueue_dream_guard_covers_internal_callers(
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Direct enqueue_dream calls enforce the same invariant as the route."""
from src.deriver.enqueue import enqueue_dream
from src.schemas.configuration import DreamType
test_workspace, test_peer = sample_data
scope_name = str(generate_nanoid())
await db_session.commit()
with pytest.raises(ValidationException):
await enqueue_dream(
test_workspace.name,
observer=test_peer.name,
observed=scope_peer_name(scope_name),
dream_type=DreamType.OMNI,
)
def test_prefixed_nul_name_is_422_not_500(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""A reserved-prefix name containing NUL must not reach a text comparison.
It passes the request schemas and PeerSpec, so without pre-SQL rejection it
reaches psycopg inside the scope lookup and raises DataError — a 500.
"""
test_workspace, _ = sample_data
session_name = _create_session(client, test_workspace.name)
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/messages",
json={"messages": [{"peer_id": "scope.future\x00name", "content": "hi"}]},
)
assert response.status_code == 422, response.text
# ---------------------------------------------------------------------------
# Observer limit. Scope memberships carry observe_others=True but must not
# consume SESSION_OBSERVERS_LIMIT: that would cap scopes-per-session at the
# limit and report it as an observer-shaped 400 through a facade whose whole
# job is hiding observers.
# ---------------------------------------------------------------------------
def test_scopes_do_not_count_toward_observer_limit(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""A session can join more scopes than SESSION_OBSERVERS_LIMIT allows observers."""
test_workspace, _ = sample_data
session_name = _create_session(client, test_workspace.name)
scope_names = [
str(generate_nanoid()) for _ in range(settings.SESSION_OBSERVERS_LIMIT + 2)
]
for scope_name in scope_names:
assert _create_scope(client, test_workspace.name, scope_name).status_code == 201
assert (
_add_sessions(client, test_workspace.name, scope_name, [session_name])
== 204
)
# Every membership is live, and the session-create path agrees.
for scope_name in scope_names:
assert _scope_sessions(client, test_workspace.name, scope_name) == [
session_name
]
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions",
json={"id": str(generate_nanoid()), "scopes": scope_names},
)
assert response.status_code == 201, response.text
def test_observer_limit_still_applies_to_real_peers(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""The exclusion is scope-only: real observers are still capped.
Without this the scope carve-out could quietly disable the limit entirely.
"""
test_workspace, _ = sample_data
session_name = _create_session(client, test_workspace.name)
scope_name = str(generate_nanoid())
assert _create_scope(client, test_workspace.name, scope_name).status_code == 201
assert _add_sessions(client, test_workspace.name, scope_name, [session_name]) == 204
observers = {
str(generate_nanoid()): {"observe_others": True}
for _ in range(settings.SESSION_OBSERVERS_LIMIT + 1)
}
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/peers",
json=observers,
)
assert response.status_code == 400, response.text
def test_session_create_scopes_list_is_capped(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""`scopes` is bounded like `session_ids` on the add-sessions route.
Nothing downstream bounds it now that scopes are outside the observer limit,
so an unbounded list would mint a scope peer per element in one request.
"""
test_workspace, _ = sample_data
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions",
json={
"id": str(generate_nanoid()),
"scopes": [str(generate_nanoid()) for _ in range(101)],
},
)
assert response.status_code == 422, response.text