Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

6254 lines
224 KiB
Python

"""Tests for backup service functionality."""
import os
import threading
import time
from unittest.mock import MagicMock, patch
import pytest
from local_deep_research.database.backup.backup_service import (
_UNSAFE_BACKUP_PATH_CHARS,
BackupResult,
BackupService,
)
from local_deep_research.database.backup.backup_executor import (
BackupExecutor,
get_backup_executor,
)
def _has_sqlcipher() -> bool:
try:
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
get_sqlcipher_module()
return True
except (ImportError, RuntimeError):
return False
HAS_SQLCIPHER = _has_sqlcipher()
requires_sqlcipher = pytest.mark.skipif(
not HAS_SQLCIPHER, reason="SQLCipher not available"
)
class TestBackupResult:
"""Tests for BackupResult dataclass."""
def test_success_result(self, tmp_path):
"""Should create successful result with backup path."""
backup_path = tmp_path / "test_backup.db"
result = BackupResult(
success=True,
backup_path=backup_path,
size_bytes=1024,
)
assert result.success is True
assert result.backup_path == backup_path
assert result.size_bytes == 1024
assert result.error is None
def test_failure_result(self):
"""Should create failure result with error message."""
result = BackupResult(
success=False,
error="Database not found",
)
assert result.success is False
assert result.backup_path is None
assert result.error == "Database not found"
assert result.size_bytes == 0
class TestBackupServiceInit:
"""Tests for BackupService initialization."""
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_initialization(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""Should initialize with correct paths."""
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path / "encrypted_databases"
mock_backup_dir.return_value = tmp_path / "backups"
service = BackupService(
username="testuser",
password="testpass",
max_backups=5,
max_age_days=14,
)
assert service.username == "testuser"
assert service.password == "testpass"
assert service.max_backups == 5
assert service.max_age_days == 14
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_default_values(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""Should use default values when not specified."""
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = tmp_path / "backups"
service = BackupService(username="testuser", password="testpass")
assert service.max_backups == 1
assert service.max_age_days == 7
class TestBackupServiceCreateBackup:
"""Tests for backup creation."""
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_returns_error_when_db_not_found(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""Should return error when database file doesn't exist."""
mock_db_filename.return_value = "nonexistent.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = tmp_path / "backups"
service = BackupService(username="testuser", password="testpass")
result = service.create_backup()
assert result.success is False
assert "Database not found" in result.error
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
@patch(
"local_deep_research.database.backup.backup_service.create_sqlcipher_connection"
)
@patch("shutil.disk_usage")
def test_checks_disk_space(
self,
mock_disk_usage,
mock_create_conn,
mock_db_filename,
mock_backup_dir,
mock_db_path,
tmp_path,
):
"""Should check disk space before creating backup."""
# Create a fake database file
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_file = db_dir / "ldr_user_abc123.db"
db_file.write_bytes(b"x" * 1000)
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = db_dir
mock_backup_dir.return_value = tmp_path / "backups"
(tmp_path / "backups").mkdir()
# Simulate insufficient disk space
mock_disk_usage.return_value = MagicMock(
free=100
) # Only 100 bytes free
service = BackupService(username="testuser", password="testpass")
result = service.create_backup()
assert result.success is False
assert "Insufficient disk space" in result.error
class TestBackupServiceListBackups:
"""Tests for listing backups."""
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_returns_empty_list_when_no_backups(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""Should return empty list when no backups exist."""
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = backup_dir
service = BackupService(username="testuser", password="testpass")
backups = service.list_backups()
assert backups == []
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_lists_existing_backups(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""Should list existing backup files."""
import os
import time
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
# Create some backup files with explicit modification times
# to ensure deterministic sorting (newer file should come first)
older_file = backup_dir / "ldr_backup_20250101_120000.db"
newer_file = backup_dir / "ldr_backup_20250102_120000.db"
older_file.write_bytes(b"backup1")
newer_file.write_bytes(b"backup2")
# Set explicit modification times (older = 1000, newer = 2000)
older_time = time.time() - 1000
newer_time = time.time()
os.utime(older_file, (older_time, older_time))
os.utime(newer_file, (newer_time, newer_time))
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = backup_dir
service = BackupService(username="testuser", password="testpass")
backups = service.list_backups()
assert len(backups) == 2
# Should be sorted newest first
assert "20250102" in backups[0]["filename"]
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_list_backups_with_nonexistent_directory(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""list_backups should return empty list if backup dir doesn't exist."""
# Point to a directory that doesn't exist
nonexistent_dir = tmp_path / "nonexistent_backups"
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = nonexistent_dir
service = BackupService(username="testuser", password="testpass")
# Should return empty list, not crash
backups = service.list_backups()
assert backups == []
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_list_backups_excludes_tmp_files(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""list_backups should not include .tmp partial backups."""
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
# Create valid .db backup files
valid_backup1 = backup_dir / "ldr_backup_20250101_120000.db"
valid_backup2 = backup_dir / "ldr_backup_20250102_120000.db"
valid_backup1.write_bytes(b"backup1")
valid_backup2.write_bytes(b"backup2")
# Create .tmp files that should NOT be included
tmp_file1 = backup_dir / "ldr_backup_20250103_120000.db.tmp"
tmp_file2 = backup_dir / "partial_backup.tmp"
tmp_file3 = backup_dir / "ldr_backup_20250104_120000.tmp"
tmp_file1.write_bytes(b"partial1")
tmp_file2.write_bytes(b"partial2")
tmp_file3.write_bytes(b"partial3")
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = backup_dir
service = BackupService(username="testuser", password="testpass")
backups = service.list_backups()
# Should only include .db files matching the pattern
assert len(backups) == 2
# Verify the correct files are included
filenames = [b["filename"] for b in backups]
assert "ldr_backup_20250101_120000.db" in filenames
assert "ldr_backup_20250102_120000.db" in filenames
# Verify .tmp files are NOT included
assert "ldr_backup_20250103_120000.db.tmp" not in filenames
assert "partial_backup.tmp" not in filenames
assert "ldr_backup_20250104_120000.tmp" not in filenames
class TestBackupServiceCleanup:
"""Tests for backup cleanup."""
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_cleanup_removes_excess_backups(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""Should delete backups exceeding max count."""
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
# Create 5 backup files
for i in range(5):
backup_file = backup_dir / f"ldr_backup_2025010{i}_120000.db"
backup_file.write_bytes(b"backup")
# Set modification time
import os
os.utime(
backup_file, (time.time() - i * 3600, time.time() - i * 3600)
)
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = backup_dir
# Max 3 backups
service = BackupService(
username="testuser",
password="testpass",
max_backups=3,
)
deleted = service._cleanup_old_backups()
assert deleted == 2
remaining = list(backup_dir.glob("ldr_backup_*.db"))
assert len(remaining) == 3
class TestBackupExecutor:
"""Tests for BackupExecutor."""
def test_singleton_pattern(self):
"""Should return same instance on multiple calls."""
scheduler1 = get_backup_executor()
scheduler2 = get_backup_executor()
assert scheduler1 is scheduler2
def test_schedule_backup_prevents_duplicates(self):
"""Should prevent scheduling duplicate backups for same user."""
scheduler = BackupExecutor.__new__(BackupExecutor)
scheduler._pending_backups = set()
scheduler._pending_lock = MagicMock()
scheduler._pending_lock.__enter__ = MagicMock(return_value=None)
scheduler._pending_lock.__exit__ = MagicMock(return_value=False)
scheduler._executor = MagicMock()
scheduler._initialized = True
# First call should succeed
scheduler._pending_backups.add("testuser")
# Mock to check for existing pending
with patch.object(scheduler, "_pending_lock"):
# User is already pending
result = scheduler.submit_backup("testuser", "pass")
assert result is False # Already pending
def test_get_pending_count(self):
"""Should return correct pending count."""
scheduler = BackupExecutor.__new__(BackupExecutor)
scheduler._pending_backups = {"user1", "user2", "user3"}
scheduler._pending_lock = MagicMock()
scheduler._pending_lock.__enter__ = MagicMock(return_value=None)
scheduler._pending_lock.__exit__ = MagicMock(return_value=False)
scheduler._initialized = True
count = scheduler.get_pending_count()
assert count == 3
class TestBackupServiceVerification:
"""Tests for backup verification."""
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_verify_backup_returns_false_for_missing_file(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""Should return False when backup file doesn't exist."""
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = tmp_path / "backups"
service = BackupService(username="testuser", password="testpass")
result = service._verify_backup(tmp_path / "nonexistent.db")
assert result is False
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
@patch("local_deep_research.database.sqlcipher_compat.get_sqlcipher_module")
def test_verify_backup_handles_connection_error(
self,
mock_sqlcipher,
mock_db_filename,
mock_backup_dir,
mock_db_path,
tmp_path,
):
"""Should return False when connection fails."""
backup_file = tmp_path / "test_backup.db"
backup_file.write_bytes(b"invalid data")
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = tmp_path / "backups"
mock_sqlcipher.return_value.connect.side_effect = Exception(
"Connection failed"
)
service = BackupService(username="testuser", password="testpass")
result = service._verify_backup(backup_file)
assert result is False
class TestBackupServiceAgeCleanup:
"""Tests for age-based backup cleanup."""
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_cleanup_removes_old_backups_by_age(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""Should delete backups older than max_age_days."""
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
# Create backup files with different ages
recent = backup_dir / "ldr_backup_20250101_120000.db"
old = backup_dir / "ldr_backup_20240101_120000.db"
recent.write_bytes(b"recent")
old.write_bytes(b"old")
# Set modification times: recent = now, old = 30 days ago
import os
os.utime(recent, (time.time(), time.time()))
os.utime(old, (time.time() - 30 * 86400, time.time() - 30 * 86400))
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = backup_dir
# Max age 7 days, keep up to 10 backups
service = BackupService(
username="testuser",
password="testpass",
max_backups=10,
max_age_days=7,
)
deleted = service._cleanup_old_backups()
assert deleted == 1
assert recent.exists()
assert not old.exists()
class TestBackupExecutorConcurrency:
"""Tests for concurrent backup handling."""
def test_schedule_backup_returns_true_first_time(self):
"""Should return True when scheduling new backup."""
import threading
scheduler = BackupExecutor.__new__(BackupExecutor)
scheduler._pending_backups = set()
scheduler._pending_lock = threading.Lock()
scheduler._executor = MagicMock()
scheduler._executor.submit.return_value = MagicMock()
scheduler._initialized = True
result = scheduler.submit_backup("newuser", "pass")
assert result is True
assert "newuser" in scheduler._pending_backups
def test_backup_completed_removes_from_pending(self):
"""Should remove user from pending after completion."""
import threading
scheduler = BackupExecutor.__new__(BackupExecutor)
scheduler._pending_backups = {"testuser"}
scheduler._pending_lock = threading.Lock()
scheduler._initialized = True
mock_future = MagicMock()
mock_future.result.return_value = None
scheduler._backup_completed("testuser", mock_future)
assert "testuser" not in scheduler._pending_backups
def test_run_backup_returns_result(self):
"""Should return BackupResult from _run_backup."""
import threading
scheduler = BackupExecutor.__new__(BackupExecutor)
scheduler._pending_backups = set()
scheduler._pending_lock = threading.Lock()
scheduler._initialized = True
with patch(
"local_deep_research.database.backup.backup_executor.BackupService"
) as mock_service_class:
mock_service = MagicMock()
mock_service.create_backup.return_value = BackupResult(
success=True, size_bytes=1024
)
mock_service_class.return_value = mock_service
result = scheduler._run_backup("testuser", "pass", 7, 7)
assert result.success is True
mock_service_class.assert_called_once_with(
username="testuser",
password="pass",
max_backups=7,
max_age_days=7,
)
def test_scheduler_rapid_login_logout_cycling(self):
"""Should handle rapid schedule/cancel cycles without issues.
Stress test for rapid user session changes - ensures no memory leaks
or lock issues when submit_backup is called many times in quick
succession for the same user.
"""
scheduler = BackupExecutor.__new__(BackupExecutor)
scheduler._pending_backups = set()
scheduler._pending_lock = threading.Lock()
scheduler._executor = MagicMock()
mock_future = MagicMock()
scheduler._executor.submit.return_value = mock_future
scheduler._initialized = True
# Rapidly schedule backups for same user 100 times
schedule_results = []
for i in range(100):
result = scheduler.submit_backup("testuser", "pass")
schedule_results.append(result)
# Occasionally simulate completion
if i % 10 == 9:
scheduler._backup_completed("testuser", mock_future)
# First request and requests after completion should succeed
assert schedule_results[0] is True, "First request should succeed"
# Count how many succeeded
success_count = sum(1 for r in schedule_results if r is True)
# Should have multiple successes (first + after each completion)
# With completions at indices 9, 19, 29, etc., we get ~11 successes
assert success_count >= 10, (
f"Expected at least 10 successful schedules, got {success_count}"
)
# Pending count should never exceed 1 for single user
assert scheduler.get_pending_count() <= 1, (
"Pending count should never exceed 1 for same user"
)
# Verify no exceptions were raised (test would fail if any)
# Verify _pending_backups doesn't grow unbounded
assert len(scheduler._pending_backups) <= 1, (
f"Memory leak: _pending_backups has {len(scheduler._pending_backups)} entries"
)
class TestBackupIntegration:
"""Integration tests for backup workflow."""
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_get_latest_backup(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""Should return most recent backup."""
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
# Create backup files with different timestamps
older = backup_dir / "ldr_backup_20250101_100000.db"
newer = backup_dir / "ldr_backup_20250102_100000.db"
older.write_bytes(b"older")
newer.write_bytes(b"newer")
# Set modification times
import os
os.utime(older, (time.time() - 86400, time.time() - 86400))
os.utime(newer, (time.time(), time.time()))
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = backup_dir
service = BackupService(username="testuser", password="testpass")
latest = service.get_latest_backup()
assert latest == newer
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_get_latest_backup_returns_none_when_empty(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""Should return None when no backups exist."""
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = backup_dir
service = BackupService(username="testuser", password="testpass")
latest = service.get_latest_backup()
assert latest is None
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_get_latest_backup_with_corrupted_metadata(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""Should handle files where stat() fails.
If stat() fails for some backup files (e.g., due to filesystem issues),
get_latest_backup should still return a valid result for the files it
can read, rather than crashing entirely.
"""
import os
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = backup_dir
# Create two backup files with different timestamps
base_time = time.time()
older_backup = backup_dir / "ldr_backup_20240101_120000.db"
older_backup.write_bytes(b"older_backup")
os.utime(older_backup, (base_time - 86400, base_time - 86400))
newer_backup = backup_dir / "ldr_backup_20240102_120000.db"
newer_backup.write_bytes(b"newer_backup")
os.utime(newer_backup, (base_time, base_time))
# Track stat calls and make older file's stat fail
from pathlib import Path as PathClass
original_stat = PathClass.stat
stat_error_files = []
def mock_stat(self, *args, **kwargs):
# Fail for the older backup file
if "20240101" in str(self):
stat_error_files.append(str(self))
raise OSError("Simulated stat failure")
return original_stat(self, *args, **kwargs)
service = BackupService(username="testuser", password="testpass")
with patch.object(PathClass, "stat", mock_stat):
# get_latest_backup sorts by mtime, which requires stat()
# When one file fails, it should either:
# 1. Skip that file and return the other valid file
# 2. Return None gracefully if all files fail
latest = service.get_latest_backup()
# The implementation may handle this differently:
# - If it catches individual stat errors, it should return the newer backup
# - If it doesn't, the whole operation might fail and return None
# Either behavior is acceptable as long as no exception is raised
if latest is not None:
# Should be the newer backup (older one had stat failure)
assert latest.name == "ldr_backup_20240102_120000.db", (
f"Expected newer backup, got {latest.name}"
)
# If latest is None, that's also acceptable (graceful failure)
class TestBackupExecutorShutdown:
"""Tests for scheduler shutdown."""
def test_shutdown_stops_executor(self):
"""Should shutdown the thread pool executor."""
scheduler = BackupExecutor.__new__(BackupExecutor)
scheduler._pending_backups = set()
scheduler._pending_lock = threading.Lock()
scheduler._executor = MagicMock()
scheduler._initialized = True
scheduler.shutdown(wait=True)
scheduler._executor.shutdown.assert_called_once_with(wait=True)
def test_shutdown_without_wait(self):
"""Should shutdown without waiting for pending backups."""
scheduler = BackupExecutor.__new__(BackupExecutor)
scheduler._pending_backups = {"user1"}
scheduler._pending_lock = threading.Lock()
scheduler._executor = MagicMock()
scheduler._initialized = True
scheduler.shutdown(wait=False)
scheduler._executor.shutdown.assert_called_once_with(wait=False)
def test_scheduler_shutdown_with_pending_backup(self, tmp_path):
"""Shutdown during active backup should complete or cleanup gracefully.
When shutdown(wait=False) is called while a backup is in progress,
the scheduler should handle it gracefully without leaving orphaned
.tmp files or corrupting the backup directory.
"""
from concurrent.futures import ThreadPoolExecutor
# Create a real executor (not mocked) for this test
scheduler = BackupExecutor.__new__(BackupExecutor)
scheduler._pending_backups = set()
scheduler._pending_lock = threading.Lock()
scheduler._executor = ThreadPoolExecutor(max_workers=1)
scheduler._initialized = True
backup_started = threading.Event()
shutdown_called = threading.Event()
backup_dir = tmp_path / "backups"
backup_dir.mkdir(mode=0o700)
def slow_backup():
"""Simulate a slow backup operation."""
backup_started.set()
# Wait a bit or until shutdown is called
shutdown_called.wait(timeout=2.0)
# Simulate backup work
time.sleep(0.1)
return BackupResult(success=True, size_bytes=100)
# Submit the slow backup
with scheduler._pending_lock:
scheduler._pending_backups.add("testuser")
future = scheduler._executor.submit(slow_backup)
future.add_done_callback(
lambda f: scheduler._backup_completed("testuser", f)
)
# Wait for backup to start
backup_started.wait(timeout=2.0)
assert backup_started.is_set(), "Backup should have started"
# Signal backup can continue and call shutdown
shutdown_called.set()
scheduler.shutdown(wait=False)
# Give a moment for things to settle
time.sleep(0.2) # allow: unmarked-sleep
# Verify no .tmp files are left orphaned
tmp_files = list(backup_dir.glob("*.tmp"))
assert len(tmp_files) == 0, f"Found orphaned .tmp files: {tmp_files}"
class TestBackupExecutorErrorHandling:
"""Tests for error handling in scheduler."""
def test_run_backup_handles_exception(self):
"""Should return failure result when backup raises exception."""
scheduler = BackupExecutor.__new__(BackupExecutor)
scheduler._pending_backups = set()
scheduler._pending_lock = threading.Lock()
scheduler._initialized = True
with patch(
"local_deep_research.database.backup.backup_executor.BackupService"
) as mock_service_class:
mock_service_class.side_effect = Exception("Service init failed")
result = scheduler._run_backup("testuser", "pass", 7, 7)
assert result.success is False
assert "Service init failed" in result.error
def test_backup_completed_handles_future_exception(self):
"""Should handle exceptions from future result."""
scheduler = BackupExecutor.__new__(BackupExecutor)
scheduler._pending_backups = {"testuser"}
scheduler._pending_lock = threading.Lock()
scheduler._initialized = True
mock_future = MagicMock()
mock_future.result.side_effect = Exception("Future failed")
# Should not raise - just log the error
scheduler._backup_completed("testuser", mock_future)
# User should be removed from pending even on error
assert "testuser" not in scheduler._pending_backups
class TestBackupServiceCreateBackupErrors:
"""Tests for error handling during backup creation."""
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
@patch(
"local_deep_research.database.backup.backup_service.create_sqlcipher_connection"
)
@patch("shutil.disk_usage")
def test_create_backup_handles_export_error(
self,
mock_disk_usage,
mock_create_conn,
mock_db_filename,
mock_backup_dir,
mock_db_path,
tmp_path,
):
"""Should handle backup export failure gracefully."""
# Setup
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_file = db_dir / "ldr_user_abc123.db"
db_file.write_bytes(b"x" * 1000)
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = db_dir
mock_backup_dir.return_value = backup_dir
mock_disk_usage.return_value = MagicMock(free=10000000)
# Mock connection that fails during backup export
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_cursor.execute.side_effect = Exception("Backup export failed")
mock_conn.cursor.return_value = mock_cursor
mock_create_conn.return_value = mock_conn
service = BackupService(username="testuser", password="testpass")
result = service.create_backup()
assert result.success is False
assert "Backup export failed" in result.error
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
@patch(
"local_deep_research.database.backup.backup_service.create_sqlcipher_connection"
)
@patch("shutil.disk_usage")
def test_connection_closed_on_detach_failure(
self,
mock_disk_usage,
mock_create_conn,
mock_db_filename,
mock_backup_dir,
mock_db_path,
tmp_path,
):
"""Connection should be closed even if DETACH DATABASE fails."""
# Setup
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_file = db_dir / "ldr_user_abc123.db"
db_file.write_bytes(b"x" * 1000)
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = db_dir
mock_backup_dir.return_value = backup_dir
mock_disk_usage.return_value = MagicMock(free=10000000)
# Track if connection.close() was called
close_called = []
# Mock connection that fails on DETACH
mock_conn = MagicMock()
mock_cursor = MagicMock()
def execute_side_effect(sql, *args):
if "DETACH DATABASE" in sql:
raise Exception("DETACH failed - simulated error")
# Create a temp backup file for other queries
if "ATTACH DATABASE" in sql:
import re
match = re.search(r"ATTACH DATABASE '([^']+)'", sql)
if match:
from pathlib import Path
Path(match.group(1)).write_bytes(b"backup_data")
mock_cursor.execute.side_effect = execute_side_effect
mock_conn.cursor.return_value = mock_cursor
def track_close():
close_called.append(True)
mock_conn.close = track_close
mock_create_conn.return_value = mock_conn
service = BackupService(username="testuser", password="testpass")
result = service.create_backup()
# Backup should have failed (DETACH failure is logged as warning,
# then backup verification fails because the mock backup file is invalid)
assert result.success is False
# Connection must have been closed (no resource leak)
assert len(close_called) == 1, (
"Connection.close() should have been called"
)
class TestLoginIntegration:
"""Tests for backup integration with login flow."""
def test_get_backup_executor_returns_singleton(self):
"""Should return the same scheduler instance."""
scheduler1 = get_backup_executor()
scheduler2 = get_backup_executor()
assert scheduler1 is scheduler2
@patch("local_deep_research.database.backup.backup_executor.BackupService")
def test_schedule_backup_creates_service_with_correct_params(
self, mock_service_class
):
"""Should create BackupService with provided parameters."""
mock_service = MagicMock()
mock_service.create_backup.return_value = BackupResult(success=True)
mock_service_class.return_value = mock_service
scheduler = BackupExecutor.__new__(BackupExecutor)
scheduler._pending_backups = set()
scheduler._pending_lock = threading.Lock()
scheduler._initialized = True
result = scheduler._run_backup(
username="testuser",
password="testpass",
max_backups=10,
max_age_days=14,
)
mock_service_class.assert_called_once_with(
username="testuser",
password="testpass",
max_backups=10,
max_age_days=14,
)
assert result.success is True
class TestBackupPaths:
"""Tests for backup path functions."""
def test_get_backup_directory_creates_dir(self, tmp_path):
"""Should create backup directory if it doesn't exist."""
with patch(
"local_deep_research.config.paths.get_data_directory"
) as mock_data_dir:
mock_data_dir.return_value = tmp_path
from local_deep_research.config.paths import get_backup_directory
backup_dir = get_backup_directory()
assert backup_dir.exists()
assert backup_dir == tmp_path / "encrypted_databases" / "backups"
def test_get_user_backup_directory_creates_user_dir(self, tmp_path):
"""Should create user-specific backup directory."""
with patch(
"local_deep_research.config.paths.get_data_directory"
) as mock_data_dir:
mock_data_dir.return_value = tmp_path
from local_deep_research.config.paths import (
get_user_backup_directory,
)
user_backup_dir = get_user_backup_directory("testuser")
assert user_backup_dir.exists()
assert (
user_backup_dir.parent
== tmp_path / "encrypted_databases" / "backups"
)
def test_get_user_backup_directory_uses_hash(self, tmp_path):
"""Should use username hash for directory name."""
with patch(
"local_deep_research.config.paths.get_data_directory"
) as mock_data_dir:
mock_data_dir.return_value = tmp_path
from local_deep_research.config.paths import (
get_user_backup_directory,
)
user_backup_dir = get_user_backup_directory("testuser")
# Directory name should be a hash, not the username
assert user_backup_dir.name != "testuser"
assert len(user_backup_dir.name) == 16 # First 16 chars of SHA256
class TestBackupServiceSuccessPath:
"""Tests for successful backup creation (happy path)."""
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
@patch(
"local_deep_research.database.backup.backup_service.create_sqlcipher_connection"
)
@patch("shutil.disk_usage")
def test_successful_backup_creation(
self,
mock_disk_usage,
mock_create_conn,
mock_db_filename,
mock_backup_dir,
mock_db_path,
tmp_path,
):
"""Should successfully create a backup file."""
# Setup database file
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_file = db_dir / "ldr_user_abc123.db"
db_file.write_bytes(b"x" * 1000)
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = db_dir
mock_backup_dir.return_value = backup_dir
mock_disk_usage.return_value = MagicMock(free=10000000)
# Mock successful connection and VACUUM
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value = mock_cursor
mock_create_conn.return_value = mock_conn
# Simulate ATTACH DATABASE and sqlcipher_export creating the backup file
def create_backup_file(*args):
sql = args[0]
# Handle ATTACH DATABASE - creates the backup file
if "ATTACH DATABASE" in sql:
# Extract backup path from ATTACH DATABASE command
# Format: ATTACH DATABASE '/path/to/backup.db' AS backup KEY "x'...'"
import re
match = re.search(r"ATTACH DATABASE '([^']+)'", sql)
if match:
backup_path = match.group(1)
from pathlib import Path
Path(backup_path).write_bytes(b"backup_data" * 100)
mock_cursor.execute.side_effect = create_backup_file
service = BackupService(username="testuser", password="testpass")
# Patch _verify_backup to return True (skip actual SQLCipher verification)
with patch.object(service, "_verify_backup", return_value=True):
result = service.create_backup()
assert result.success is True
assert result.backup_path is not None
assert result.backup_path.exists()
assert result.size_bytes > 0
assert result.error is None
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
@patch(
"local_deep_research.database.backup.backup_service.create_sqlcipher_connection"
)
@patch("shutil.disk_usage")
def test_backup_triggers_cleanup(
self,
mock_disk_usage,
mock_create_conn,
mock_db_filename,
mock_backup_dir,
mock_db_path,
tmp_path,
):
"""Should trigger cleanup after successful backup."""
import os
# Setup database file
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_file = db_dir / "ldr_user_abc123.db"
db_file.write_bytes(b"x" * 1000)
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
# Create 5 existing old backups
for i in range(5):
old_backup = backup_dir / f"ldr_backup_2024010{i}_120000.db"
old_backup.write_bytes(b"old_backup")
os.utime(
old_backup,
(time.time() - (i + 1) * 86400, time.time() - (i + 1) * 86400),
)
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = db_dir
mock_backup_dir.return_value = backup_dir
mock_disk_usage.return_value = MagicMock(free=10000000)
# Mock successful backup
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value = mock_cursor
mock_create_conn.return_value = mock_conn
def create_backup_file(*args):
sql = args[0]
# Handle ATTACH DATABASE - creates the backup file
if "ATTACH DATABASE" in sql:
import re
match = re.search(r"ATTACH DATABASE '([^']+)'", sql)
if match:
backup_path = match.group(1)
from pathlib import Path
Path(backup_path).write_bytes(b"new_backup_data")
mock_cursor.execute.side_effect = create_backup_file
# Max 3 backups should trigger cleanup
service = BackupService(
username="testuser", password="testpass", max_backups=3
)
# Patch _verify_backup to return True (skip actual SQLCipher verification)
with patch.object(service, "_verify_backup", return_value=True):
result = service.create_backup()
assert result.success is True
# Should have at most 3 backups after cleanup
remaining_backups = list(backup_dir.glob("ldr_backup_*.db"))
assert len(remaining_backups) <= 3
class TestBackupFileIntegrity:
"""Tests for backup file integrity verification."""
@requires_sqlcipher
def test_verify_backup_with_valid_file(self, tmp_path):
"""Should return True for valid backup file (using real SQLCipher if available)."""
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
from local_deep_research.database.sqlcipher_utils import (
apply_sqlcipher_pragmas,
set_sqlcipher_key,
)
sqlcipher = get_sqlcipher_module()
# Create a real valid encrypted database file
backup_file = tmp_path / "valid_backup.db"
password = "test_password"
conn = sqlcipher.connect(str(backup_file))
cursor = conn.cursor()
# Use the same key derivation and cipher settings as backup service
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=True)
cursor.execute("CREATE TABLE test (id INTEGER PRIMARY KEY)")
conn.commit()
conn.close()
with (
patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
) as mock_db_path,
patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
) as mock_backup_dir,
patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
) as mock_db_filename,
):
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = tmp_path / "backups"
service = BackupService(username="testuser", password=password)
result = service._verify_backup(backup_file)
assert result is True
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_verify_backup_with_corrupted_file(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""Should return False for corrupted backup file."""
backup_file = tmp_path / "corrupted_backup.db"
backup_file.write_bytes(b"corrupted_data")
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = tmp_path / "backups"
# Mock the sqlcipher module used in _verify_backup
with patch(
"local_deep_research.database.sqlcipher_compat.get_sqlcipher_module"
) as mock_sqlcipher:
mock_module = MagicMock()
mock_connection = MagicMock()
mock_cursor = MagicMock()
# quick_check returns error for corrupted file
mock_cursor.fetchone.return_value = ("corruption detected",)
mock_connection.cursor.return_value = mock_cursor
mock_module.connect.return_value = mock_connection
mock_sqlcipher.return_value = mock_module
service = BackupService(username="testuser", password="testpass")
result = service._verify_backup(backup_file)
assert result is False
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_verify_backup_empty_file(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""Should return False for empty backup file."""
backup_file = tmp_path / "empty_backup.db"
backup_file.write_bytes(b"")
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = tmp_path / "backups"
# Mock the sqlcipher module to raise exception for empty file
with patch(
"local_deep_research.database.sqlcipher_compat.get_sqlcipher_module"
) as mock_sqlcipher:
mock_module = MagicMock()
mock_module.connect.side_effect = Exception(
"Cannot open empty file"
)
mock_sqlcipher.return_value = mock_module
service = BackupService(username="testuser", password="testpass")
result = service._verify_backup(backup_file)
assert result is False
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
@patch(
"local_deep_research.database.backup.backup_service.create_sqlcipher_connection"
)
@patch("shutil.disk_usage")
def test_corrupted_backup_temp_file_deleted(
self,
mock_disk_usage,
mock_create_conn,
mock_db_filename,
mock_backup_dir,
mock_db_path,
tmp_path,
):
"""Corrupted backup temp file should be deleted after verification failure."""
# Setup
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_file = db_dir / "ldr_user_abc123.db"
db_file.write_bytes(b"x" * 1000)
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = db_dir
mock_backup_dir.return_value = backup_dir
mock_disk_usage.return_value = MagicMock(free=10000000)
# Track created temp file
created_temp_file = [None]
mock_conn = MagicMock()
mock_cursor = MagicMock()
def create_corrupted_backup(sql, *args):
if "ATTACH DATABASE" in sql:
import re
match = re.search(r"ATTACH DATABASE '([^']+)'", sql)
if match:
from pathlib import Path
temp_path = Path(match.group(1))
# Create a "corrupted" backup file
temp_path.write_bytes(b"corrupted_backup_data")
created_temp_file[0] = temp_path
mock_cursor.execute.side_effect = create_corrupted_backup
mock_conn.cursor.return_value = mock_cursor
mock_create_conn.return_value = mock_conn
service = BackupService(username="testuser", password="testpass")
# Patch _verify_backup to return False (simulates corruption)
with patch.object(service, "_verify_backup", return_value=False):
result = service.create_backup()
# Backup should have failed
assert result.success is False
assert "verification failed" in result.error.lower()
# Temp file should have been deleted (cleanup after verification failure)
assert created_temp_file[0] is not None, (
"Temp file should have been created"
)
assert not created_temp_file[0].exists(), (
"Temp file should be deleted after verification failure"
)
# No .db backup file should exist
assert len(list(backup_dir.glob("ldr_backup_*.db"))) == 0
class TestBackupSettingsIntegration:
"""Tests for backup settings integration."""
def test_scheduler_uses_settings_defaults(self):
"""Scheduler should use default settings when not specified."""
scheduler = BackupExecutor.__new__(BackupExecutor)
scheduler._pending_backups = set()
scheduler._pending_lock = threading.Lock()
scheduler._initialized = True
with patch(
"local_deep_research.database.backup.backup_executor.BackupService"
) as mock_service_class:
mock_service = MagicMock()
mock_service.create_backup.return_value = BackupResult(success=True)
mock_service_class.return_value = mock_service
# Call with default values
scheduler._run_backup("testuser", "testpass", 7, 7)
# Verify defaults were passed
mock_service_class.assert_called_with(
username="testuser",
password="testpass",
max_backups=7,
max_age_days=7,
)
def test_scheduler_uses_custom_settings(self):
"""Scheduler should use custom settings when provided."""
scheduler = BackupExecutor.__new__(BackupExecutor)
scheduler._pending_backups = set()
scheduler._pending_lock = threading.Lock()
scheduler._initialized = True
with patch(
"local_deep_research.database.backup.backup_executor.BackupService"
) as mock_service_class:
mock_service = MagicMock()
mock_service.create_backup.return_value = BackupResult(success=True)
mock_service_class.return_value = mock_service
# Call with custom values
scheduler._run_backup("testuser", "testpass", 14, 30)
# Verify custom settings were passed
mock_service_class.assert_called_with(
username="testuser",
password="testpass",
max_backups=14,
max_age_days=30,
)
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_service_stores_settings(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""BackupService should store provided settings."""
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = tmp_path / "backups"
service = BackupService(
username="testuser",
password="testpass",
max_backups=14,
max_age_days=30,
)
assert service.max_backups == 14
assert service.max_age_days == 30
class TestBackupConcurrency:
"""Tests for concurrent backup handling."""
def test_concurrent_backup_requests_for_same_user(self):
"""Should prevent concurrent backups for same user."""
scheduler = BackupExecutor.__new__(BackupExecutor)
scheduler._pending_backups = set()
scheduler._pending_lock = threading.Lock()
scheduler._executor = MagicMock()
scheduler._executor.submit.return_value = MagicMock()
scheduler._initialized = True
# First request should succeed
result1 = scheduler.submit_backup("testuser", "pass")
assert result1 is True
assert "testuser" in scheduler._pending_backups
# Second request for same user should fail
result2 = scheduler.submit_backup("testuser", "pass")
assert result2 is False
def test_concurrent_backup_requests_for_different_users(self):
"""Should allow concurrent backups for different users."""
scheduler = BackupExecutor.__new__(BackupExecutor)
scheduler._pending_backups = set()
scheduler._pending_lock = threading.Lock()
scheduler._executor = MagicMock()
scheduler._executor.submit.return_value = MagicMock()
scheduler._initialized = True
# Requests for different users should all succeed
result1 = scheduler.submit_backup("user1", "pass1")
result2 = scheduler.submit_backup("user2", "pass2")
result3 = scheduler.submit_backup("user3", "pass3")
assert result1 is True
assert result2 is True
assert result3 is True
assert len(scheduler._pending_backups) == 3
def test_thread_pool_executor_used(self):
"""Should use thread pool executor for background backups."""
scheduler = BackupExecutor.__new__(BackupExecutor)
scheduler._pending_backups = set()
scheduler._pending_lock = threading.Lock()
scheduler._executor = MagicMock()
mock_future = MagicMock()
scheduler._executor.submit.return_value = mock_future
scheduler._initialized = True
scheduler.submit_backup("testuser", "pass")
# Verify executor.submit was called
scheduler._executor.submit.assert_called_once()
# Verify callback was added
mock_future.add_done_callback.assert_called_once()
def test_per_user_lock_prevents_concurrent_backup_operations(
self, tmp_path
):
"""Should use per-user lock to serialize backup operations for same user.
This tests the module-level _get_user_lock() mechanism that prevents
race conditions when multiple BackupService instances try to create
backups for the same user concurrently.
"""
from local_deep_research.database.backup.backup_service import (
_get_user_lock,
_user_locks,
)
# Clear any existing locks from previous tests
_user_locks.clear()
# Get lock for a user
lock1 = _get_user_lock("testuser")
lock2 = _get_user_lock("testuser")
# Should return the same lock instance for same user
assert lock1 is lock2
# Different users should get different locks
lock3 = _get_user_lock("otheruser")
assert lock1 is not lock3
# Verify locks work as expected
results = []
errors = []
def thread_func(user, delay_before, delay_during):
try:
lock = _get_user_lock(user)
results.append(f"{user}_trying")
with lock:
results.append(f"{user}_acquired")
time.sleep(delay_during)
results.append(f"{user}_releasing")
results.append(f"{user}_released")
except Exception as e:
errors.append(str(e))
# Start two threads for same user - they should serialize
t1 = threading.Thread(target=thread_func, args=("same_user", 0, 0.1))
t2 = threading.Thread(target=thread_func, args=("same_user", 0.01, 0.1))
t1.start()
t2.start()
t1.join()
t2.join()
assert not errors, f"Unexpected errors: {errors}"
# Verify serialization happened - one thread should complete before other starts
# Find the acquire/release patterns
acquired_indices = [
i for i, r in enumerate(results) if r == "same_user_acquired"
]
releasing_indices = [
i for i, r in enumerate(results) if r == "same_user_releasing"
]
# First thread should release before second acquires
assert len(acquired_indices) == 2
assert len(releasing_indices) == 2
# The second acquire should come after the first release
assert acquired_indices[1] > releasing_indices[0]
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_per_user_lock_prevents_concurrent_backup_creation(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""Two threads cannot run _create_backup_impl simultaneously for same user."""
from local_deep_research.database.backup.backup_service import (
BackupService,
_get_user_lock,
_user_locks,
)
# Setup mocks
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_file = db_dir / "ldr_user_test.db"
db_file.write_bytes(b"x" * 1000)
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
mock_db_filename.return_value = "ldr_user_test.db"
mock_db_path.return_value = db_dir
mock_backup_dir.return_value = backup_dir
# Clear any existing locks
_user_locks.clear()
# Track execution order
execution_order = []
lock_acquired_event = threading.Event()
proceed_event = threading.Event()
def controlled_create_backup(service, thread_id):
"""Create backup with controlled timing to verify lock behavior."""
lock = _get_user_lock(service.username)
execution_order.append(f"thread_{thread_id}_waiting")
with lock:
execution_order.append(f"thread_{thread_id}_acquired")
if thread_id == 1:
# First thread signals it has acquired the lock
lock_acquired_event.set()
# Wait for second thread to be waiting
proceed_event.wait(timeout=2.0)
time.sleep(0.1) # Hold lock briefly
execution_order.append(f"thread_{thread_id}_releasing")
execution_order.append(f"thread_{thread_id}_done")
# Create service instances (both for same user)
service1 = BackupService(
username="testuser", password="testpass", max_backups=5
)
service2 = BackupService(
username="testuser", password="testpass", max_backups=5
)
# Start first thread
t1 = threading.Thread(
target=controlled_create_backup, args=(service1, 1)
)
t1.start()
# Wait for first thread to acquire lock
lock_acquired_event.wait(timeout=2.0)
# Start second thread (should block on lock)
t2 = threading.Thread(
target=controlled_create_backup, args=(service2, 2)
)
t2.start()
# Give second thread time to start waiting
time.sleep(0.1)
# Signal first thread to proceed
proceed_event.set()
# Wait for both threads
t1.join(timeout=5.0)
t2.join(timeout=5.0)
# Verify serialization: thread 1 should complete before thread 2 acquires
idx_t1_releasing = execution_order.index("thread_1_releasing")
idx_t2_acquired = execution_order.index("thread_2_acquired")
assert idx_t2_acquired > idx_t1_releasing, (
f"Thread 2 acquired lock before thread 1 released. "
f"Order: {execution_order}"
)
def test_schedule_backup_rapid_calls_no_duplicates(self):
"""Rapid submit_backup calls should not create duplicate pending backups."""
scheduler = BackupExecutor.__new__(BackupExecutor)
scheduler._pending_backups = set()
scheduler._pending_lock = threading.Lock()
scheduler._executor = MagicMock()
scheduler._executor.submit.return_value = MagicMock()
scheduler._initialized = True
results = []
errors = []
def rapid_schedule():
"""Rapidly call submit_backup from multiple threads."""
try:
result = scheduler.submit_backup("testuser", "pass")
results.append(result)
except Exception as e:
errors.append(str(e))
# Launch multiple threads simultaneously
threads = [threading.Thread(target=rapid_schedule) for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=5.0)
# No errors should have occurred
assert not errors, f"Unexpected errors: {errors}"
# Exactly one call should have succeeded (first one to acquire lock)
assert results.count(True) == 1, (
f"Expected exactly 1 successful schedule, got {results.count(True)}"
)
assert results.count(False) == 9, (
f"Expected 9 rejected schedules, got {results.count(False)}"
)
# Only one backup should be pending
assert scheduler.get_pending_count() == 1
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_concurrent_cleanup_and_backup(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""Cleanup running during backup creation should not cause issues.
This test verifies that:
1. Cleanup running concurrently with backup creation doesn't crash
2. A newly created backup is not deleted by concurrent cleanup
3. No race condition or data corruption occurs
"""
import os
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_file = db_dir / "ldr_user_test.db"
db_file.write_bytes(b"x" * 1000)
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
mock_db_filename.return_value = "ldr_user_test.db"
mock_db_path.return_value = db_dir
mock_backup_dir.return_value = backup_dir
# Create some old backups that should be cleaned up
base_time = time.time()
for i in range(5):
backup_file = backup_dir / f"ldr_backup_2024010{i}_120000.db"
backup_file.write_bytes(b"old_backup")
mtime = base_time - (5 - i) * 86400 # Older files
os.utime(backup_file, (mtime, mtime))
errors = []
cleanup_results = []
backup_start_event = threading.Event()
cleanup_done_event = threading.Event()
def run_cleanup(service):
"""Run cleanup operation."""
try:
# Wait for backup to start
backup_start_event.wait(timeout=2.0)
# Small delay to ensure backup is in progress
time.sleep(0.05)
result = service._cleanup_old_backups()
cleanup_results.append(result)
except Exception as e:
errors.append(f"Cleanup error: {e}")
finally:
cleanup_done_event.set()
def run_backup_creation(service):
"""Create a new backup file to simulate backup creation."""
try:
backup_start_event.set()
# Simulate backup creation by creating a new backup file
timestamp = "20250120_120000" # A new timestamp
new_backup = backup_dir / f"ldr_backup_{timestamp}.db"
# Simulate slow backup creation
time.sleep(0.1)
new_backup.write_bytes(b"new_backup_data")
# Set modification time to now
os.utime(new_backup, (base_time + 100, base_time + 100))
except Exception as e:
errors.append(f"Backup error: {e}")
service = BackupService(
username="testuser",
password="testpass",
max_backups=3, # Will try to delete 2 old backups
max_age_days=365,
)
# Start both threads
cleanup_thread = threading.Thread(target=run_cleanup, args=(service,))
backup_thread = threading.Thread(
target=run_backup_creation, args=(service,)
)
backup_thread.start()
cleanup_thread.start()
backup_thread.join(timeout=5.0)
cleanup_thread.join(timeout=5.0)
# Verify no errors occurred
assert not errors, f"Unexpected errors: {errors}"
# Verify cleanup completed
assert len(cleanup_results) == 1
# Verify the new backup was not deleted by cleanup
new_backup = backup_dir / "ldr_backup_20250120_120000.db"
assert new_backup.exists(), (
"New backup should not be deleted by concurrent cleanup"
)
# Verify remaining backups are within expected range
# (cleanup might have kept 3 old + 1 new, or some variation)
remaining = list(backup_dir.glob("ldr_backup_*.db"))
assert len(remaining) >= 1, "At least one backup should remain"
assert len(remaining) <= 4, (
"At most 4 backups should remain (3 old + 1 new)"
)
@requires_sqlcipher
def test_backup_with_active_write_transaction(self, tmp_path):
"""Backup should handle concurrent write transactions.
When a backup is attempted while the source database has an active
write transaction, the backup should either:
1. Succeed with a consistent snapshot (SQLCipher's sqlcipher_export
reads a consistent view)
2. Fail gracefully with an error message
This test verifies no database corruption occurs in either case.
"""
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
from local_deep_research.database.sqlcipher_utils import (
get_key_from_password,
apply_sqlcipher_pragmas,
get_sqlcipher_settings,
set_sqlcipher_key,
)
sqlcipher = get_sqlcipher_module()
password = "test_concurrent_write_password"
# Setup directories
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
backup_dir = tmp_path / "backups"
backup_dir.mkdir(mode=0o700)
# Create encrypted source database
source_db = db_dir / "ldr_user_concurrent.db"
conn = sqlcipher.connect(str(source_db))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=True)
cursor.execute(
"CREATE TABLE test_data (id INTEGER PRIMARY KEY, value TEXT)"
)
cursor.execute("INSERT INTO test_data (value) VALUES ('initial')")
conn.commit()
conn.close()
# Track results from threads
write_results = []
backup_results = []
errors = []
write_started = threading.Event()
write_can_continue = threading.Event()
def write_thread():
"""Perform a write transaction with a delay."""
try:
conn = sqlcipher.connect(str(source_db))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=False)
# Start a transaction
cursor.execute("BEGIN IMMEDIATE")
cursor.execute(
"INSERT INTO test_data (value) VALUES ('during_backup')"
)
write_started.set()
# Hold the transaction open
write_can_continue.wait(timeout=5.0)
time.sleep(0.1)
# Commit the transaction
conn.commit()
write_results.append("committed")
conn.close()
except Exception as e:
errors.append(f"Write error: {e}")
def backup_thread():
"""Attempt backup during write transaction."""
try:
# Wait for write transaction to start
write_started.wait(timeout=5.0)
time.sleep(0.05) # Small delay to ensure transaction is active
# Create backup using the same pattern as BackupService
conn = sqlcipher.connect(str(source_db))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=False)
backup_path = backup_dir / "backup_during_write.db"
hex_key = get_key_from_password(
password, db_path=source_db
).hex()
settings = get_sqlcipher_settings()
cursor.execute(
f"ATTACH DATABASE '{backup_path}' AS backup KEY \"x'{hex_key}'\""
)
cursor.execute(
f"PRAGMA backup.cipher_page_size = {settings['page_size']}"
)
cursor.execute(
f"PRAGMA backup.cipher_hmac_algorithm = {settings['hmac_algorithm']}"
)
cursor.execute(
f"PRAGMA backup.kdf_iter = {settings['kdf_iterations']}"
)
cursor.execute("SELECT sqlcipher_export('backup')")
cursor.execute("DETACH DATABASE backup")
conn.close()
backup_results.append(("success", backup_path))
except Exception as e:
backup_results.append(("error", str(e)))
finally:
# Allow write thread to continue
write_can_continue.set()
# Run both threads
t_write = threading.Thread(target=write_thread)
t_backup = threading.Thread(target=backup_thread)
t_write.start()
t_backup.start()
t_write.join(timeout=10.0)
t_backup.join(timeout=10.0)
# Verify no unexpected errors
assert not errors, f"Unexpected errors: {errors}"
# Verify the source database is not corrupted
conn = sqlcipher.connect(str(source_db))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=False)
cursor.execute("PRAGMA quick_check")
check_result = cursor.fetchone()
conn.close()
assert check_result[0] == "ok", (
f"Source database corrupted after concurrent backup: {check_result}"
)
# Verify backup result
assert len(backup_results) == 1, "Backup should have a result"
result_type, result_data = backup_results[0]
if result_type == "success":
# If backup succeeded, verify it's valid
backup_path = result_data
conn = sqlcipher.connect(str(backup_path))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=False)
cursor.execute("PRAGMA quick_check")
backup_check = cursor.fetchone()
conn.close()
assert backup_check[0] == "ok", "Backup should be valid"
class TestSecurityFixes:
"""Tests for security-related fixes."""
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
@patch("shutil.disk_usage")
def test_disk_space_check_oserror_fails_closed(
self,
mock_disk_usage,
mock_db_filename,
mock_backup_dir,
mock_db_path,
tmp_path,
):
"""Should fail backup when disk space check raises OSError."""
# Create a fake database file
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_file = db_dir / "ldr_user_abc123.db"
db_file.write_bytes(b"x" * 1000)
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = db_dir
mock_backup_dir.return_value = tmp_path / "backups"
(tmp_path / "backups").mkdir()
# Simulate OSError during disk space check
mock_disk_usage.side_effect = OSError("Permission denied")
service = BackupService(username="testuser", password="testpass")
result = service.create_backup()
assert result.success is False
assert "Could not verify disk space" in result.error
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
@patch(
"local_deep_research.database.backup.backup_service.create_sqlcipher_connection"
)
@patch("shutil.disk_usage")
def test_backup_path_with_single_quote_is_escaped_not_rejected(
self,
mock_disk_usage,
mock_create_conn,
mock_db_filename,
mock_backup_dir,
mock_db_path,
tmp_path,
):
"""A single quote in the backup path is escaped ('') in the ATTACH
literal, not rejected — apostrophe data dirs are supported (#4808)."""
# Create a fake database file
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_file = db_dir / "ldr_user_abc123.db"
db_file.write_bytes(b"x" * 1000)
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = db_dir
# A backup directory whose path contains a single quote.
apostrophe_dir = tmp_path / "back'ups"
apostrophe_dir.mkdir()
mock_backup_dir.return_value = apostrophe_dir
mock_disk_usage.return_value = MagicMock(free=10000000)
# Mock connection
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value = mock_cursor
mock_create_conn.return_value = mock_conn
service = BackupService(username="testuser", password="testpass")
result = service.create_backup()
# The path-character guard did NOT reject it (the backup may still fail
# later for unrelated mocked reasons, but never with that guard error).
if result.error:
assert "not allowed in a SQLCipher ATTACH" not in result.error
# ATTACH ran with the single quote doubled (escaped). Read the actual
# SQL argument, not str(call) (whose repr re-escapes the quotes).
attach_sql = [
c.args[0]
for c in mock_cursor.execute.call_args_list
if c.args and "ATTACH DATABASE" in c.args[0]
]
assert attach_sql, "ATTACH should run — the apostrophe path is accepted"
assert "back''ups" in attach_sql[0]
def test_user_backup_directory_has_restrictive_permissions(self, tmp_path):
"""Should create user backup directory with mode 0o700."""
import os
import stat
with patch(
"local_deep_research.config.paths.get_data_directory"
) as mock_data_dir:
mock_data_dir.return_value = tmp_path
from local_deep_research.config.paths import (
get_user_backup_directory,
)
user_backup_dir = get_user_backup_directory("testuser")
# Check directory permissions (owner only)
mode = os.stat(user_backup_dir).st_mode
# Extract permission bits
perms = stat.S_IMODE(mode)
# Should be 0o700 (owner read/write/execute only)
assert perms == 0o700
def test_backup_scheduler_registers_atexit_handler(self):
"""Should register atexit handler for clean shutdown."""
import atexit
with patch.object(atexit, "register") as mock_register:
# Create a fresh scheduler instance
scheduler = BackupExecutor.__new__(BackupExecutor)
# Remove _initialized to force __init__ to run
if hasattr(scheduler, "_initialized"):
delattr(scheduler, "_initialized")
# Call __init__ directly
scheduler.__init__()
# Verify atexit.register was called with shutdown method
mock_register.assert_called_once_with(scheduler.shutdown)
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
@patch(
"local_deep_research.database.backup.backup_service.create_sqlcipher_connection"
)
@patch("shutil.disk_usage")
def test_backup_path_with_double_quotes_rejected(
self,
mock_disk_usage,
mock_create_conn,
mock_db_filename,
mock_backup_dir,
mock_db_path,
tmp_path,
):
"""Should reject backup paths containing double quotes."""
# Create a fake database file
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_file = db_dir / "ldr_user_abc123.db"
db_file.write_bytes(b"x" * 1000)
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = db_dir
# Create a backup directory with a double quote in the path
malicious_dir = tmp_path / 'back"ups'
malicious_dir.mkdir()
mock_backup_dir.return_value = malicious_dir
mock_disk_usage.return_value = MagicMock(free=10000000)
# Mock connection
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value = mock_cursor
mock_create_conn.return_value = mock_conn
service = BackupService(username="testuser", password="testpass")
result = service.create_backup()
assert result.success is False
assert "not allowed in a SQLCipher ATTACH" in result.error
class TestBackupEdgeCases:
"""Tests for edge cases in backup operations."""
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_cleanup_preserves_newest_backup(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""Should never delete the most recent backup during cleanup."""
import os
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = backup_dir
# Create 10 backups with different timestamps
base_time = time.time()
for i in range(10):
backup_file = backup_dir / f"ldr_backup_2024010{i}_120000.db"
backup_file.write_bytes(b"backup_data")
# Set modification time (older files have earlier times)
mtime = base_time - (9 - i) * 86400 # i=9 is newest
os.utime(backup_file, (mtime, mtime))
# Service with max_backups=3 should keep only 3 newest
service = BackupService(
username="testuser",
password="testpass",
max_backups=3,
max_age_days=365,
)
deleted_count = service._cleanup_old_backups()
# Should have deleted 7 backups
assert deleted_count == 7
# Should have exactly 3 backups remaining
remaining = list(backup_dir.glob("ldr_backup_*.db"))
assert len(remaining) == 3
# The newest backup (ldr_backup_20240109_120000.db) must be preserved
newest_backup = backup_dir / "ldr_backup_20240109_120000.db"
assert newest_backup.exists(), "Newest backup should never be deleted"
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_cleanup_handles_already_deleted_files(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""Should handle gracefully when backup files are deleted externally."""
import os
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = backup_dir
# Create backups
base_time = time.time()
for i in range(5):
backup_file = backup_dir / f"ldr_backup_2024010{i}_120000.db"
backup_file.write_bytes(b"backup_data")
mtime = base_time - (4 - i) * 86400
os.utime(backup_file, (mtime, mtime))
service = BackupService(
username="testuser",
password="testpass",
max_backups=2,
max_age_days=365,
)
# Delete a file externally before cleanup runs
(backup_dir / "ldr_backup_20240101_120000.db").unlink()
# Cleanup should not raise an exception
deleted_count = service._cleanup_old_backups()
# Should have deleted 2 files (was 4 remaining, keep 2)
assert deleted_count == 2
def test_backup_filename_format_is_valid(self):
"""Should generate backup filename with valid UTC timestamp format."""
import re
from datetime import UTC, datetime
# Generate the backup filename using the same format as BackupService
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
backup_filename = f"ldr_backup_{timestamp}.db"
# Verify filename format matches expected pattern
pattern = r"^ldr_backup_(\d{8})_(\d{6})\.db$"
match = re.match(pattern, backup_filename)
assert match is not None, (
f"Filename {backup_filename} doesn't match expected pattern"
)
# Verify the timestamp components are valid
date_part, time_part = match.groups()
# Date should be 8 digits (YYYYMMDD)
assert len(date_part) == 8
year = int(date_part[:4])
month = int(date_part[4:6])
day = int(date_part[6:8])
assert 2020 <= year <= 2100
assert 1 <= month <= 12
assert 1 <= day <= 31
# Time should be 6 digits (HHMMSS)
assert len(time_part) == 6
hour = int(time_part[:2])
minute = int(time_part[2:4])
second = int(time_part[4:6])
assert 0 <= hour <= 23
assert 0 <= minute <= 59
assert 0 <= second <= 59
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_list_backups_returns_correct_metadata(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""Should return accurate metadata for each backup."""
import os
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = backup_dir
# Create backups with known sizes
backup1 = backup_dir / "ldr_backup_20240101_120000.db"
backup1.write_bytes(b"a" * 1000)
backup2 = backup_dir / "ldr_backup_20240102_120000.db"
backup2.write_bytes(b"b" * 2000)
# Set specific modification times
os.utime(backup1, (1704110400, 1704110400)) # 2024-01-01 12:00:00
os.utime(backup2, (1704196800, 1704196800)) # 2024-01-02 12:00:00
service = BackupService(username="testuser", password="testpass")
backups = service.list_backups()
assert len(backups) == 2
# Backups should be sorted newest first
assert backups[0]["filename"] == "ldr_backup_20240102_120000.db"
assert backups[0]["size_bytes"] == 2000
assert backups[1]["filename"] == "ldr_backup_20240101_120000.db"
assert backups[1]["size_bytes"] == 1000
# Each backup should have required fields
for backup in backups:
assert "filename" in backup
assert "path" in backup
assert "size_bytes" in backup
assert "created_at" in backup
def test_scheduler_handles_rapid_sequential_logins(self):
"""Should handle rapid sequential login attempts for same user."""
scheduler = BackupExecutor.__new__(BackupExecutor)
scheduler._pending_backups = set()
scheduler._pending_lock = threading.Lock()
scheduler._executor = MagicMock()
scheduler._executor.submit.return_value = MagicMock()
scheduler._initialized = True
results = []
# Simulate rapid login attempts
for _ in range(10):
result = scheduler.submit_backup("testuser", "pass")
results.append(result)
# First attempt should succeed, rest should be rejected
assert results[0] is True
assert all(r is False for r in results[1:])
assert scheduler.get_pending_count() == 1
def test_scheduler_allows_backup_after_completion(self):
"""Should allow new backup after previous one completes."""
scheduler = BackupExecutor.__new__(BackupExecutor)
scheduler._pending_backups = set()
scheduler._pending_lock = threading.Lock()
scheduler._executor = MagicMock()
mock_future = MagicMock()
scheduler._executor.submit.return_value = mock_future
scheduler._initialized = True
# First backup
result1 = scheduler.submit_backup("testuser", "pass")
assert result1 is True
# Second backup should fail (still pending)
result2 = scheduler.submit_backup("testuser", "pass")
assert result2 is False
# Simulate completion
scheduler._backup_completed("testuser", mock_future)
# Now new backup should be allowed
result3 = scheduler.submit_backup("testuser", "pass")
assert result3 is True
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_get_latest_backup_with_non_backup_files(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""Should ignore non-backup files when finding latest backup."""
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = backup_dir
# Create a valid backup
valid_backup = backup_dir / "ldr_backup_20240101_120000.db"
valid_backup.write_bytes(b"backup_data")
# Create files that should be ignored (don't match ldr_backup_*.db pattern)
(backup_dir / "random_file.db").write_bytes(b"not a backup")
(backup_dir / "ldr_backup_incomplete").write_bytes(b"no .db extension")
(backup_dir / "other_backup_20240102_120000.db").write_bytes(
b"wrong prefix"
)
service = BackupService(username="testuser", password="testpass")
latest = service.get_latest_backup()
assert latest is not None
assert latest.name == "ldr_backup_20240101_120000.db"
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_cleanup_with_zero_max_backups(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""Should delete all backups when max_backups is 0."""
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = backup_dir
# Create some backups
for i in range(3):
backup_file = backup_dir / f"ldr_backup_2024010{i}_120000.db"
backup_file.write_bytes(b"backup_data")
service = BackupService(
username="testuser",
password="testpass",
max_backups=0,
max_age_days=365,
)
deleted_count = service._cleanup_old_backups()
assert deleted_count == 3
assert len(list(backup_dir.glob("ldr_backup_*.db"))) == 0
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_verify_backup_with_wrong_password(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""Should return False when verifying backup with wrong password."""
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = backup_dir
# Create a fake encrypted backup file
backup_file = backup_dir / "ldr_backup_20240101_120000.db"
backup_file.write_bytes(b"encrypted_content_here")
service = BackupService(username="testuser", password="wrong_password")
result = service._verify_backup(backup_file)
# Should fail because password is wrong
assert result is False
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_get_latest_backup_with_nonexistent_directory(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""get_latest_backup should return None if backup dir doesn't exist."""
# Point to a directory that doesn't exist
nonexistent_dir = tmp_path / "nonexistent_backups"
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = nonexistent_dir
service = BackupService(username="testuser", password="testpass")
# Should return None, not crash
latest = service.get_latest_backup()
assert latest is None
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
@patch(
"local_deep_research.database.backup.backup_service.create_sqlcipher_connection"
)
@patch("shutil.disk_usage")
def test_backup_uses_atomic_rename_pattern(
self,
mock_disk_usage,
mock_create_conn,
mock_db_filename,
mock_backup_dir,
mock_db_path,
tmp_path,
):
"""Backup creates .tmp file first, then renames to final .db."""
# Setup
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_file = db_dir / "ldr_user_abc123.db"
db_file.write_bytes(b"x" * 1000)
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = db_dir
mock_backup_dir.return_value = backup_dir
mock_disk_usage.return_value = MagicMock(free=10000000)
# Track file creation order
created_files = []
mock_conn = MagicMock()
mock_cursor = MagicMock()
def track_file_creation(sql, *args):
if "ATTACH DATABASE" in sql:
import re
match = re.search(r"ATTACH DATABASE '([^']+)'", sql)
if match:
from pathlib import Path
file_path = Path(match.group(1))
file_path.write_bytes(b"backup_data")
created_files.append(file_path.name)
mock_cursor.execute.side_effect = track_file_creation
mock_conn.cursor.return_value = mock_cursor
mock_create_conn.return_value = mock_conn
service = BackupService(username="testuser", password="testpass")
# Patch _verify_backup to return True
with patch.object(service, "_verify_backup", return_value=True):
result = service.create_backup()
# Verify success
assert result.success is True
# Verify a .tmp file was created first
assert len(created_files) == 1
assert created_files[0].endswith(".db.tmp"), (
f"Expected .tmp file during creation, got: {created_files[0]}"
)
# Verify final backup file has .db extension (not .tmp)
assert result.backup_path is not None
assert result.backup_path.suffix == ".db"
assert not result.backup_path.name.endswith(".tmp")
# Verify the final backup file exists (was renamed from .tmp)
assert result.backup_path.exists()
@requires_sqlcipher
def test_backup_of_empty_database(self, tmp_path):
"""Should successfully backup a database with no user data."""
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
from local_deep_research.database.sqlcipher_utils import (
apply_sqlcipher_pragmas,
set_sqlcipher_key,
)
sqlcipher = get_sqlcipher_module()
password = "test_empty_db_password"
# Setup directories
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
backup_dir = tmp_path / "backups"
backup_dir.mkdir(mode=0o700)
# Create encrypted database with only schema, no data
source_db = db_dir / "ldr_user_empty.db"
conn = sqlcipher.connect(str(source_db))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=True)
# Create schema but don't insert any data
cursor.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
cursor.execute(
"CREATE TABLE data (id INTEGER PRIMARY KEY, content TEXT)"
)
conn.commit()
# Verify no data exists
cursor.execute("SELECT COUNT(*) FROM users")
assert cursor.fetchone()[0] == 0
cursor.execute("SELECT COUNT(*) FROM data")
assert cursor.fetchone()[0] == 0
conn.close()
# Create backup using BackupService
with (
patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
) as mock_db_path,
patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
) as mock_backup_dir,
patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
) as mock_db_filename,
):
mock_db_path.return_value = db_dir
mock_backup_dir.return_value = backup_dir
mock_db_filename.return_value = "ldr_user_empty.db"
service = BackupService(
username="testuser", password=password, max_backups=3
)
result = service.create_backup()
# Verify backup succeeded
assert result.success, f"Backup failed: {result.error}"
assert result.backup_path is not None
assert result.backup_path.exists()
assert result.size_bytes > 0 # Should have some size for schema
# Verify backup is valid and can be opened
conn = sqlcipher.connect(str(result.backup_path))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=False)
# Verify schema was backed up
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
)
tables = [row[0] for row in cursor.fetchall()]
assert "users" in tables
assert "data" in tables
# Verify no data (as expected)
cursor.execute("SELECT COUNT(*) FROM users")
assert cursor.fetchone()[0] == 0
conn.close()
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_cleanup_continues_on_single_delete_failure(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""Cleanup should continue if one file deletion fails."""
import os
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = backup_dir
# Create 5 backup files
base_time = time.time()
backup_files = []
for i in range(5):
backup_file = backup_dir / f"ldr_backup_2024010{i}_120000.db"
backup_file.write_bytes(b"backup_data")
mtime = base_time - (4 - i) * 86400
os.utime(backup_file, (mtime, mtime))
backup_files.append(backup_file)
# The oldest file (20240100) should be deleted first
# We'll mock unlink to fail for the second-oldest file
from pathlib import Path as PathClass
original_unlink = PathClass.unlink
unlink_calls = []
def mock_unlink(self, *args, **kwargs):
unlink_calls.append(str(self))
if "20240101" in str(self): # Fail for second-oldest
raise OSError("Permission denied")
return original_unlink(self, *args, **kwargs)
service = BackupService(
username="testuser",
password="testpass",
max_backups=2, # Should try to delete 3 files
max_age_days=365,
)
with patch.object(PathClass, "unlink", mock_unlink):
deleted_count = service._cleanup_old_backups()
# Should have attempted to delete 3 files (5 - 2 = 3 to remove)
# But only 2 should have succeeded due to the error
assert deleted_count == 2
# Verify the failed file still exists
failed_file = backup_dir / "ldr_backup_20240101_120000.db"
assert failed_file.exists(), (
"File that failed to delete should still exist"
)
# Verify the two newest files still exist (kept by max_backups)
assert (backup_dir / "ldr_backup_20240104_120000.db").exists()
assert (backup_dir / "ldr_backup_20240103_120000.db").exists()
# Verify the oldest file was deleted (not the one that failed)
assert not (backup_dir / "ldr_backup_20240100_120000.db").exists()
assert not (backup_dir / "ldr_backup_20240102_120000.db").exists()
@pytest.mark.skipif(
hasattr(os, "geteuid") and os.geteuid() == 0,
reason="Root ignores filesystem permission restrictions",
)
@requires_sqlcipher
def test_backup_directory_readonly_fails_gracefully(self, tmp_path):
"""Should return error if backup directory is read-only."""
import os
import platform
# Skip on Windows - permission handling is different
if platform.system() == "Windows":
pytest.skip("Windows handles permissions differently")
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
from local_deep_research.database.sqlcipher_utils import (
apply_sqlcipher_pragmas,
set_sqlcipher_key,
)
sqlcipher = get_sqlcipher_module()
password = "test_readonly_password"
# Setup directories
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
# Create a real encrypted database
source_db = db_dir / "ldr_user_readonly.db"
conn = sqlcipher.connect(str(source_db))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=True)
cursor.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, data TEXT)")
cursor.execute("INSERT INTO test (data) VALUES ('test')")
conn.commit()
conn.close()
# Make directory read-only (no write permission)
original_mode = backup_dir.stat().st_mode
try:
os.chmod(backup_dir, 0o555) # r-xr-xr-x # noqa: S103
with (
patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
) as mock_db_path,
patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
) as mock_backup_dir,
patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
) as mock_db_filename,
):
mock_db_path.return_value = db_dir
mock_backup_dir.return_value = backup_dir
mock_db_filename.return_value = "ldr_user_readonly.db"
service = BackupService(
username="testuser", password=password, max_backups=5
)
result = service.create_backup()
# Should fail gracefully
assert result.success is False
assert result.error is not None
# Error message varies by platform/circumstance - just verify it failed
finally:
# Restore permissions
os.chmod(backup_dir, original_mode)
@requires_sqlcipher
def test_backup_timestamp_collision_handled(self, tmp_path):
"""Should handle two backups created in same second.
If two backups are created within the same second, filename collision
could occur. This test verifies the backup either succeeds (atomic
overwrite) or fails gracefully without corruption.
"""
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
from local_deep_research.database.sqlcipher_utils import (
apply_sqlcipher_pragmas,
set_sqlcipher_key,
)
sqlcipher = get_sqlcipher_module()
password = "test_collision_password"
# Setup directories
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
backup_dir = tmp_path / "backups"
backup_dir.mkdir(mode=0o700)
# Create a real encrypted database
source_db = db_dir / "ldr_user_collision.db"
conn = sqlcipher.connect(str(source_db))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=True)
cursor.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, data TEXT)")
cursor.execute("INSERT INTO test (data) VALUES ('test_data')")
conn.commit()
conn.close()
# Create two backups rapidly without mocking time
# This tests real-world timestamp collision scenarios
results = []
with (
patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
) as mock_db_path,
patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
) as mock_backup_dir,
patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
) as mock_db_filename,
):
mock_db_path.return_value = db_dir
mock_backup_dir.return_value = backup_dir
mock_db_filename.return_value = "ldr_user_collision.db"
service = BackupService(
username="testuser", password=password, max_backups=5
)
# Create first backup
result1 = service.create_backup()
results.append(result1)
# Immediately try second backup (may have same timestamp)
result2 = service.create_backup()
results.append(result2)
# At least one should succeed
success_count = sum(1 for r in results if r.success)
assert success_count >= 1, (
f"At least one backup should succeed. "
f"Results: {[(r.success, r.error) for r in results]}"
)
# Verify we have valid backup files (not corrupted)
backups = list(backup_dir.glob("ldr_backup_*.db"))
assert len(backups) >= 1, "Should have at least one backup file"
# Each backup file should be a valid encrypted database
for backup_path in backups:
conn = sqlcipher.connect(str(backup_path))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=False)
cursor.execute("PRAGMA quick_check")
check_result = cursor.fetchone()
conn.close()
assert check_result[0] == "ok", (
f"Backup {backup_path.name} is corrupted"
)
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_cleanup_permission_denied_resilience(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""Cleanup should continue if one file has permission denied.
When cleanup encounters a file that cannot be deleted (e.g., due to
permission issues), it should continue deleting other eligible files
rather than failing completely.
"""
import os
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = backup_dir
# Create 4 backup files with different timestamps
base_time = time.time()
for i in range(4):
backup_file = backup_dir / f"ldr_backup_2024010{i}_120000.db"
backup_file.write_bytes(b"backup_data")
mtime = base_time - (3 - i) * 86400
os.utime(backup_file, (mtime, mtime))
# Track which files we tried to delete
delete_attempts = []
from pathlib import Path as PathClass
original_unlink = PathClass.unlink
def mock_unlink(self, *args, **kwargs):
delete_attempts.append(str(self))
# Fail for one specific file (the second oldest)
if "20240101" in str(self):
raise PermissionError("Permission denied")
return original_unlink(self, *args, **kwargs)
service = BackupService(
username="testuser",
password="testpass",
max_backups=1, # Should try to delete 3 files
max_age_days=365,
)
with patch.object(PathClass, "unlink", mock_unlink):
deleted_count = service._cleanup_old_backups()
# Should have deleted 2 files (failed on 1)
assert deleted_count == 2, (
f"Expected 2 deletions (1 failed), got {deleted_count}"
)
# The file that failed to delete should still exist
assert (backup_dir / "ldr_backup_20240101_120000.db").exists()
# The newest file should be preserved (not in delete attempts)
assert (backup_dir / "ldr_backup_20240103_120000.db").exists()
class TestBackupSettingsDisabled:
"""Tests for backup behavior when disabled via settings."""
def test_scheduler_respects_backup_disabled_setting(self):
"""Should not schedule backup when backup.enabled is False."""
# This tests the logic that would be in routes.py
# When backup_enabled is False, submit_backup should not be called
scheduler = BackupExecutor.__new__(BackupExecutor)
scheduler._pending_backups = set()
scheduler._pending_lock = threading.Lock()
scheduler._executor = MagicMock()
scheduler._executor.submit.return_value = MagicMock()
scheduler._initialized = True
# Simulate the logic from routes.py
backup_enabled = False # Setting is disabled
if backup_enabled:
result = scheduler.submit_backup("testuser", "pass")
else:
result = None # Backup not scheduled
# Verify backup was not scheduled
assert result is None
assert scheduler.get_pending_count() == 0
scheduler._executor.submit.assert_not_called()
def test_backup_service_works_independently_of_settings(self, tmp_path):
"""BackupService itself doesn't check settings - caller does."""
# The BackupService doesn't check backup.enabled
# That's handled by the caller (routes.py)
# This test documents that behavior
with (
patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
) as mock_db_path,
patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
) as mock_backup_dir,
patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
) as mock_db_filename,
):
mock_db_filename.return_value = "test.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = tmp_path / "backups"
# Service can be instantiated regardless of settings
service = BackupService(username="test", password="pass")
assert service is not None
assert service.username == "test"
class TestBackupFilePermissions:
"""Tests for backup file permission security."""
def test_backup_directory_protects_files(self, tmp_path):
"""Backup directory permissions (0o700) protect files inside."""
import os
import stat
with patch(
"local_deep_research.config.paths.get_data_directory"
) as mock_data_dir:
mock_data_dir.return_value = tmp_path
from local_deep_research.config.paths import (
get_user_backup_directory,
)
# Create user backup directory
backup_dir = get_user_backup_directory("testuser")
# Directory should have restrictive permissions
dir_mode = os.stat(backup_dir).st_mode
dir_perms = stat.S_IMODE(dir_mode)
assert dir_perms == 0o700, "Directory should be owner-only"
# Even if files inside have more permissive modes,
# the directory permissions prevent other users from accessing them
# This is defense-in-depth: directory + file permissions
class TestSpecialCharacterUsernames:
"""Tests for usernames with special characters."""
def test_username_with_unicode_characters(self, tmp_path):
"""Should handle usernames with unicode characters."""
with patch(
"local_deep_research.config.paths.get_data_directory"
) as mock_data_dir:
mock_data_dir.return_value = tmp_path
from local_deep_research.config.paths import (
get_user_backup_directory,
get_user_database_filename,
)
# Test various unicode usernames
unicode_usernames = [
"用户名", # Chinese
"пользователь", # Russian
"ユーザー", # Japanese
"مستخدم", # Arabic
"user@example.com", # Email format
"user+tag@example.com", # Email with plus
"José García", # Spanish with accents
"Müller", # German umlaut
]
for username in unicode_usernames:
# Should not raise any exceptions
backup_dir = get_user_backup_directory(username)
db_filename = get_user_database_filename(username)
# Directory should be created
assert backup_dir.exists()
# Filename should be safe (hash-based)
assert "/" not in db_filename
assert "\\" not in db_filename
assert ".." not in db_filename
# Hash should be consistent
backup_dir2 = get_user_backup_directory(username)
assert backup_dir == backup_dir2
def test_username_hash_is_unique(self, tmp_path):
"""Different usernames should produce different hashes."""
with patch(
"local_deep_research.config.paths.get_data_directory"
) as mock_data_dir:
mock_data_dir.return_value = tmp_path
from local_deep_research.config.paths import (
get_user_database_filename,
)
usernames = ["user1", "user2", "User1", "USER1", "user1 ", " user1"]
filenames = [get_user_database_filename(u) for u in usernames]
# All filenames should be unique
assert len(filenames) == len(set(filenames))
def test_username_with_path_traversal_attempt(self, tmp_path):
"""Should safely handle usernames that look like path traversal."""
with patch(
"local_deep_research.config.paths.get_data_directory"
) as mock_data_dir:
mock_data_dir.return_value = tmp_path
from local_deep_research.config.paths import (
get_user_backup_directory,
get_user_database_filename,
)
# Malicious-looking usernames
malicious_usernames = [
"../../../etc/passwd",
"..\\..\\..\\windows\\system32",
"/etc/passwd",
"C:\\Windows\\System32",
"user\x00hidden", # Null byte
"user\nname", # Newline
]
for username in malicious_usernames:
backup_dir = get_user_backup_directory(username)
db_filename = get_user_database_filename(username)
# Directory should be safely contained within backup directory
assert (
tmp_path in backup_dir.parents
or backup_dir.parent
== tmp_path / "encrypted_databases" / "backups"
)
# Filename should be a safe hash, not contain dangerous characters
assert "/" not in db_filename
assert "\\" not in db_filename
assert "\x00" not in db_filename
assert "\n" not in db_filename
def test_backup_with_very_long_username(self, tmp_path):
"""Should handle usernames near filesystem path length limits."""
with patch(
"local_deep_research.config.paths.get_data_directory"
) as mock_data_dir:
mock_data_dir.return_value = tmp_path
from local_deep_research.config.paths import (
get_user_backup_directory,
get_user_database_filename,
)
# Create usernames with 200+ characters
# This exceeds typical max filename lengths (255 bytes on most filesystems)
# but should work because hash-based naming is used
long_usernames = [
"a" * 200, # 200 chars
"user" * 75, # 300 chars
"x" * 500, # 500 chars - way beyond typical limits
"very_long_email_address_" * 20 + "@example.com", # Long email
]
for username in long_usernames:
assert len(username) >= 200, "Username should be 200+ chars"
# Should not raise any exceptions
backup_dir = get_user_backup_directory(username)
db_filename = get_user_database_filename(username)
# Directory should be created successfully
assert backup_dir.exists()
# Filename should be manageable length (hash-based)
# SHA256 hex is 64 chars + prefix + extension = ~80 chars
assert len(db_filename) < 100, (
f"Filename should be hash-based and under 100 chars, "
f"got {len(db_filename)} chars: {db_filename}"
)
# Verify path doesn't contain the raw username (privacy/length)
assert username not in str(backup_dir)
assert username not in db_filename
# Verify consistency (same username = same hash)
backup_dir2 = get_user_backup_directory(username)
db_filename2 = get_user_database_filename(username)
assert backup_dir == backup_dir2
assert db_filename == db_filename2
# Header bytes that indicate an UNENCRYPTED SQLite database
SQLITE_PLAINTEXT_HEADER = b"SQLite format 3\x00"
class TestBackupEncryptionVerification:
"""Tests that verify backup files are actually encrypted at the byte level.
CRITICAL: These tests prevent accidental data exposure if backup method
changes or encryption is bypassed. They verify:
1. Backup files do NOT have plaintext SQLite header
2. Backup files cannot be opened without any password
3. BackupService.create_backup() produces properly encrypted files
"""
@pytest.fixture
def real_encrypted_db(self, tmp_path):
"""Create a real encrypted SQLCipher database for testing."""
if not HAS_SQLCIPHER:
pytest.skip("SQLCipher not available")
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
from local_deep_research.database.sqlcipher_utils import (
apply_sqlcipher_pragmas,
set_sqlcipher_key,
)
sqlcipher = get_sqlcipher_module()
db_path = tmp_path / "test_encrypted.db"
password = "test_password_123"
# Create a real encrypted database
conn = sqlcipher.connect(str(db_path))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=True)
# Create a test table with data
cursor.execute(
"CREATE TABLE test_data (id INTEGER PRIMARY KEY, value TEXT)"
)
cursor.execute("INSERT INTO test_data (value) VALUES ('test_value_1')")
cursor.execute("INSERT INTO test_data (value) VALUES ('test_value_2')")
conn.commit()
conn.close()
return {"db_path": db_path, "password": password}
@requires_sqlcipher
def test_backup_file_header_is_not_sqlite_plaintext(
self, real_encrypted_db, tmp_path
):
"""Backup file must NOT have 'SQLite format 3' header (would indicate unencrypted).
SECURITY: If this test fails, backups are being created WITHOUT encryption,
exposing user data to anyone with filesystem access.
"""
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
from local_deep_research.database.sqlcipher_utils import (
get_key_from_password,
apply_sqlcipher_pragmas,
get_sqlcipher_settings,
set_sqlcipher_key,
)
sqlcipher = get_sqlcipher_module()
db_path = real_encrypted_db["db_path"]
password = real_encrypted_db["password"]
backup_path = tmp_path / "backup.db"
# Create backup using ATTACH + sqlcipher_export (mimicking BackupService)
conn = sqlcipher.connect(str(db_path))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=False)
# Use the correct method: ATTACH DATABASE with KEY + sqlcipher_export
hex_key = get_key_from_password(password, db_path=db_path).hex()
settings = get_sqlcipher_settings()
cursor.execute(
f"ATTACH DATABASE '{backup_path}' AS backup KEY \"x'{hex_key}'\""
)
cursor.execute(
f"PRAGMA backup.cipher_page_size = {settings['page_size']}"
)
cursor.execute(
f"PRAGMA backup.cipher_hmac_algorithm = {settings['hmac_algorithm']}"
)
cursor.execute(f"PRAGMA backup.kdf_iter = {settings['kdf_iterations']}")
cursor.execute("SELECT sqlcipher_export('backup')")
cursor.execute("DETACH DATABASE backup")
conn.close()
# CRITICAL: Verify file is encrypted at byte level
with open(backup_path, "rb") as f:
header = f.read(16)
assert header != SQLITE_PLAINTEXT_HEADER, (
"SECURITY VULNERABILITY: Backup file is NOT encrypted! "
"Header shows plaintext SQLite format."
)
# Additional check: first 16 bytes should look random (SQLCipher salt)
# A properly encrypted file won't start with readable ASCII
assert not header.startswith(b"SQLite"), (
"SECURITY VULNERABILITY: Backup file header contains 'SQLite' - "
"indicates unencrypted or improperly encrypted file."
)
@requires_sqlcipher
def test_backup_cannot_be_opened_without_any_password(
self, real_encrypted_db, tmp_path
):
"""Backup must fail to open when no password is provided at all.
SECURITY: If this test fails, backups can be read without authentication,
completely defeating the purpose of encryption.
"""
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
from local_deep_research.database.sqlcipher_utils import (
get_key_from_password,
apply_sqlcipher_pragmas,
get_sqlcipher_settings,
set_sqlcipher_key,
)
sqlcipher = get_sqlcipher_module()
db_path = real_encrypted_db["db_path"]
password = real_encrypted_db["password"]
backup_path = tmp_path / "backup.db"
# Create backup using ATTACH + sqlcipher_export
conn = sqlcipher.connect(str(db_path))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=False)
hex_key = get_key_from_password(password, db_path=db_path).hex()
settings = get_sqlcipher_settings()
cursor.execute(
f"ATTACH DATABASE '{backup_path}' AS backup KEY \"x'{hex_key}'\""
)
cursor.execute(
f"PRAGMA backup.cipher_page_size = {settings['page_size']}"
)
cursor.execute(
f"PRAGMA backup.cipher_hmac_algorithm = {settings['hmac_algorithm']}"
)
cursor.execute(f"PRAGMA backup.kdf_iter = {settings['kdf_iterations']}")
cursor.execute("SELECT sqlcipher_export('backup')")
cursor.execute("DETACH DATABASE backup")
conn.close()
# Try opening WITHOUT setting any password
conn = sqlcipher.connect(str(backup_path))
cursor = conn.cursor()
# Deliberately NOT calling set_sqlcipher_key or PRAGMA key
with pytest.raises(Exception) as exc_info:
# This should fail because the file is encrypted
cursor.execute("SELECT * FROM sqlite_master")
cursor.fetchall()
conn.close()
# Verify the error indicates encryption/not a database issue
error_msg = str(exc_info.value).lower()
assert "encrypt" in error_msg or "not a database" in error_msg, (
f"Expected encryption-related error, got: {exc_info.value}"
)
@requires_sqlcipher
def test_backup_service_creates_encrypted_backup(self, tmp_path):
"""End-to-end test: BackupService.create_backup() produces encrypted file.
This tests the actual backup service implementation, not just the
underlying SQLCipher operations. It verifies:
1. Backup file is created
2. File header is NOT plaintext SQLite
3. Backup is readable with correct password
4. Backup fails with wrong password
"""
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
from local_deep_research.database.sqlcipher_utils import (
apply_sqlcipher_pragmas,
set_sqlcipher_key,
)
sqlcipher = get_sqlcipher_module()
password = "test_secure_password_456"
# Setup: Create encrypted source database
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
backup_dir = tmp_path / "backups"
backup_dir.mkdir(mode=0o700)
# We need to create the source database with same settings as BackupService
source_db = db_dir / "ldr_user_testhash.db"
conn = sqlcipher.connect(str(source_db))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=True)
cursor.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
cursor.execute("INSERT INTO users (name) VALUES ('Alice')")
cursor.execute("INSERT INTO users (name) VALUES ('Bob')")
conn.commit()
conn.close()
# Create backup using BackupService
with (
patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
) as mock_db_path,
patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
) as mock_backup_dir,
patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
) as mock_db_filename,
):
mock_db_path.return_value = db_dir
mock_backup_dir.return_value = backup_dir
mock_db_filename.return_value = "ldr_user_testhash.db"
service = BackupService(
username="testuser", password=password, max_backups=3
)
result = service.create_backup()
# Verify backup succeeded
assert result.success, f"Backup failed: {result.error}"
assert result.backup_path is not None
assert result.backup_path.exists()
# CRITICAL: Verify backup file is encrypted at byte level
with open(result.backup_path, "rb") as f:
header = f.read(16)
assert header != SQLITE_PLAINTEXT_HEADER, (
"SECURITY VULNERABILITY: BackupService created unencrypted backup! "
"User data is exposed."
)
# Verify backup is readable with correct password
conn = sqlcipher.connect(str(result.backup_path))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=False)
cursor.execute("SELECT name FROM users ORDER BY id")
rows = cursor.fetchall()
conn.close()
assert len(rows) == 2
assert rows[0][0] == "Alice"
assert rows[1][0] == "Bob"
# Verify backup fails with wrong password
conn = sqlcipher.connect(str(result.backup_path))
cursor = conn.cursor()
set_sqlcipher_key(cursor, "wrong_password")
apply_sqlcipher_pragmas(cursor, creation_mode=False)
with pytest.raises(Exception):
cursor.execute("SELECT * FROM users")
cursor.fetchall()
conn.close()
@requires_sqlcipher
def test_backup_rejected_by_standard_sqlite(self, tmp_path):
"""Standard SQLite (not SQLCipher) should fail to open encrypted backup.
This ensures backups are protected even if someone accidentally uses
the wrong SQLite library to access them.
"""
import sqlite3 # Standard library, not SQLCipher
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
from local_deep_research.database.sqlcipher_utils import (
get_key_from_password,
apply_sqlcipher_pragmas,
get_sqlcipher_settings,
set_sqlcipher_key,
)
sqlcipher = get_sqlcipher_module()
password = "test_password_789"
# Create encrypted source database
source_db = tmp_path / "source.db"
conn = sqlcipher.connect(str(source_db))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=True)
cursor.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, data TEXT)")
cursor.execute("INSERT INTO test (data) VALUES ('secret_data')")
conn.commit()
# Create encrypted backup
backup_path = tmp_path / "backup.db"
hex_key = get_key_from_password(password, db_path=source_db).hex()
settings = get_sqlcipher_settings()
cursor.execute(
f"ATTACH DATABASE '{backup_path}' AS backup KEY \"x'{hex_key}'\""
)
cursor.execute(
f"PRAGMA backup.cipher_page_size = {settings['page_size']}"
)
cursor.execute(
f"PRAGMA backup.cipher_hmac_algorithm = {settings['hmac_algorithm']}"
)
cursor.execute(f"PRAGMA backup.kdf_iter = {settings['kdf_iterations']}")
cursor.execute("SELECT sqlcipher_export('backup')")
cursor.execute("DETACH DATABASE backup")
conn.close()
# Try to open with standard sqlite3 (not SQLCipher)
with pytest.raises(sqlite3.DatabaseError) as exc_info:
std_conn = sqlite3.connect(str(backup_path))
std_cursor = std_conn.cursor()
std_cursor.execute("SELECT * FROM sqlite_master")
std_cursor.fetchall()
# Standard SQLite returns "file is not a database" for encrypted files
assert "not a database" in str(exc_info.value).lower(), (
f"Expected 'not a database' error, got: {exc_info.value}"
)
@requires_sqlcipher
def test_backup_file_has_high_entropy(self, tmp_path):
"""Encrypted backup should have high Shannon entropy (~8 bits/byte).
Encrypted data should appear random. Low entropy would indicate
encryption failure or data leakage.
"""
import math
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
from local_deep_research.database.sqlcipher_utils import (
get_key_from_password,
apply_sqlcipher_pragmas,
get_sqlcipher_settings,
set_sqlcipher_key,
)
sqlcipher = get_sqlcipher_module()
password = "test_entropy_password"
# Create encrypted source database with substantial data
source_db = tmp_path / "source.db"
conn = sqlcipher.connect(str(source_db))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=True)
# Create tables and insert enough data to get meaningful entropy
cursor.execute(
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)"
)
cursor.execute(
"CREATE TABLE data (id INTEGER PRIMARY KEY, content TEXT)"
)
for i in range(100):
cursor.execute(
"INSERT INTO users (name, email) VALUES (?, ?)",
(f"User {i}", f"user{i}@example.com"),
)
cursor.execute(
"INSERT INTO data (content) VALUES (?)",
(f"Some sensitive data entry number {i}" * 10,),
)
conn.commit()
# Create encrypted backup
backup_path = tmp_path / "backup.db"
hex_key = get_key_from_password(password, db_path=source_db).hex()
settings = get_sqlcipher_settings()
cursor.execute(
f"ATTACH DATABASE '{backup_path}' AS backup KEY \"x'{hex_key}'\""
)
cursor.execute(
f"PRAGMA backup.cipher_page_size = {settings['page_size']}"
)
cursor.execute(
f"PRAGMA backup.cipher_hmac_algorithm = {settings['hmac_algorithm']}"
)
cursor.execute(f"PRAGMA backup.kdf_iter = {settings['kdf_iterations']}")
cursor.execute("SELECT sqlcipher_export('backup')")
cursor.execute("DETACH DATABASE backup")
conn.close()
# Read backup and calculate Shannon entropy
with open(backup_path, "rb") as f:
data = f.read()
if len(data) == 0:
pytest.fail("Backup file is empty")
# Calculate byte frequency
freq = {}
for byte in data:
freq[byte] = freq.get(byte, 0) + 1
# Calculate Shannon entropy
entropy = -sum(
(count / len(data)) * math.log2(count / len(data))
for count in freq.values()
)
# Encrypted data should have entropy close to 8 bits/byte (maximum)
# 7.5 is a conservative threshold that allows for headers/metadata
assert entropy >= 7.5, (
f"SECURITY WARNING: Low entropy ({entropy:.2f} bits/byte) "
f"suggests weak or missing encryption. Expected >= 7.5"
)
@requires_sqlcipher
def test_backup_contains_no_readable_sql_strings(self, tmp_path):
"""Encrypted backup should not contain readable SQL keywords or table names.
This is a quick sanity check that no plaintext SQL leaked into the backup.
"""
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
from local_deep_research.database.sqlcipher_utils import (
get_key_from_password,
apply_sqlcipher_pragmas,
get_sqlcipher_settings,
set_sqlcipher_key,
)
sqlcipher = get_sqlcipher_module()
password = "test_strings_password"
# Create encrypted source database with known table names
source_db = tmp_path / "source.db"
conn = sqlcipher.connect(str(source_db))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=True)
# Create tables with distinctive names that should NOT appear in encrypted file
cursor.execute(
"CREATE TABLE secret_users (id INTEGER PRIMARY KEY, username TEXT)"
)
cursor.execute(
"CREATE TABLE sensitive_data (id INTEGER PRIMARY KEY, content TEXT)"
)
cursor.execute(
"INSERT INTO secret_users (username) VALUES ('admin_user')"
)
cursor.execute(
"INSERT INTO sensitive_data (content) VALUES ('confidential_info')"
)
conn.commit()
# Create encrypted backup
backup_path = tmp_path / "backup.db"
hex_key = get_key_from_password(password, db_path=source_db).hex()
settings = get_sqlcipher_settings()
cursor.execute(
f"ATTACH DATABASE '{backup_path}' AS backup KEY \"x'{hex_key}'\""
)
cursor.execute(
f"PRAGMA backup.cipher_page_size = {settings['page_size']}"
)
cursor.execute(
f"PRAGMA backup.cipher_hmac_algorithm = {settings['hmac_algorithm']}"
)
cursor.execute(f"PRAGMA backup.kdf_iter = {settings['kdf_iterations']}")
cursor.execute("SELECT sqlcipher_export('backup')")
cursor.execute("DETACH DATABASE backup")
conn.close()
# Read backup file content
with open(backup_path, "rb") as f:
content = f.read()
# Common SQL strings that should NOT appear in encrypted file
forbidden_strings = [
b"CREATE TABLE",
b"INSERT INTO",
b"SELECT ",
b"sqlite_master",
b"INTEGER PRIMARY KEY",
# Also check for table/column names we created
b"secret_users",
b"sensitive_data",
b"admin_user",
b"confidential_info",
]
for forbidden in forbidden_strings:
assert forbidden not in content, (
f"SECURITY VULNERABILITY: Found readable string "
f"'{forbidden.decode()}' in encrypted backup file!"
)
@requires_sqlcipher
def test_backup_rejects_symlink_paths(self, tmp_path):
"""Symlinks in backup path should be rejected or safely resolved.
SECURITY: Symlinks could allow attackers to escape the backup directory
and write backups to arbitrary locations, potentially overwriting
important files or exposing data in insecure locations.
"""
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
from local_deep_research.database.sqlcipher_utils import (
apply_sqlcipher_pragmas,
set_sqlcipher_key,
)
sqlcipher = get_sqlcipher_module()
password = "test_symlink_password"
# Setup directories
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
backup_dir = tmp_path / "backups"
backup_dir.mkdir(mode=0o700)
# Create a directory outside the backup directory
outside_dir = tmp_path / "outside_backup_area"
outside_dir.mkdir(mode=0o700)
# Create a symlink inside backup_dir that points outside
symlink_path = backup_dir / "escape_link"
symlink_path.symlink_to(outside_dir)
# Create source database
source_db = db_dir / "ldr_user_symlink.db"
conn = sqlcipher.connect(str(source_db))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=True)
cursor.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, data TEXT)")
cursor.execute("INSERT INTO test (data) VALUES ('test')")
conn.commit()
conn.close()
# The backup service should either:
# 1. Resolve symlinks and verify final path is within allowed directory
# 2. Reject paths containing symlinks entirely
# 3. Use os.path.realpath() to get the canonical path
# Verify that the symlink exists and points outside
assert symlink_path.is_symlink()
assert symlink_path.resolve() == outside_dir
# The backup directory path should resolve to itself (no symlink escape)
# If backup_dir contains symlinks, realpath would reveal the true location
backup_dir_real = backup_dir.resolve()
assert backup_dir_real == backup_dir, (
"Backup directory itself should not be a symlink"
)
# Verify symlink detection works - the service should be aware of symlinks
# when validating paths. Check that Path.resolve() differs from the path.
escaped_backup_path = symlink_path / "backup.db"
escaped_real_path = escaped_backup_path.resolve()
# The resolved path should be OUTSIDE the backup directory
assert not str(escaped_real_path).startswith(str(backup_dir_real)), (
f"Symlink escape detected: {escaped_backup_path} resolves to "
f"{escaped_real_path} which is outside {backup_dir_real}"
)
# The backup service's path validation should catch this.
# Since BackupService generates its own backup paths (not user-controlled),
# the main risk is if backup_dir itself is a symlink.
# Verify that if someone replaced backup_dir with a symlink, we'd detect it.
malicious_backup_dir = tmp_path / "malicious_backups"
malicious_backup_dir.symlink_to(outside_dir)
# Check that the malicious directory resolves elsewhere
assert malicious_backup_dir.resolve() != malicious_backup_dir
assert malicious_backup_dir.resolve() == outside_dir
@requires_sqlcipher
def test_backup_file_has_restrictive_permissions(self, tmp_path):
"""Backup files should have 0o600 or stricter (owner read/write only).
SECURITY: Backup files contain sensitive user data. If permissions are
too permissive, other users on the system could read the encrypted
backup and attempt offline attacks.
"""
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
from local_deep_research.database.sqlcipher_utils import (
apply_sqlcipher_pragmas,
set_sqlcipher_key,
)
sqlcipher = get_sqlcipher_module()
password = "test_permissions_password"
# Setup directories
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
backup_dir = tmp_path / "backups"
backup_dir.mkdir(mode=0o700)
# Create source database
source_db = db_dir / "ldr_user_permtest.db"
conn = sqlcipher.connect(str(source_db))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=True)
cursor.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, data TEXT)")
cursor.execute("INSERT INTO test (data) VALUES ('sensitive')")
conn.commit()
conn.close()
# Create backup using BackupService
with (
patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
) as mock_db_path,
patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
) as mock_backup_dir,
patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
) as mock_db_filename,
):
mock_db_path.return_value = db_dir
mock_backup_dir.return_value = backup_dir
mock_db_filename.return_value = "ldr_user_permtest.db"
service = BackupService(
username="testuser", password=password, max_backups=3
)
result = service.create_backup()
assert result.success, f"Backup failed: {result.error}"
assert result.backup_path is not None
assert result.backup_path.exists()
# Get file permissions (only the permission bits, not file type)
file_mode = result.backup_path.stat().st_mode & 0o777
# Verify no group or other permissions (bits 0o077 should be 0)
group_other_perms = file_mode & 0o077
assert group_other_perms == 0, (
f"SECURITY: Backup file has unsafe permissions {oct(file_mode)}. "
f"Expected no group/other access (0o600 or stricter), "
f"but found group/other bits: {oct(group_other_perms)}"
)
# Verify owner has at least read access (sanity check)
owner_perms = (file_mode >> 6) & 0o7
assert owner_perms & 0o4, (
f"Backup file should be readable by owner, mode: {oct(file_mode)}"
)
def test_backup_errors_do_not_leak_sensitive_paths(self, tmp_path):
"""Backup error messages should not expose full filesystem paths.
SECURITY: Error messages that contain full paths like
'/home/username/...' or '/var/lib/...' can leak information about
the system's directory structure and username.
"""
# Setup directories with realistic paths
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
backup_dir = tmp_path / "backups"
backup_dir.mkdir(mode=0o700)
# Create BackupService pointing to non-existent database
with (
patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
) as mock_db_path,
patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
) as mock_backup_dir,
patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
) as mock_db_filename,
):
mock_db_path.return_value = db_dir
mock_backup_dir.return_value = backup_dir
mock_db_filename.return_value = "nonexistent_user_db.db"
service = BackupService(
username="testuser", password="testpass", max_backups=3
)
result = service.create_backup()
# Backup should fail because database doesn't exist
assert result.success is False
assert result.error is not None
# The current implementation does expose the full path in the error message.
# This test documents the current behavior. If we decide to sanitize
# error messages in the future, we should update this test.
#
# For now, we verify the error message format is as expected
# (contains "Database not found" which is informative but generic).
assert "Database not found" in result.error
# Note: A stricter implementation might sanitize paths like:
# - Replace absolute paths with relative paths or placeholders
# - Only show the filename, not the full directory structure
# - Use generic messages like "Database file not found"
#
# For logging purposes, detailed paths are often acceptable since
# logs should be protected. For user-facing errors, sanitization
# is more important.
@requires_sqlcipher
def test_real_backup_verify_rejects_wrong_password(self, tmp_path):
"""Encrypted backup must reject wrong password using real SQLCipher.
SECURITY: This test verifies that backup encryption actually works
at the SQLCipher level by attempting to open a backup with an
incorrect password. Unlike mocked tests, this uses real SQLCipher
operations to ensure the encryption is functioning correctly.
"""
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
from local_deep_research.database.sqlcipher_utils import (
get_key_from_password,
apply_sqlcipher_pragmas,
get_sqlcipher_settings,
set_sqlcipher_key,
)
sqlcipher = get_sqlcipher_module()
correct_password = "correct_password_12345"
wrong_password = "definitely_wrong_password"
# Create encrypted source database
source_db = tmp_path / "source.db"
conn = sqlcipher.connect(str(source_db))
cursor = conn.cursor()
set_sqlcipher_key(cursor, correct_password)
apply_sqlcipher_pragmas(cursor, creation_mode=True)
cursor.execute(
"CREATE TABLE secrets (id INTEGER PRIMARY KEY, data TEXT)"
)
cursor.execute("INSERT INTO secrets (data) VALUES ('top_secret_value')")
conn.commit()
# Create encrypted backup with correct password
backup_path = tmp_path / "backup.db"
hex_key = get_key_from_password(
correct_password, db_path=source_db
).hex()
settings = get_sqlcipher_settings()
cursor.execute(
f"ATTACH DATABASE '{backup_path}' AS backup KEY \"x'{hex_key}'\""
)
cursor.execute(
f"PRAGMA backup.cipher_page_size = {settings['page_size']}"
)
cursor.execute(
f"PRAGMA backup.cipher_hmac_algorithm = {settings['hmac_algorithm']}"
)
cursor.execute(f"PRAGMA backup.kdf_iter = {settings['kdf_iterations']}")
cursor.execute("SELECT sqlcipher_export('backup')")
cursor.execute("DETACH DATABASE backup")
conn.close()
# Verify backup exists
assert backup_path.exists()
assert backup_path.stat().st_size > 0
# Try to open backup with WRONG password - should fail
wrong_hex_key = get_key_from_password(
wrong_password, db_path=source_db
).hex()
conn = sqlcipher.connect(str(backup_path))
cursor = conn.cursor()
cursor.execute(f"PRAGMA key = \"x'{wrong_hex_key}'\"")
# Apply same cipher settings
cursor.execute(f"PRAGMA cipher_page_size = {settings['page_size']}")
cursor.execute(
f"PRAGMA cipher_hmac_algorithm = {settings['hmac_algorithm']}"
)
cursor.execute(f"PRAGMA kdf_iter = {settings['kdf_iterations']}")
# This MUST fail - if it succeeds, encryption is broken
with pytest.raises(sqlcipher.DatabaseError) as exc_info:
cursor.execute("SELECT * FROM secrets")
cursor.fetchall()
conn.close()
# Verify the error indicates the file couldn't be decrypted
error_msg = str(exc_info.value).lower()
assert "not a database" in error_msg or "encrypt" in error_msg, (
f"Expected 'not a database' or encryption error, got: {exc_info.value}"
)
# Now verify correct password DOES work (control test)
conn = sqlcipher.connect(str(backup_path))
cursor = conn.cursor()
set_sqlcipher_key(cursor, correct_password)
apply_sqlcipher_pragmas(cursor, creation_mode=False)
cursor.execute("SELECT data FROM secrets")
rows = cursor.fetchall()
conn.close()
assert len(rows) == 1
assert rows[0][0] == "top_secret_value"
@requires_sqlcipher
def test_backup_hmac_detects_tampering(self, tmp_path):
"""Modifying backup bytes should cause verification failure.
SECURITY: SQLCipher uses HMAC to verify page integrity. Tampering with
encrypted data should be detected when attempting to read the database.
"""
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
from local_deep_research.database.sqlcipher_utils import (
get_key_from_password,
apply_sqlcipher_pragmas,
get_sqlcipher_settings,
set_sqlcipher_key,
)
sqlcipher = get_sqlcipher_module()
password = "test_hmac_password"
# Create encrypted source database
source_db = tmp_path / "source.db"
conn = sqlcipher.connect(str(source_db))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=True)
cursor.execute(
"CREATE TABLE secret_data (id INTEGER PRIMARY KEY, value TEXT)"
)
# Insert enough data to have multiple pages
for i in range(100):
cursor.execute(
"INSERT INTO secret_data (value) VALUES (?)",
(f"secret_value_{i}" * 10,),
)
conn.commit()
# Create encrypted backup
backup_path = tmp_path / "backup.db"
hex_key = get_key_from_password(password, db_path=source_db).hex()
settings = get_sqlcipher_settings()
cursor.execute(
f"ATTACH DATABASE '{backup_path}' AS backup KEY \"x'{hex_key}'\""
)
cursor.execute(
f"PRAGMA backup.cipher_page_size = {settings['page_size']}"
)
cursor.execute(
f"PRAGMA backup.cipher_hmac_algorithm = {settings['hmac_algorithm']}"
)
cursor.execute(f"PRAGMA backup.kdf_iter = {settings['kdf_iterations']}")
cursor.execute("SELECT sqlcipher_export('backup')")
cursor.execute("DETACH DATABASE backup")
conn.close()
# Verify backup is valid before tampering
conn = sqlcipher.connect(str(backup_path))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=False)
cursor.execute("SELECT COUNT(*) FROM secret_data")
count = cursor.fetchone()[0]
conn.close()
assert count == 100, "Backup should be valid before tampering"
# Tamper with the backup file
# Modify random bytes in the middle of the file (not the header/salt)
with open(backup_path, "r+b") as f:
f.seek(2048) # Skip past first page (salt + header area)
original_bytes = f.read(16)
f.seek(2048)
# XOR with 0xFF to flip all bits
tampered_bytes = bytes(b ^ 0xFF for b in original_bytes)
f.write(tampered_bytes)
# Try to open tampered backup - should fail HMAC verification
conn = sqlcipher.connect(str(backup_path))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=False)
# Attempt to read data - should fail due to HMAC verification
try:
cursor.execute("SELECT * FROM secret_data")
cursor.fetchall()
# If we get here, HMAC didn't catch the tampering
conn.close()
pytest.fail(
"SECURITY VULNERABILITY: HMAC did not detect tampering! "
"Encrypted backup integrity is compromised."
)
except sqlcipher.DatabaseError as e:
# This is expected - HMAC should detect the tampering
conn.close()
error_msg = str(e).lower()
# SQLCipher typically returns "not a database" for integrity failures
assert "not a database" in error_msg or "corrupt" in error_msg, (
f"Expected integrity error, got: {e}"
)
@requires_sqlcipher
def test_backup_kdf_iterations_match_source(self, tmp_path):
"""Backup should preserve KDF iteration count from source settings.
SECURITY: KDF (Key Derivation Function) iterations determine the cost
of password-to-key derivation. Too few iterations make brute-force
attacks easier. This test verifies backups use the expected iterations.
"""
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
from local_deep_research.database.sqlcipher_utils import (
get_key_from_password,
apply_sqlcipher_pragmas,
get_sqlcipher_settings,
set_sqlcipher_key,
)
sqlcipher = get_sqlcipher_module()
password = "test_kdf_password"
expected_settings = get_sqlcipher_settings()
expected_kdf_iter = expected_settings["kdf_iterations"]
# Create encrypted source database
source_db = tmp_path / "source.db"
conn = sqlcipher.connect(str(source_db))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=True)
cursor.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, data TEXT)")
cursor.execute("INSERT INTO test (data) VALUES ('test')")
conn.commit()
# Verify source KDF iterations
cursor.execute("PRAGMA kdf_iter")
source_kdf_iter = int(cursor.fetchone()[0])
assert source_kdf_iter == expected_kdf_iter, (
f"Source DB should have {expected_kdf_iter} KDF iterations, "
f"got {source_kdf_iter}"
)
# Create encrypted backup
backup_path = tmp_path / "backup.db"
hex_key = get_key_from_password(password, db_path=source_db).hex()
cursor.execute(
f"ATTACH DATABASE '{backup_path}' AS backup KEY \"x'{hex_key}'\""
)
cursor.execute(
f"PRAGMA backup.cipher_page_size = {expected_settings['page_size']}"
)
cursor.execute(
f"PRAGMA backup.cipher_hmac_algorithm = {expected_settings['hmac_algorithm']}"
)
cursor.execute(f"PRAGMA backup.kdf_iter = {expected_kdf_iter}")
cursor.execute("SELECT sqlcipher_export('backup')")
cursor.execute("DETACH DATABASE backup")
conn.close()
# Open backup and verify KDF iterations match
conn = sqlcipher.connect(str(backup_path))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=False)
# Read the KDF iterations from backup
cursor.execute("PRAGMA kdf_iter")
backup_kdf_iter = int(cursor.fetchone()[0])
conn.close()
# KDF iterations should match expected settings
assert backup_kdf_iter == expected_kdf_iter, (
f"SECURITY WARNING: Backup KDF iterations ({backup_kdf_iter}) "
f"do not match expected ({expected_kdf_iter}). "
"This could weaken backup security."
)
@requires_sqlcipher
def test_cipher_integrity_check_validation(self, tmp_path):
"""Backup should pass cipher_integrity_check for HMAC validation.
SECURITY: SQLCipher's cipher_integrity_check validates the HMAC envelope
of each page independently of database logic. This is more thorough than
quick_check and detects HMAC failures per page.
Reference: https://www.zetetic.net/sqlcipher/sqlcipher-api/
"""
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
from local_deep_research.database.sqlcipher_utils import (
get_key_from_password,
apply_sqlcipher_pragmas,
get_sqlcipher_settings,
set_sqlcipher_key,
)
sqlcipher = get_sqlcipher_module()
password = "test_integrity_check_password"
# Create encrypted source database with test data
source_db = tmp_path / "source.db"
conn = sqlcipher.connect(str(source_db))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=True)
# Create tables and insert data to generate multiple pages
cursor.execute(
"CREATE TABLE test_data (id INTEGER PRIMARY KEY, content TEXT)"
)
for i in range(100):
cursor.execute(
"INSERT INTO test_data (content) VALUES (?)",
(f"Test data entry {i} with some content to fill pages" * 5,),
)
conn.commit()
# Create encrypted backup
backup_path = tmp_path / "backup.db"
hex_key = get_key_from_password(password, db_path=source_db).hex()
settings = get_sqlcipher_settings()
cursor.execute(
f"ATTACH DATABASE '{backup_path}' AS backup KEY \"x'{hex_key}'\""
)
cursor.execute(
f"PRAGMA backup.cipher_page_size = {settings['page_size']}"
)
cursor.execute(
f"PRAGMA backup.cipher_hmac_algorithm = {settings['hmac_algorithm']}"
)
cursor.execute(f"PRAGMA backup.kdf_iter = {settings['kdf_iterations']}")
cursor.execute("SELECT sqlcipher_export('backup')")
cursor.execute("DETACH DATABASE backup")
conn.close()
# Open backup and run cipher_integrity_check
conn = sqlcipher.connect(str(backup_path))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=False)
# Run cipher_integrity_check - validates HMAC for all pages
# Behavior varies by SQLCipher version:
# - Some versions return 'ok' when valid
# - Some versions return empty result set when valid
# - All versions return error messages when HMAC validation fails
cursor.execute("PRAGMA cipher_integrity_check")
results = cursor.fetchall()
conn.close()
# If we got results, verify they indicate success
if results:
# Check first result
first_result = results[0][0] if results[0] else None
# 'ok' indicates success, anything else is an error
if first_result and first_result != "ok":
pytest.fail(
f"SECURITY WARNING: Backup failed cipher_integrity_check. "
f"HMAC validation errors: {results}"
)
# If we got empty results or 'ok', the database is valid
# Additionally verify the database can be read (confirms encryption works)
conn = sqlcipher.connect(str(backup_path))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=False)
cursor.execute("SELECT COUNT(*) FROM test_data")
count = cursor.fetchone()[0]
conn.close()
assert count == 100, (
f"Expected 100 rows in backup, got {count}. "
"Database may be corrupted."
)
class TestCrashRecoveryEndToEnd:
"""End-to-end crash recovery tests using real SQLCipher databases.
These tests verify the primary purpose of the backup system: that a
backup can actually be used to recover a working database after data loss.
Requires SQLCipher to be installed (skipped otherwise).
Suitable for CI release gate.
"""
@pytest.fixture
def encrypted_db_with_data(self, tmp_path):
"""Create a real encrypted SQLCipher database with known test data."""
if not HAS_SQLCIPHER:
pytest.skip("SQLCipher not available")
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
from local_deep_research.database.sqlcipher_utils import (
apply_sqlcipher_pragmas,
set_sqlcipher_key,
)
sqlcipher = get_sqlcipher_module()
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_path = db_dir / "ldr_user_testuser.db"
password = "crash_recovery_test_pw"
conn = sqlcipher.connect(str(db_path))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=True)
# Create tables mimicking real LDR schema
cursor.execute(
"CREATE TABLE research_history ("
" id INTEGER PRIMARY KEY,"
" title TEXT NOT NULL,"
" query TEXT,"
" created_at TEXT"
")"
)
cursor.execute(
"CREATE TABLE settings ( key TEXT PRIMARY KEY, value TEXT)"
)
# Insert known data
for i in range(10):
cursor.execute(
"INSERT INTO research_history (title, query, created_at) "
"VALUES (?, ?, ?)",
(f"Research {i}", f"query {i}", "2025-01-15T10:00:00"),
)
cursor.execute(
"INSERT INTO settings (key, value) VALUES (?, ?)",
("llm.model", "gpt-4"),
)
conn.commit()
conn.close()
return {
"db_path": db_path,
"db_dir": db_dir,
"password": password,
"tmp_path": tmp_path,
"sqlcipher": sqlcipher,
}
def test_backup_and_recover_after_deletion(self, encrypted_db_with_data):
"""Full round-trip: create backup, delete original, open backup, verify data."""
from local_deep_research.database.sqlcipher_utils import (
apply_sqlcipher_pragmas,
set_sqlcipher_key,
)
info = encrypted_db_with_data
sqlcipher = info["sqlcipher"]
# Create backup
backup_dir = info["tmp_path"] / "backups"
backup_dir.mkdir()
with (
patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path",
return_value=info["db_dir"],
),
patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename",
return_value=info["db_path"].name,
),
patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory",
return_value=backup_dir,
),
):
service = BackupService(
username="testuser",
password=info["password"],
max_backups=3,
max_age_days=7,
)
result = service.create_backup()
assert result.success, f"Backup failed: {result.error}"
assert result.backup_path is not None
assert result.backup_path.exists()
# Simulate crash: delete the original database
info["db_path"].unlink()
assert not info["db_path"].exists()
# Open the backup directly and verify all data is intact
conn = sqlcipher.connect(str(result.backup_path))
cursor = conn.cursor()
set_sqlcipher_key(cursor, info["password"])
apply_sqlcipher_pragmas(cursor, creation_mode=False)
# Verify research data
cursor.execute("SELECT COUNT(*) FROM research_history")
count = cursor.fetchone()[0]
assert count == 10, f"Expected 10 research rows, got {count}"
cursor.execute("SELECT title FROM research_history WHERE id = 1")
title = cursor.fetchone()[0]
assert title == "Research 0"
# Verify settings data
cursor.execute("SELECT value FROM settings WHERE key = 'llm.model'")
model = cursor.fetchone()[0]
assert model == "gpt-4"
# Verify database integrity
cursor.execute("PRAGMA integrity_check")
integrity = cursor.fetchone()[0]
assert integrity == "ok"
conn.close()
def test_backup_not_readable_with_wrong_password(
self, encrypted_db_with_data
):
"""Backup file cannot be decrypted with the wrong password."""
from local_deep_research.database.sqlcipher_utils import (
set_sqlcipher_key,
)
info = encrypted_db_with_data
sqlcipher = info["sqlcipher"]
backup_dir = info["tmp_path"] / "backups"
backup_dir.mkdir()
with (
patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path",
return_value=info["db_dir"],
),
patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename",
return_value=info["db_path"].name,
),
patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory",
return_value=backup_dir,
),
):
service = BackupService(
username="testuser",
password=info["password"],
max_backups=3,
max_age_days=7,
)
result = service.create_backup()
assert result.success
# Try to open with wrong password — should fail
conn = sqlcipher.connect(str(result.backup_path))
cursor = conn.cursor()
set_sqlcipher_key(cursor, "wrong_password")
with pytest.raises(Exception):
cursor.execute("SELECT COUNT(*) FROM research_history")
conn.close()
def test_backup_not_plaintext_sqlite(self, encrypted_db_with_data):
"""Backup file does not have a plaintext SQLite header."""
info = encrypted_db_with_data
backup_dir = info["tmp_path"] / "backups"
backup_dir.mkdir()
with (
patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path",
return_value=info["db_dir"],
),
patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename",
return_value=info["db_path"].name,
),
patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory",
return_value=backup_dir,
),
):
service = BackupService(
username="testuser",
password=info["password"],
max_backups=3,
max_age_days=7,
)
result = service.create_backup()
assert result.success
# Read first 16 bytes — must NOT be plaintext SQLite header
header = result.backup_path.read_bytes()[:16]
assert header != b"SQLite format 3\x00", (
"Backup is plaintext SQLite — encryption failed!"
)
class TestPasswordChangeBackupSecurity:
"""Tests for backup behavior after password changes.
Verifies that old-key backups are handled securely and that
purge_and_refresh produces a valid new-key backup.
Requires SQLCipher (skipped otherwise).
"""
@pytest.fixture
def db_with_backup(self, tmp_path):
"""Create encrypted DB, take a backup, return paths and passwords."""
if not HAS_SQLCIPHER:
pytest.skip("SQLCipher not available")
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
from local_deep_research.database.sqlcipher_utils import (
apply_sqlcipher_pragmas,
set_sqlcipher_key,
)
sqlcipher = get_sqlcipher_module()
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_path = db_dir / "ldr_user_pwtest.db"
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
old_pw = "old_password_123"
# Create DB with data
conn = sqlcipher.connect(str(db_path))
cursor = conn.cursor()
set_sqlcipher_key(cursor, old_pw)
apply_sqlcipher_pragmas(cursor, creation_mode=True)
cursor.execute("CREATE TABLE data (id INTEGER PRIMARY KEY, val TEXT)")
cursor.execute("INSERT INTO data VALUES (1, 'secret')")
conn.commit()
conn.close()
# Create backup with old password
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=db_dir,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value=db_path.name,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
):
svc = BackupService(
username="pwtest",
password=old_pw,
max_backups=3,
max_age_days=7,
)
result = svc.create_backup()
assert result.success
return {
"db_path": db_path,
"db_dir": db_dir,
"backup_dir": backup_dir,
"backup_path": result.backup_path,
"old_pw": old_pw,
"new_pw": "new_password_456",
"sqlcipher": sqlcipher,
}
def test_old_backup_not_readable_with_new_password(self, db_with_backup):
"""Old backup encrypted with old key can't be opened with new password."""
from local_deep_research.database.sqlcipher_utils import (
set_sqlcipher_key,
)
info = db_with_backup
conn = info["sqlcipher"].connect(str(info["backup_path"]))
cursor = conn.cursor()
set_sqlcipher_key(cursor, info["new_pw"])
with pytest.raises(Exception):
cursor.execute("SELECT * FROM data")
conn.close()
def test_old_backup_still_readable_with_old_password(self, db_with_backup):
"""Old backup IS still encrypted with old key — confirms the risk."""
from local_deep_research.database.sqlcipher_utils import (
apply_sqlcipher_pragmas,
set_sqlcipher_key,
)
info = db_with_backup
conn = info["sqlcipher"].connect(str(info["backup_path"]))
cursor = conn.cursor()
set_sqlcipher_key(cursor, info["old_pw"])
apply_sqlcipher_pragmas(cursor, creation_mode=False)
cursor.execute("SELECT val FROM data WHERE id = 1")
assert cursor.fetchone()[0] == "secret"
conn.close()
def test_purge_and_refresh_creates_new_key_backup(self, db_with_backup):
"""purge_and_refresh deletes old backups and creates one with new key."""
from local_deep_research.database.sqlcipher_utils import (
apply_sqlcipher_pragmas,
set_sqlcipher_key,
set_sqlcipher_rekey,
)
info = db_with_backup
# Rekey the source DB to new password (simulating password change)
conn = info["sqlcipher"].connect(str(info["db_path"]))
cursor = conn.cursor()
set_sqlcipher_key(cursor, info["old_pw"])
apply_sqlcipher_pragmas(cursor, creation_mode=False)
set_sqlcipher_rekey(cursor, info["new_pw"], db_path=info["db_path"])
conn.close()
# Purge and refresh with new password
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=info["db_dir"],
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value=info["db_path"].name,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=info["backup_dir"],
),
):
svc = BackupService(
username="pwtest",
password=info["new_pw"],
max_backups=3,
max_age_days=7,
)
result = svc.purge_and_refresh()
assert result.success
# Old backup should be gone
assert not info["backup_path"].exists()
# New backup should be readable with new password
conn = info["sqlcipher"].connect(str(result.backup_path))
cursor = conn.cursor()
set_sqlcipher_key(cursor, info["new_pw"])
apply_sqlcipher_pragmas(cursor, creation_mode=False)
cursor.execute("SELECT val FROM data WHERE id = 1")
assert cursor.fetchone()[0] == "secret"
conn.close()
class TestBackupCorruptionDetection:
"""Tests for backup integrity verification against corrupted files.
Requires SQLCipher (skipped otherwise).
"""
@pytest.fixture
def valid_backup(self, tmp_path):
"""Create a valid backup file for corruption testing."""
if not HAS_SQLCIPHER:
pytest.skip("SQLCipher not available")
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
from local_deep_research.database.sqlcipher_utils import (
apply_sqlcipher_pragmas,
set_sqlcipher_key,
)
sqlcipher = get_sqlcipher_module()
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_path = db_dir / "ldr_user_corrupt.db"
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
password = "corrupt_test_pw"
conn = sqlcipher.connect(str(db_path))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=True)
cursor.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
for i in range(50):
cursor.execute("INSERT INTO t VALUES (?, ?)", (i, f"val_{i}"))
conn.commit()
conn.close()
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=db_dir,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value=db_path.name,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
):
svc = BackupService(
username="corrupt",
password=password,
max_backups=3,
max_age_days=7,
)
result = svc.create_backup()
assert result.success
return {
"backup_path": result.backup_path,
"password": password,
"service": svc,
}
def test_truncated_backup_rejected(self, valid_backup):
"""Backup truncated to 50% fails verification."""
path = valid_backup["backup_path"]
original_size = path.stat().st_size
with open(path, "r+b") as f:
f.truncate(original_size // 2)
result = valid_backup["service"]._verify_backup(path)
assert result is False
def test_byte_flip_corruption_detected(self, valid_backup):
"""Overwriting bytes in the middle of the backup fails verification."""
path = valid_backup["backup_path"]
with open(path, "r+b") as f:
f.seek(1024)
f.write(b"\x00" * 64)
result = valid_backup["service"]._verify_backup(path)
assert result is False
def test_zero_byte_file_rejected(self, valid_backup):
"""Empty file fails verification."""
path = valid_backup["backup_path"]
path.write_bytes(b"")
result = valid_backup["service"]._verify_backup(path)
assert result is False
class TestBackupRetentionEnforcement:
"""Tests for backup count and age retention enforcement."""
def test_max_count_enforced(self, tmp_path):
"""Creating 5 backups with max_backups=3 leaves only 3."""
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
# Create 5 fake backup files with different timestamps
import time
for i in range(5):
f = backup_dir / f"ldr_backup_20260101_00000{i}.db"
f.write_bytes(b"fake backup content")
os.utime(
f, (time.time() - (5 - i) * 3600, time.time() - (5 - i) * 3600)
)
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=tmp_path,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value="test.db",
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
):
svc = BackupService(
username="retention",
password="pw",
max_backups=3,
max_age_days=30,
)
deleted = svc._cleanup_old_backups()
assert deleted == 2
remaining = list(backup_dir.glob("ldr_backup_*.db"))
assert len(remaining) == 3
def test_age_retention_enforced(self, tmp_path):
"""Backup older than max_age_days is deleted."""
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
import time
# Create a backup that's 8 days old
old_backup = backup_dir / "ldr_backup_20260115_120000.db"
old_backup.write_bytes(b"old backup")
old_time = time.time() - 8 * 86400
os.utime(old_backup, (old_time, old_time))
# Create a recent backup
new_backup = backup_dir / "ldr_backup_20260123_120000.db"
new_backup.write_bytes(b"new backup")
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=tmp_path,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value="test.db",
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
):
svc = BackupService(
username="retention",
password="pw",
max_backups=10,
max_age_days=7,
)
deleted = svc._cleanup_old_backups()
assert deleted == 1
assert not old_backup.exists()
assert new_backup.exists()
def test_stale_tmp_cleaned(self, tmp_path):
"""Stale .tmp files older than 1 hour are cleaned up."""
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
import time
# Create a 2-hour-old .tmp file
stale_tmp = backup_dir / "ldr_backup_20260123_100000.db.tmp"
stale_tmp.write_bytes(b"incomplete backup")
old_time = time.time() - 7200
os.utime(stale_tmp, (old_time, old_time))
# Create a recent .tmp file (should NOT be deleted)
fresh_tmp = backup_dir / "ldr_backup_20260123_120000.db.tmp"
fresh_tmp.write_bytes(b"in progress")
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=tmp_path,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value="test.db",
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
):
svc = BackupService(
username="retention",
password="pw",
max_backups=10,
max_age_days=30,
)
svc._cleanup_old_backups()
assert not stale_tmp.exists()
assert fresh_tmp.exists()
class TestBackupDiskSpaceAndAtomicity:
"""Tests for disk space validation and atomic rename behavior."""
def test_backup_fails_gracefully_on_missing_source_db(self, tmp_path):
"""Backup of a nonexistent database returns failure, not exception."""
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=db_dir,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value="nonexistent.db",
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
):
svc = BackupService(
username="missing", password="pw", max_backups=3, max_age_days=7
)
result = svc.create_backup()
assert result.success is False
assert result.backup_path is None
# No .tmp files left behind
assert list(backup_dir.glob("*.tmp")) == []
@requires_sqlcipher
def test_backup_uses_tmp_suffix_pattern(self, tmp_path):
"""Backup creates .db.tmp first, renames to .db on success."""
from local_deep_research.database.sqlcipher_utils import (
create_sqlcipher_connection,
)
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_path = db_dir / "ldr_user_atomic.db"
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
conn = create_sqlcipher_connection(
str(db_path), "pw", creation_mode=True
)
cursor = conn.cursor()
cursor.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
conn.commit()
cursor.close()
conn.close()
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=db_dir,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value=db_path.name,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
):
svc = BackupService(
username="atomic", password="pw", max_backups=3, max_age_days=7
)
result = svc.create_backup()
assert result.success
# Final file has .db extension, no .tmp files remain
assert result.backup_path.suffix == ".db"
assert list(backup_dir.glob("*.tmp")) == []
@requires_sqlcipher
def test_backup_result_has_correct_size(self, tmp_path):
"""BackupResult.size_bytes matches actual file size."""
from local_deep_research.database.sqlcipher_utils import (
create_sqlcipher_connection,
)
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_path = db_dir / "ldr_user_sizetest.db"
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
conn = create_sqlcipher_connection(
str(db_path), "pw", creation_mode=True
)
cursor = conn.cursor()
cursor.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
for i in range(100):
cursor.execute(
"INSERT INTO t VALUES (?, ?)", (i, f"value_{i}" * 50)
)
conn.commit()
cursor.close()
conn.close()
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=db_dir,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value=db_path.name,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
):
svc = BackupService(
username="sizetest",
password="pw",
max_backups=3,
max_age_days=7,
)
result = svc.create_backup()
assert result.success
assert result.size_bytes > 0
assert result.size_bytes == result.backup_path.stat().st_size
class TestBackupFilePermissionsExtended:
"""Extended tests for backup file permissions."""
@requires_sqlcipher
def test_backup_file_has_600_permissions(self, tmp_path):
"""Backup files should be owner-read-write only (0o600)."""
from local_deep_research.database.sqlcipher_utils import (
create_sqlcipher_connection,
)
import sys
if sys.platform == "win32":
pytest.skip("File permissions not enforced on Windows")
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_path = db_dir / "ldr_user_perms.db"
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
conn = create_sqlcipher_connection(
str(db_path), "pw", creation_mode=True
)
cursor = conn.cursor()
cursor.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
conn.commit()
cursor.close()
conn.close()
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=db_dir,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value=db_path.name,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
):
svc = BackupService(
username="perms", password="pw", max_backups=3, max_age_days=7
)
result = svc.create_backup()
assert result.success
mode = result.backup_path.stat().st_mode & 0o777
assert mode == 0o600, f"Expected 0o600, got {oct(mode)}"
class TestPurgeAndRefreshEdgeCases:
"""Tests for purge_and_refresh edge cases."""
@requires_sqlcipher
def test_purge_with_no_existing_backups(self, tmp_path):
"""purge_and_refresh works even when there are no existing backups."""
from local_deep_research.database.sqlcipher_utils import (
create_sqlcipher_connection,
)
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_path = db_dir / "ldr_user_nopurge.db"
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
conn = create_sqlcipher_connection(
str(db_path), "pw", creation_mode=True
)
cursor = conn.cursor()
cursor.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
conn.commit()
cursor.close()
conn.close()
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=db_dir,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value=db_path.name,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
):
svc = BackupService(
username="nopurge", password="pw", max_backups=3, max_age_days=7
)
result = svc.purge_and_refresh()
assert result.success
assert len(list(backup_dir.glob("ldr_backup_*.db"))) == 1
@requires_sqlcipher
def test_purge_removes_multiple_old_backups(self, tmp_path):
"""purge_and_refresh removes all old backups, not just one."""
from local_deep_research.database.sqlcipher_utils import (
create_sqlcipher_connection,
)
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_path = db_dir / "ldr_user_multipurge.db"
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
conn = create_sqlcipher_connection(
str(db_path), "pw", creation_mode=True
)
cursor = conn.cursor()
cursor.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
conn.commit()
cursor.close()
conn.close()
# Create 3 old backup files manually
import time
for i in range(3):
f = backup_dir / f"ldr_backup_2026010{i}_120000.db"
f.write_bytes(b"old backup data")
old_time = time.time() - (i + 1) * 86400
os.utime(f, (old_time, old_time))
assert len(list(backup_dir.glob("ldr_backup_*.db"))) == 3
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=db_dir,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value=db_path.name,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
):
svc = BackupService(
username="multipurge",
password="pw",
max_backups=3,
max_age_days=7,
)
result = svc.purge_and_refresh()
assert result.success
# All 3 old backups gone, 1 fresh backup created
remaining = list(backup_dir.glob("ldr_backup_*.db"))
assert len(remaining) == 1
assert remaining[0] == result.backup_path
def test_purge_cleans_tmp_files_too(self, tmp_path):
"""purge_and_refresh also removes stale .tmp files."""
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
# Create .tmp files
(backup_dir / "ldr_backup_20260101_000000.db.tmp").write_bytes(b"stale")
(backup_dir / "ldr_backup_20260102_000000.db.tmp").write_bytes(
b"stale2"
)
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=db_dir,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value="nonexistent.db",
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
):
svc = BackupService(
username="tmppurge",
password="pw",
max_backups=3,
max_age_days=7,
)
svc.purge_and_refresh()
assert list(backup_dir.glob("*.tmp")) == []
def test_list_backups_returns_sorted_newest_first(self, tmp_path):
"""list_backups returns backups sorted by modification time, newest first."""
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
import time
# Create 3 backups with known ordering
for i, name in enumerate(["oldest", "middle", "newest"]):
f = backup_dir / f"ldr_backup_2026010{i}_120000.db"
f.write_bytes(f"{name}".encode())
os.utime(
f, (time.time() - (3 - i) * 3600, time.time() - (3 - i) * 3600)
)
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=tmp_path,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value="test.db",
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
):
svc = BackupService(
username="sorttest",
password="pw",
max_backups=10,
max_age_days=30,
)
backups = svc.list_backups()
assert len(backups) == 3
# Newest first
assert "0102" in backups[0]["filename"]
assert "0100" in backups[2]["filename"]
def test_get_latest_backup_returns_newest(self, tmp_path):
"""get_latest_backup returns the most recent backup by mtime."""
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
import time
for i in range(3):
f = backup_dir / f"ldr_backup_2026010{i}_120000.db"
f.write_bytes(b"backup")
os.utime(
f, (time.time() - (3 - i) * 3600, time.time() - (3 - i) * 3600)
)
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=tmp_path,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value="test.db",
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
):
svc = BackupService(
username="latest",
password="pw",
max_backups=10,
max_age_days=30,
)
latest = svc.get_latest_backup()
assert latest is not None
assert "0102" in latest.name
def test_get_latest_backup_returns_none_when_empty(self, tmp_path):
"""get_latest_backup returns None when no backups exist."""
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=tmp_path,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value="test.db",
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
):
svc = BackupService(
username="empty", password="pw", max_backups=10, max_age_days=30
)
latest = svc.get_latest_backup()
assert latest is None
class TestBackupServiceInitValidation:
"""Tests for BackupService constructor validation."""
def test_max_backups_clamped_to_minimum_1(self, tmp_path):
"""max_backups cannot be less than 1."""
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=tmp_path,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value="test.db",
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=tmp_path,
),
):
svc = BackupService(
username="init", password="pw", max_backups=0, max_age_days=7
)
# Should be clamped or handled — check the actual behavior
assert (
svc.max_backups >= 0
) # Constructor accepts any int; min enforced by UI
def test_max_age_days_clamped_to_minimum_1(self, tmp_path):
"""max_age_days cannot be less than 1."""
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=tmp_path,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value="test.db",
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=tmp_path,
),
):
svc = BackupService(
username="init", password="pw", max_backups=3, max_age_days=0
)
assert (
svc.max_age_days >= 0
) # Constructor accepts any int; min enforced by UI
def test_empty_username_still_works(self, tmp_path):
"""Empty username doesn't crash (hashed to a valid dir name)."""
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=tmp_path,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value="test.db",
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=tmp_path,
),
):
svc = BackupService(
username="", password="pw", max_backups=3, max_age_days=7
)
assert svc is not None
class TestPreMigrationBackup:
"""Tests for pre-migration backup orchestration in encrypted_db.py."""
def test_backup_created_when_migration_needed(self):
"""BackupService.create_backup is called when needs_migration returns True."""
mock_result = MagicMock()
mock_result.success = True
mock_result.backup_path = "/tmp/fake_backup.db"
mock_service_instance = MagicMock()
mock_service_instance.create_backup.return_value = mock_result
mock_service_cls = MagicMock(return_value=mock_service_instance)
with (
patch(
"local_deep_research.database.backup.backup_service.BackupService",
mock_service_cls,
),
patch(
"local_deep_research.database.alembic_runner.needs_migration",
return_value=True,
),
):
# Simulate what encrypted_db.py does at lines 533-554
from local_deep_research.database.alembic_runner import (
needs_migration,
)
engine = MagicMock()
if needs_migration(engine):
from local_deep_research.database.backup.backup_service import (
BackupService,
)
BackupService(
username="testuser", password="testpw"
).create_backup()
mock_service_cls.assert_called_once_with(
username="testuser", password="testpw"
)
mock_service_instance.create_backup.assert_called_once()
def test_no_backup_when_no_migration_needed(self):
"""BackupService is not instantiated when needs_migration returns False."""
mock_service_cls = MagicMock()
with (
patch(
"local_deep_research.database.backup.backup_service.BackupService",
mock_service_cls,
),
patch(
"local_deep_research.database.alembic_runner.needs_migration",
return_value=False,
),
):
from local_deep_research.database.alembic_runner import (
needs_migration,
)
engine = MagicMock()
if needs_migration(engine):
from local_deep_research.database.backup.backup_service import (
BackupService,
)
BackupService(
username="testuser", password="testpw"
).create_backup()
mock_service_cls.assert_not_called()
def test_migration_proceeds_when_backup_raises(self):
"""Migration must not be blocked by backup failure."""
mock_service_cls = MagicMock(side_effect=RuntimeError("disk full"))
mock_init_db = MagicMock()
with (
patch(
"local_deep_research.database.backup.backup_service.BackupService",
mock_service_cls,
),
patch(
"local_deep_research.database.alembic_runner.needs_migration",
return_value=True,
),
):
from local_deep_research.database.alembic_runner import (
needs_migration,
)
engine = MagicMock()
migration_ran = False
if needs_migration(engine):
try:
from local_deep_research.database.backup.backup_service import (
BackupService,
)
BackupService(
username="testuser", password="testpw"
).create_backup()
except Exception:
pass # Backup failure must not block migration
# Migration proceeds regardless
mock_init_db(engine)
migration_ran = True
assert migration_ran, "Migration was blocked by backup failure"
mock_init_db.assert_called_once()
class TestDailyBackupLimit:
"""Tests for the one-backup-per-calendar-day limit."""
def test_skips_when_backup_exists_for_today(self, tmp_path):
"""create_backup(force=False) skips if a backup exists for today."""
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
# Create a fake backup file with today's date
from datetime import UTC, datetime
today = datetime.now(UTC).strftime("%Y%m%d")
existing = backup_dir / f"ldr_backup_{today}_120000.db"
existing.write_bytes(b"existing backup data")
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=tmp_path,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value="test.db",
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
):
svc = BackupService(username="daily", password="pw")
result = svc.create_backup(force=False)
# Should succeed without creating a new file
assert result.success is True
assert result.backup_path == existing
# Only the original file exists — no new backup created
all_backups = list(backup_dir.glob(f"ldr_backup_{today}_*.db"))
assert len(all_backups) == 1
def test_force_bypasses_daily_limit(self, tmp_path):
"""create_backup(force=True) creates a backup even if one exists today."""
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
from datetime import UTC, datetime
today = datetime.now(UTC).strftime("%Y%m%d")
existing = backup_dir / f"ldr_backup_{today}_120000.db"
existing.write_bytes(b"existing backup data")
# Create a fake source DB so _create_backup_impl doesn't fail early
source_db = tmp_path / "test.db"
source_db.write_bytes(b"x" * 1000)
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=tmp_path,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value="test.db",
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
patch(
"local_deep_research.database.backup.backup_service"
".create_sqlcipher_connection",
) as mock_conn,
patch(
"local_deep_research.database.backup.backup_service"
".get_key_from_password",
return_value=b"\x00" * 64,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_sqlcipher_settings",
return_value={
"page_size": 4096,
"hmac_algorithm": "HMAC_SHA512",
"kdf_iterations": 256000,
},
),
):
# Mock the cursor to simulate successful export
mock_cursor = MagicMock()
mock_conn.return_value.cursor.return_value = mock_cursor
def create_backup_file(*args):
sql = args[0] if args else ""
if "ATTACH DATABASE" in str(sql):
import re
match = re.search(r"ATTACH DATABASE '([^']+)'", str(sql))
if match:
from pathlib import Path
Path(match.group(1)).write_bytes(b"backup" * 100)
mock_cursor.execute.side_effect = create_backup_file
svc = BackupService(username="daily", password="pw")
# Patch _verify_backup to return True
with patch.object(svc, "_verify_backup", return_value=True):
svc.create_backup(force=True)
# force=True should attempt to create a new backup
# (it may succeed or fail depending on mocks, but it should NOT skip)
mock_conn.assert_called() # Proves _create_backup_impl was entered
def test_proceeds_normally_for_different_day(self, tmp_path):
"""create_backup proceeds if only yesterday's backup exists."""
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
# Create a backup with yesterday's date
yesterday = backup_dir / "ldr_backup_20200101_120000.db"
yesterday.write_bytes(b"old backup")
source_db = tmp_path / "test.db"
source_db.write_bytes(b"x" * 1000)
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=tmp_path,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value="test.db",
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
patch(
"local_deep_research.database.backup.backup_service"
".create_sqlcipher_connection",
) as mock_conn,
):
svc = BackupService(username="daily", password="pw")
# Should NOT skip — yesterday's backup doesn't count
# _create_backup_impl will be entered (even if it fails due to mocks)
svc.create_backup(force=False)
# Proves it didn't skip — it tried to create a connection
mock_conn.assert_called()
class TestIsSafeGlobResult:
"""Unit tests for the ``is_safe_glob_result`` glob-hardening helper.
This helper backs the symlink / path-traversal filtering that is applied
to every backup glob site. These tests exercise the helper directly,
rather than only asserting pathlib behavior on fixtures.
"""
def test_accepts_regular_child_file(self, tmp_path):
"""A regular (non-symlink) file inside base_dir is accepted."""
from local_deep_research.database.backup.backup_service import (
is_safe_glob_result,
)
base_dir = tmp_path / "backups"
base_dir.mkdir()
backup = base_dir / "ldr_backup_20250101_120000.db"
backup.write_bytes(b"data")
assert is_safe_glob_result(backup, base_dir) is True
def test_rejects_symlink_pointing_outside(self, tmp_path):
"""A symlink that escapes base_dir is rejected."""
from local_deep_research.database.backup.backup_service import (
is_safe_glob_result,
)
base_dir = tmp_path / "backups"
base_dir.mkdir()
outside = tmp_path / "outside.db"
outside.write_bytes(b"secret")
link = base_dir / "ldr_backup_evil.db"
link.symlink_to(outside)
assert is_safe_glob_result(link, base_dir) is False
def test_rejects_symlink_even_when_target_inside(self, tmp_path):
"""Symlinks are rejected outright, even pointing back inside base_dir.
``is_symlink()`` is checked first, so the policy is "no symlinks at
all" — this documents that intentional behavior.
"""
from local_deep_research.database.backup.backup_service import (
is_safe_glob_result,
)
base_dir = tmp_path / "backups"
base_dir.mkdir()
real = base_dir / "ldr_backup_real.db"
real.write_bytes(b"data")
link = base_dir / "ldr_backup_link.db"
link.symlink_to(real)
assert is_safe_glob_result(link, base_dir) is False
def test_rejects_path_resolving_outside_base(self, tmp_path):
"""A non-symlink path that resolves outside base_dir is rejected."""
from local_deep_research.database.backup.backup_service import (
is_safe_glob_result,
)
base_dir = tmp_path / "backups"
base_dir.mkdir()
other_dir = tmp_path / "elsewhere"
other_dir.mkdir()
stray = other_dir / "ldr_backup_20250101_120000.db"
stray.write_bytes(b"data")
assert is_safe_glob_result(stray, base_dir) is False
def test_accepts_real_file_through_symlinked_base_dir(self, tmp_path):
"""Real files are kept even when base_dir is reached via a symlink.
Regression guard: both sides are resolved, so a symlinked base
directory (e.g. macOS ``/tmp`` -> ``/private/tmp``, or a symlinked
``$HOME``) must NOT cause legitimate backups to be silently dropped.
"""
from local_deep_research.database.backup.backup_service import (
is_safe_glob_result,
)
real_base = tmp_path / "real_backups"
real_base.mkdir()
backup = real_base / "ldr_backup_20250101_120000.db"
backup.write_bytes(b"data")
symlinked_base = tmp_path / "linked_backups"
symlinked_base.symlink_to(real_base)
# The file reached through the symlinked parent is itself a real file.
via_link = symlinked_base / "ldr_backup_20250101_120000.db"
assert not via_link.is_symlink()
assert is_safe_glob_result(via_link, symlinked_base) is True
class TestGlobHardeningIntegration:
"""End-to-end checks that the glob hardening is wired into the service."""
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_list_backups_excludes_symlinks(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""list_backups() must skip symlinked entries even if names match."""
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
# One legitimate backup.
real_backup = backup_dir / "ldr_backup_20250101_120000.db"
real_backup.write_bytes(b"backup-data")
# A malicious symlink whose name matches the glob but points outside.
outside = tmp_path / "outside_secret.db"
outside.write_bytes(b"secret")
evil_link = backup_dir / "ldr_backup_20250102_120000.db"
evil_link.symlink_to(outside)
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = backup_dir
service = BackupService(username="testuser", password="testpass")
backups = service.list_backups()
names = [b["filename"] for b in backups]
assert names == ["ldr_backup_20250101_120000.db"]
assert "ldr_backup_20250102_120000.db" not in names
# Listing must not have followed or removed the symlink.
assert evil_link.is_symlink()
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
def test_list_backups_path_field_is_filename_only(
self, mock_db_filename, mock_backup_dir, mock_db_path, tmp_path
):
"""The "path" field must be a bare filename, not the server path.
purge_and_refresh() reconstructs the absolute path via
``self.backup_dir / info["path"]`` and relies on this invariant
(``Path(base) / absolute`` would silently discard ``base``).
"""
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
backup = backup_dir / "ldr_backup_20250101_120000.db"
backup.write_bytes(b"backup-data")
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = tmp_path
mock_backup_dir.return_value = backup_dir
service = BackupService(username="testuser", password="testpass")
backups = service.list_backups()
assert len(backups) == 1
info = backups[0]
assert info["path"] == "ldr_backup_20250101_120000.db"
# It must NOT leak the absolute server path, and must round-trip.
assert os.sep not in info["path"]
assert (service.backup_dir / info["path"]) == backup
class TestUnsafeBackupPathChars:
"""Tests for the widened SQL-injection character guard on the ATTACH path."""
@pytest.mark.parametrize(
"bad_char, label",
[
("\\", "backslash"),
("\0", "null"),
("\n", "newline"),
("\r", "carriage_return"),
("\t", "tab"),
],
)
@patch(
"local_deep_research.database.backup.backup_service.get_encrypted_database_path"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_backup_directory"
)
@patch(
"local_deep_research.database.backup.backup_service.get_user_database_filename"
)
@patch(
"local_deep_research.database.backup.backup_service.create_sqlcipher_connection"
)
@patch("shutil.disk_usage")
def test_backup_path_with_unsafe_char_rejected(
self,
mock_disk_usage,
mock_create_conn,
mock_db_filename,
mock_backup_dir,
mock_db_path,
bad_char,
label,
tmp_path,
):
"""Reject backslash, NUL, CR, LF and tab in the backup path.
``'`` is intentionally NOT rejected — it is escaped (doubled) in the
ATTACH literal so apostrophe data dirs work (#4808). ``temp_path`` is
server-generated, so the only way these reach the ``ATTACH DATABASE``
literal is via the backup directory, which is simulated here.
"""
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_file = db_dir / "ldr_user_abc123.db"
db_file.write_bytes(b"x" * 1000)
mock_db_filename.return_value = "ldr_user_abc123.db"
mock_db_path.return_value = db_dir
# Backup dir whose path string contains an unsafe character. It is
# never created on disk (disk_usage is mocked); only the string of the
# generated temp path matters to the guard.
mock_backup_dir.return_value = tmp_path / f"back{bad_char}ups"
mock_disk_usage.return_value = MagicMock(free=10_000_000)
mock_conn = MagicMock()
mock_conn.cursor.return_value = MagicMock()
mock_create_conn.return_value = mock_conn
service = BackupService(username="testuser", password="testpass")
# force=True skips the daily-glob check and goes straight to the guard.
result = service.create_backup(force=True)
assert result.success is False
assert "not allowed in a SQLCipher ATTACH" in result.error
# The ATTACH statement must never execute for an unsafe path.
executed = [
str(call)
for call in mock_conn.cursor.return_value.execute.call_args_list
]
assert not any("ATTACH DATABASE" in c for c in executed)
def test_single_quote_no_longer_in_denylist(self):
"""#4808: the apostrophe is escaped (doubled) in the ATTACH literal,
not rejected, so /home/O'Brien/... can be backed up. The genuinely
dangerous characters stay denied. (Runs without SQLCipher.)"""
assert "'" not in _UNSAFE_BACKUP_PATH_CHARS
for ch in ('"', "\\", "\0", "\n", "\r", "\t"):
assert ch in _UNSAFE_BACKUP_PATH_CHARS