"""Read-only chat sharing: the token, the owner check, the public payload.
The link is a capability — anyone holding it reads the chat and nothing else.
So these tests pin three things: one stable token per session, an owner-only
issuer, and a public payload that carries display history without leaking
ownership or the session id.
"""
from datetime import datetime, timezone
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
import navi.api.routes.sessions as sessions_mod
import navi.api.routes.shared as shared_mod
from navi.auth import User
from navi.core.session import InMemorySessionStore, Session
from navi.exceptions import ProfileNotFound
from navi.llm.base import Message
_OWNER = User(
id="owner", email="owner@example.com", display_name="Owner", role="user", permissions=[]
)
_STRANGER = User(
id="stranger", email="s@example.com", display_name="S", role="user",
permissions=["navi.sessions.read_all"],
)
_ADMIN = User(
id="admin", email="a@example.com", display_name="A", role="admin", permissions=[]
)
def _session(user_id: str | None = "owner") -> Session:
return Session(
id="s-1",
profile_id="assistant",
user_id=user_id,
name="A chat",
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
last_active=datetime(2026, 1, 2, tzinfo=timezone.utc),
)
class _Store:
"""The slice of the store the endpoints touch, over a real in-memory one."""
def __init__(self, session: Session | None, messages: list[Message] | None = None):
self.session = session
self.messages = messages or []
self.memory = InMemorySessionStore()
if session is not None:
self.memory._sessions[session.id] = session
async def get(self, session_id):
return self.session if self.session and session_id == self.session.id else None
async def get_meta(self, session_id):
return await self.get(session_id)
async def get_history_page(self, session_id, before_seq=None, limit=150):
return list(self.messages), False
async def get_or_create_share(self, session_id, owner_id):
if await self.get(session_id) is None:
return None
return await self.memory.get_or_create_share(session_id, owner_id)
async def get_share(self, token):
return await self.memory.get_share(token)
class _Profiles:
def __init__(self, name: str | None = "Assistant"):
self.name = name
def get(self, profile_id):
if self.name is None:
raise ProfileNotFound(profile_id)
return SimpleNamespace(name=self.name)
# ── the store ───────────────────────────────────────────────────────────────
class TestInMemoryShares:
async def test_one_session_hands_out_one_link(self):
store = InMemorySessionStore()
session = await store.create("assistant", user_id="owner")
first = await store.get_or_create_share(session.id, "owner")
second = await store.get_or_create_share(session.id, "owner")
assert first["token"] == second["token"]
assert len(first["token"]) >= 32
async def test_a_token_resolves_back_to_its_session(self):
store = InMemorySessionStore()
session = await store.create("assistant", user_id="owner")
share = await store.get_or_create_share(session.id, "owner")
assert (await store.get_share(share["token"]))["session_id"] == session.id
assert await store.get_share("never-issued") is None
async def test_deleting_the_session_kills_the_link(self):
store = InMemorySessionStore()
session = await store.create("assistant", user_id="owner")
share = await store.get_or_create_share(session.id, "owner")
await store.delete(session.id)
assert await store.get_share(share["token"]) is None
async def test_unknown_session_has_no_link(self):
assert await InMemorySessionStore().get_or_create_share("missing", None) is None
# ── issuing ─────────────────────────────────────────────────────────────────
class TestShareSession:
def _settings(self, monkeypatch, *, auth: bool = True):
monkeypatch.setattr(
sessions_mod,
"settings",
SimpleNamespace(public_url="https://navi.example", navi_auth_enabled=auth),
)
async def test_the_owner_gets_a_link_to_the_read_only_page(self, monkeypatch):
self._settings(monkeypatch)
result = await sessions_mod.share_session("s-1", _Store(_session()), _OWNER)
assert result["url"] == f"https://navi.example/#share={result['token']}"
assert result["created_at"]
async def test_asking_twice_returns_the_same_link(self, monkeypatch):
self._settings(monkeypatch)
store = _Store(_session())
first = await sessions_mod.share_session("s-1", store, _OWNER)
second = await sessions_mod.share_session("s-1", store, _OWNER)
assert first["token"] == second["token"]
async def test_a_stranger_with_read_all_may_read_but_not_share(self, monkeypatch):
self._settings(monkeypatch)
with pytest.raises(HTTPException) as exc:
await sessions_mod.share_session("s-1", _Store(_session()), _STRANGER)
assert exc.value.status_code == 403
async def test_an_admin_may_share(self, monkeypatch):
self._settings(monkeypatch)
assert (await sessions_mod.share_session("s-1", _Store(_session()), _ADMIN))["token"]
async def test_a_missing_session_is_404(self, monkeypatch):
self._settings(monkeypatch)
with pytest.raises(HTTPException) as exc:
await sessions_mod.share_session("nope", _Store(_session()), _OWNER)
assert exc.value.status_code == 404
async def test_local_mode_shares_only_ownerless_sessions(self, monkeypatch):
self._settings(monkeypatch, auth=False)
result = await sessions_mod.share_session("s-1", _Store(_session(None)), _ADMIN)
assert result["token"]
with pytest.raises(HTTPException):
await sessions_mod.share_session("s-1", _Store(_session("someone")), _ADMIN)
# ── the public payload ──────────────────────────────────────────────────────
class TestSharedChat:
def _store(self) -> _Store:
return _Store(_session(), [Message(role="user", content="hi", sequence_number=3)])
async def _token(self, store) -> str:
return (await store.get_or_create_share("s-1", None))["token"]
async def test_history_is_served_without_auth(self):
store = self._store()
body = await shared_mod.get_shared_chat(await self._token(store), store, _Profiles(), 200)
assert [m["content"] for m in body["messages"]] == ["hi"]
assert body["name"] == "A chat"
assert body["profile_name"] == "Assistant"
async def test_the_payload_does_not_name_the_session_or_its_owner(self):
store = self._store()
body = await shared_mod.get_shared_chat(await self._token(store), store, _Profiles(), 200)
assert "session_id" not in body
assert "user_id" not in body
async def test_a_missing_profile_does_not_break_the_page(self):
store = self._store()
body = await shared_mod.get_shared_chat(await self._token(store), store, _Profiles(None), 200)
assert body["profile_name"] is None
async def test_an_unknown_token_is_404(self):
with pytest.raises(HTTPException) as exc:
await shared_mod.get_shared_chat("nope", self._store(), _Profiles(), 200)
assert exc.value.status_code == 404
async def test_a_deleted_session_is_404(self):
store = self._store()
token = await self._token(store)
store.session = None
with pytest.raises(HTTPException) as exc:
await shared_mod.get_shared_chat(token, store, _Profiles(), 200)
assert exc.value.status_code == 404