Files
odysseus/tests/test_companion_readonly.py
Mahdi Salmanzade 000bd6d1ab Add read-only companion endpoints (ping/info/owner-scoped models) (#863)
First, smallest cut of a LAN companion bridge (split out of #855 per review):
a thin, additive, read-only layer so a LAN client can discover what a server
offers. No new LLM logic; auth is enforced by the existing AuthMiddleware.

- GET /api/companion/ping  -- cheap auth-validated health check
- GET /api/companion/info  -- server identity + capability flags
- GET /api/companion/models -- the CALLER's own model endpoints

/models scopes to the caller's real owner (the token's owner for bearer callers)
plus legacy null-owner shared rows, mirroring owner_filter, and never returns
api_key material. The owner rule lives in two pure helpers (token_owner,
owner_can_see) with direct tests proving a token for owner A cannot see owner B's
rows and that null-owner rows don't widen access.
2026-06-02 11:20:53 +09:00

79 lines
2.7 KiB
Python

"""Owner-scope tests for the read-only companion bridge.
Mirrors the direct-helper style of tests/test_null_owner_gates.py: exercise the
small pure helpers against mock request state and owner values, so the scoping
rule can't silently regress. A bearer token for owner A must never see owner B's
rows, and legacy null-owner rows must not widen a token's access.
"""
import os
import sys
import types
from types import SimpleNamespace
from unittest.mock import MagicMock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# core.database instantiates SQLAlchemy declarative classes at import time, which
# blows up under conftest's sqlalchemy MagicMock stubs. companion.routes only
# imports it lazily inside the /models handler, but stub it defensively so the
# import is robust regardless of collection order.
if "core.database" not in sys.modules:
_db = types.ModuleType("core.database")
_db.SessionLocal = MagicMock()
_db.ModelEndpoint = MagicMock()
sys.modules["core.database"] = _db
from companion.routes import token_owner, owner_can_see
def _request(**state):
return SimpleNamespace(state=SimpleNamespace(**state))
# --- token_owner: who a request is attributed to ---------------------------
def test_token_owner_bearer_resolves_to_token_owner():
# A paired bearer caller runs as the "api" pseudo-user, but must attribute
# to the token's real owner.
req = _request(api_token=True, api_token_owner="alice", current_user="api")
assert token_owner(req) == "alice"
def test_token_owner_cookie_uses_logged_in_user():
req = _request(api_token=False, current_user="alice")
assert token_owner(req) == "alice"
def test_token_owner_none_when_unresolved():
req = _request(api_token=True, api_token_owner=None, current_user="api")
assert token_owner(req) is None
# --- owner_can_see: the read-scope rule ------------------------------------
def test_owner_sees_their_own_rows():
assert owner_can_see("alice", "alice") is True
def test_null_owner_shared_rows_are_visible():
# Legacy shared rows (owner is None) are visible to everyone by design...
assert owner_can_see(None, "alice") is True
def test_null_owner_does_not_widen_access_to_others_rows():
# ...but a null-owner row must not be a backdoor to another OWNER's rows.
assert owner_can_see("bob", "alice") is False
def test_cross_owner_is_blocked():
assert owner_can_see("bob", "alice") is False
assert owner_can_see("alice", "bob") is False
def test_unauthenticated_owner_sees_only_shared_rows():
# owner=None (no resolved caller): only null-owner shared rows are visible,
# never any owned row.
assert owner_can_see(None, None) is True
assert owner_can_see("alice", None) is False