7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
190 lines
7.1 KiB
Python
190 lines
7.1 KiB
Python
"""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
|