chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:08:55 +08:00
commit 7a0da7932b
2985 changed files with 1049377 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Tests for database backup module."""
@@ -0,0 +1,577 @@
"""
Docker-based end-to-end backup crash recovery test.
This test runs in CI (release gate) inside the LDR Docker container
with SQLCipher available. It exercises the full lifecycle in 10 steps:
1. Create encrypted DB with real schema + data
2. Backup via BackupService
3. Verify backup is encrypted (no plaintext SQLite header)
4. Destroy the original DB (simulate crash)
5. Copy backup to replace original
6. Open with same password, verify data intact
7. Simulate password change (rekey)
8. Verify old password fails on rekeyed DB
9. Purge old backups + create fresh one with new key
10. Verify fresh backup works with new password, old backup gone
All files are created in tmp_path — nothing is uploaded or committed.
Requires SQLCipher (skips gracefully if unavailable).
"""
import os
import shutil
from pathlib import Path
from unittest.mock import patch
import pytest
from local_deep_research.database.backup.backup_service import (
BackupService,
)
def _get_sqlcipher():
"""Import SQLCipher or skip the test."""
try:
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
return get_sqlcipher_module()
except (ImportError, RuntimeError):
pytest.skip("SQLCipher not available")
def _create_test_database(db_path: Path, password: str):
"""Create a real encrypted SQLCipher database with LDR-like schema.
Uses create_sqlcipher_connection() for correct pragma ordering:
apply_cipher_defaults_before_key → set_sqlcipher_key → apply_sqlcipher_pragmas
"""
from local_deep_research.database.sqlcipher_utils import (
create_sqlcipher_connection,
)
conn = create_sqlcipher_connection(
str(db_path), password, creation_mode=True
)
cursor = conn.cursor()
# Create tables mimicking real LDR schema
cursor.execute(
"""CREATE TABLE research_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
query TEXT,
status TEXT DEFAULT 'completed',
created_at TEXT DEFAULT (datetime('now'))
)"""
)
cursor.execute(
"""CREATE TABLE app_settings (
key TEXT PRIMARY KEY,
value TEXT,
type TEXT DEFAULT 'APP'
)"""
)
cursor.execute(
"""CREATE TABLE documents (
id TEXT PRIMARY KEY,
research_id INTEGER,
file_name TEXT,
FOREIGN KEY (research_id) REFERENCES research_history(id)
)"""
)
# Insert test data
for i in range(20):
cursor.execute(
"INSERT INTO research_history (title, query) VALUES (?, ?)",
(f"Research on Topic {i}", f"What is topic {i}?"),
)
cursor.execute(
"INSERT INTO app_settings (key, value) VALUES (?, ?)",
("llm.model", "gpt-4o-mini"),
)
cursor.execute(
"INSERT INTO app_settings (key, value) VALUES (?, ?)",
("backup.enabled", "true"),
)
for i in range(5):
cursor.execute(
"INSERT INTO documents (id, research_id, file_name) VALUES (?, ?, ?)",
(f"doc-{i}", i + 1, f"paper_{i}.pdf"),
)
conn.commit()
cursor.close()
conn.close()
def _open_and_verify(
db_path: Path, password: str, expected_research_count: int = 20
):
"""Open a database with create_sqlcipher_connection and verify contents."""
from local_deep_research.database.sqlcipher_utils import (
create_sqlcipher_connection,
)
conn = create_sqlcipher_connection(str(db_path), password)
cursor = conn.cursor()
# Integrity check
cursor.execute("PRAGMA integrity_check")
assert cursor.fetchone()[0] == "ok"
# Verify research data
cursor.execute("SELECT COUNT(*) FROM research_history")
count = cursor.fetchone()[0]
assert count == expected_research_count, (
f"Expected {expected_research_count} research rows, got {count}"
)
# Verify settings
cursor.execute("SELECT value FROM app_settings WHERE key = 'llm.model'")
model = cursor.fetchone()[0]
assert model == "gpt-4o-mini"
# Verify documents
cursor.execute("SELECT COUNT(*) FROM documents")
doc_count = cursor.fetchone()[0]
assert doc_count == 5
cursor.close()
conn.close()
return True
@pytest.mark.timeout(120)
class TestFullCrashRecoveryCycle:
"""Full end-to-end crash recovery cycle with password change.
Exercises the complete backup lifecycle in a single sequential flow,
as it would happen in production. Requires SQLCipher — skips in
environments without it.
"""
def test_full_backup_restore_password_change_cycle(self, tmp_path):
"""Complete lifecycle: backup → crash → restore → password change → verify."""
_get_sqlcipher() # Skip early if no SQLCipher
from local_deep_research.database.sqlcipher_utils import (
create_sqlcipher_connection,
set_sqlcipher_rekey,
)
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_path = db_dir / "ldr_user_crashtest.db"
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
original_pw = "original_password_789"
new_pw = "changed_password_456"
# ── Step 1: Create database with real data ──
_create_test_database(db_path, original_pw)
_open_and_verify(db_path, original_pw)
# ── Step 2: Create backup ──
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="crashtest",
password=original_pw,
max_backups=3,
max_age_days=7,
)
backup_result = svc.create_backup()
assert backup_result.success, f"Backup failed: {backup_result.error}"
assert backup_result.backup_path.exists()
assert backup_result.backup_path.stat().st_size > 0
# ── Step 3: Verify backup is encrypted (not plaintext SQLite) ──
header = backup_result.backup_path.read_bytes()[:16]
assert header != b"SQLite format 3\x00", (
"Backup has plaintext SQLite header — encryption failed!"
)
# ── Step 4: Simulate crash — destroy original database ──
db_path.unlink()
for suffix in ["-wal", "-shm"]:
wal_path = db_path.parent / (db_path.name + suffix)
if wal_path.exists():
wal_path.unlink()
assert not db_path.exists()
# ── Step 5: Restore from backup — copy to original path ──
shutil.copy2(str(backup_result.backup_path), str(db_path))
os.chmod(db_path, 0o600)
# ── Step 6: Verify restored database works ──
_open_and_verify(db_path, original_pw)
# ── Step 7: Simulate password change (rekey) ──
conn = create_sqlcipher_connection(str(db_path), original_pw)
set_sqlcipher_rekey(conn, new_pw, db_path=db_path)
conn.close()
# Verify new password works
_open_and_verify(db_path, new_pw)
# ── Step 8: Verify old password fails on rekeyed DB ──
with pytest.raises(Exception):
create_sqlcipher_connection(str(db_path), original_pw)
# ── Step 9: Purge old backups and create fresh one ──
old_backup_path = backup_result.backup_path
assert old_backup_path.exists()
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,
),
):
new_svc = BackupService(
username="crashtest",
password=new_pw,
max_backups=3,
max_age_days=7,
)
refresh_result = new_svc.purge_and_refresh()
assert refresh_result.success, f"Refresh failed: {refresh_result.error}"
assert not old_backup_path.exists(), "Old-key backup was not purged"
assert refresh_result.backup_path.exists()
# ── Step 10: Verify fresh backup ──
# Works with new password
_open_and_verify(refresh_result.backup_path, new_pw)
# Is encrypted (not plaintext)
fresh_header = refresh_result.backup_path.read_bytes()[:16]
assert fresh_header != b"SQLite format 3\x00", (
"Fresh backup has plaintext SQLite header!"
)
# Does NOT work with old password
with pytest.raises(Exception):
create_sqlcipher_connection(
str(refresh_result.backup_path), original_pw
)
# Only 1 backup exists (the fresh one)
backups = list(backup_dir.glob("ldr_backup_*.db"))
assert len(backups) == 1, f"Expected 1 backup, found {len(backups)}"
@pytest.mark.timeout(120)
class TestBackupDataIntegrity:
"""Tests that verify backup data integrity properties.
These tests require real SQLCipher and validate that sqlcipher_export()
preserves schema, foreign keys, and produces a fully functional database.
"""
def test_backup_preserves_all_schema_objects(self, tmp_path):
"""Backup must contain all tables, indexes, and triggers from source."""
_get_sqlcipher()
from local_deep_research.database.sqlcipher_utils import (
create_sqlcipher_connection,
)
password = "schema-test-pw"
db_dir = tmp_path / "db"
db_dir.mkdir()
db_path = db_dir / "ldr_user_schematest.db"
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
# Create DB with tables, explicit index, and trigger
conn = create_sqlcipher_connection(
str(db_path), password, creation_mode=True
)
cursor = conn.cursor()
cursor.execute(
"""CREATE TABLE research_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
query TEXT
)"""
)
cursor.execute(
"""CREATE TABLE documents (
id TEXT PRIMARY KEY,
research_id INTEGER,
file_name TEXT,
FOREIGN KEY (research_id) REFERENCES research_history(id)
)"""
)
cursor.execute(
"CREATE INDEX idx_docs_research ON documents(research_id)"
)
cursor.execute("INSERT INTO research_history (title) VALUES ('Test')")
conn.commit()
cursor.close()
conn.close()
# Get source schema
conn = create_sqlcipher_connection(str(db_path), password)
cursor = conn.cursor()
cursor.execute(
"SELECT type, name, sql FROM sqlite_master "
"WHERE sql IS NOT NULL ORDER BY type, name"
)
source_schema = cursor.fetchall()
cursor.close()
conn.close()
# Create backup
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="schematest", password=password, max_backups=3
)
result = svc.create_backup()
assert result.success, f"Backup failed: {result.error}"
# Compare schemas
conn = create_sqlcipher_connection(str(result.backup_path), password)
cursor = conn.cursor()
cursor.execute(
"SELECT type, name, sql FROM sqlite_master "
"WHERE sql IS NOT NULL ORDER BY type, name"
)
backup_schema = cursor.fetchall()
cursor.close()
conn.close()
assert source_schema == backup_schema, (
f"Schema mismatch!\nSource: {source_schema}\nBackup: {backup_schema}"
)
def test_backup_passes_foreign_key_check(self, tmp_path):
"""Backup must have zero foreign key violations."""
_get_sqlcipher()
from local_deep_research.database.sqlcipher_utils import (
create_sqlcipher_connection,
)
password = "fk-test-pw"
db_dir = tmp_path / "db"
db_dir.mkdir()
db_path = db_dir / "ldr_user_fktest.db"
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
_create_test_database(db_path, 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="fktest", password=password, max_backups=3
)
result = svc.create_backup()
assert result.success, f"Backup failed: {result.error}"
conn = create_sqlcipher_connection(str(result.backup_path), password)
cursor = conn.cursor()
cursor.execute("PRAGMA foreign_key_check")
violations = cursor.fetchall()
cursor.close()
conn.close()
assert violations == [], f"FK violations in backup: {violations}"
def test_restored_backup_accepts_new_writes(self, tmp_path):
"""A restored backup must be a fully writable, functional database."""
_get_sqlcipher()
from local_deep_research.database.sqlcipher_utils import (
create_sqlcipher_connection,
)
password = "write-test-pw"
db_dir = tmp_path / "db"
db_dir.mkdir()
db_path = db_dir / "ldr_user_writetest.db"
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
_create_test_database(db_path, 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="writetest", password=password, max_backups=3
)
result = svc.create_backup()
assert result.success, f"Backup failed: {result.error}"
# Restore: copy backup over original
restored_path = tmp_path / "restored.db"
shutil.copy2(str(result.backup_path), str(restored_path))
# Copy salt file too (needed for key derivation)
salt_src = Path(str(db_path) + ".salt")
if salt_src.exists():
shutil.copy2(str(salt_src), str(restored_path) + ".salt")
# Open restored DB and write new data
conn = create_sqlcipher_connection(str(restored_path), password)
cursor = conn.cursor()
cursor.execute(
"INSERT INTO research_history (title, query) VALUES (?, ?)",
("New after restore", "Does restore work?"),
)
cursor.execute(
"UPDATE research_history SET title = ? WHERE id = 1",
("Modified after restore",),
)
conn.commit()
# Verify writes persisted
cursor.execute("SELECT COUNT(*) FROM research_history")
assert cursor.fetchone()[0] == 21 # 20 original + 1 new
cursor.execute("SELECT title FROM research_history WHERE id = 1")
assert cursor.fetchone()[0] == "Modified after restore"
cursor.close()
conn.close()
# Reopen to verify durability
conn = create_sqlcipher_connection(str(restored_path), password)
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM research_history")
assert cursor.fetchone()[0] == 21
cursor.close()
conn.close()
class TestBackupPathWithApostrophe:
"""Regression for #4808: a backup/data-dir path containing an apostrophe
(e.g. /home/O'Brien) must back up successfully. ATTACH DATABASE can't be
parameterized, so the path literal escapes the single quote (doubling it)
instead of the denylist rejecting the path."""
def test_backup_succeeds_with_apostrophe_in_path(self, tmp_path):
_get_sqlcipher() # skip early if SQLCipher is unavailable
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_path = db_dir / "ldr_user_apos.db"
# The offending component: a directory whose name has a single quote.
backup_dir = tmp_path / "O'Brien" / "backups"
backup_dir.mkdir(parents=True)
password = "apostrophe_password_123"
_create_test_database(db_path, 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="apostuser",
password=password,
max_backups=3,
max_age_days=7,
)
result = svc.create_backup()
assert result.success, f"Backup failed: {result.error}"
assert "'" in str(result.backup_path) # the apostrophe path was used
assert result.backup_path.exists()
assert result.backup_path.stat().st_size > 0
# Encrypted, not a plaintext SQLite file
assert result.backup_path.read_bytes()[:16] != b"SQLite format 3\x00"
File diff suppressed because it is too large Load Diff