7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
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
388 lines
12 KiB
Python
388 lines
12 KiB
Python
"""
|
|
Tests for SQLCipher thread safety.
|
|
|
|
These tests verify that SQLCipher connections work correctly when
|
|
accessed from multiple threads.
|
|
"""
|
|
|
|
import shutil
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from src.local_deep_research.database.sqlcipher_utils import (
|
|
apply_cipher_defaults_before_key,
|
|
apply_performance_pragmas,
|
|
apply_sqlcipher_pragmas,
|
|
set_sqlcipher_key,
|
|
)
|
|
from src.local_deep_research.database.sqlcipher_compat import (
|
|
get_sqlcipher_module,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_db_path():
|
|
"""Create a temporary database path."""
|
|
temp_dir = tempfile.mkdtemp()
|
|
db_path = Path(temp_dir) / "test_threads.db"
|
|
yield db_path
|
|
shutil.rmtree(temp_dir)
|
|
|
|
|
|
@pytest.fixture
|
|
def sqlcipher_module():
|
|
"""Get the SQLCipher module."""
|
|
return get_sqlcipher_module()
|
|
|
|
|
|
def create_configured_connection(sqlcipher_module, db_path, password):
|
|
"""Helper to create a properly configured connection."""
|
|
# Detect if we're creating a new database or opening an existing one
|
|
creation_mode = not db_path.exists()
|
|
conn = sqlcipher_module.connect(str(db_path), check_same_thread=False)
|
|
cursor = conn.cursor()
|
|
if creation_mode:
|
|
apply_cipher_defaults_before_key(cursor)
|
|
set_sqlcipher_key(cursor, password)
|
|
apply_sqlcipher_pragmas(cursor, creation_mode=creation_mode)
|
|
apply_performance_pragmas(cursor)
|
|
cursor.close()
|
|
return conn
|
|
|
|
|
|
class TestThreadSafety:
|
|
"""Tests for basic thread safety."""
|
|
|
|
def test_connection_per_thread(self, sqlcipher_module, temp_db_path):
|
|
"""Verify each thread can create its own connection."""
|
|
password = "thread_test"
|
|
|
|
# Create initial database
|
|
conn = create_configured_connection(
|
|
sqlcipher_module, temp_db_path, password
|
|
)
|
|
conn.execute("CREATE TABLE thread_data (thread_id INTEGER, value TEXT)")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
results = []
|
|
errors = []
|
|
|
|
def thread_work(thread_id):
|
|
try:
|
|
# Each thread creates its own connection
|
|
thread_conn = create_configured_connection(
|
|
sqlcipher_module, temp_db_path, password
|
|
)
|
|
thread_conn.execute(
|
|
"INSERT INTO thread_data VALUES (?, ?)",
|
|
(thread_id, f"data_from_{thread_id}"),
|
|
)
|
|
thread_conn.commit()
|
|
thread_conn.close()
|
|
results.append(thread_id)
|
|
except Exception as e:
|
|
errors.append((thread_id, str(e)))
|
|
|
|
# Run 5 threads concurrently
|
|
threads = []
|
|
for i in range(5):
|
|
t = threading.Thread(target=thread_work, args=(i,))
|
|
threads.append(t)
|
|
t.start()
|
|
|
|
for t in threads:
|
|
t.join()
|
|
|
|
# Verify no errors
|
|
assert not errors, f"Thread errors: {errors}"
|
|
|
|
# Verify all data was written
|
|
conn = create_configured_connection(
|
|
sqlcipher_module, temp_db_path, password
|
|
)
|
|
count = conn.execute("SELECT COUNT(*) FROM thread_data").fetchone()[0]
|
|
assert count == 5, f"Expected 5 rows, got {count}"
|
|
conn.close()
|
|
|
|
def test_concurrent_reads_different_connections(
|
|
self, sqlcipher_module, temp_db_path
|
|
):
|
|
"""Verify concurrent reads work from different connections."""
|
|
password = "concurrent_read_test"
|
|
|
|
# Create and populate database
|
|
conn = create_configured_connection(
|
|
sqlcipher_module, temp_db_path, password
|
|
)
|
|
conn.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, data TEXT)")
|
|
for i in range(100):
|
|
conn.execute("INSERT INTO test VALUES (?, ?)", (i, f"data_{i}"))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
results = []
|
|
errors = []
|
|
|
|
def read_work(reader_id):
|
|
try:
|
|
reader_conn = create_configured_connection(
|
|
sqlcipher_module, temp_db_path, password
|
|
)
|
|
# Read random rows
|
|
data = reader_conn.execute(
|
|
"SELECT COUNT(*) FROM test"
|
|
).fetchone()[0]
|
|
results.append((reader_id, data))
|
|
reader_conn.close()
|
|
except Exception as e:
|
|
errors.append((reader_id, str(e)))
|
|
|
|
# Run 10 concurrent readers
|
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
|
futures = [executor.submit(read_work, i) for i in range(10)]
|
|
for future in as_completed(futures):
|
|
pass # Just wait for completion
|
|
|
|
assert not errors, f"Read errors: {errors}"
|
|
assert len(results) == 10
|
|
# All readers should see 100 rows
|
|
assert all(count == 100 for _, count in results)
|
|
|
|
def test_write_serialization(self, sqlcipher_module, temp_db_path):
|
|
"""Verify concurrent writes are properly serialized."""
|
|
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: keep but increase timeout or mark @pytest.mark.slow).
|
|
password = "write_serial_test"
|
|
|
|
# Create database
|
|
conn = create_configured_connection(
|
|
sqlcipher_module, temp_db_path, password
|
|
)
|
|
conn.execute(
|
|
"CREATE TABLE counter (id INTEGER PRIMARY KEY, count INTEGER)"
|
|
)
|
|
conn.execute("INSERT INTO counter VALUES (1, 0)")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
errors = []
|
|
|
|
def increment_work(worker_id, increments):
|
|
try:
|
|
worker_conn = create_configured_connection(
|
|
sqlcipher_module, temp_db_path, password
|
|
)
|
|
for _ in range(increments):
|
|
# SQLite handles serialization with busy_timeout
|
|
worker_conn.execute(
|
|
"UPDATE counter SET count = count + 1 WHERE id = 1"
|
|
)
|
|
worker_conn.commit()
|
|
worker_conn.close()
|
|
except Exception as e:
|
|
errors.append((worker_id, str(e)))
|
|
|
|
# 5 workers each doing 10 increments = 50 total
|
|
threads = []
|
|
for i in range(5):
|
|
t = threading.Thread(target=increment_work, args=(i, 10))
|
|
threads.append(t)
|
|
t.start()
|
|
|
|
for t in threads:
|
|
t.join()
|
|
|
|
assert not errors, f"Write errors: {errors}"
|
|
|
|
# Verify final count
|
|
conn = create_configured_connection(
|
|
sqlcipher_module, temp_db_path, password
|
|
)
|
|
count = conn.execute(
|
|
"SELECT count FROM counter WHERE id = 1"
|
|
).fetchone()[0]
|
|
assert count == 50, f"Expected 50, got {count}"
|
|
conn.close()
|
|
|
|
|
|
class TestConnectionPooling:
|
|
"""Tests for connection pooling behavior."""
|
|
|
|
def test_multiple_connections_same_database(
|
|
self, sqlcipher_module, temp_db_path
|
|
):
|
|
"""Verify multiple simultaneous connections to same database."""
|
|
password = "pool_test"
|
|
|
|
# Create database
|
|
conn0 = create_configured_connection(
|
|
sqlcipher_module, temp_db_path, password
|
|
)
|
|
conn0.execute(
|
|
"CREATE TABLE shared (id INTEGER PRIMARY KEY, owner TEXT)"
|
|
)
|
|
conn0.commit()
|
|
|
|
# Create additional connections
|
|
conns = [conn0]
|
|
for i in range(4):
|
|
c = create_configured_connection(
|
|
sqlcipher_module, temp_db_path, password
|
|
)
|
|
conns.append(c)
|
|
|
|
# Each connection writes to shared table
|
|
for i, conn in enumerate(conns):
|
|
conn.execute("INSERT INTO shared VALUES (?, ?)", (i, f"conn_{i}"))
|
|
conn.commit()
|
|
|
|
# All connections should see all data
|
|
for conn in conns:
|
|
count = conn.execute("SELECT COUNT(*) FROM shared").fetchone()[0]
|
|
assert count == 5, f"Connection should see 5 rows, got {count}"
|
|
|
|
# Close all connections
|
|
for conn in conns:
|
|
conn.close()
|
|
|
|
def test_connection_reuse_safe(self, sqlcipher_module, temp_db_path):
|
|
"""Verify connections can be safely reused across operations."""
|
|
password = "reuse_test"
|
|
|
|
conn = create_configured_connection(
|
|
sqlcipher_module, temp_db_path, password
|
|
)
|
|
conn.execute(
|
|
"CREATE TABLE reuse_test (id INTEGER PRIMARY KEY, data TEXT)"
|
|
)
|
|
conn.commit()
|
|
|
|
# Perform many operations on same connection
|
|
for i in range(100):
|
|
conn.execute(
|
|
"INSERT INTO reuse_test VALUES (?, ?)", (i, f"data_{i}")
|
|
)
|
|
if i % 10 == 0:
|
|
conn.commit()
|
|
|
|
conn.commit()
|
|
|
|
# Verify all data
|
|
count = conn.execute("SELECT COUNT(*) FROM reuse_test").fetchone()[0]
|
|
assert count == 100
|
|
|
|
conn.close()
|
|
|
|
|
|
class TestBackgroundThreads:
|
|
"""Tests for background thread database access."""
|
|
|
|
def test_background_thread_writes(self, sqlcipher_module, temp_db_path):
|
|
"""Verify background thread can write to database."""
|
|
password = "bg_write_test"
|
|
|
|
# Create database in main thread
|
|
main_conn = create_configured_connection(
|
|
sqlcipher_module, temp_db_path, password
|
|
)
|
|
main_conn.execute(
|
|
"CREATE TABLE bg_data (id INTEGER PRIMARY KEY, source TEXT)"
|
|
)
|
|
main_conn.commit()
|
|
main_conn.close()
|
|
|
|
background_done = threading.Event()
|
|
background_error = []
|
|
|
|
def background_work():
|
|
try:
|
|
bg_conn = create_configured_connection(
|
|
sqlcipher_module, temp_db_path, password
|
|
)
|
|
bg_conn.execute(
|
|
"INSERT INTO bg_data VALUES (1, 'background_thread')"
|
|
)
|
|
bg_conn.commit()
|
|
bg_conn.close()
|
|
except Exception as e:
|
|
background_error.append(str(e))
|
|
finally:
|
|
background_done.set()
|
|
|
|
# Start background thread
|
|
bg_thread = threading.Thread(target=background_work, daemon=True)
|
|
bg_thread.start()
|
|
|
|
# Wait for completion
|
|
background_done.wait(timeout=10)
|
|
|
|
assert not background_error, (
|
|
f"Background thread error: {background_error}"
|
|
)
|
|
|
|
# Verify data in main thread
|
|
main_conn = create_configured_connection(
|
|
sqlcipher_module, temp_db_path, password
|
|
)
|
|
result = main_conn.execute(
|
|
"SELECT source FROM bg_data WHERE id = 1"
|
|
).fetchone()
|
|
assert result[0] == "background_thread"
|
|
main_conn.close()
|
|
|
|
def test_main_thread_reads_background_writes(
|
|
self, sqlcipher_module, temp_db_path
|
|
):
|
|
"""Verify main thread can read data written by background thread."""
|
|
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: keep but consider removing sleep (not load-bearing for read-after-write semantics)).
|
|
password = "main_bg_test"
|
|
|
|
# Create database
|
|
conn = create_configured_connection(
|
|
sqlcipher_module, temp_db_path, password
|
|
)
|
|
conn.execute(
|
|
"CREATE TABLE bg_writes (id INTEGER PRIMARY KEY, timestamp REAL)"
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
write_count = 20
|
|
writes_done = threading.Event()
|
|
|
|
def writer_thread():
|
|
writer_conn = create_configured_connection(
|
|
sqlcipher_module, temp_db_path, password
|
|
)
|
|
for i in range(write_count):
|
|
writer_conn.execute(
|
|
"INSERT INTO bg_writes VALUES (?, ?)",
|
|
(i, time.time()),
|
|
)
|
|
writer_conn.commit()
|
|
time.sleep(0.01) # Small delay between writes
|
|
writer_conn.close()
|
|
writes_done.set()
|
|
|
|
# Start writer thread
|
|
writer = threading.Thread(target=writer_thread, daemon=True)
|
|
writer.start()
|
|
|
|
# Wait for writes to complete
|
|
writes_done.wait(timeout=30)
|
|
|
|
# Read from main thread
|
|
reader_conn = create_configured_connection(
|
|
sqlcipher_module, temp_db_path, password
|
|
)
|
|
count = reader_conn.execute(
|
|
"SELECT COUNT(*) FROM bg_writes"
|
|
).fetchone()[0]
|
|
assert count == write_count, f"Expected {write_count} rows, got {count}"
|
|
reader_conn.close()
|