"""Unit tests for ChatService user isolation. These tests verify that users cannot access each other's data. In LDR's architecture, each user has a separate encrypted database, providing strong isolation at the storage layer. These are isolation tests — they only care that the correct username reaches ``get_user_db_session``. Whether the service then succeeds, returns empty, or raises is irrelevant to the isolation property, so each test catches the expected service-layer exception (``ChatSessionNotFound`` for missing rows, ``SQLAlchemyError`` from the now-strict read methods) and proceeds to assert on the captured username. """ import contextlib from contextlib import contextmanager from unittest.mock import MagicMock, patch import pytest from sqlalchemy.exc import SQLAlchemyError from src.local_deep_research.chat.service import ChatSessionNotFound # Exceptions every isolation test must tolerate: the MagicMock query # chains don't match the real SQLAlchemy shape used by some methods, so # the service may raise after the username has already been captured — # isolation only cares about the capture. _ISOLATION_EXPECTED = (ChatSessionNotFound, SQLAlchemyError, TypeError) @pytest.fixture def username_capturing_db(): """Patch ``get_user_db_session`` to capture every username that reaches the data layer. Returns a tuple ``(captured_usernames, configure)`` where ``configure`` lets each test customise the inner ``MagicMock`` session (e.g. wire a specific ``query`` chain) before the patch goes live. Replaces the 8-line ``captured_username`` + ``@contextmanager`` boilerplate that was duplicated across 7 test methods. """ captured: list[str] = [] mock_session = MagicMock() @contextmanager def _fake_get_user_db_session(username, password=None): captured.append(username) yield mock_session with patch( "src.local_deep_research.chat.service.get_user_db_session", _fake_get_user_db_session, ): yield captured, mock_session class TestUserIsolation: """Tests verifying user data isolation in ChatService.""" def test_get_session_uses_correct_username_for_db_access( self, username_capturing_db ): """get_session queries the correct user's database.""" from src.local_deep_research.chat.service import ChatService captured, mock_session = username_capturing_db mock_session.query.return_value.filter_by.return_value.first.return_value = None service = ChatService(username="alice") with contextlib.suppress(ChatSessionNotFound): service.get_session("some-session-id") assert captured == ["alice"] def test_get_messages_uses_correct_username_for_db_access( self, username_capturing_db ): """get_session_messages queries the correct user's database.""" from src.local_deep_research.chat.service import ChatService captured, _ = username_capturing_db service = ChatService(username="bob") with contextlib.suppress(*_ISOLATION_EXPECTED): service.get_session_messages("some-session-id") assert captured == ["bob"] def test_list_sessions_uses_correct_username_for_db_access( self, username_capturing_db ): """list_sessions queries the correct user's database.""" from src.local_deep_research.chat.service import ChatService captured, _ = username_capturing_db service = ChatService(username="charlie") with contextlib.suppress(*_ISOLATION_EXPECTED): service.list_sessions() assert captured == ["charlie"] def test_update_context_uses_correct_username_for_db_access( self, username_capturing_db ): """update_accumulated_context queries the correct user's database.""" from src.local_deep_research.chat.service import ChatService captured, mock_session = username_capturing_db mock_session.query.return_value.filter_by.return_value.with_for_update.return_value.first.return_value = None mock_session.query.return_value.filter_by.return_value.first.return_value = None service = ChatService(username="diana") service.update_accumulated_context("some-session-id") assert captured == ["diana"] def test_delete_session_uses_correct_username_for_db_access( self, username_capturing_db ): """delete_session queries the correct user's database.""" from src.local_deep_research.chat.service import ChatService captured, mock_session = username_capturing_db mock_session.query.return_value.filter_by.return_value.first.return_value = None service = ChatService(username="eve") service.delete_session("some-session-id") assert captured == ["eve"] class TestCrossUserAccess: """Tests verifying cross-user access is prevented.""" def test_different_services_use_different_usernames( self, username_capturing_db ): """Different ChatService instances use their own usernames.""" from src.local_deep_research.chat.service import ChatService captured, _ = username_capturing_db service_alice = ChatService(username="alice") service_bob = ChatService(username="bob") # Both create sessions service_alice.create_session() service_bob.create_session() # Verify each service used its own username — order-preserving. assert captured[0] == "alice" assert captured[1] == "bob" def test_each_operation_passes_username_to_database( self, username_capturing_db ): """All CRUD operations consistently use the service username.""" from src.local_deep_research.chat.service import ChatService captured, mock_session = username_capturing_db mock_session.query.return_value.filter_by.return_value.first.return_value = None mock_session.query.return_value.filter_by.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [] mock_session.query.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [] service = ChatService(username="consistent_user") # Perform various operations. Each get_user_db_session entry # must record the username; the operations themselves may # legitimately raise (ChatSessionNotFound for missing rows, # SQLAlchemyError from the now-strict list/messages reads, # or TypeError from MagicMock chains that don't match the # real query shape). Isolate each call so a later op still # gets to record its username. service.create_session() for op in ( lambda: service.get_session("session-1"), lambda: service.get_session_messages("session-1"), lambda: service.list_sessions(), lambda: service.delete_session("session-1"), ): with contextlib.suppress(*_ISOLATION_EXPECTED): op() # Every operation must have used the same username assert all(u == "consistent_user" for u in captured), captured assert len(captured) >= 5 # create + 4 ops