mirror of
https://github.com/LearningCircuit/local-deep-research.git
synced 2026-06-16 03:51:07 +03:00
cipher_memory_security=ON calls mlock() which fails in Docker without IPC_LOCK, spamming logs. Defaulting to OFF because the password already sits unprotected in Flask session, db_manager.connections, and thread-local storage — mlock on SQLCipher's internal buffers alone doesn't meaningfully help when the same secret is exposed elsewhere. Users can opt back in with LDR_DB_CONFIG_CIPHER_MEMORY_SECURITY=ON (requires IPC_LOCK capability in Docker). Also moves the pragma outside the creation_mode guard so the env var override works for existing databases too.
1301 lines
47 KiB
Python
1301 lines
47 KiB
Python
"""Tests for database/sqlcipher_utils.py and env settings deprecated alias support."""
|
|
|
|
import os
|
|
import pytest
|
|
from unittest.mock import Mock, patch
|
|
|
|
|
|
class TestEnvSettingDeprecatedAlias:
|
|
"""Tests for EnvSetting deprecated_env_var support in the settings infrastructure."""
|
|
|
|
def test_canonical_env_var_used_when_set(self):
|
|
"""Test that canonical env var is used when set."""
|
|
from local_deep_research.settings.env_settings import StringSetting
|
|
|
|
setting = StringSetting(
|
|
key="test.canonical",
|
|
description="test",
|
|
default="default_val",
|
|
deprecated_env_var="OLD_TEST_VAR",
|
|
)
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"LDR_TEST_CANONICAL": "canonical_value",
|
|
"OLD_TEST_VAR": "deprecated_value",
|
|
},
|
|
clear=True,
|
|
):
|
|
assert setting.get_value() == "canonical_value"
|
|
|
|
def test_deprecated_fallback_when_canonical_not_set(self):
|
|
"""Test that deprecated env var is used when canonical not set."""
|
|
from local_deep_research.settings.env_settings import StringSetting
|
|
|
|
setting = StringSetting(
|
|
key="test.canonical",
|
|
description="test",
|
|
default="default_val",
|
|
deprecated_env_var="OLD_TEST_VAR",
|
|
)
|
|
with patch.dict(
|
|
os.environ, {"OLD_TEST_VAR": "deprecated_value"}, clear=True
|
|
):
|
|
assert setting.get_value() == "deprecated_value"
|
|
|
|
def test_default_when_neither_set(self):
|
|
"""Test that default is returned when neither env var is set."""
|
|
from local_deep_research.settings.env_settings import StringSetting
|
|
|
|
setting = StringSetting(
|
|
key="test.canonical",
|
|
description="test",
|
|
default="default_val",
|
|
deprecated_env_var="OLD_TEST_VAR",
|
|
)
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
assert setting.get_value() == "default_val"
|
|
|
|
def test_canonical_takes_precedence(self):
|
|
"""Test that canonical value takes precedence even when both are set."""
|
|
from local_deep_research.settings.env_settings import StringSetting
|
|
|
|
setting = StringSetting(
|
|
key="test.canonical",
|
|
description="test",
|
|
default="default_val",
|
|
deprecated_env_var="OLD_TEST_VAR",
|
|
)
|
|
with patch.dict(
|
|
os.environ,
|
|
{"LDR_TEST_CANONICAL": "canonical", "OLD_TEST_VAR": "deprecated"},
|
|
clear=True,
|
|
):
|
|
assert setting.get_value() == "canonical"
|
|
|
|
def test_empty_string_canonical_takes_precedence(self):
|
|
"""Empty string canonical is treated as 'set' and takes precedence."""
|
|
from local_deep_research.settings.env_settings import StringSetting
|
|
|
|
setting = StringSetting(
|
|
key="test.canonical",
|
|
description="test",
|
|
default="default_val",
|
|
deprecated_env_var="OLD_TEST_VAR",
|
|
)
|
|
with patch.dict(
|
|
os.environ,
|
|
{"LDR_TEST_CANONICAL": "", "OLD_TEST_VAR": "deprecated_value"},
|
|
clear=True,
|
|
):
|
|
assert setting.get_value() == ""
|
|
|
|
def test_deprecated_warning_logged(self, loguru_caplog):
|
|
"""Using deprecated env var logs a deprecation warning."""
|
|
from local_deep_research.settings.env_settings import StringSetting
|
|
|
|
setting = StringSetting(
|
|
key="test.canonical",
|
|
description="test",
|
|
default="default_val",
|
|
deprecated_env_var="OLD_TEST_VAR",
|
|
)
|
|
loguru_caplog.set_level("WARNING")
|
|
with patch.dict(os.environ, {"OLD_TEST_VAR": "value"}, clear=True):
|
|
setting.get_value()
|
|
|
|
assert "OLD_TEST_VAR" in loguru_caplog.text
|
|
assert "deprecated" in loguru_caplog.text.lower()
|
|
assert "LDR_TEST_CANONICAL" in loguru_caplog.text
|
|
|
|
def test_canonical_no_warning(self, loguru_caplog):
|
|
"""Using canonical env var does not log a warning."""
|
|
from local_deep_research.settings.env_settings import StringSetting
|
|
|
|
setting = StringSetting(
|
|
key="test.canonical",
|
|
description="test",
|
|
default="default_val",
|
|
deprecated_env_var="OLD_TEST_VAR",
|
|
)
|
|
loguru_caplog.set_level("WARNING")
|
|
with patch.dict(
|
|
os.environ, {"LDR_TEST_CANONICAL": "value"}, clear=True
|
|
):
|
|
setting.get_value()
|
|
|
|
assert "deprecated" not in loguru_caplog.text.lower()
|
|
|
|
def test_default_no_warning(self, loguru_caplog):
|
|
"""Using default (neither set) does not log a warning."""
|
|
from local_deep_research.settings.env_settings import StringSetting
|
|
|
|
setting = StringSetting(
|
|
key="test.canonical",
|
|
description="test",
|
|
default="default_val",
|
|
deprecated_env_var="OLD_TEST_VAR",
|
|
)
|
|
loguru_caplog.set_level("WARNING")
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
setting.get_value()
|
|
|
|
assert "deprecated" not in loguru_caplog.text.lower()
|
|
|
|
def test_integer_setting_with_deprecated_alias(self):
|
|
"""IntegerSetting handles deprecated alias correctly."""
|
|
from local_deep_research.settings.env_settings import IntegerSetting
|
|
|
|
setting = IntegerSetting(
|
|
key="test.count",
|
|
description="test",
|
|
default=42,
|
|
min_value=1,
|
|
max_value=1000,
|
|
deprecated_env_var="OLD_COUNT",
|
|
)
|
|
with patch.dict(os.environ, {"OLD_COUNT": "100"}, clear=True):
|
|
assert setting.get_value() == 100
|
|
|
|
def test_enum_setting_with_deprecated_alias(self):
|
|
"""EnumSetting handles deprecated alias correctly."""
|
|
from local_deep_research.settings.env_settings import EnumSetting
|
|
|
|
setting = EnumSetting(
|
|
key="test.mode",
|
|
description="test",
|
|
allowed_values={"FAST", "SLOW"},
|
|
default="FAST",
|
|
case_sensitive=False,
|
|
deprecated_env_var="OLD_MODE",
|
|
)
|
|
with patch.dict(os.environ, {"OLD_MODE": "slow"}, clear=True):
|
|
assert setting.get_value() == "SLOW"
|
|
|
|
def test_boolean_setting_with_deprecated_alias(self):
|
|
"""BooleanSetting handles deprecated alias correctly."""
|
|
from local_deep_research.settings.env_settings import BooleanSetting
|
|
|
|
setting = BooleanSetting(
|
|
key="test.flag",
|
|
description="test",
|
|
default=False,
|
|
deprecated_env_var="OLD_FLAG",
|
|
)
|
|
with patch.dict(os.environ, {"OLD_FLAG": "true"}, clear=True):
|
|
assert setting.get_value() is True
|
|
|
|
|
|
class TestGetSqlcipherSettings:
|
|
"""Tests for get_sqlcipher_settings function."""
|
|
|
|
def test_returns_default_values(self):
|
|
"""Test that default values are returned when no env vars set."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_sqlcipher_settings,
|
|
DEFAULT_KDF_ITERATIONS,
|
|
DEFAULT_PAGE_SIZE,
|
|
DEFAULT_HMAC_ALGORITHM,
|
|
DEFAULT_KDF_ALGORITHM,
|
|
)
|
|
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
settings = get_sqlcipher_settings()
|
|
|
|
assert settings["kdf_iterations"] == DEFAULT_KDF_ITERATIONS
|
|
assert settings["page_size"] == DEFAULT_PAGE_SIZE
|
|
assert settings["hmac_algorithm"] == DEFAULT_HMAC_ALGORITHM
|
|
assert settings["kdf_algorithm"] == DEFAULT_KDF_ALGORITHM
|
|
|
|
def test_respects_canonical_env_var_kdf_iterations(self):
|
|
"""Test that canonical LDR_DB_CONFIG_KDF_ITERATIONS env var is respected."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_sqlcipher_settings,
|
|
)
|
|
|
|
with patch.dict(os.environ, {"LDR_DB_CONFIG_KDF_ITERATIONS": "100000"}):
|
|
settings = get_sqlcipher_settings()
|
|
assert settings["kdf_iterations"] == 100000
|
|
|
|
def test_respects_deprecated_env_var_kdf_iterations(self):
|
|
"""Test that deprecated LDR_DB_KDF_ITERATIONS env var still works (backward compat)."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_sqlcipher_settings,
|
|
)
|
|
|
|
with patch.dict(
|
|
os.environ, {"LDR_DB_KDF_ITERATIONS": "100000"}, clear=True
|
|
):
|
|
settings = get_sqlcipher_settings()
|
|
assert settings["kdf_iterations"] == 100000
|
|
|
|
def test_respects_canonical_env_var_page_size(self):
|
|
"""Test that canonical LDR_DB_CONFIG_PAGE_SIZE env var is respected."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_sqlcipher_settings,
|
|
)
|
|
|
|
with patch.dict(os.environ, {"LDR_DB_CONFIG_PAGE_SIZE": "8192"}):
|
|
settings = get_sqlcipher_settings()
|
|
assert settings["page_size"] == 8192
|
|
|
|
def test_respects_deprecated_env_var_page_size(self):
|
|
"""Test that deprecated LDR_DB_PAGE_SIZE env var still works (backward compat)."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_sqlcipher_settings,
|
|
)
|
|
|
|
with patch.dict(os.environ, {"LDR_DB_PAGE_SIZE": "8192"}, clear=True):
|
|
settings = get_sqlcipher_settings()
|
|
assert settings["page_size"] == 8192
|
|
|
|
def test_respects_canonical_env_var_hmac_algorithm(self):
|
|
"""Test that canonical LDR_DB_CONFIG_HMAC_ALGORITHM env var is respected."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_sqlcipher_settings,
|
|
)
|
|
|
|
with patch.dict(
|
|
os.environ, {"LDR_DB_CONFIG_HMAC_ALGORITHM": "HMAC_SHA256"}
|
|
):
|
|
settings = get_sqlcipher_settings()
|
|
assert settings["hmac_algorithm"] == "HMAC_SHA256"
|
|
|
|
def test_respects_deprecated_env_var_hmac_algorithm(self):
|
|
"""Test that deprecated LDR_DB_HMAC_ALGORITHM env var still works (backward compat)."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_sqlcipher_settings,
|
|
)
|
|
|
|
with patch.dict(
|
|
os.environ, {"LDR_DB_HMAC_ALGORITHM": "HMAC_SHA256"}, clear=True
|
|
):
|
|
settings = get_sqlcipher_settings()
|
|
assert settings["hmac_algorithm"] == "HMAC_SHA256"
|
|
|
|
def test_canonical_takes_precedence_over_deprecated(self):
|
|
"""Test that canonical env var takes precedence over deprecated."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_sqlcipher_settings,
|
|
)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"LDR_DB_CONFIG_KDF_ITERATIONS": "200000",
|
|
"LDR_DB_KDF_ITERATIONS": "100000", # Should be ignored
|
|
},
|
|
):
|
|
settings = get_sqlcipher_settings()
|
|
assert settings["kdf_iterations"] == 200000
|
|
|
|
def test_returns_dict_type(self):
|
|
"""Test that settings returns a dictionary."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_sqlcipher_settings,
|
|
)
|
|
|
|
settings = get_sqlcipher_settings()
|
|
assert isinstance(settings, dict)
|
|
assert "kdf_iterations" in settings
|
|
assert "page_size" in settings
|
|
assert "hmac_algorithm" in settings
|
|
assert "kdf_algorithm" in settings
|
|
|
|
|
|
class TestSetSqlcipherKey:
|
|
"""Tests for set_sqlcipher_key function."""
|
|
|
|
def test_executes_pragma_key_command(self):
|
|
"""Test that PRAGMA key is executed with hex-encoded password."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
set_sqlcipher_key,
|
|
)
|
|
|
|
mock_cursor = Mock()
|
|
|
|
with patch(
|
|
"local_deep_research.database.sqlcipher_utils._get_key_from_password"
|
|
) as mock_get_key:
|
|
mock_get_key.return_value = b"\x01\x02\x03"
|
|
set_sqlcipher_key(mock_cursor, "testpass")
|
|
|
|
# Check that execute was called with PRAGMA key
|
|
mock_cursor.execute.assert_called_once()
|
|
call_args = mock_cursor.execute.call_args[0][0]
|
|
assert "PRAGMA key" in call_args
|
|
assert "x'" in call_args
|
|
|
|
|
|
class TestSetSqlcipherKeyFromHex:
|
|
"""Tests for set_sqlcipher_key_from_hex function."""
|
|
|
|
def test_executes_pragma_key_with_hex(self):
|
|
"""Test that PRAGMA key is executed with pre-derived hex key."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
set_sqlcipher_key_from_hex,
|
|
)
|
|
|
|
mock_cursor = Mock()
|
|
set_sqlcipher_key_from_hex(mock_cursor, "abcdef0123456789")
|
|
|
|
mock_cursor.execute.assert_called_once()
|
|
call_args = mock_cursor.execute.call_args[0][0]
|
|
assert "PRAGMA key" in call_args
|
|
assert "abcdef0123456789" in call_args
|
|
|
|
|
|
class TestApplySqlcipherPragmas:
|
|
"""Tests for apply_sqlcipher_pragmas function."""
|
|
|
|
def test_applies_core_pragmas_existing_db(self):
|
|
"""Test that cipher_* and kdf_iter PRAGMAs are applied for existing DBs."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
apply_sqlcipher_pragmas,
|
|
)
|
|
|
|
mock_cursor = Mock()
|
|
|
|
apply_sqlcipher_pragmas(mock_cursor, creation_mode=False)
|
|
|
|
call_args_list = [
|
|
call[0][0] for call in mock_cursor.execute.call_args_list
|
|
]
|
|
# For existing DBs, cipher_* pragmas go AFTER the key
|
|
assert any("cipher_page_size" in arg for arg in call_args_list)
|
|
assert any("cipher_hmac_algorithm" in arg for arg in call_args_list)
|
|
assert any("cipher_kdf_algorithm" in arg for arg in call_args_list)
|
|
assert any("kdf_iter" in arg for arg in call_args_list)
|
|
|
|
def test_creation_mode_only_sets_kdf_iter(self):
|
|
"""Test that creation mode only sets kdf_iter (defaults already set before key)."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
apply_sqlcipher_pragmas,
|
|
)
|
|
|
|
mock_cursor = Mock()
|
|
|
|
apply_sqlcipher_pragmas(mock_cursor, creation_mode=True)
|
|
|
|
call_args_list = [
|
|
call[0][0] for call in mock_cursor.execute.call_args_list
|
|
]
|
|
assert any("kdf_iter" in arg for arg in call_args_list)
|
|
# cipher_* should NOT be set in creation mode (handled by apply_cipher_defaults_before_key)
|
|
assert not any("cipher_page_size" in arg for arg in call_args_list)
|
|
assert not any("cipher_hmac_algorithm" in arg for arg in call_args_list)
|
|
assert not any("cipher_kdf_algorithm" in arg for arg in call_args_list)
|
|
|
|
|
|
class TestApplyCipherDefaultsBeforeKey:
|
|
"""Tests for apply_cipher_defaults_before_key function (SQLCipher 4.x)."""
|
|
|
|
def test_applies_cipher_default_settings_for_new_database(self):
|
|
"""Test that cipher_default_* pragmas are set for new databases."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
apply_cipher_defaults_before_key,
|
|
)
|
|
|
|
mock_cursor = Mock()
|
|
|
|
apply_cipher_defaults_before_key(mock_cursor)
|
|
|
|
call_args_list = [
|
|
call[0][0] for call in mock_cursor.execute.call_args_list
|
|
]
|
|
# Should use cipher_default_* prefix for new databases
|
|
assert any("cipher_default_page_size" in arg for arg in call_args_list)
|
|
assert any(
|
|
"cipher_default_hmac_algorithm" in arg for arg in call_args_list
|
|
)
|
|
assert any(
|
|
"cipher_default_kdf_algorithm" in arg for arg in call_args_list
|
|
)
|
|
|
|
def test_uses_settings_from_get_sqlcipher_settings(self):
|
|
"""Test that settings values come from get_sqlcipher_settings."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
apply_cipher_defaults_before_key,
|
|
)
|
|
|
|
mock_cursor = Mock()
|
|
|
|
with patch.dict(os.environ, {"LDR_DB_PAGE_SIZE": "4096"}):
|
|
apply_cipher_defaults_before_key(mock_cursor)
|
|
|
|
call_args_list = [
|
|
call[0][0] for call in mock_cursor.execute.call_args_list
|
|
]
|
|
page_size_call = [arg for arg in call_args_list if "page_size" in arg][
|
|
0
|
|
]
|
|
assert "4096" in page_size_call
|
|
|
|
def test_backwards_compat_alias(self):
|
|
"""Test that apply_cipher_settings_before_key alias works."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
apply_cipher_settings_before_key,
|
|
apply_cipher_defaults_before_key,
|
|
)
|
|
|
|
assert (
|
|
apply_cipher_settings_before_key is apply_cipher_defaults_before_key
|
|
)
|
|
|
|
|
|
class TestApplyPerformancePragmas:
|
|
"""Tests for apply_performance_pragmas function."""
|
|
|
|
def test_applies_default_performance_pragmas(self):
|
|
"""Test that default performance pragmas are applied."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
apply_performance_pragmas,
|
|
)
|
|
|
|
mock_cursor = Mock()
|
|
|
|
apply_performance_pragmas(mock_cursor)
|
|
|
|
call_args_list = [
|
|
call[0][0] for call in mock_cursor.execute.call_args_list
|
|
]
|
|
assert any("temp_store = MEMORY" in arg for arg in call_args_list)
|
|
assert any("busy_timeout" in arg for arg in call_args_list)
|
|
assert any("cache_size" in arg for arg in call_args_list)
|
|
assert any("journal_mode" in arg for arg in call_args_list)
|
|
assert any("synchronous" in arg for arg in call_args_list)
|
|
|
|
def test_respects_canonical_cache_size_env_var(self):
|
|
"""Test that canonical LDR_DB_CONFIG_CACHE_SIZE_MB env var is respected."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
apply_performance_pragmas,
|
|
)
|
|
|
|
mock_cursor = Mock()
|
|
|
|
with patch.dict(os.environ, {"LDR_DB_CONFIG_CACHE_SIZE_MB": "128"}):
|
|
apply_performance_pragmas(mock_cursor)
|
|
|
|
call_args_list = [
|
|
call[0][0] for call in mock_cursor.execute.call_args_list
|
|
]
|
|
# 128 MB = -131072 KB (negative for KB interpretation)
|
|
cache_call = [arg for arg in call_args_list if "cache_size" in arg][
|
|
0
|
|
]
|
|
assert "-131072" in cache_call
|
|
|
|
def test_respects_deprecated_cache_size_env_var(self):
|
|
"""Test that deprecated LDR_DB_CACHE_SIZE_MB env var still works (backward compat)."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
apply_performance_pragmas,
|
|
)
|
|
|
|
mock_cursor = Mock()
|
|
|
|
with patch.dict(
|
|
os.environ, {"LDR_DB_CACHE_SIZE_MB": "128"}, clear=True
|
|
):
|
|
apply_performance_pragmas(mock_cursor)
|
|
|
|
call_args_list = [
|
|
call[0][0] for call in mock_cursor.execute.call_args_list
|
|
]
|
|
cache_call = [arg for arg in call_args_list if "cache_size" in arg][
|
|
0
|
|
]
|
|
assert "-131072" in cache_call
|
|
|
|
def test_respects_canonical_journal_mode_env_var(self):
|
|
"""Test that canonical LDR_DB_CONFIG_JOURNAL_MODE env var is respected."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
apply_performance_pragmas,
|
|
)
|
|
|
|
mock_cursor = Mock()
|
|
|
|
with patch.dict(os.environ, {"LDR_DB_CONFIG_JOURNAL_MODE": "DELETE"}):
|
|
apply_performance_pragmas(mock_cursor)
|
|
|
|
call_args_list = [
|
|
call[0][0] for call in mock_cursor.execute.call_args_list
|
|
]
|
|
journal_call = [
|
|
arg for arg in call_args_list if "journal_mode" in arg
|
|
][0]
|
|
assert "DELETE" in journal_call
|
|
|
|
def test_respects_deprecated_journal_mode_env_var(self):
|
|
"""Test that deprecated LDR_DB_JOURNAL_MODE env var still works (backward compat)."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
apply_performance_pragmas,
|
|
)
|
|
|
|
mock_cursor = Mock()
|
|
|
|
with patch.dict(
|
|
os.environ, {"LDR_DB_JOURNAL_MODE": "DELETE"}, clear=True
|
|
):
|
|
apply_performance_pragmas(mock_cursor)
|
|
|
|
call_args_list = [
|
|
call[0][0] for call in mock_cursor.execute.call_args_list
|
|
]
|
|
journal_call = [
|
|
arg for arg in call_args_list if "journal_mode" in arg
|
|
][0]
|
|
assert "DELETE" in journal_call
|
|
|
|
|
|
class TestVerifySqlcipherConnection:
|
|
"""Tests for verify_sqlcipher_connection function."""
|
|
|
|
def test_returns_true_for_valid_connection(self):
|
|
"""Test that True is returned for valid connection."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
verify_sqlcipher_connection,
|
|
)
|
|
|
|
mock_cursor = Mock()
|
|
mock_cursor.fetchone.return_value = (1,)
|
|
|
|
result = verify_sqlcipher_connection(mock_cursor)
|
|
assert result is True
|
|
|
|
def test_returns_false_for_invalid_connection(self):
|
|
"""Test that False is returned for invalid connection."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
verify_sqlcipher_connection,
|
|
)
|
|
|
|
mock_cursor = Mock()
|
|
mock_cursor.execute.side_effect = Exception("Connection error")
|
|
|
|
result = verify_sqlcipher_connection(mock_cursor)
|
|
assert result is False
|
|
|
|
def test_returns_false_for_wrong_result(self):
|
|
"""Test that False is returned when SELECT 1 returns wrong value."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
verify_sqlcipher_connection,
|
|
)
|
|
|
|
mock_cursor = Mock()
|
|
mock_cursor.fetchone.return_value = (0,)
|
|
|
|
result = verify_sqlcipher_connection(mock_cursor)
|
|
assert result is False
|
|
|
|
|
|
class TestGetSqlcipherVersion:
|
|
"""Tests for get_sqlcipher_version function."""
|
|
|
|
def test_returns_version_string(self):
|
|
"""Test that version string is returned."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_sqlcipher_version,
|
|
)
|
|
|
|
mock_cursor = Mock()
|
|
mock_cursor.fetchone.return_value = ("4.6.1 community",)
|
|
|
|
result = get_sqlcipher_version(mock_cursor)
|
|
assert result == "4.6.1 community"
|
|
|
|
def test_returns_none_on_error(self):
|
|
"""Test that None is returned on error."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_sqlcipher_version,
|
|
)
|
|
|
|
mock_cursor = Mock()
|
|
mock_cursor.execute.side_effect = Exception("Not supported")
|
|
|
|
result = get_sqlcipher_version(mock_cursor)
|
|
assert result is None
|
|
|
|
|
|
class TestConstants:
|
|
"""Tests for module constants."""
|
|
|
|
def test_default_kdf_iterations_is_reasonable(self):
|
|
"""Test that default KDF iterations is a reasonable security value."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
DEFAULT_KDF_ITERATIONS,
|
|
)
|
|
|
|
# Should be at least 100000 for security
|
|
assert DEFAULT_KDF_ITERATIONS >= 100000
|
|
assert isinstance(DEFAULT_KDF_ITERATIONS, int)
|
|
|
|
def test_default_page_size_is_power_of_two(self):
|
|
"""Test that default page size is a power of 2."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
DEFAULT_PAGE_SIZE,
|
|
)
|
|
|
|
# Page size should be a power of 2
|
|
assert DEFAULT_PAGE_SIZE > 0
|
|
assert (DEFAULT_PAGE_SIZE & (DEFAULT_PAGE_SIZE - 1)) == 0
|
|
|
|
def test_pbkdf2_placeholder_salt_exists(self):
|
|
"""Test that the PBKDF2 placeholder salt is defined."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
PBKDF2_PLACEHOLDER_SALT,
|
|
)
|
|
|
|
assert PBKDF2_PLACEHOLDER_SALT is not None
|
|
assert isinstance(PBKDF2_PLACEHOLDER_SALT, bytes)
|
|
|
|
|
|
class TestCreateSqlcipherConnection:
|
|
"""Tests for create_sqlcipher_connection function."""
|
|
|
|
def test_raises_import_error_when_sqlcipher_unavailable(self):
|
|
"""Test that ImportError is raised when sqlcipher3 not available."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
create_sqlcipher_connection,
|
|
)
|
|
|
|
with patch(
|
|
"local_deep_research.database.sqlcipher_compat.get_sqlcipher_module",
|
|
side_effect=ImportError("No module"),
|
|
):
|
|
with pytest.raises(
|
|
ImportError, match="sqlcipher3 is not available"
|
|
):
|
|
create_sqlcipher_connection("/tmp/test.db", password="password")
|
|
|
|
def test_creates_connection_with_correct_password(self):
|
|
"""Test that connection is created with correct password handling."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
create_sqlcipher_connection,
|
|
)
|
|
|
|
mock_sqlcipher = Mock()
|
|
mock_conn = Mock()
|
|
mock_cursor = Mock()
|
|
mock_cursor.fetchone.return_value = (1,)
|
|
mock_conn.cursor.return_value = mock_cursor
|
|
mock_sqlcipher.connect.return_value = mock_conn
|
|
|
|
with patch(
|
|
"local_deep_research.database.sqlcipher_compat.get_sqlcipher_module",
|
|
return_value=mock_sqlcipher,
|
|
):
|
|
with patch(
|
|
"local_deep_research.database.sqlcipher_utils.set_sqlcipher_key"
|
|
) as mock_set_key:
|
|
create_sqlcipher_connection(
|
|
"/tmp/test.db", password="mypassword"
|
|
)
|
|
|
|
mock_sqlcipher.connect.assert_called_once_with("/tmp/test.db")
|
|
mock_set_key.assert_called_once()
|
|
|
|
def test_raises_value_error_on_verification_failure(self):
|
|
"""Test that ValueError is raised when connection verification fails."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
create_sqlcipher_connection,
|
|
)
|
|
|
|
mock_sqlcipher = Mock()
|
|
mock_conn = Mock()
|
|
mock_cursor = Mock()
|
|
mock_cursor.fetchone.return_value = (0,) # Wrong result
|
|
mock_conn.cursor.return_value = mock_cursor
|
|
mock_sqlcipher.connect.return_value = mock_conn
|
|
|
|
with patch(
|
|
"local_deep_research.database.sqlcipher_compat.get_sqlcipher_module",
|
|
return_value=mock_sqlcipher,
|
|
):
|
|
with patch(
|
|
"local_deep_research.database.sqlcipher_utils.set_sqlcipher_key"
|
|
):
|
|
with pytest.raises(ValueError, match="Failed to establish"):
|
|
create_sqlcipher_connection(
|
|
"/tmp/test.db", password="badpassword"
|
|
)
|
|
|
|
def test_closes_conn_on_failure(self):
|
|
"""Test that connection is closed when setup fails."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
create_sqlcipher_connection,
|
|
)
|
|
|
|
mock_sqlcipher = Mock()
|
|
mock_conn = Mock()
|
|
mock_cursor = Mock()
|
|
mock_cursor.fetchone.return_value = (0,)
|
|
mock_conn.cursor.return_value = mock_cursor
|
|
mock_sqlcipher.connect.return_value = mock_conn
|
|
|
|
with patch(
|
|
"local_deep_research.database.sqlcipher_compat.get_sqlcipher_module",
|
|
return_value=mock_sqlcipher,
|
|
):
|
|
with patch(
|
|
"local_deep_research.database.sqlcipher_utils.set_sqlcipher_key"
|
|
):
|
|
with pytest.raises(ValueError):
|
|
create_sqlcipher_connection("/tmp/test.db", password="bad")
|
|
|
|
mock_conn.close.assert_called_once()
|
|
|
|
def test_accepts_hex_key(self):
|
|
"""Test that hex_key parameter works."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
create_sqlcipher_connection,
|
|
)
|
|
|
|
mock_sqlcipher = Mock()
|
|
mock_conn = Mock()
|
|
mock_cursor = Mock()
|
|
mock_cursor.fetchone.return_value = (1,)
|
|
mock_conn.cursor.return_value = mock_cursor
|
|
mock_sqlcipher.connect.return_value = mock_conn
|
|
|
|
with patch(
|
|
"local_deep_research.database.sqlcipher_compat.get_sqlcipher_module",
|
|
return_value=mock_sqlcipher,
|
|
):
|
|
with patch(
|
|
"local_deep_research.database.sqlcipher_utils.set_sqlcipher_key_from_hex"
|
|
) as mock_hex_key:
|
|
create_sqlcipher_connection("/tmp/test.db", hex_key="abcdef")
|
|
mock_hex_key.assert_called_once()
|
|
|
|
|
|
class TestPragmaOrder:
|
|
"""Tests for correct PRAGMA execution order."""
|
|
|
|
def test_sqlcipher_4x_pragma_order_existing_db(self):
|
|
"""Verify correct order for opening existing DB: key -> cipher_* -> kdf_iter -> verify."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
create_sqlcipher_connection,
|
|
)
|
|
|
|
execution_order = []
|
|
|
|
def track_execute(sql, *args, **kwargs):
|
|
execution_order.append(sql)
|
|
|
|
mock_cursor = Mock()
|
|
mock_cursor.execute = Mock(side_effect=track_execute)
|
|
mock_cursor.fetchone.return_value = (1,)
|
|
|
|
mock_conn = Mock()
|
|
mock_conn.cursor.return_value = mock_cursor
|
|
|
|
mock_sqlcipher = Mock()
|
|
mock_sqlcipher.connect.return_value = mock_conn
|
|
|
|
with patch(
|
|
"local_deep_research.database.sqlcipher_compat.get_sqlcipher_module",
|
|
return_value=mock_sqlcipher,
|
|
):
|
|
with patch(
|
|
"local_deep_research.database.sqlcipher_utils._get_key_from_password",
|
|
return_value=b"\x01\x02\x03",
|
|
):
|
|
create_sqlcipher_connection("/tmp/test.db", password="password")
|
|
|
|
# Find indices of key operations
|
|
key_idx = next(
|
|
i for i, sql in enumerate(execution_order) if "PRAGMA key" in sql
|
|
)
|
|
cipher_page_idx = next(
|
|
i
|
|
for i, sql in enumerate(execution_order)
|
|
if "cipher_page_size" in sql
|
|
)
|
|
kdf_iter_idx = next(
|
|
i for i, sql in enumerate(execution_order) if "kdf_iter" in sql
|
|
)
|
|
verify_idx = next(
|
|
i for i, sql in enumerate(execution_order) if "SELECT 1" in sql
|
|
)
|
|
|
|
# For existing DB: key BEFORE cipher_page_size (cipher_* come after key)
|
|
assert key_idx < cipher_page_idx, (
|
|
"PRAGMA key must be set BEFORE cipher_page_size for existing DBs"
|
|
)
|
|
|
|
# kdf_iter comes AFTER the key
|
|
assert key_idx < kdf_iter_idx, "kdf_iter must be set AFTER key"
|
|
|
|
# Verify (SELECT 1) must be AFTER all pragmas
|
|
assert verify_idx > kdf_iter_idx, "Verification must be after kdf_iter"
|
|
|
|
def test_sqlcipher_4x_pragma_order_new_db(self):
|
|
"""Verify correct order for new DB: cipher_default_* -> key -> kdf_iter -> verify."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
create_sqlcipher_connection,
|
|
)
|
|
|
|
execution_order = []
|
|
|
|
def track_execute(sql, *args, **kwargs):
|
|
execution_order.append(sql)
|
|
|
|
mock_cursor = Mock()
|
|
mock_cursor.execute = Mock(side_effect=track_execute)
|
|
mock_cursor.fetchone.return_value = (1,)
|
|
|
|
mock_conn = Mock()
|
|
mock_conn.cursor.return_value = mock_cursor
|
|
|
|
mock_sqlcipher = Mock()
|
|
mock_sqlcipher.connect.return_value = mock_conn
|
|
|
|
with patch(
|
|
"local_deep_research.database.sqlcipher_compat.get_sqlcipher_module",
|
|
return_value=mock_sqlcipher,
|
|
):
|
|
with patch(
|
|
"local_deep_research.database.sqlcipher_utils._get_key_from_password",
|
|
return_value=b"\x01\x02\x03",
|
|
):
|
|
create_sqlcipher_connection(
|
|
"/tmp/test.db", password="password", creation_mode=True
|
|
)
|
|
|
|
# Find indices
|
|
key_idx = next(
|
|
i for i, sql in enumerate(execution_order) if "PRAGMA key" in sql
|
|
)
|
|
default_page_idx = next(
|
|
i
|
|
for i, sql in enumerate(execution_order)
|
|
if "cipher_default_page_size" in sql
|
|
)
|
|
|
|
# For new DB: cipher_default_* BEFORE key
|
|
assert default_page_idx < key_idx, (
|
|
"cipher_default_page_size must be set BEFORE key for new DBs"
|
|
)
|
|
|
|
def test_all_cipher_pragmas_present_in_correct_functions(self):
|
|
"""Verify cipher pragmas are in correct functions for SQLCipher 4.x.
|
|
|
|
- apply_cipher_defaults_before_key(): cipher_default_* (for creation)
|
|
- apply_sqlcipher_pragmas(creation_mode=False): cipher_* + kdf_iter (for existing)
|
|
- apply_sqlcipher_pragmas(creation_mode=True): only kdf_iter
|
|
"""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
apply_cipher_defaults_before_key,
|
|
apply_sqlcipher_pragmas,
|
|
)
|
|
|
|
# Test apply_cipher_defaults_before_key - should have cipher_default_* settings
|
|
mock_cursor_before = Mock()
|
|
apply_cipher_defaults_before_key(mock_cursor_before)
|
|
before_key_args = [
|
|
call[0][0] for call in mock_cursor_before.execute.call_args_list
|
|
]
|
|
assert any("cipher_default_page_size" in arg for arg in before_key_args)
|
|
assert any(
|
|
"cipher_default_hmac_algorithm" in arg for arg in before_key_args
|
|
)
|
|
assert any(
|
|
"cipher_default_kdf_algorithm" in arg for arg in before_key_args
|
|
)
|
|
|
|
# Test apply_sqlcipher_pragmas(creation_mode=False) - should have cipher_* + kdf_iter
|
|
mock_cursor_existing = Mock()
|
|
apply_sqlcipher_pragmas(mock_cursor_existing, creation_mode=False)
|
|
existing_args = [
|
|
call[0][0] for call in mock_cursor_existing.execute.call_args_list
|
|
]
|
|
assert any("cipher_page_size" in arg for arg in existing_args)
|
|
assert any("cipher_hmac_algorithm" in arg for arg in existing_args)
|
|
assert any("cipher_kdf_algorithm" in arg for arg in existing_args)
|
|
assert any("kdf_iter" in arg for arg in existing_args)
|
|
|
|
# Test apply_sqlcipher_pragmas(creation_mode=True) - should only have kdf_iter
|
|
mock_cursor_create = Mock()
|
|
apply_sqlcipher_pragmas(mock_cursor_create, creation_mode=True)
|
|
create_args = [
|
|
call[0][0] for call in mock_cursor_create.execute.call_args_list
|
|
]
|
|
assert any("kdf_iter" in arg for arg in create_args)
|
|
assert not any("cipher_page_size" in arg for arg in create_args)
|
|
|
|
|
|
class TestGetSqlcipherSettingsValidation:
|
|
"""Tests for type conversion and validation in get_sqlcipher_settings.
|
|
|
|
The registry's typed settings (IntegerSetting, EnumSetting) handle
|
|
validation and fall back to defaults for invalid values.
|
|
"""
|
|
|
|
def test_non_numeric_kdf_iterations_uses_default(self):
|
|
"""Non-numeric KDF iterations should fall back to default."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_sqlcipher_settings,
|
|
DEFAULT_KDF_ITERATIONS,
|
|
)
|
|
|
|
with patch.dict(
|
|
os.environ, {"LDR_DB_CONFIG_KDF_ITERATIONS": "abc"}, clear=True
|
|
):
|
|
settings = get_sqlcipher_settings()
|
|
assert settings["kdf_iterations"] == DEFAULT_KDF_ITERATIONS
|
|
|
|
def test_empty_string_kdf_iterations_uses_default(self):
|
|
"""Empty string KDF iterations should fall back to default."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_sqlcipher_settings,
|
|
DEFAULT_KDF_ITERATIONS,
|
|
)
|
|
|
|
with patch.dict(
|
|
os.environ, {"LDR_DB_CONFIG_KDF_ITERATIONS": ""}, clear=True
|
|
):
|
|
settings = get_sqlcipher_settings()
|
|
assert settings["kdf_iterations"] == DEFAULT_KDF_ITERATIONS
|
|
|
|
def test_invalid_hmac_algorithm_uses_default(self):
|
|
"""Invalid HMAC algorithm via deprecated name is validated and replaced with default."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_sqlcipher_settings,
|
|
DEFAULT_HMAC_ALGORITHM,
|
|
)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{"LDR_DB_HMAC_ALGORITHM": "INVALID_ALGO"},
|
|
clear=True,
|
|
):
|
|
settings = get_sqlcipher_settings()
|
|
assert settings["hmac_algorithm"] == DEFAULT_HMAC_ALGORITHM
|
|
|
|
def test_invalid_page_size_uses_default(self):
|
|
"""Non-power-of-2 page size uses default."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_sqlcipher_settings,
|
|
DEFAULT_PAGE_SIZE,
|
|
)
|
|
|
|
with patch.dict(
|
|
os.environ, {"LDR_DB_CONFIG_PAGE_SIZE": "3000"}, clear=True
|
|
):
|
|
settings = get_sqlcipher_settings()
|
|
assert settings["page_size"] == DEFAULT_PAGE_SIZE
|
|
|
|
def test_valid_deprecated_kdf_algorithm_accepted(self):
|
|
"""Valid KDF algorithm via deprecated name passes validation."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_sqlcipher_settings,
|
|
)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{"LDR_DB_KDF_ALGORITHM": "PBKDF2_HMAC_SHA256"},
|
|
clear=True,
|
|
):
|
|
settings = get_sqlcipher_settings()
|
|
assert settings["kdf_algorithm"] == "PBKDF2_HMAC_SHA256"
|
|
|
|
|
|
class TestCombinedSettingsScenarios:
|
|
"""Tests for multiple settings used together."""
|
|
|
|
def test_all_canonical_names_set(self):
|
|
"""All settings use canonical names."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_sqlcipher_settings,
|
|
)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"LDR_DB_CONFIG_KDF_ITERATIONS": "500000",
|
|
"LDR_DB_CONFIG_PAGE_SIZE": "8192",
|
|
"LDR_DB_CONFIG_HMAC_ALGORITHM": "HMAC_SHA256",
|
|
"LDR_DB_CONFIG_KDF_ALGORITHM": "PBKDF2_HMAC_SHA256",
|
|
},
|
|
clear=True,
|
|
):
|
|
settings = get_sqlcipher_settings()
|
|
|
|
assert settings["kdf_iterations"] == 500000
|
|
assert settings["page_size"] == 8192
|
|
assert settings["hmac_algorithm"] == "HMAC_SHA256"
|
|
assert settings["kdf_algorithm"] == "PBKDF2_HMAC_SHA256"
|
|
|
|
def test_all_deprecated_names_set(self):
|
|
"""All settings use deprecated names (backward compat)."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_sqlcipher_settings,
|
|
)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"LDR_DB_KDF_ITERATIONS": "500000",
|
|
"LDR_DB_PAGE_SIZE": "8192",
|
|
"LDR_DB_HMAC_ALGORITHM": "HMAC_SHA256",
|
|
"LDR_DB_KDF_ALGORITHM": "PBKDF2_HMAC_SHA256",
|
|
},
|
|
clear=True,
|
|
):
|
|
settings = get_sqlcipher_settings()
|
|
|
|
assert settings["kdf_iterations"] == 500000
|
|
assert settings["page_size"] == 8192
|
|
assert settings["hmac_algorithm"] == "HMAC_SHA256"
|
|
assert settings["kdf_algorithm"] == "PBKDF2_HMAC_SHA256"
|
|
|
|
def test_mixed_canonical_and_deprecated(self):
|
|
"""Some settings canonical, some deprecated."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_sqlcipher_settings,
|
|
)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"LDR_DB_CONFIG_KDF_ITERATIONS": "400000",
|
|
"LDR_DB_CONFIG_HMAC_ALGORITHM": "HMAC_SHA512",
|
|
"LDR_DB_PAGE_SIZE": "4096",
|
|
"LDR_DB_KDF_ALGORITHM": "PBKDF2_HMAC_SHA512",
|
|
},
|
|
clear=True,
|
|
):
|
|
settings = get_sqlcipher_settings()
|
|
|
|
assert settings["kdf_iterations"] == 400000
|
|
assert settings["page_size"] == 4096
|
|
assert settings["hmac_algorithm"] == "HMAC_SHA512"
|
|
assert settings["kdf_algorithm"] == "PBKDF2_HMAC_SHA512"
|
|
|
|
def test_canonical_overrides_deprecated_for_each_setting(self):
|
|
"""Canonical takes precedence for each individual setting."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_sqlcipher_settings,
|
|
)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"LDR_DB_CONFIG_KDF_ITERATIONS": "300000",
|
|
"LDR_DB_CONFIG_PAGE_SIZE": "16384",
|
|
"LDR_DB_KDF_ITERATIONS": "100000",
|
|
"LDR_DB_PAGE_SIZE": "4096",
|
|
},
|
|
clear=True,
|
|
):
|
|
settings = get_sqlcipher_settings()
|
|
|
|
assert settings["kdf_iterations"] == 300000
|
|
assert settings["page_size"] == 16384
|
|
|
|
def test_performance_pragmas_with_mixed_settings(self):
|
|
"""Test apply_performance_pragmas with mixed canonical/deprecated."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
apply_performance_pragmas,
|
|
)
|
|
|
|
mock_cursor = Mock()
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"LDR_DB_CONFIG_CACHE_SIZE_MB": "256",
|
|
"LDR_DB_JOURNAL_MODE": "DELETE",
|
|
"LDR_DB_CONFIG_SYNCHRONOUS": "OFF",
|
|
},
|
|
clear=True,
|
|
):
|
|
apply_performance_pragmas(mock_cursor)
|
|
|
|
call_args_list = [
|
|
call[0][0] for call in mock_cursor.execute.call_args_list
|
|
]
|
|
|
|
cache_call = [arg for arg in call_args_list if "cache_size" in arg][
|
|
0
|
|
]
|
|
assert "-262144" in cache_call
|
|
|
|
journal_call = [
|
|
arg for arg in call_args_list if "journal_mode" in arg
|
|
][0]
|
|
assert "DELETE" in journal_call
|
|
|
|
sync_call = [arg for arg in call_args_list if "synchronous" in arg][
|
|
0
|
|
]
|
|
assert "OFF" in sync_call
|
|
|
|
|
|
class TestSetSqlcipherRekey:
|
|
"""Tests for set_sqlcipher_rekey function."""
|
|
|
|
def test_rekey_uses_pbkdf2_derivation(self):
|
|
"""Verify rekey uses get_key_from_password (PBKDF2), not raw hex encoding."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
set_sqlcipher_rekey,
|
|
)
|
|
|
|
mock_conn = Mock()
|
|
# Make it look like a raw connection (raises TypeError on text())
|
|
mock_conn.execute.side_effect = [TypeError("not sqlalchemy"), None]
|
|
|
|
with patch(
|
|
"local_deep_research.database.sqlcipher_utils.get_key_from_password",
|
|
return_value=b"\xab\xcd\xef",
|
|
) as mock_get_key:
|
|
set_sqlcipher_rekey(mock_conn, "new_password")
|
|
mock_get_key.assert_called_once_with("new_password", db_path=None)
|
|
|
|
def test_rekey_does_not_use_raw_hex(self):
|
|
"""Verify rekey does NOT use raw password.encode().hex()."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
set_sqlcipher_rekey,
|
|
)
|
|
|
|
mock_conn = Mock()
|
|
mock_conn.execute.side_effect = [TypeError("not sqlalchemy"), None]
|
|
|
|
with patch(
|
|
"local_deep_research.database.sqlcipher_utils.get_key_from_password",
|
|
return_value=b"\xab\xcd\xef",
|
|
):
|
|
set_sqlcipher_rekey(mock_conn, "test_password")
|
|
|
|
# The SQL should contain the PBKDF2-derived hex, not raw password hex
|
|
rekey_call = mock_conn.execute.call_args_list[-1][0][0]
|
|
assert "test_password".encode().hex() not in rekey_call
|
|
assert "abcdef" in rekey_call
|
|
|
|
def test_rekey_works_with_sqlalchemy(self):
|
|
"""Verify rekey works through SQLAlchemy connection (text() wrapper)."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
set_sqlcipher_rekey,
|
|
)
|
|
|
|
mock_conn = Mock()
|
|
|
|
with patch(
|
|
"local_deep_research.database.sqlcipher_utils.get_key_from_password",
|
|
return_value=b"\xab\xcd\xef",
|
|
):
|
|
set_sqlcipher_rekey(mock_conn, "new_password")
|
|
|
|
# Should have called execute with text() wrapped SQL
|
|
mock_conn.execute.assert_called_once()
|
|
|
|
|
|
class TestCIAwareKDFMinimum:
|
|
"""Tests for CI-aware MIN_KDF_ITERATIONS enforcement."""
|
|
|
|
def test_production_enforces_minimum(self):
|
|
"""Test that production enforces MIN_KDF_ITERATIONS_PRODUCTION."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_sqlcipher_settings,
|
|
DEFAULT_KDF_ITERATIONS,
|
|
)
|
|
|
|
# Without CI/TESTING env vars, low KDF should be rejected
|
|
env = {
|
|
"LDR_DB_KDF_ITERATIONS": "100",
|
|
}
|
|
# Clear CI vars
|
|
for key in ["LDR_TEST_MODE", "PYTEST_CURRENT_TEST"]:
|
|
env[key] = ""
|
|
|
|
with patch.dict(os.environ, env, clear=True):
|
|
settings = get_sqlcipher_settings()
|
|
# Should fall back to default since 100 < 100_000
|
|
assert settings["kdf_iterations"] == DEFAULT_KDF_ITERATIONS
|
|
|
|
def test_testing_allows_low_kdf(self):
|
|
"""Test that LDR_TEST_MODE env var allows low KDF iterations."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_sqlcipher_settings,
|
|
)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{"LDR_DB_KDF_ITERATIONS": "1000", "LDR_TEST_MODE": "true"},
|
|
clear=True,
|
|
):
|
|
settings = get_sqlcipher_settings()
|
|
assert settings["kdf_iterations"] == 1000
|
|
|
|
|
|
class TestCipherMemorySecurityEnvVar:
|
|
"""Tests for configurable cipher_memory_security."""
|
|
|
|
def test_defaults_to_off(self):
|
|
"""Test that cipher_memory_security defaults to OFF in both modes."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
apply_sqlcipher_pragmas,
|
|
)
|
|
|
|
for mode in (True, False):
|
|
mock_cursor = Mock()
|
|
|
|
with patch.dict(os.environ, {}, clear=False):
|
|
os.environ.pop("LDR_DB_CONFIG_CIPHER_MEMORY_SECURITY", None)
|
|
apply_sqlcipher_pragmas(mock_cursor, creation_mode=mode)
|
|
|
|
call_args_list = [
|
|
call[0][0] for call in mock_cursor.execute.call_args_list
|
|
]
|
|
mem_calls = [
|
|
arg for arg in call_args_list if "cipher_memory_security" in arg
|
|
]
|
|
assert len(mem_calls) == 1, f"Failed for creation_mode={mode}"
|
|
assert "OFF" in mem_calls[0], f"Failed for creation_mode={mode}"
|
|
|
|
def test_respects_on_env_var(self):
|
|
"""Test that LDR_DB_CONFIG_CIPHER_MEMORY_SECURITY=ON is respected in both modes."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
apply_sqlcipher_pragmas,
|
|
)
|
|
|
|
for mode in (True, False):
|
|
mock_cursor = Mock()
|
|
|
|
with patch.dict(
|
|
os.environ, {"LDR_DB_CONFIG_CIPHER_MEMORY_SECURITY": "ON"}
|
|
):
|
|
apply_sqlcipher_pragmas(mock_cursor, creation_mode=mode)
|
|
|
|
call_args_list = [
|
|
call[0][0] for call in mock_cursor.execute.call_args_list
|
|
]
|
|
mem_calls = [
|
|
arg for arg in call_args_list if "cipher_memory_security" in arg
|
|
]
|
|
assert len(mem_calls) == 1, f"Failed for creation_mode={mode}"
|
|
assert "ON" in mem_calls[0], f"Failed for creation_mode={mode}"
|
|
|
|
def test_invalid_value_falls_back_to_off(self):
|
|
"""Test that invalid value falls back to OFF (default) in both modes."""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
apply_sqlcipher_pragmas,
|
|
)
|
|
|
|
for mode in (True, False):
|
|
mock_cursor = Mock()
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{"LDR_DB_CONFIG_CIPHER_MEMORY_SECURITY": "INVALID"},
|
|
):
|
|
apply_sqlcipher_pragmas(mock_cursor, creation_mode=mode)
|
|
|
|
call_args_list = [
|
|
call[0][0] for call in mock_cursor.execute.call_args_list
|
|
]
|
|
mem_calls = [
|
|
arg for arg in call_args_list if "cipher_memory_security" in arg
|
|
]
|
|
assert len(mem_calls) == 1, f"Failed for creation_mode={mode}"
|
|
assert "OFF" in mem_calls[0], f"Failed for creation_mode={mode}"
|