Files
local-deep-research/tests/database/test_encrypted_db_manager.py
LearningCircuit 7af8ec5436 fix(encrypted-db): fail loud when create_user_database hits migration error (#3663)
Mirror of #3635's open_user_database fail-loud change for the create
path, the last remaining swallow site for migration errors.

The create path used to log the migration exception and return the
engine anyway with the comment "tables exist but schema version not
stamped — migrations will be retried on next process restart". After
#3635 made open_user_database fail loud, that note no longer holds:
the next login re-runs alembic, hits the same error, and 503s. Net
effect was a permanently-locked-out account — registration appeared
to succeed, then every subsequent login failed.

This was visible in the auth-login Puppeteer shard log on run
24932815020 (the run that motivated #3650): init_test_database.py
swallowed the chmod-bit error during create_user_database, then every
test_admin login attempt 503'd because the on-disk DB had tables but
no alembic_version stamp.

Now create_user_database disposes the engine, removes the partial DB
file, and re-raises — same shape as the existing structure-creation
cleanup a few lines above. The registration route's outer try/except
already catches it and surfaces a 500 to the user, which is what we
want: the real error (e.g. world-writable migrations dir) now blocks
account creation instead of silently producing an unusable account.

Test mirrors the regression test #3635 added for open_user_database.
2026-04-26 12:04:20 +02:00

1502 lines
57 KiB
Python

"""
Comprehensive tests for encrypted_db.py DatabaseManager.
Tests cover:
- Database creation and opening
- Password validation and changes
- User existence checks
- Database integrity verification
- Memory usage tracking
- Thread engine management
- Session creation
- Multi-user isolation
- Error scenarios
"""
import threading
import time
from unittest.mock import MagicMock, patch
import pytest
class TestDatabaseCreation:
"""Tests for create_user_database functionality."""
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_create_database_with_valid_password(self, mock_data_dir, tmp_path):
"""Test database creation with a valid password."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=False
):
with patch.dict("os.environ", {"LDR_ALLOW_UNENCRYPTED": "true"}):
manager = DatabaseManager()
# Mock the internal operations
with patch.object(manager, "_get_user_db_path") as mock_path:
mock_db_path = tmp_path / "test_user.db"
mock_path.return_value = mock_db_path
with patch(
"local_deep_research.database.encrypted_db.create_engine"
) as mock_engine:
mock_engine_instance = MagicMock()
mock_engine.return_value = mock_engine_instance
with patch(
"local_deep_research.database.encrypted_db.event"
):
with patch(
"local_deep_research.database.initialize.initialize_database"
):
engine = manager.create_user_database(
"testuser", "validpassword"
)
assert engine is mock_engine_instance
assert "testuser" in manager.connections
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_create_database_with_empty_password_raises(
self, mock_data_dir, tmp_path
):
"""Test database creation fails with empty password."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
with pytest.raises(ValueError, match="Invalid encryption key"):
manager.create_user_database("testuser", "")
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_create_database_with_none_password_raises(
self, mock_data_dir, tmp_path
):
"""Test database creation fails with None password."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
with pytest.raises(ValueError, match="Invalid encryption key"):
manager.create_user_database("testuser", None)
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_create_database_existing_user_raises(
self, mock_data_dir, tmp_path
):
"""Test database creation fails if database already exists."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
# Create the database file
db_file = tmp_path / "encrypted_databases" / "test_db.db"
db_file.parent.mkdir(parents=True, exist_ok=True)
db_file.touch()
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
with patch.object(
manager, "_get_user_db_path", return_value=db_file
):
with pytest.raises(ValueError, match="already exists"):
manager.create_user_database("existinguser", "password")
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_create_database_migration_failure_raises_and_cleans_up(
self, mock_data_dir, tmp_path
):
"""A failure inside ``initialize_database`` must propagate, not swallow.
Regression: the create path used to log the migration exception and
return the engine anyway, leaving a partial DB on disk (tables but
no alembic_version stamp). Every subsequent login then re-ran
alembic, hit the same error, and (post-#3635) 503'd — the user
could register but never log in. Now the failure surfaces, the
engine is disposed, the partial DB file is removed, and nothing
is cached in ``connections``.
"""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
db_file = tmp_path / "encrypted_databases" / "newuser.db"
db_file.parent.mkdir(parents=True, exist_ok=True)
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=False
):
with patch.dict("os.environ", {"LDR_ALLOW_UNENCRYPTED": "true"}):
manager = DatabaseManager()
with patch.object(
manager, "_get_user_db_path", return_value=db_file
):
# Simulate the encrypted-path structure-creation step
# having materialised the file before migrations run,
# so the cleanup branch has something to remove.
created_engines = []
def fake_engine(*args, **kwargs):
db_file.touch()
engine = MagicMock()
created_engines.append(engine)
return engine
with patch(
"local_deep_research.database.encrypted_db.create_engine",
side_effect=fake_engine,
):
with patch(
"local_deep_research.database.encrypted_db.event"
):
with patch(
"local_deep_research.database.initialize.initialize_database",
side_effect=ValueError(
"Migrations directory has insecure permissions (world-writable)"
),
):
with pytest.raises(
ValueError, match="world-writable"
):
manager.create_user_database(
"newuser", "password"
)
assert "newuser" not in manager.connections
assert len(created_engines) == 1
created_engines[0].dispose.assert_called_once()
assert not db_file.exists()
class TestDatabaseOpening:
"""Tests for open_user_database functionality."""
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_open_database_with_valid_password(self, mock_data_dir, tmp_path):
"""Test opening database with valid password."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
# Create db file
db_file = tmp_path / "encrypted_databases" / "test.db"
db_file.parent.mkdir(parents=True, exist_ok=True)
db_file.touch()
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=False
):
with patch.dict("os.environ", {"LDR_ALLOW_UNENCRYPTED": "true"}):
manager = DatabaseManager()
with patch.object(
manager, "_get_user_db_path", return_value=db_file
):
with patch(
"local_deep_research.database.encrypted_db.get_key_from_password",
return_value=b"\x00" * 32,
):
with patch(
"local_deep_research.database.encrypted_db.create_engine"
) as mock_engine:
mock_engine_instance = MagicMock()
mock_conn = MagicMock()
mock_engine_instance.connect.return_value.__enter__ = MagicMock(
return_value=mock_conn
)
mock_engine_instance.connect.return_value.__exit__ = MagicMock(
return_value=False
)
mock_engine.return_value = mock_engine_instance
with patch(
"local_deep_research.database.encrypted_db.event"
):
with patch(
"local_deep_research.database.alembic_runner.needs_migration",
return_value=False,
):
with patch(
"local_deep_research.database.initialize.initialize_database"
):
engine = manager.open_user_database(
"testuser", "validpassword"
)
assert engine is mock_engine_instance
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_open_database_with_empty_password_raises(
self, mock_data_dir, tmp_path
):
"""Test opening database fails with empty password."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
with pytest.raises(ValueError, match="Invalid encryption key"):
manager.open_user_database("testuser", "")
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_open_database_nonexistent_returns_none(
self, mock_data_dir, tmp_path
):
"""Test opening nonexistent database returns None."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
nonexistent = tmp_path / "nonexistent.db"
with patch.object(
manager, "_get_user_db_path", return_value=nonexistent
):
result = manager.open_user_database("nonexistent", "password")
assert result is None
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_open_database_already_open_returns_cached(
self, mock_data_dir, tmp_path
):
"""Test opening already-open database returns cached engine."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
mock_engine = MagicMock()
manager.connections["testuser"] = mock_engine
result = manager.open_user_database("testuser", "password")
assert result is mock_engine
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_open_database_connection_error_returns_none(
self, mock_data_dir, tmp_path
):
"""Test that connection errors return None."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
db_file = tmp_path / "encrypted_databases" / "test.db"
db_file.parent.mkdir(parents=True, exist_ok=True)
db_file.touch()
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=False
):
with patch.dict("os.environ", {"LDR_ALLOW_UNENCRYPTED": "true"}):
manager = DatabaseManager()
with patch.object(
manager, "_get_user_db_path", return_value=db_file
):
with patch(
"local_deep_research.database.encrypted_db.create_engine"
) as mock_engine:
mock_engine_instance = MagicMock()
# Use context manager that raises on enter
mock_context = MagicMock()
mock_context.__enter__ = MagicMock(
side_effect=Exception("Connection failed")
)
mock_context.__exit__ = MagicMock(return_value=False)
mock_engine_instance.connect.return_value = mock_context
mock_engine.return_value = mock_engine_instance
with patch(
"local_deep_research.database.encrypted_db.event"
):
result = manager.open_user_database(
"testuser", "password"
)
assert result is None
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_open_database_migration_failure_raises_typed_error(
self, mock_data_dir, tmp_path
):
"""Init failures raise DatabaseInitializationError, not return None.
The login route uses the type distinction to skip the lockout
counter (credentials are valid; only the schema couldn't come up)
and to flash a server-error message instead of "Invalid username
or password". The engine must still be disposed and not cached.
"""
from local_deep_research.database.encrypted_db import (
DatabaseInitializationError,
DatabaseManager,
)
mock_data_dir.return_value = tmp_path
db_file = tmp_path / "encrypted_databases" / "test.db"
db_file.parent.mkdir(parents=True, exist_ok=True)
db_file.touch()
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=False
):
with patch.dict("os.environ", {"LDR_ALLOW_UNENCRYPTED": "true"}):
manager = DatabaseManager()
with patch.object(
manager, "_get_user_db_path", return_value=db_file
):
with patch(
"local_deep_research.database.encrypted_db.create_engine"
) as mock_engine_factory:
mock_engine = MagicMock()
mock_engine_factory.return_value = mock_engine
# SELECT 1 connection check passes
mock_engine.connect.return_value.__enter__ = MagicMock(
return_value=MagicMock()
)
mock_engine.connect.return_value.__exit__ = MagicMock(
return_value=False
)
with patch(
"local_deep_research.database.encrypted_db.event"
):
with patch(
"local_deep_research.database.alembic_runner.needs_migration",
return_value=False,
):
with patch(
"local_deep_research.database.initialize.initialize_database",
side_effect=ValueError(
"Migrations directory has insecure permissions (world-writable)"
),
):
with pytest.raises(
DatabaseInitializationError
):
manager.open_user_database(
"testuser", "password"
)
assert "testuser" not in manager.connections
mock_engine.dispose.assert_called_once()
class TestDatabaseClosure:
"""Tests for close_user_database functionality."""
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_close_database_disposes_engine(self, mock_data_dir, tmp_path):
"""Test closing database disposes engine and removes from connections."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
mock_engine = MagicMock()
manager.connections["testuser"] = mock_engine
manager.close_user_database("testuser")
mock_engine.dispose.assert_called_once()
assert "testuser" not in manager.connections
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_close_nonexistent_database_no_error(self, mock_data_dir, tmp_path):
"""Test closing nonexistent database doesn't raise error."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
# Should not raise
manager.close_user_database("nonexistent")
class TestCloseAllDatabases:
"""Tests for close_all_databases functionality."""
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_close_all_databases_continues_after_dispose_failure(
self, mock_data_dir, tmp_path
):
"""Verify one engine's dispose failure doesn't prevent others from closing."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
engine_ok = MagicMock()
engine_fail = MagicMock()
engine_fail.dispose.side_effect = RuntimeError("dispose exploded")
manager.connections["alice"] = engine_fail
manager.connections["bob"] = engine_ok
manager.close_all_databases()
# Both engines attempted dispose
engine_fail.dispose.assert_called_once()
engine_ok.dispose.assert_called_once()
# Connections dict is cleared regardless
assert len(manager.connections) == 0
class TestPasswordChange:
"""Tests for change_password functionality."""
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_change_password_no_encryption_returns_false(
self, mock_data_dir, tmp_path
):
"""Test password change returns False when encryption not available."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=False
):
with patch.dict("os.environ", {"LDR_ALLOW_UNENCRYPTED": "true"}):
manager = DatabaseManager()
result = manager.change_password(
"testuser", "oldpass", "newpass"
)
assert result is False
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_change_password_nonexistent_db_returns_false(
self, mock_data_dir, tmp_path
):
"""Test password change returns False for nonexistent database."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
nonexistent = tmp_path / "nonexistent.db"
with patch.object(
manager, "_get_user_db_path", return_value=nonexistent
):
result = manager.change_password(
"testuser", "oldpass", "newpass"
)
assert result is False
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_change_password_wrong_old_password_returns_false(
self, mock_data_dir, tmp_path
):
"""Test password change returns False with wrong old password."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
db_file = tmp_path / "encrypted_databases" / "test.db"
db_file.parent.mkdir(parents=True, exist_ok=True)
db_file.touch()
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
with patch.object(
manager, "_get_user_db_path", return_value=db_file
):
# open_user_database fails with wrong password
with patch.object(
manager, "open_user_database", return_value=None
):
result = manager.change_password(
"testuser", "wrongpass", "newpass"
)
assert result is False
class TestUserExists:
"""Tests for user_exists functionality."""
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_user_exists_true(self, mock_data_dir, tmp_path):
"""Test user_exists returns True for existing user."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
with patch(
"local_deep_research.database.auth_db.get_auth_db_session"
) as mock_auth:
mock_session = MagicMock()
mock_auth.return_value = mock_session
mock_user = MagicMock()
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_user
result = manager.user_exists("existinguser")
assert result is True
mock_session.close.assert_called_once()
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_user_exists_false(self, mock_data_dir, tmp_path):
"""Test user_exists returns False for nonexistent user."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
with patch(
"local_deep_research.database.auth_db.get_auth_db_session"
) as mock_auth:
mock_session = MagicMock()
mock_auth.return_value = mock_session
mock_session.query.return_value.filter_by.return_value.first.return_value = None
result = manager.user_exists("nonexistent")
assert result is False
class TestDatabaseIntegrity:
"""Tests for check_database_integrity functionality."""
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_integrity_check_no_connection_returns_false(
self, mock_data_dir, tmp_path
):
"""Test integrity check returns False when no connection exists."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
result = manager.check_database_integrity("nonexistent")
assert result is False
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_integrity_check_success(self, mock_data_dir, tmp_path):
"""Test successful integrity check returns True."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
mock_engine = MagicMock()
mock_conn = MagicMock()
mock_engine.connect.return_value.__enter__ = MagicMock(
return_value=mock_conn
)
mock_engine.connect.return_value.__exit__ = MagicMock(
return_value=False
)
# Mock successful integrity checks
mock_conn.execute.side_effect = [
MagicMock(
fetchone=MagicMock(return_value=("ok",))
), # quick_check
iter([]), # cipher_integrity_check - no failures
]
manager.connections["testuser"] = mock_engine
result = manager.check_database_integrity("testuser")
assert result is True
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_integrity_check_quick_check_failure(self, mock_data_dir, tmp_path):
"""Test integrity check fails on quick_check failure."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
mock_engine = MagicMock()
mock_conn = MagicMock()
mock_engine.connect.return_value.__enter__ = MagicMock(
return_value=mock_conn
)
mock_engine.connect.return_value.__exit__ = MagicMock(
return_value=False
)
# Mock failed quick_check
mock_conn.execute.return_value.fetchone.return_value = ("corrupt",)
manager.connections["testuser"] = mock_engine
result = manager.check_database_integrity("testuser")
assert result is False
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_integrity_check_cipher_failure(self, mock_data_dir, tmp_path):
"""Test integrity check fails on cipher_integrity_check failure."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
mock_engine = MagicMock()
mock_conn = MagicMock()
mock_engine.connect.return_value.__enter__ = MagicMock(
return_value=mock_conn
)
mock_engine.connect.return_value.__exit__ = MagicMock(
return_value=False
)
# Mock successful quick_check but failed cipher check
mock_conn.execute.side_effect = [
MagicMock(
fetchone=MagicMock(return_value=("ok",))
), # quick_check
iter(
[("HMAC failure",)]
), # cipher_integrity_check - has failures
]
manager.connections["testuser"] = mock_engine
result = manager.check_database_integrity("testuser")
assert result is False
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_integrity_check_exception_returns_false(
self, mock_data_dir, tmp_path
):
"""Test integrity check returns False on exception."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
mock_engine = MagicMock()
mock_engine.connect.side_effect = Exception("Connection failed")
manager.connections["testuser"] = mock_engine
result = manager.check_database_integrity("testuser")
assert result is False
class TestMemoryUsage:
"""Tests for get_memory_usage functionality."""
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_memory_usage_empty(self, mock_data_dir, tmp_path):
"""Test memory usage with no connections."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
usage = manager.get_memory_usage()
assert usage["active_connections"] == 0
assert usage["active_sessions"] == 0
assert usage["estimated_memory_mb"] == 0
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_memory_usage_with_connections(self, mock_data_dir, tmp_path):
"""Test memory usage with active connections."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
manager.connections["user1"] = MagicMock()
manager.connections["user2"] = MagicMock()
usage = manager.get_memory_usage()
assert usage["active_connections"] == 2
assert usage["estimated_memory_mb"] == 7.0 # 2 * 3.5
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_memory_usage_calculation(self, mock_data_dir, tmp_path):
"""Test memory usage calculation formula."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
for i in range(5):
manager.connections[f"user{i}"] = MagicMock()
usage = manager.get_memory_usage()
assert usage["estimated_memory_mb"] == 17.5 # 5 * 3.5
class TestThreadSafeSessionForMetrics:
"""Tests for create_thread_safe_session_for_metrics functionality."""
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_thread_safe_session_nonexistent_db_raises(
self, mock_data_dir, tmp_path
):
"""Test thread-safe session raises for nonexistent database."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
nonexistent = tmp_path / "nonexistent.db"
with patch.object(
manager, "_get_user_db_path", return_value=nonexistent
):
with pytest.raises(ValueError, match="No database found"):
manager.create_thread_safe_session_for_metrics(
"nonexistent", "password"
)
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_thread_safe_session_binds_to_queuepool_engine(
self, mock_data_dir, tmp_path
):
"""The returned session is bound to the per-user QueuePool engine."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
db_file = tmp_path / "encrypted_databases" / "test.db"
db_file.parent.mkdir(parents=True, exist_ok=True)
db_file.touch()
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
mock_engine = MagicMock()
manager.connections["testuser"] = mock_engine
with patch.object(
manager, "_get_user_db_path", return_value=db_file
):
session = manager.create_thread_safe_session_for_metrics(
"testuser", "password"
)
assert session.bind is mock_engine
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_concurrent_metrics_sessions_via_shared_queuepool(
self, mock_data_dir, tmp_path
):
"""Multiple threads sharing a QueuePool can write concurrently
without errors, deadlocks, or lost writes.
Regression test for the #3441 refactor that replaced per-thread
NullPool engines with a single shared per-user QueuePool.
"""
from sqlalchemy import text
from sqlalchemy.pool import QueuePool
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
db_file = tmp_path / "encrypted_databases" / "concurrent.db"
db_file.parent.mkdir(parents=True, exist_ok=True)
manager = DatabaseManager()
manager._use_static_pool = False
manager._pool_class = QueuePool
# Create a plain SQLite database (no encryption needed for this test)
from sqlalchemy import create_engine
engine = create_engine(
f"sqlite:///{db_file}",
connect_args={"check_same_thread": False, "timeout": 30},
poolclass=QueuePool,
pool_size=5,
max_overflow=10,
)
with engine.connect() as conn:
conn.execute(
text(
"CREATE TABLE metric_test "
"(id INTEGER PRIMARY KEY, thread_id INTEGER, seq INTEGER)"
)
)
conn.commit()
# Inject the engine so create_thread_safe_session_for_metrics
# finds it on cache hit.
manager.connections["testuser"] = engine
try:
with patch.object(
manager, "_get_user_db_path", return_value=db_file
):
num_threads = 10
writes_per_thread = 10
errors = []
write_count = [0]
lock = threading.Lock()
def writer(tid):
try:
session = (
manager.create_thread_safe_session_for_metrics(
"testuser", "unused"
)
)
for seq in range(writes_per_thread):
uid = tid * 1000 + seq
session.execute(
text(
f"INSERT INTO metric_test VALUES "
f"({uid}, {tid}, {seq})"
)
)
session.commit()
with lock:
write_count[0] += 1
session.close()
except Exception as e:
errors.append(f"Thread {tid}: {e}")
threads = [
threading.Thread(target=writer, args=(i,))
for i in range(num_threads)
]
for t in threads:
t.start()
for t in threads:
t.join(timeout=30)
for t in threads:
assert not t.is_alive(), f"{t.name} hung past timeout"
assert not errors, f"Concurrent write errors: {errors}"
expected = num_threads * writes_per_thread
assert write_count[0] == expected
# Verify all rows persisted
with engine.connect() as conn:
result = conn.execute(
text("SELECT COUNT(*) FROM metric_test")
)
count = result.fetchone()[0]
assert count == expected, (
f"Expected {expected} rows, got {count}"
)
finally:
engine.dispose()
class TestSessionManagement:
"""Tests for get_session functionality."""
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_get_session_no_connection_returns_none(
self, mock_data_dir, tmp_path
):
"""Test get_session returns None when no connection exists."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
result = manager.get_session("nonexistent")
assert result is None
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_get_session_creates_new_session(self, mock_data_dir, tmp_path):
"""Test get_session creates a new session from existing connection."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
mock_engine = MagicMock()
manager.connections["testuser"] = mock_engine
with patch(
"local_deep_research.database.encrypted_db.sessionmaker"
) as mock_sm:
mock_session = MagicMock()
mock_sm.return_value = MagicMock(return_value=mock_session)
result = manager.get_session("testuser")
assert result is mock_session
mock_sm.assert_called_once_with(bind=mock_engine)
class TestMultiUserIsolation:
"""Tests for multi-user database isolation."""
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_multiple_users_separate_connections(self, mock_data_dir, tmp_path):
"""Test multiple users have separate connections."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
mock_engine1 = MagicMock()
mock_engine2 = MagicMock()
manager.connections["user1"] = mock_engine1
manager.connections["user2"] = mock_engine2
assert (
manager.connections["user1"] is not manager.connections["user2"]
)
assert len(manager.connections) == 2
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_closing_one_user_doesnt_affect_others(
self, mock_data_dir, tmp_path
):
"""Test closing one user's database doesn't affect others."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
mock_engine1 = MagicMock()
mock_engine2 = MagicMock()
manager.connections["user1"] = mock_engine1
manager.connections["user2"] = mock_engine2
manager.close_user_database("user1")
assert "user1" not in manager.connections
assert "user2" in manager.connections
assert manager.connections["user2"] is mock_engine2
class TestConcurrentAccess:
"""Tests for concurrent access handling."""
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_concurrent_connection_storage(self, mock_data_dir, tmp_path):
"""Test concurrent connection storage operations."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
results = {}
def add_connection(user_id):
mock_engine = MagicMock()
manager.connections[f"user{user_id}"] = mock_engine
time.sleep(0.001)
results[user_id] = manager.connections.get(f"user{user_id}")
threads = [
threading.Thread(target=add_connection, args=(i,))
for i in range(20)
]
for t in threads:
t.start()
for t in threads:
t.join()
# All connections should be stored
assert len(manager.connections) == 20
class TestEncryptionAvailability:
"""Tests for encryption availability checking."""
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_encryption_available_true(self, mock_data_dir, tmp_path):
"""Test has_encryption is True when SQLCipher available."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
assert manager.has_encryption is True
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_encryption_available_false(self, mock_data_dir, tmp_path):
"""Test has_encryption is False when SQLCipher not available."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.dict("os.environ", {"LDR_ALLOW_UNENCRYPTED": "true"}):
with patch.object(
DatabaseManager,
"_check_encryption_available",
return_value=False,
):
manager = DatabaseManager()
assert manager.has_encryption is False
class TestPoolConfiguration:
"""Tests for connection pool configuration."""
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_static_pool_in_testing_mode(self, mock_data_dir, tmp_path):
"""Test StaticPool is used in testing mode."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.dict("os.environ", {"TESTING": "true"}):
with patch.object(
DatabaseManager,
"_check_encryption_available",
return_value=True,
):
manager = DatabaseManager()
assert manager._use_static_pool is True
assert manager._get_pool_kwargs() == {}
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_queue_pool_in_production_mode(self, mock_data_dir, tmp_path):
"""Test QueuePool is used in production mode."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.dict("os.environ", {}, clear=True):
with patch.object(
DatabaseManager,
"_check_encryption_available",
return_value=True,
):
manager = DatabaseManager()
manager._use_static_pool = False
kwargs = manager._get_pool_kwargs()
assert "pool_size" in kwargs
assert kwargs["pool_size"] == 20
assert kwargs["max_overflow"] == 40
assert kwargs["pool_timeout"] == 10
class TestValidEncryptionKey:
"""Tests for _is_valid_encryption_key functionality."""
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_valid_keys(self, mock_data_dir, tmp_path):
"""Test valid encryption keys are accepted."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
assert manager._is_valid_encryption_key("password") is True
assert manager._is_valid_encryption_key("a") is True
assert manager._is_valid_encryption_key("complex!@#$%^&*()") is True
assert manager._is_valid_encryption_key("123456") is True
assert manager._is_valid_encryption_key(" spaces ") is True
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_invalid_keys(self, mock_data_dir, tmp_path):
"""Test invalid encryption keys are rejected."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
assert manager._is_valid_encryption_key(None) is False
assert manager._is_valid_encryption_key("") is False
class TestMakeSqlcipherConnectionCursorCloseLogging:
"""Tests for cursor.close() failure logging in _make_sqlcipher_connection (PR #2145)."""
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_cursor_close_failure_logs_warning(self, mock_data_dir, tmp_path):
"""When cursor.close() fails during cleanup, logger.warning is called."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
DatabaseManager()
# Mock sqlcipher3 module
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value = mock_cursor
mock_sqlcipher3 = MagicMock()
mock_sqlcipher3.connect.return_value = mock_conn
# Make set_sqlcipher_key raise to trigger cleanup path
with patch(
"local_deep_research.database.encrypted_db.get_sqlcipher_module",
return_value=mock_sqlcipher3,
):
with patch(
"local_deep_research.database.encrypted_db.set_sqlcipher_key",
side_effect=ValueError("Bad key"),
):
# Make cursor.close() also fail
mock_cursor.close.side_effect = Exception(
"Cursor already closed"
)
with patch(
"local_deep_research.database.encrypted_db.logger"
) as mock_logger:
with pytest.raises(ValueError, match="Bad key"):
DatabaseManager._make_sqlcipher_connection(
tmp_path / "test.db", "password"
)
# Verify logger.warning was called about cursor close failure
mock_logger.warning.assert_called_once()
call_args = mock_logger.warning.call_args
assert "Failed to close cursor" in call_args[0][0]
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_original_exception_propagates_despite_cursor_close_failure(
self, mock_data_dir, tmp_path
):
"""Original exception still propagates even when cursor.close() fails."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value = mock_cursor
mock_sqlcipher3 = MagicMock()
mock_sqlcipher3.connect.return_value = mock_conn
with patch(
"local_deep_research.database.encrypted_db.get_sqlcipher_module",
return_value=mock_sqlcipher3,
):
with patch(
"local_deep_research.database.encrypted_db.set_sqlcipher_key",
side_effect=ValueError("Verification failed"),
):
mock_cursor.close.side_effect = RuntimeError("Cannot close")
# The original ValueError should propagate, not the RuntimeError
with pytest.raises(ValueError, match="Verification failed"):
DatabaseManager._make_sqlcipher_connection(
tmp_path / "test.db", "password"
)
# conn.close() should still be called
mock_conn.close.assert_called_once()
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_cursor_close_success_no_warning(self, mock_data_dir, tmp_path):
"""No warning logged when cursor.close() succeeds during cleanup."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value = mock_cursor
mock_sqlcipher3 = MagicMock()
mock_sqlcipher3.connect.return_value = mock_conn
with patch(
"local_deep_research.database.encrypted_db.get_sqlcipher_module",
return_value=mock_sqlcipher3,
):
with patch(
"local_deep_research.database.encrypted_db.set_sqlcipher_key",
side_effect=ValueError("Bad key"),
):
# cursor.close() succeeds (no side_effect)
with patch(
"local_deep_research.database.encrypted_db.logger"
) as mock_logger:
with pytest.raises(ValueError, match="Bad key"):
DatabaseManager._make_sqlcipher_connection(
tmp_path / "test.db", "password"
)
# No warning should be logged when cursor.close() succeeds
mock_logger.warning.assert_not_called()
class TestTimingAttackPrevention:
"""Tests for timing attack prevention in open_user_database and
create_thread_safe_session_for_metrics (PR #2168).
Key derivation (get_key_from_password) must happen BEFORE the
db_path.exists() check so that both existing and non-existing users
take the same amount of time, preventing username enumeration.
"""
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_open_user_database_derives_key_before_file_check(
self, mock_data_dir, tmp_path
):
"""open_user_database derives key before checking if user db exists."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
with patch(
"local_deep_research.database.encrypted_db.get_key_from_password"
) as mock_derive:
mock_derive.return_value = b"\x00" * 32
# Non-existent user — no database file exists
result = manager.open_user_database(
"nonexistent_user", "password123"
)
# Should return None
assert result is None
# Key derivation must have been called
mock_derive.assert_called_once()
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_open_user_database_derives_key_for_existing_user(
self, mock_data_dir, tmp_path
):
"""Key derivation called for existing users too (timing consistency)."""
from local_deep_research.config.paths import get_user_database_filename
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=False
):
manager = DatabaseManager()
# Create database at the correct path
username = "test_user"
db_path = tmp_path / get_user_database_filename(username)
db_path.parent.mkdir(parents=True, exist_ok=True)
db_path.touch()
with patch.object(manager, "_get_user_db_path", return_value=db_path):
with patch(
"local_deep_research.database.encrypted_db.get_key_from_password"
) as mock_derive:
mock_derive.return_value = b"\x00" * 32
try:
manager.open_user_database(username, "password123")
except Exception:
pass # May fail later; we verify key derivation happened
# Verify key derivation was called with the expected arguments.
# Use assert_any_call instead of assert_called_once because
# concurrent xdist workers sharing the same process may trigger
# additional calls from other tests (e.g. auth route tests).
mock_derive.assert_any_call("password123", db_path=db_path)
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_metrics_session_skips_key_derivation_for_nonexistent_db(
self, mock_data_dir, tmp_path
):
"""create_thread_safe_session_for_metrics skips key derivation when db missing.
Unlike open_user_database (the login path), this method does not need
timing-attack resistance because it is only called for already-authenticated
users in background threads. It should fail fast without wasting ~0.5s on
PBKDF2 when the database file does not exist.
"""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
with patch(
"local_deep_research.database.encrypted_db.get_key_from_password"
) as mock_derive:
mock_derive.return_value = b"\x00" * 32
with pytest.raises(ValueError, match="No database found"):
manager.create_thread_safe_session_for_metrics(
"nonexistent_user", "password123"
)
mock_derive.assert_not_called()
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_salt_warning_not_called_for_nonexistent_db(
self, mock_data_dir, tmp_path
):
"""has_per_database_salt not called when db doesn't exist."""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = tmp_path
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
with patch(
"local_deep_research.database.encrypted_db.has_per_database_salt"
) as mock_salt:
with pytest.raises(ValueError, match="No database found"):
manager.create_thread_safe_session_for_metrics(
"nonexistent_user", "password123"
)
# has_per_database_salt must NOT be called for non-existent db
mock_salt.assert_not_called()