Files
odysseus/tests/test_memory_routes_session_owner.py
T
RaresKeY 2b519bf355 fix(routes): normalize session owner fallback helpers (#4313)
* fix(memory): normalize import session fallback

* fix(chat): use token owner for compaction scope

* fix(background): honor session endpoint fallback
2026-06-16 06:07:42 +01:00

290 lines
11 KiB
Python

"""Memory routes must owner-scope caller-supplied session ids.
SessionManager.get_session returns any session by id (no owner scoping). The
/api/memory extract, audit, import, and by-session handlers accept a
caller-supplied session id, so without an ownership gate a user could target
another tenant's session and leak their chat history, session-scoped LLM
credentials, or session title.
"""
import asyncio
import io
import sys
import types
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException, UploadFile
import routes.memory_routes as mr
from src.request_models import MemoryAddRequest
def _route(router, path, method):
for r in router.routes:
if r.path == path and method in getattr(r, "methods", set()):
return r.endpoint
raise AssertionError(path)
def _router(monkeypatch, caller):
monkeypatch.setattr(mr, "get_current_user", lambda request: caller, raising=False)
monkeypatch.setattr(mr, "require_user", lambda request: caller, raising=False)
sm = MagicMock()
sm.sessions = {}
sm.get_session = lambda sid: SimpleNamespace(
owner="alice", name="Secret project", endpoint_url="http://x", model="m",
headers={"Authorization": "Bearer victim-secret"},
get_context_messages=lambda: [],
)
mem = MagicMock()
mem.load = lambda owner=None: []
return mr.setup_memory_routes(mem, sm)
def _request(user):
return SimpleNamespace(
state=SimpleNamespace(current_user=user),
app=SimpleNamespace(state=SimpleNamespace(auth_manager=None)),
)
def _upload(name="memories.json"):
return UploadFile(
filename=name,
file=io.BytesIO(b'[{"text": "Project Phoenix uses Python", "category": "project"}]'),
)
def _allow_memory_management(monkeypatch):
monkeypatch.setattr("src.auth_helpers.require_privilege", lambda request, privilege: "alice")
def test_extract_rejects_other_users_session(monkeypatch):
router = _router(monkeypatch, caller="bob")
extract = _route(router, "/api/memory/extract", "POST")
with pytest.raises(HTTPException) as exc:
asyncio.run(extract(request=None, session="alice-sess"))
assert exc.value.status_code == 404
def test_by_session_rejects_other_users_session(monkeypatch):
router = _router(monkeypatch, caller="bob")
gbs = _route(router, "/api/memory/by-session/{session_id}", "GET")
with pytest.raises(HTTPException) as exc:
gbs(request=None, session_id="alice-sess")
assert exc.value.status_code == 404
def test_owner_can_access_own_session(monkeypatch):
router = _router(monkeypatch, caller="alice")
gbs = _route(router, "/api/memory/by-session/{session_id}", "GET")
out = gbs(request=None, session_id="alice-sess")
assert out["session_name"] == "Secret project"
def test_audit_session_fallback_uses_resolver_without_manual_default(monkeypatch):
import src.task_endpoint as task_endpoint
memory_manager = MagicMock()
memory_vector = MagicMock()
session_headers = {"Authorization": "Bearer session"}
session_manager = MagicMock()
session_manager.get_session.return_value = SimpleNamespace(
owner="alice",
endpoint_url="http://session.example/v1/chat/completions",
model="session-model",
headers=session_headers,
)
router = mr.setup_memory_routes(memory_manager, session_manager, memory_vector)
audit_route = _route(router, "/api/memory/audit", "POST")
resolver_calls = []
audit_calls = []
def fake_resolve_task_endpoint(
fallback_url=None,
fallback_model=None,
fallback_headers=None,
owner=None,
):
resolver_calls.append((fallback_url, fallback_model, fallback_headers, owner))
if fallback_url and fallback_model:
return fallback_url, fallback_model, fallback_headers
return None, None, {}
async def fake_audit_memories(memory_manager_arg, memory_vector_arg, endpoint_url, model, headers, owner=None):
audit_calls.append((memory_manager_arg, memory_vector_arg, endpoint_url, model, headers, owner))
return {"before": 2, "after": 1}
fake_model_routes = types.ModuleType("routes.model_routes")
fake_model_routes._load_settings = lambda: {
"default_endpoint_id": "default",
"default_model": "default-model",
}
fake_model_routes._normalize_base = lambda base: base.rstrip("/")
fake_model_routes.build_chat_url = lambda base: f"{base}/chat/completions"
monkeypatch.setattr(mr, "resolve_task_endpoint", fake_resolve_task_endpoint)
monkeypatch.setattr(task_endpoint, "resolve_task_endpoint", fake_resolve_task_endpoint)
monkeypatch.setattr(mr, "audit_memories", fake_audit_memories)
monkeypatch.setitem(sys.modules, "routes.model_routes", fake_model_routes)
monkeypatch.setattr(
mr,
"SessionLocal",
lambda: (_ for _ in ()).throw(AssertionError("manual default branch should not run")),
)
out = asyncio.run(audit_route(request=_request("alice"), session="session-1"))
assert resolver_calls == [(
"http://session.example/v1/chat/completions",
"session-model",
session_headers,
"alice",
)]
assert audit_calls == [(
memory_manager,
memory_vector,
"http://session.example/v1/chat/completions",
"session-model",
session_headers,
"alice",
)]
assert out["ok"] is True
assert out["removed"] == 1
def test_add_memory_rejects_other_users_session(monkeypatch):
memory_manager = MagicMock()
session_manager = MagicMock()
memory_vector = MagicMock(healthy=True)
router = mr.setup_memory_routes(
memory_manager=memory_manager,
session_manager=session_manager,
memory_vector=memory_vector,
)
add_memory = _route(router, "/api/memory/add", "POST")
memory_manager.load.return_value = []
memory_manager.find_duplicates.return_value = False
session_manager.get_session.return_value = SimpleNamespace(owner="bob", name="Bob session")
with pytest.raises(HTTPException) as exc:
asyncio.run(
add_memory(
request=_request("alice"),
memory_data=MemoryAddRequest(
text="Alice note",
category="fact",
source="user",
session_id="bob-session",
),
)
)
assert exc.value.status_code == 404
assert exc.value.detail == "Session not found"
session_manager.get_session.assert_called_once_with("bob-session")
memory_manager.add_entry.assert_not_called()
memory_manager.save.assert_not_called()
memory_vector.add.assert_not_called()
def test_timeline_does_not_expose_other_users_session_name():
memory_manager = MagicMock()
session_manager = MagicMock()
session_manager.sessions = {"bob-session": object()}
session_manager.get_session.return_value = SimpleNamespace(owner="bob", name="Bob roadmap")
memory_manager.load.return_value = [
{
"id": "m1",
"text": "Alice note",
"owner": "alice",
"session_id": "bob-session",
"timestamp": 1,
}
]
router = mr.setup_memory_routes(memory_manager, session_manager)
timeline = _route(router, "/api/memory/timeline", "GET")
out = timeline(request=_request("alice"))
assert out["timeline"][0]["session_name"] == "Unknown"
def test_import_missing_session_uses_utility_fallback(monkeypatch):
_allow_memory_management(monkeypatch)
memory_manager = MagicMock()
session_manager = MagicMock()
session_manager.get_session.side_effect = KeyError
resolve_endpoint = MagicMock(return_value=("http://utility", "utility-model", {}))
resolve_task_endpoint = MagicMock(side_effect=AssertionError("session task endpoint should not be used"))
monkeypatch.setattr(mr, "resolve_endpoint", resolve_endpoint)
monkeypatch.setattr(mr, "resolve_task_endpoint", resolve_task_endpoint)
router = mr.setup_memory_routes(memory_manager, session_manager)
import_memories = _route(router, "/api/memory/import", "POST")
out = asyncio.run(import_memories(request=_request("alice"), session="missing-session", file=_upload()))
assert out == {
"suggestions": [{"text": "Project Phoenix uses Python", "category": "project"}],
"filename": "memories.json",
}
session_manager.get_session.assert_called_once_with("missing-session")
resolve_endpoint.assert_called_once_with("utility", owner="alice")
def test_import_foreign_session_uses_same_utility_fallback(monkeypatch):
_allow_memory_management(monkeypatch)
memory_manager = MagicMock()
session_manager = MagicMock()
session_manager.get_session.return_value = SimpleNamespace(
owner="bob",
endpoint_url="http://bob-llm",
model="bob-model",
headers={"Authorization": "Bearer bob-secret"},
)
resolve_endpoint = MagicMock(return_value=("http://utility", "utility-model", {}))
resolve_task_endpoint = MagicMock(side_effect=AssertionError("foreign session endpoint should not be used"))
monkeypatch.setattr(mr, "resolve_endpoint", resolve_endpoint)
monkeypatch.setattr(mr, "resolve_task_endpoint", resolve_task_endpoint)
router = mr.setup_memory_routes(memory_manager, session_manager)
import_memories = _route(router, "/api/memory/import", "POST")
out = asyncio.run(import_memories(request=_request("alice"), session="bob-session", file=_upload()))
assert out["suggestions"] == [{"text": "Project Phoenix uses Python", "category": "project"}]
session_manager.get_session.assert_called_once_with("bob-session")
resolve_endpoint.assert_called_once_with("utility", owner="alice")
def test_import_owned_session_uses_session_endpoint(monkeypatch):
_allow_memory_management(monkeypatch)
memory_manager = MagicMock()
session_manager = MagicMock()
session_manager.get_session.return_value = SimpleNamespace(
owner="alice",
endpoint_url="http://alice-llm",
model="alice-model",
headers={"X-Session": "alice"},
)
resolve_endpoint = MagicMock(side_effect=AssertionError("utility fallback should not be used"))
resolve_task_endpoint = MagicMock(return_value=("http://alice-task", "alice-task-model", {"X-Task": "alice"}))
monkeypatch.setattr(mr, "resolve_endpoint", resolve_endpoint)
monkeypatch.setattr(mr, "resolve_task_endpoint", resolve_task_endpoint)
router = mr.setup_memory_routes(memory_manager, session_manager)
import_memories = _route(router, "/api/memory/import", "POST")
out = asyncio.run(import_memories(request=_request("alice"), session="alice-session", file=_upload()))
assert out["suggestions"] == [{"text": "Project Phoenix uses Python", "category": "project"}]
session_manager.get_session.assert_called_once_with("alice-session")
resolve_task_endpoint.assert_called_once_with(
"http://alice-llm",
"alice-model",
{"X-Session": "alice"},
owner="alice",
)
resolve_endpoint.assert_not_called()