"""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.`` 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.` 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