honcho/tests/routes/test_scopes.py

968 lines
36 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
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.get(f"{scopes_url}/{scope_name}/sessions").status_code == 401
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
def test_scope_peer_cannot_author_messages(
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
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_peer_name(scope_name),
"content": "I should not speak",
}
]
},
)
assert response.status_code == 422
assert SCOPE_PEER_PREFIX in response.json()["detail"]
def test_scope_peer_cannot_be_chat_target(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Chat validation happens before any LLM work, so this is safe to exercise."""
test_workspace, test_peer = 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/{test_peer.name}/chat",
json={"query": "what do you know?", "target": scope_peer_name(scope_name)},
)
assert response.status_code == 422
def test_scope_peer_cannot_be_representation_target(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, test_peer = 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/{test_peer.name}/representation",
json={"target": scope_peer_name(scope_name)},
)
assert response.status_code == 422
def test_generic_session_peer_routes_reject_scope_peers(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Scope membership is managed only via the scopes facade."""
test_workspace, _ = 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)
session_name = _create_session(client, test_workspace.name)
base = f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}"
response = client.post(f"{base}/peers", json={backing_peer_name: {}})
assert response.status_code == 422
assert "scopes" in response.json()["detail"]
response = client.put(f"{base}/peers", json={backing_peer_name: {}})
assert response.status_code == 422
response = client.request("DELETE", f"{base}/peers", json=[backing_peer_name])
assert response.status_code == 422
# Session creation with a scope peer in the generic peers mapping is also
# rejected; the `scopes` field is the supported path.
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions",
json={"id": str(generate_nanoid()), "peers": {backing_peer_name: {}}},
)
assert response.status_code == 422
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
response = client.post(
f"{scope_base}/sessions", json={"session_ids": [session_1, session_2]}
)
assert response.status_code == 200
assert set(response.json()["session_ids"]) == {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
# List memberships
response = client.get(f"{scope_base}/sessions")
assert response.status_code == 200
assert set(response.json()["session_ids"]) == {session_1, session_2}
# 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
response = client.get(f"{scope_base}/sessions")
assert response.status_code == 200
assert response.json()["session_ids"] == [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)
response = client.post(
f"/v3/workspaces/{test_workspace.name}/scopes/{scope_name}/sessions",
json={"session_ids": [existing_session, str(generate_nanoid())]},
)
assert response.status_code == 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)
scope_base = f"/v3/workspaces/{test_workspace.name}/scopes/{generate_nanoid()}"
response = client.post(
f"{scope_base}/sessions", json={"session_ids": [session_name]}
)
assert response.status_code == 404
assert client.get(f"{scope_base}/sessions").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
response = client.get(
f"/v3/workspaces/{test_workspace.name}/scopes/{scope_a}/sessions"
)
assert response.status_code == 200
assert response.json()["session_ids"] == [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)
response = client.post(
f"/v3/workspaces/{test_workspace.name}/scopes/{scope_name}/sessions",
json={"session_ids": [scope_session]},
)
assert response.status_code == 200
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
response = client.post(
f"/v3/workspaces/{test_workspace.name}/scopes/{scope_name}/sessions",
json={"session_ids": [session_name]},
)
assert response.status_code == 200
# 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_update_peer_rejects_reserved_prefix(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""PUT on a reserved-namespace name is a clean 422, never a 500."""
test_workspace, _ = sample_data
response = client.put(
f"/v3/workspaces/{test_workspace.name}/peers/{scope_peer_name('therapy')}",
json={"metadata": {"k": "v"}},
)
assert response.status_code == 422
assert SCOPE_PEER_PREFIX in response.json()["detail"]
async def test_scope_peer_cannot_be_chat_or_representation_observer(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""The path-level peer_id is guarded, not just `target`."""
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)
response = client.post(
f"/v3/workspaces/{test_workspace.name}/peers/{backing}/chat",
json={"query": "what do you know?"},
)
assert response.status_code == 422, response.text
response = client.post(
f"/v3/workspaces/{test_workspace.name}/peers/{backing}/representation",
json={},
)
assert response.status_code == 422, 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"]]
def test_message_author_cannot_squat_scope_namespace(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""The reserved namespace must not be reachable via the message author path.
Without create-path validation this minted an unflagged `scope.<name>` peer,
which then permanently 409-blocked creating that scope — a denial of service
on the namespace by any caller who can post a message.
"""
test_workspace, _ = sample_data
scope_name = str(generate_nanoid())
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_peer_name(scope_name), "content": "hello"}]
},
)
assert response.status_code == 422, response.text
assert SCOPE_PEER_PREFIX in response.json()["detail"]
# The scope name is still available
assert _create_scope(client, test_workspace.name, scope_name).status_code == 201
def test_session_peer_map_cannot_squat_scope_namespace(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Same hole, via the session peer mapping rather than a message author."""
test_workspace, _ = sample_data
scope_name = str(generate_nanoid())
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions",
json={
"id": str(generate_nanoid()),
"peers": {scope_peer_name(scope_name): {}},
},
)
assert response.status_code == 422, response.text
assert _create_scope(client, test_workspace.name, scope_name).status_code == 201
async def test_unflagged_squatter_can_still_be_updated(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""An existing unflagged peer in the namespace is a normal peer.
Three-way behavior on PUT /peers/{id}: a real scope is refused, an existing
unflagged squatter updates fine, and a missing reserved-prefix name is
refused by create-path validation rather than being minted.
"""
test_workspace, _ = 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()
# existing unflagged peer -> allowed
response = client.put(
f"/v3/workspaces/{test_workspace.name}/peers/{squatter}",
json={"metadata": {"k": "v"}},
)
assert response.status_code == 200, response.text
assert response.json()["metadata"] == {"k": "v"}
# real scope -> refused
scope_name = str(generate_nanoid())
assert _create_scope(client, test_workspace.name, scope_name).status_code == 201
response = client.put(
f"/v3/workspaces/{test_workspace.name}/peers/{scope_peer_name(scope_name)}",
json={"metadata": {"k": "v"}},
)
assert response.status_code == 422, response.text
# missing reserved-prefix name -> refused, not created
missing = scope_peer_name(str(generate_nanoid()))
response = client.put(
f"/v3/workspaces/{test_workspace.name}/peers/{missing}",
json={"metadata": {"k": "v"}},
)
assert response.status_code == 422, response.text
response = client.post(
f"/v3/workspaces/{test_workspace.name}/peers/list", json={"kind": "all"}
)
assert missing 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.
Simulates the race by flagging the peer *after* the route-level name check
would have passed: the peer exists and is unflagged when named, and is a real
scope by the time membership is upserted.
"""
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,
)