e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
175 lines
5.9 KiB
Python
175 lines
5.9 KiB
Python
"""Per-user session isolation on the PocketBase backend (issue #596).
|
|
|
|
PocketBase is a single shared server with no filesystem-level isolation, so
|
|
``PocketBaseSessionStore`` must scope every session row by ``user_id`` (derived
|
|
from the request-scoped current-user ContextVar). These tests stand up a tiny
|
|
in-memory fake of the PocketBase SDK — the real ``pocketbase`` package is not a
|
|
test dependency — and assert that one user can never see or mutate another
|
|
user's sessions.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
import re
|
|
|
|
import pytest
|
|
|
|
from deeptutor.multi_user.context import reset_current_user, set_current_user
|
|
from deeptutor.multi_user.models import CurrentUser, UserScope
|
|
from deeptutor.services.session.pocketbase_store import PocketBaseSessionStore
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
_CLAUSE = re.compile(r'(\w+)\s*=\s*"([^"]*)"')
|
|
|
|
|
|
@contextmanager
|
|
def as_user(uid: str, *, role: str = "user"):
|
|
scope = UserScope(kind=role, user_id=uid, root=Path("/tmp") / uid) # noqa: S108
|
|
token = set_current_user(CurrentUser(id=uid, username=uid, role=role, scope=scope))
|
|
try:
|
|
yield
|
|
finally:
|
|
reset_current_user(token)
|
|
|
|
|
|
class _Record:
|
|
def __init__(self, pb_id: str, data: dict) -> None:
|
|
self.id = pb_id
|
|
for key, value in data.items():
|
|
setattr(self, key, value)
|
|
|
|
|
|
class _Result:
|
|
def __init__(self, items: list[_Record]) -> None:
|
|
self.items = items
|
|
|
|
|
|
class _Collection:
|
|
"""In-memory stand-in for a PocketBase collection (equality filters only)."""
|
|
|
|
def __init__(self) -> None:
|
|
self._rows: list[_Record] = []
|
|
self._seq = 0
|
|
|
|
def _matches(self, record: _Record, query_params: dict | None) -> bool:
|
|
flt = (query_params or {}).get("filter") or ""
|
|
for field, expected in _CLAUSE.findall(flt):
|
|
if str(getattr(record, field, "")) != expected:
|
|
return False
|
|
return True
|
|
|
|
def create(self, data: dict) -> _Record:
|
|
self._seq += 1
|
|
record = _Record(f"pb{self._seq:04d}", data)
|
|
self._rows.append(record)
|
|
return record
|
|
|
|
def get_full_list(self, query_params: dict | None = None) -> list[_Record]:
|
|
return [r for r in self._rows if self._matches(r, query_params)]
|
|
|
|
def get_list(self, page: int, per_page: int, query_params: dict | None = None) -> _Result:
|
|
matched = self.get_full_list(query_params)
|
|
start = (page - 1) * per_page
|
|
return _Result(matched[start : start + per_page])
|
|
|
|
def update(self, pb_id: str, data: dict) -> _Record:
|
|
record = next(r for r in self._rows if r.id == pb_id)
|
|
for key, value in data.items():
|
|
setattr(record, key, value)
|
|
return record
|
|
|
|
def delete(self, pb_id: str) -> None:
|
|
self._rows = [r for r in self._rows if r.id != pb_id]
|
|
|
|
|
|
class _FakeClient:
|
|
def __init__(self) -> None:
|
|
self._collections: dict[str, _Collection] = {}
|
|
|
|
def collection(self, name: str) -> _Collection:
|
|
return self._collections.setdefault(name, _Collection())
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_pb(monkeypatch):
|
|
client = _FakeClient()
|
|
monkeypatch.setattr(
|
|
"deeptutor.services.pocketbase_client.get_pb_client", lambda: client, raising=True
|
|
)
|
|
return client
|
|
|
|
|
|
async def test_create_session_stamps_current_user(fake_pb) -> None:
|
|
store = PocketBaseSessionStore()
|
|
with as_user("alice"):
|
|
await store.create_session(title="A's chat", session_id="s_alice")
|
|
[row] = fake_pb.collection("sessions").get_full_list()
|
|
assert row.user_id == "alice"
|
|
assert row.session_id == "s_alice"
|
|
|
|
|
|
async def test_list_sessions_only_returns_own(fake_pb) -> None:
|
|
store = PocketBaseSessionStore()
|
|
with as_user("alice"):
|
|
await store.create_session(title="a1", session_id="s_a1")
|
|
await store.create_session(title="a2", session_id="s_a2")
|
|
with as_user("bob"):
|
|
await store.create_session(title="b1", session_id="s_b1")
|
|
|
|
bob_sessions = await store.list_sessions()
|
|
assert {s["session_id"] for s in bob_sessions} == {"s_b1"}
|
|
|
|
with as_user("alice"):
|
|
alice_sessions = await store.list_sessions()
|
|
assert {s["session_id"] for s in alice_sessions} == {"s_a1", "s_a2"}
|
|
|
|
|
|
async def test_get_session_404s_for_other_user(fake_pb) -> None:
|
|
store = PocketBaseSessionStore()
|
|
with as_user("alice"):
|
|
await store.create_session(title="secret", session_id="s_secret")
|
|
|
|
# Bob must not be able to read Alice's session by id.
|
|
with as_user("bob"):
|
|
assert await store.get_session("s_secret") is None
|
|
assert await store.get_session_with_messages("s_secret") is None
|
|
|
|
# The owner still reads it fine.
|
|
with as_user("alice"):
|
|
own = await store.get_session("s_secret")
|
|
assert own is not None and own["session_id"] == "s_secret"
|
|
|
|
|
|
async def test_mutations_are_scoped_to_owner(fake_pb) -> None:
|
|
store = PocketBaseSessionStore()
|
|
with as_user("alice"):
|
|
await store.create_session(title="orig", session_id="s_m")
|
|
|
|
with as_user("bob"):
|
|
assert await store.update_session_title("s_m", "hijacked") is False
|
|
assert await store.delete_session("s_m") is False
|
|
assert await store.update_summary("s_m", "x", 1) is False
|
|
|
|
# Alice's row is untouched and still present.
|
|
with as_user("alice"):
|
|
assert await store.update_session_title("s_m", "renamed") is True
|
|
session = await store.get_session("s_m")
|
|
assert session is not None and session["title"] == "renamed"
|
|
|
|
|
|
async def test_create_turn_rejects_foreign_session(fake_pb) -> None:
|
|
store = PocketBaseSessionStore()
|
|
with as_user("alice"):
|
|
await store.create_session(title="orig", session_id="s_t")
|
|
|
|
with as_user("bob"):
|
|
with pytest.raises(ValueError, match="Session not found"):
|
|
await store.create_turn("s_t")
|
|
|
|
with as_user("alice"):
|
|
turn = await store.create_turn("s_t")
|
|
assert turn["session_id"] == "s_t"
|