961 lines
33 KiB
Python
961 lines
33 KiB
Python
"""Route-policy enumeration for the scopes facade, per peer *position*.
|
|
|
|
Four review passes over the scopes work each found the same class of defect: a
|
|
place nobody had checked, rather than logic that was subtly wrong. The first
|
|
version of this module enumerated routes and classified each one guarded or
|
|
exempt — and that model was itself the fifth defect. A binary per-route verdict
|
|
cannot express the actual invariant, which is positional:
|
|
|
|
A scope may be an OBSERVER. A scope may never be OBSERVED.
|
|
|
|
`POST /conclusions` is the case that proves it: a scope as `observer_id` is how
|
|
scoped conclusions are stored and must work, while a scope as `observed_id`
|
|
persisted a conclusion about something that carries ``observe_me=false``. One
|
|
route, two positions, opposite verdicts. The same split applies to
|
|
`schedule_dream`, the peer-card routes, and session context.
|
|
|
|
One refinement, added with the `scope` read option: on the *read* routes an
|
|
observer position is refused too, even though a scope there is mechanically
|
|
legitimate. Asking for a scope's perspective is what `scope` is for, and routing
|
|
through it is what keeps the observer mechanics hidden — so `peer_perspective`,
|
|
`GET /peers/{peer_id}/context`, chat and representation all refuse a raw scope
|
|
peer name and point at `scope` instead. The invariant above still governs the
|
|
storage side, where `observer_id` / `observer` remain ALLOW: a scope observing is
|
|
the entire mechanism. Read "OBSERVER" as "may observe", not "may be named as one
|
|
on any route".
|
|
|
|
So classification here is keyed by ``(method, path, position)``, where position is
|
|
the request parameter carrying the peer name. Every derived triple must appear in
|
|
`POLICY` as either REFUSE or ALLOW-with-a-reason; a new one fails
|
|
`test_every_peer_position_is_classified` until someone classifies it.
|
|
|
|
Each REFUSE case is then asserted behaviorally — by calling the route, because the
|
|
guards deliberately live in crud (which is what makes `messages/upload` guarded
|
|
for free via `crud.create_messages`) — in both directions:
|
|
|
|
1. a real scope is refused, and the rejection must actually name it, so an
|
|
unrelated 422 cannot pass the assertion;
|
|
2. an *unflagged* peer merely occupying the reserved namespace is NOT a scope and
|
|
is unaffected. That half regressed once already when `update_peer` keyed off
|
|
the name prefix.
|
|
|
|
Known limitation: this covers the HTTP surface only. Peer names also reach the
|
|
system through the deriver, dreamer, and queue, which have no route table to
|
|
enumerate; a gap there would not be caught here.
|
|
"""
|
|
|
|
from collections.abc import Callable, Iterator
|
|
from dataclasses import dataclass
|
|
|
|
import pytest
|
|
from fastapi.routing import APIRoute
|
|
from fastapi.testclient import TestClient
|
|
from httpx import Response
|
|
from nanoid import generate as generate_nanoid
|
|
from pydantic import BaseModel
|
|
from pydantic.fields import FieldInfo
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src import models
|
|
from src.main import app
|
|
from src.models import Peer, Workspace
|
|
from src.utils.scopes import scope_peer_name
|
|
|
|
# Request parameters and model fields that carry a peer name, in any position.
|
|
_PEER_PARAM_NAMES = {
|
|
"peer_id",
|
|
"peer_name",
|
|
"peer_names",
|
|
"observer",
|
|
"observer_id",
|
|
"observed",
|
|
"observed_id",
|
|
"sender_id",
|
|
"target",
|
|
"peer_target",
|
|
"peer_perspective",
|
|
}
|
|
|
|
# Peer names arriving as dict keys or an aliased body field are invisible to
|
|
# parameter-name detection, so these paths are matched by shape instead. The
|
|
# position recorded for them is the body field or key role.
|
|
_KEY_POSITION = "body_peer_keys"
|
|
|
|
# The scopes router *is* the facade; scope peers are its whole subject.
|
|
_SCOPES_PREFIX = "/v3/workspaces/{workspace_id}/scopes"
|
|
|
|
# A builder places `peer` into one position of one route and returns the response.
|
|
Builder = Callable[[TestClient, str, str, str], Response]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Case:
|
|
"""Policy for one peer position on one route."""
|
|
|
|
method: str
|
|
path: str
|
|
position: str
|
|
refuse: bool
|
|
reason: str = ""
|
|
build: Builder | None = None
|
|
# REFUSE cases only. Whether a reserved name that does NOT YET EXIST is also
|
|
# refused — the third axis, and the one that is not derivable from `refuse`.
|
|
# It follows from the guard the call site picked:
|
|
#
|
|
# validate_no_scope_peer_names name-only, no DB refuses missing
|
|
# reject_scope_observed strict on the observed refuses missing
|
|
# reject_scope_peers flag-based, permissive allows missing
|
|
#
|
|
# Permissive is correct where something downstream still stops it (the create
|
|
# path validates new names) or where the name simply resolves to nothing (404
|
|
# before any guard runs). Each False therefore needs `missing_reason`.
|
|
refuse_missing: bool | None = None
|
|
missing_reason: str = ""
|
|
# Required when refuse_missing is False: the exact status(es) a missing
|
|
# reserved name may receive. Deliberately not a bare `!= 422` — that passes on
|
|
# a 5xx too, which is the same hole the squatter assertion below had.
|
|
missing_status: tuple[int, ...] = ()
|
|
# Set when the 422 legitimately comes from request-schema validation rather
|
|
# than a scope guard, so the detail is pydantic's rather than ours.
|
|
schema_level: bool = False
|
|
# Set when the squatter direction cannot be asserted here, with why.
|
|
skip_squatter: str = ""
|
|
# ALLOW cases only: builder plus the status a real scope must receive, so the
|
|
# suite proves legitimate observer positions keep working.
|
|
allow_status: tuple[int, ...] = ()
|
|
|
|
@property
|
|
def key(self) -> tuple[str, str, str]:
|
|
return (self.method, self.path, self.position)
|
|
|
|
|
|
_W = "/v3/workspaces/{workspace_id}"
|
|
|
|
|
|
def _b_create_peer(c: TestClient, ws: str, _s: str, p: str):
|
|
return c.post(f"/v3/workspaces/{ws}/peers", json={"id": p})
|
|
|
|
|
|
def _b_update_peer(c: TestClient, ws: str, _s: str, p: str):
|
|
return c.put(f"/v3/workspaces/{ws}/peers/{p}", json={"metadata": {"k": "v"}})
|
|
|
|
|
|
def _b_chat_observer(c: TestClient, ws: str, _s: str, p: str):
|
|
return c.post(f"/v3/workspaces/{ws}/peers/{p}/chat", json={"query": "hi"})
|
|
|
|
|
|
def _b_chat_target(c: TestClient, ws: str, _s: str, p: str):
|
|
return c.post(
|
|
f"/v3/workspaces/{ws}/peers/{_OTHER}/chat", json={"query": "hi", "target": p}
|
|
)
|
|
|
|
|
|
def _b_repr_observer(c: TestClient, ws: str, _s: str, p: str):
|
|
return c.post(f"/v3/workspaces/{ws}/peers/{p}/representation", json={})
|
|
|
|
|
|
def _b_repr_target(c: TestClient, ws: str, _s: str, p: str):
|
|
return c.post(
|
|
f"/v3/workspaces/{ws}/peers/{_OTHER}/representation", json={"target": p}
|
|
)
|
|
|
|
|
|
def _b_card_target(c: TestClient, ws: str, _s: str, p: str):
|
|
return c.put(
|
|
f"/v3/workspaces/{ws}/peers/{_OTHER}/card?target={p}",
|
|
json={"peer_card": ["note"]},
|
|
)
|
|
|
|
|
|
def _b_conclusion_observed(c: TestClient, ws: str, _s: str, p: str):
|
|
return c.post(
|
|
f"/v3/workspaces/{ws}/conclusions",
|
|
json={
|
|
"conclusions": [
|
|
{
|
|
"observer_id": _OTHER,
|
|
"observed_id": p,
|
|
"content": "something",
|
|
"level": "explicit",
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
|
|
def _b_dream_observed(c: TestClient, ws: str, _s: str, p: str):
|
|
return c.post(
|
|
f"/v3/workspaces/{ws}/schedule_dream",
|
|
json={"observer": _OTHER, "observed": p, "dream_type": "omni"},
|
|
)
|
|
|
|
|
|
def _b_session_create(c: TestClient, ws: str, _s: str, p: str):
|
|
return c.post(
|
|
f"/v3/workspaces/{ws}/sessions",
|
|
json={"id": str(generate_nanoid()), "peers": {p: {}}},
|
|
)
|
|
|
|
|
|
def _b_session_context_target(c: TestClient, ws: str, s: str, p: str):
|
|
return c.get(f"/v3/workspaces/{ws}/sessions/{s}/context?peer_target={p}")
|
|
|
|
|
|
def _b_message(c: TestClient, ws: str, s: str, p: str):
|
|
return c.post(
|
|
f"/v3/workspaces/{ws}/sessions/{s}/messages",
|
|
json={"messages": [{"peer_id": p, "content": "hello"}]},
|
|
)
|
|
|
|
|
|
def _b_upload(c: TestClient, ws: str, s: str, p: str):
|
|
return c.post(
|
|
f"/v3/workspaces/{ws}/sessions/{s}/messages/upload",
|
|
data={"peer_id": p},
|
|
files={"file": ("note.txt", b"hello there", "text/plain")},
|
|
)
|
|
|
|
|
|
def _b_add_peers(c: TestClient, ws: str, s: str, p: str):
|
|
return c.post(f"/v3/workspaces/{ws}/sessions/{s}/peers", json={p: {}})
|
|
|
|
|
|
def _b_set_peers(c: TestClient, ws: str, s: str, p: str):
|
|
return c.put(f"/v3/workspaces/{ws}/sessions/{s}/peers", json={p: {}})
|
|
|
|
|
|
def _b_remove_peers(c: TestClient, ws: str, s: str, p: str):
|
|
return c.request("DELETE", f"/v3/workspaces/{ws}/sessions/{s}/peers", json=[p])
|
|
|
|
|
|
def _b_conclusion_observer(c: TestClient, ws: str, _s: str, p: str):
|
|
return c.post(
|
|
f"/v3/workspaces/{ws}/conclusions",
|
|
json={
|
|
"conclusions": [
|
|
{
|
|
"observer_id": p,
|
|
"observed_id": _OTHER,
|
|
"content": "something",
|
|
"level": "explicit",
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
|
|
def _b_dream_observer(c: TestClient, ws: str, _s: str, p: str):
|
|
return c.post(
|
|
f"/v3/workspaces/{ws}/schedule_dream",
|
|
json={"observer": p, "observed": _OTHER, "dream_type": "omni"},
|
|
)
|
|
|
|
|
|
def _b_card_observer_put(c: TestClient, ws: str, _s: str, p: str):
|
|
return c.put(
|
|
f"/v3/workspaces/{ws}/peers/{p}/card?target={_OTHER}",
|
|
json={"peer_card": ["note"]},
|
|
)
|
|
|
|
|
|
def _b_card_observer_get(c: TestClient, ws: str, _s: str, p: str):
|
|
return c.get(f"/v3/workspaces/{ws}/peers/{p}/card?target={_OTHER}")
|
|
|
|
|
|
def _b_peer_context_observer(c: TestClient, ws: str, _s: str, p: str):
|
|
return c.get(f"/v3/workspaces/{ws}/peers/{p}/context")
|
|
|
|
|
|
def _b_peer_context_target(c: TestClient, ws: str, _s: str, p: str):
|
|
return c.get(f"/v3/workspaces/{ws}/peers/{_OTHER}/context?target={p}")
|
|
|
|
|
|
def _b_context_perspective(c: TestClient, ws: str, s: str, p: str):
|
|
query = f"?peer_perspective={p}&peer_target={_OTHER}"
|
|
return c.get(f"/v3/workspaces/{ws}/sessions/{s}/context{query}")
|
|
|
|
|
|
def _b_queue_status_observer(c: TestClient, ws: str, _s: str, p: str):
|
|
return c.get(f"/v3/workspaces/{ws}/queue/status?observer_id={p}")
|
|
|
|
|
|
def _b_queue_status_sender(c: TestClient, ws: str, _s: str, p: str):
|
|
return c.get(f"/v3/workspaces/{ws}/queue/status?sender_id={p}")
|
|
|
|
|
|
def _b_peer_config(c: TestClient, ws: str, s: str, p: str):
|
|
return c.put(
|
|
f"/v3/workspaces/{ws}/sessions/{s}/peers/{p}/config",
|
|
json={"observe_others": False, "observe_me": True},
|
|
)
|
|
|
|
|
|
def _b_peer_config_get(c: TestClient, ws: str, s: str, p: str):
|
|
return c.get(f"/v3/workspaces/{ws}/sessions/{s}/peers/{p}/config")
|
|
|
|
|
|
# A plain peer used for the *other* side of two-position routes, so the position
|
|
# under test is the only scope in the request. Created by the fixtures below.
|
|
_OTHER = "policy-counterparty"
|
|
|
|
_OBSERVER_OK = (
|
|
"Observer position. A scope observing others is the entire mechanism scopes "
|
|
"are built on, so this must keep working."
|
|
)
|
|
_READ_ONLY_OK = (
|
|
"Read-only. Returns nothing meaningful for a scope rather than creating or "
|
|
"mutating knowledge about one."
|
|
)
|
|
|
|
POLICY: tuple[Case, ...] = (
|
|
# ---- observed position: a scope must never be the subject ----
|
|
Case(
|
|
"POST",
|
|
f"{_W}/conclusions",
|
|
"observed_id",
|
|
True,
|
|
refuse_missing=False,
|
|
missing_reason=(
|
|
"Every observer and observed peer is resolved before the scope check, "
|
|
"so a name that does not exist is a 404 and no conclusion is written. "
|
|
"The guard is still the strict variant, for if that ever changes."
|
|
),
|
|
missing_status=(404,),
|
|
build=_b_conclusion_observed,
|
|
),
|
|
Case(
|
|
"POST",
|
|
f"{_W}/schedule_dream",
|
|
"observed",
|
|
True,
|
|
refuse_missing=True,
|
|
build=_b_dream_observed,
|
|
),
|
|
Case(
|
|
"PUT",
|
|
f"{_W}/peers/{{peer_id}}/card",
|
|
"target",
|
|
True,
|
|
refuse_missing=True,
|
|
build=_b_card_target,
|
|
),
|
|
Case(
|
|
"POST",
|
|
f"{_W}/peers/{{peer_id}}/chat",
|
|
"target",
|
|
True,
|
|
refuse_missing=True,
|
|
build=_b_chat_target,
|
|
),
|
|
Case(
|
|
"POST",
|
|
f"{_W}/peers/{{peer_id}}/representation",
|
|
"target",
|
|
True,
|
|
refuse_missing=True,
|
|
build=_b_repr_target,
|
|
),
|
|
Case(
|
|
"GET",
|
|
f"{_W}/sessions/{{session_id}}/context",
|
|
"peer_target",
|
|
True,
|
|
refuse_missing=True,
|
|
build=_b_session_context_target,
|
|
),
|
|
# ---- observer position: legitimately a scope ----
|
|
Case(
|
|
"POST",
|
|
f"{_W}/conclusions",
|
|
"observer_id",
|
|
False,
|
|
reason=_OBSERVER_OK,
|
|
build=_b_conclusion_observer,
|
|
allow_status=(200, 201),
|
|
),
|
|
Case(
|
|
"POST",
|
|
f"{_W}/schedule_dream",
|
|
"observer",
|
|
False,
|
|
reason=_OBSERVER_OK,
|
|
build=_b_dream_observer,
|
|
allow_status=(204,),
|
|
),
|
|
Case(
|
|
"PUT",
|
|
f"{_W}/peers/{{peer_id}}/card",
|
|
"peer_id",
|
|
False,
|
|
reason=_OBSERVER_OK,
|
|
build=_b_card_observer_put,
|
|
allow_status=(200,),
|
|
),
|
|
Case(
|
|
"GET",
|
|
f"{_W}/peers/{{peer_id}}/card",
|
|
"peer_id",
|
|
False,
|
|
reason=_OBSERVER_OK,
|
|
build=_b_card_observer_get,
|
|
allow_status=(200,),
|
|
),
|
|
Case(
|
|
"GET",
|
|
f"{_W}/sessions/{{session_id}}/context",
|
|
"peer_perspective",
|
|
True,
|
|
refuse_missing=False,
|
|
missing_reason=(
|
|
"The perspective peer is resolved before the flag-based guard runs, so a "
|
|
"reserved name that does not exist yet is a 404 — the same answer any "
|
|
"absent peer gets here — and nothing on this path creates it."
|
|
),
|
|
missing_status=(404,),
|
|
build=_b_context_perspective,
|
|
),
|
|
Case(
|
|
"GET",
|
|
f"{_W}/queue/status",
|
|
"observer_id",
|
|
False,
|
|
reason=_OBSERVER_OK,
|
|
build=_b_queue_status_observer,
|
|
allow_status=(200,),
|
|
),
|
|
Case(
|
|
"GET",
|
|
f"{_W}/queue/status",
|
|
"sender_id",
|
|
False,
|
|
reason=(
|
|
"Filter only. `sender_id` reaches CRUD as `observed`, but it selects "
|
|
"existing queue rows rather than creating knowledge about a peer."
|
|
),
|
|
build=_b_queue_status_sender,
|
|
allow_status=(200,),
|
|
),
|
|
# ---- peer identity / membership mutation: never a scope ----
|
|
Case(
|
|
"POST",
|
|
f"{_W}/peers",
|
|
_KEY_POSITION,
|
|
True,
|
|
refuse_missing=True,
|
|
build=_b_create_peer,
|
|
schema_level=True,
|
|
skip_squatter=(
|
|
"Creating any name in the reserved namespace is refused whether flagged "
|
|
"or not — that is what reserving it means. Covered by "
|
|
"test_scopes.py::test_peer_create_rejects_reserved_prefix."
|
|
),
|
|
),
|
|
Case(
|
|
"PUT",
|
|
f"{_W}/peers/{{peer_id}}",
|
|
"peer_id",
|
|
True,
|
|
refuse_missing=True,
|
|
build=_b_update_peer,
|
|
),
|
|
Case(
|
|
"POST",
|
|
f"{_W}/sessions",
|
|
"peer_names",
|
|
True,
|
|
refuse_missing=True,
|
|
build=_b_session_create,
|
|
),
|
|
Case(
|
|
"POST",
|
|
f"{_W}/sessions/{{session_id}}/messages",
|
|
"peer_name",
|
|
True,
|
|
refuse_missing=True,
|
|
build=_b_message,
|
|
),
|
|
Case(
|
|
"POST",
|
|
f"{_W}/sessions/{{session_id}}/messages/upload",
|
|
"peer_id",
|
|
True,
|
|
refuse_missing=True,
|
|
build=_b_upload,
|
|
),
|
|
Case(
|
|
"POST",
|
|
f"{_W}/sessions/{{session_id}}/peers",
|
|
_KEY_POSITION,
|
|
True,
|
|
refuse_missing=True,
|
|
build=_b_add_peers,
|
|
),
|
|
Case(
|
|
"PUT",
|
|
f"{_W}/sessions/{{session_id}}/peers",
|
|
_KEY_POSITION,
|
|
True,
|
|
refuse_missing=True,
|
|
build=_b_set_peers,
|
|
),
|
|
Case(
|
|
"DELETE",
|
|
f"{_W}/sessions/{{session_id}}/peers",
|
|
_KEY_POSITION,
|
|
True,
|
|
refuse_missing=False,
|
|
missing_reason=(
|
|
"Removal creates nothing and a name that does not exist has no "
|
|
"membership row, so the request is a no-op. Refusing here would give a "
|
|
"reserved name a different removal result than any other absent peer."
|
|
),
|
|
missing_status=(200,),
|
|
build=_b_remove_peers,
|
|
),
|
|
Case(
|
|
"PUT",
|
|
f"{_W}/sessions/{{session_id}}/peers/{{peer_id}}/config",
|
|
"peer_id",
|
|
True,
|
|
refuse_missing=False,
|
|
missing_reason=(
|
|
"The peer is resolved before the scope check, so a name that does not "
|
|
"exist is a 404 and never reaches the guard. Nothing is created, so "
|
|
"there is no window for the name to be claimed here."
|
|
),
|
|
missing_status=(404,),
|
|
build=_b_peer_config,
|
|
),
|
|
Case(
|
|
"GET",
|
|
f"{_W}/sessions/{{session_id}}/peers/{{peer_id}}/config",
|
|
"peer_id",
|
|
True,
|
|
refuse_missing=False,
|
|
missing_reason=(
|
|
"Same resolution order as the write side of this route: an absent peer "
|
|
"is a 404 before the scope check, and a read creates nothing."
|
|
),
|
|
missing_status=(404,),
|
|
build=_b_peer_config_get,
|
|
),
|
|
# ---- path peer on the dialectic surface ----
|
|
Case(
|
|
"POST",
|
|
f"{_W}/peers/{{peer_id}}/chat",
|
|
"peer_id",
|
|
True,
|
|
refuse_missing=True,
|
|
build=_b_chat_observer,
|
|
),
|
|
Case(
|
|
"POST",
|
|
f"{_W}/peers/{{peer_id}}/representation",
|
|
"peer_id",
|
|
True,
|
|
refuse_missing=True,
|
|
build=_b_repr_observer,
|
|
),
|
|
# ---- reads that neither create nor mutate knowledge about a scope ----
|
|
Case(
|
|
"POST", f"{_W}/peers/{{peer_id}}/search", "peer_id", False, reason=_READ_ONLY_OK
|
|
),
|
|
Case(
|
|
"POST",
|
|
f"{_W}/peers/{{peer_id}}/sessions",
|
|
"peer_id",
|
|
False,
|
|
reason=(
|
|
"Read-only. A scope legitimately has member sessions; this is the "
|
|
"observer-mechanics view of POST /scopes/{scope_id}/sessions/list."
|
|
),
|
|
),
|
|
Case(
|
|
"GET",
|
|
f"{_W}/peers/{{peer_id}}/context",
|
|
"peer_id",
|
|
True,
|
|
refuse_missing=True,
|
|
build=_b_peer_context_observer,
|
|
),
|
|
Case(
|
|
"GET",
|
|
f"{_W}/peers/{{peer_id}}/context",
|
|
"target",
|
|
True,
|
|
refuse_missing=True,
|
|
build=_b_peer_context_target,
|
|
),
|
|
Case(
|
|
"GET",
|
|
f"{_W}/peers/{{peer_id}}/card",
|
|
"target",
|
|
False,
|
|
reason=(
|
|
"Read-only. The write side (PUT with target) IS refused, so this can only "
|
|
"return pre-existing rows, never create them."
|
|
),
|
|
),
|
|
Case(
|
|
"POST",
|
|
"/v3/keys",
|
|
"peer_id",
|
|
False,
|
|
reason=(
|
|
"Mints a scoped JWT rather than touching a peer, so no peer row is read "
|
|
"or written. Keys cannot be bound to a scope yet."
|
|
),
|
|
),
|
|
)
|
|
|
|
_BY_KEY = {case.key: case for case in POLICY}
|
|
|
|
# Routes whose peer names arrive as dict keys or an aliased body field, invisible
|
|
# to parameter-name detection and therefore matched by path shape.
|
|
_KEY_POSITION_PATHS = {
|
|
("POST", f"{_W}/peers"),
|
|
("POST", f"{_W}/sessions/{{session_id}}/peers"),
|
|
("PUT", f"{_W}/sessions/{{session_id}}/peers"),
|
|
("DELETE", f"{_W}/sessions/{{session_id}}/peers"),
|
|
}
|
|
|
|
|
|
def _nested_models(annotation: object, seen: set[object]) -> Iterator[type[BaseModel]]:
|
|
"""Yield `annotation` and every pydantic model nested inside it."""
|
|
if (
|
|
not isinstance(annotation, type)
|
|
or not issubclass(annotation, BaseModel)
|
|
or annotation in seen
|
|
):
|
|
return
|
|
model: type[BaseModel] = annotation
|
|
seen.add(model)
|
|
yield model
|
|
fields: dict[str, FieldInfo] = model.model_fields
|
|
for f in fields.values():
|
|
stack = [f.annotation]
|
|
while stack:
|
|
current = stack.pop()
|
|
yield from _nested_models(current, seen)
|
|
stack.extend(getattr(current, "__args__", ()) or ())
|
|
|
|
|
|
def _peer_positions(route: APIRoute) -> set[str]:
|
|
"""Peer-name-carrying parameter names anywhere in a route's dependant tree.
|
|
|
|
Walks sub-dependencies so `Form(...)` params behind a parser dependency are
|
|
seen — this is how `messages/upload` takes its `peer_id` — and descends into
|
|
request-body models so `MessageCreate.peer_name` is seen too.
|
|
"""
|
|
found: set[str] = set()
|
|
seen: set[object] = set()
|
|
stack = [route.dependant]
|
|
while stack:
|
|
dependant = stack.pop()
|
|
params = (
|
|
dependant.path_params
|
|
+ dependant.query_params
|
|
+ dependant.header_params
|
|
+ dependant.body_params
|
|
)
|
|
for param in params:
|
|
if param.name in _PEER_PARAM_NAMES:
|
|
found.add(param.name)
|
|
annotations = [param.field_info.annotation]
|
|
while annotations:
|
|
annotation = annotations.pop()
|
|
for model in _nested_models(annotation, seen):
|
|
found |= set(model.model_fields) & _PEER_PARAM_NAMES
|
|
annotations.extend(getattr(annotation, "__args__", ()) or ())
|
|
stack.extend(dependant.dependencies)
|
|
return found
|
|
|
|
|
|
def _derived_positions() -> set[tuple[str, str, str]]:
|
|
"""Every (method, path, position) through which a peer name can be supplied."""
|
|
found: set[tuple[str, str, str]] = set()
|
|
for route in app.routes:
|
|
if not isinstance(route, APIRoute):
|
|
continue
|
|
path = route.path.rstrip("/") or route.path
|
|
if path.startswith(_SCOPES_PREFIX):
|
|
continue
|
|
positions = _peer_positions(route)
|
|
for method in route.methods or set():
|
|
if method in ("HEAD", "OPTIONS"):
|
|
continue
|
|
if (method, path) in _KEY_POSITION_PATHS:
|
|
found.add((method, path, _KEY_POSITION))
|
|
for position in positions:
|
|
found.add((method, path, position))
|
|
return found
|
|
|
|
|
|
def test_every_peer_position_is_classified():
|
|
"""Each (route, peer position) pair has an explicit scope policy.
|
|
|
|
A new one fails here until classified. Decide whether a scope in that
|
|
*position* is harmful — the rule is that a scope may be an observer but never
|
|
observed — then add a Case with `refuse=True` and a builder, or `refuse=False`
|
|
and a reason.
|
|
"""
|
|
derived = _derived_positions()
|
|
classified = set(_BY_KEY)
|
|
|
|
# _peer_positions walks FastAPI/Pydantic internals (route.dependant, its
|
|
# *_params lists, field_info.annotation). An upgrade that reshapes any of them
|
|
# would make derivation silently return nothing, and every assertion below
|
|
# would then pass vacuously. Anchor on a position that must always be found.
|
|
assert (
|
|
"POST",
|
|
f"{_W}/sessions/{{session_id}}/messages",
|
|
"peer_name",
|
|
) in derived, (
|
|
"derived no peer positions for a route that certainly has one — the "
|
|
"FastAPI internals _peer_positions() traverses have probably changed shape"
|
|
)
|
|
|
|
unclassified = derived - classified
|
|
assert not unclassified, (
|
|
"peer positions with no scope policy: "
|
|
+ f"{sorted(unclassified)} — classify each as refuse or allow"
|
|
)
|
|
|
|
stale = classified - derived
|
|
assert not stale, f"classified positions that no longer exist: {sorted(stale)}"
|
|
|
|
|
|
def test_policy_entries_are_well_formed():
|
|
assert len(_BY_KEY) == len(POLICY), "duplicate (method, path, position) in POLICY"
|
|
for case in POLICY:
|
|
if case.refuse:
|
|
assert case.build is not None, f"{case.key} refuses but has no builder"
|
|
assert not case.reason, f"{case.key} refuses; reason is for allow cases"
|
|
# The missing-name axis is not derivable from `refuse`, so it must be
|
|
# stated rather than defaulted — that gap is what this field closes.
|
|
assert case.refuse_missing is not None, (
|
|
f"{case.key} refuses a real scope but does not say whether a "
|
|
"reserved name that does not exist yet is also refused"
|
|
)
|
|
if case.refuse_missing:
|
|
assert not case.missing_status, (
|
|
f"{case.key} refuses a missing reserved name, so the expected "
|
|
"status is 422 — missing_status is for the permissive cases"
|
|
)
|
|
else:
|
|
assert (
|
|
len(case.missing_reason.strip()) > 30
|
|
), f"{case.key} tolerates a missing reserved name; say why"
|
|
assert case.missing_status, (
|
|
f"{case.key} tolerates a missing reserved name; name the exact "
|
|
"status(es) it should get, so a 5xx cannot satisfy the case"
|
|
)
|
|
else:
|
|
assert len(case.reason.strip()) > 30, f"{case.key} needs a real reason"
|
|
assert bool(case.build) == bool(case.allow_status), (
|
|
f"{case.key}: an allow case needs a builder and an expected "
|
|
"allow_status together, or neither"
|
|
)
|
|
assert (
|
|
case.refuse_missing is None
|
|
), f"{case.key}: refuse_missing applies to REFUSE cases only"
|
|
|
|
|
|
_REFUSING = tuple(case for case in POLICY if case.refuse)
|
|
# Allow cases that additionally prove, behaviorally, that a real scope works here.
|
|
_ALLOWING_EXERCISED = tuple(
|
|
case for case in POLICY if not case.refuse and case.build is not None
|
|
)
|
|
|
|
|
|
def _setup(client: TestClient, workspace: str) -> tuple[str, str]:
|
|
"""Create the counterparty peer and a session, returning (session, scope name)."""
|
|
assert client.post(
|
|
f"/v3/workspaces/{workspace}/peers", json={"id": _OTHER}
|
|
).status_code in (200, 201)
|
|
session_name = str(generate_nanoid())
|
|
assert client.post(
|
|
f"/v3/workspaces/{workspace}/sessions", json={"id": session_name}
|
|
).status_code in (200, 201)
|
|
return session_name, str(generate_nanoid())
|
|
|
|
|
|
def _real_scope(
|
|
client: TestClient, workspace: str, session_name: str, scope_name: str
|
|
) -> str:
|
|
"""Create a scope, attach the session to it, and return its backing peer name."""
|
|
assert (
|
|
client.post(
|
|
f"/v3/workspaces/{workspace}/scopes", json={"id": scope_name}
|
|
).status_code
|
|
== 201
|
|
)
|
|
assert (
|
|
client.post(
|
|
f"/v3/workspaces/{workspace}/scopes/{scope_name}/sessions",
|
|
json={"session_ids": [session_name]},
|
|
).status_code
|
|
== 204
|
|
)
|
|
return scope_peer_name(scope_name)
|
|
|
|
|
|
@pytest.mark.parametrize("case", _REFUSING, ids=lambda c: f"{c.method}:{c.position}")
|
|
def test_refusing_position_rejects_a_real_scope(
|
|
client: TestClient,
|
|
sample_data: tuple[Workspace, Peer],
|
|
case: Case,
|
|
):
|
|
"""A real scope is refused in every position marked REFUSE."""
|
|
test_workspace, _ = sample_data
|
|
session_name, scope_name = _setup(client, test_workspace.name)
|
|
backing = _real_scope(client, test_workspace.name, session_name, scope_name)
|
|
|
|
assert case.build is not None
|
|
result = case.build(client, test_workspace.name, session_name, backing)
|
|
status = result.status_code
|
|
assert status == 422, (
|
|
f"{case.method} {case.path} accepted a scope in position "
|
|
f"{case.position!r} (got {status})"
|
|
)
|
|
|
|
# A 422 alone proves nothing — a malformed body would also produce one.
|
|
detail = result.text
|
|
if case.schema_level:
|
|
assert (
|
|
"pattern" in detail
|
|
), f"{case.key} expected a schema-level refusal; detail: {detail[:200]}"
|
|
else:
|
|
assert "scope" in detail.lower() and backing in detail, (
|
|
f"{case.key} returned 422 but not because of the scope; "
|
|
f"detail: {detail[:200]}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"case", _ALLOWING_EXERCISED, ids=lambda c: f"{c.method}:{c.position}"
|
|
)
|
|
def test_allowing_position_accepts_a_real_scope(
|
|
client: TestClient,
|
|
sample_data: tuple[Workspace, Peer],
|
|
case: Case,
|
|
):
|
|
"""A real scope works in every position marked ALLOW.
|
|
|
|
The other half of the contract. Refusal tests alone would be satisfied by a
|
|
guard that rejected scopes everywhere, which would break the feature: scoped
|
|
conclusions, scoped dreams and scoped peer cards all require a scope in the
|
|
observer position.
|
|
"""
|
|
test_workspace, _ = sample_data
|
|
session_name, scope_name = _setup(client, test_workspace.name)
|
|
backing = _real_scope(client, test_workspace.name, session_name, scope_name)
|
|
|
|
assert case.build is not None
|
|
result = case.build(client, test_workspace.name, session_name, backing)
|
|
assert result.status_code in case.allow_status, (
|
|
f"{case.method} {case.path} refused a scope in the legitimate position "
|
|
f"{case.position!r}: expected {case.allow_status}, got "
|
|
f"{result.status_code} — {result.text[:200]}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"case",
|
|
tuple(c for c in _REFUSING if not c.skip_squatter),
|
|
ids=lambda c: f"{c.method}:{c.position}",
|
|
)
|
|
async def test_refusing_position_allows_unflagged_squatter(
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
case: Case,
|
|
):
|
|
"""A peer merely occupying the reserved namespace is not a scope.
|
|
|
|
Peer names were length-validated only before migration d429de0e5338, so
|
|
`scope.production` is a possible real user name. Such a peer has only the name
|
|
half of the invariant and must keep working — a guard keying off the prefix
|
|
alone locks a tenant out of its own data.
|
|
"""
|
|
test_workspace, _ = sample_data
|
|
session_name, _ = _setup(client, test_workspace.name)
|
|
squatter = scope_peer_name(str(generate_nanoid()))
|
|
db_session.add(models.Peer(workspace_name=test_workspace.name, name=squatter))
|
|
await db_session.commit()
|
|
|
|
# Give it a membership so config and removal have a row to act on.
|
|
assert (
|
|
client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/peers",
|
|
json={squatter: {}},
|
|
).status_code
|
|
== 200
|
|
)
|
|
|
|
assert case.build is not None
|
|
result = case.build(client, test_workspace.name, session_name, squatter)
|
|
# Deliberately not `!= 422`: that also passes on a 5xx, so a guard regressing
|
|
# into an unhandled error (the psycopg DataError path this feature defends
|
|
# against) would keep this green.
|
|
assert result.status_code < 400, (
|
|
f"{case.method} {case.path} did not accept an unflagged squatter in "
|
|
f"position {case.position!r} (got {result.status_code}) — a 422 means the "
|
|
"guard is keying off the name prefix rather than the scope flag; anything "
|
|
f"else means the request blew up. Body: {result.text[:200]}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("case", _REFUSING, ids=lambda c: f"{c.method}:{c.position}")
|
|
async def test_refusing_position_and_a_missing_reserved_name(
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
case: Case,
|
|
):
|
|
"""The third axis: a reserved name that does not exist yet.
|
|
|
|
Neither of the other two tests reaches it — both resolve an existing subject.
|
|
A permissive guard here is sometimes correct (the create path refuses the name
|
|
itself, or it simply resolves to nothing), which is why the expected verdict is
|
|
declared per case rather than assumed.
|
|
|
|
What is NOT negotiable in either direction is that the request must not MINT
|
|
the reserved name. Minting it would let any caller squat a scope name before
|
|
the workspace owner can create it, and would leave a peer that the facade can
|
|
never adopt.
|
|
"""
|
|
test_workspace, _ = sample_data
|
|
session_name, _ = _setup(client, test_workspace.name)
|
|
missing = scope_peer_name(str(generate_nanoid()))
|
|
|
|
assert case.build is not None
|
|
result = case.build(client, test_workspace.name, session_name, missing)
|
|
|
|
if case.refuse_missing:
|
|
assert result.status_code == 422, (
|
|
f"{case.method} {case.path} accepted a not-yet-existing reserved name "
|
|
f"in position {case.position!r} (got {result.status_code}) — it could "
|
|
f"become a scope later. Body: {result.text[:200]}"
|
|
)
|
|
else:
|
|
# Exact, not `!= 422`: a permissive position still has one correct answer,
|
|
# and a 5xx must not read as tolerance.
|
|
assert result.status_code in case.missing_status, (
|
|
f"{case.method} {case.path} gave {result.status_code} for a missing "
|
|
f"reserved name in position {case.position!r}; the policy expects "
|
|
f"{case.missing_status} because {case.missing_reason!r} — update the "
|
|
f"policy or the guard. Body: {result.text[:200]}"
|
|
)
|
|
|
|
minted = await db_session.scalar(
|
|
select(models.Peer)
|
|
.where(models.Peer.workspace_name == test_workspace.name)
|
|
.where(models.Peer.name == missing)
|
|
)
|
|
assert minted is None, (
|
|
f"{case.method} {case.path} minted the reserved name {missing!r} from "
|
|
f"position {case.position!r} — the scope namespace is now squatted"
|
|
)
|