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 @@
"""Authentication tests for LDR."""
+43
View File
@@ -0,0 +1,43 @@
"""
Pytest configuration for authentication tests.
"""
import os
import sys
from pathlib import Path
import pytest
# Add src to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
# Disable HTTPS for testing
os.environ["LDR_HTTPS_TESTING"] = "1"
@pytest.fixture(autouse=True)
def reset_singletons():
"""Reset singleton instances between tests."""
# Clear database manager connections
from local_deep_research.database.encrypted_db import db_manager
db_manager.close_all_databases()
# Clear auth session manager
from local_deep_research.web.auth.session_manager import session_manager
session_manager.sessions.clear()
yield
# Cleanup after test
db_manager.close_all_databases()
session_manager.sessions.clear()
@pytest.fixture
def mock_settings(monkeypatch):
"""Mock settings for testing."""
# Settings mocking is no longer needed since the deprecated function was removed
# The app will use default values from environment or settings files
pass
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""
Run authentication tests with proper configuration.
"""
import os
import subprocess
import sys
from pathlib import Path
# Add project root to Python path
project_root = str(Path(__file__).parent.parent.parent.resolve())
sys.path.insert(0, project_root)
# Set test environment
# Note: PYTEST_CURRENT_TEST is automatically set by pytest
os.environ["LDR_HTTPS_TESTING"] = "1"
def main():
"""Run authentication tests."""
print("Running LDR Authentication Tests...")
print("=" * 60)
# Change to project root
os.chdir(project_root)
# Run tests with pytest
cmd = [
sys.executable,
"-m",
"pytest",
"tests/auth_tests/",
"-v", # Verbose output
"--tb=short", # Short traceback format
"--strict-markers", # Strict marker usage
"-p",
"no:warnings", # Disable warnings
]
# Add coverage if requested
if "--coverage" in sys.argv:
cmd.extend(
[
"--cov=src/local_deep_research/web/auth",
"--cov=src/local_deep_research/database/encrypted_db",
"--cov=src/local_deep_research/database/auth_db",
"--cov-report=term-missing",
"--cov-report=html:htmlcov",
]
)
print("Running with coverage reporting...")
# Run the tests
result = subprocess.run(cmd)
if result.returncode == 0:
print("\n" + "=" * 60)
print("✅ All authentication tests passed!")
if "--coverage" in sys.argv:
print("📊 Coverage report generated in htmlcov/index.html")
else:
print("\n" + "=" * 60)
print("❌ Some tests failed!")
sys.exit(1)
if __name__ == "__main__":
main()
+200
View File
@@ -0,0 +1,200 @@
"""
Test authentication decorators and middleware.
"""
import pytest
from flask import Flask, g, session
from local_deep_research.web.auth.decorators import (
current_user,
inject_current_user,
login_required,
)
@pytest.fixture
def app():
"""Create a minimal Flask app for testing decorators."""
app = Flask(__name__)
app.config["SECRET_KEY"] = "test-secret-key"
app.config["TESTING"] = True
app.config["WTF_CSRF_ENABLED"] = False
# Create a minimal auth blueprint for testing
from flask import Blueprint
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
@auth_bp.route("/login")
def login():
return "Login page"
app.register_blueprint(auth_bp)
# Add test routes
@app.route("/protected")
@login_required
def protected():
return "Protected content"
@app.route("/public")
def public():
return "Public content"
@app.route("/user-info")
@login_required
def user_info():
return f"User: {current_user()}"
# Register before_request handler
app.before_request(inject_current_user)
return app
@pytest.fixture
def client(app):
"""Create a test client."""
return app.test_client()
class TestAuthDecorators:
"""Test authentication decorators."""
def test_login_required_redirects(self, client):
"""Test that login_required redirects unauthenticated users."""
response = client.get("/protected")
assert response.status_code == 302
assert "/auth/login" in response.location
# Check that next parameter is set (URL encoded)
assert "next=" in response.location
assert "protected" in response.location
def test_login_required_allows_authenticated(self, client, monkeypatch):
"""Test that login_required allows authenticated users."""
# Mock the database manager to simulate having a connection
from local_deep_research.database.encrypted_db import db_manager
from unittest.mock import MagicMock
# Mock the connections dictionary to have an entry for our test user
mock_connections = {"testuser": MagicMock()}
monkeypatch.setattr(db_manager, "connections", mock_connections)
# Mock get_session to return a valid session
mock_session = MagicMock()
monkeypatch.setattr(
db_manager,
"get_session",
lambda username: mock_session if username == "testuser" else None,
)
with client.session_transaction() as sess:
sess["username"] = "testuser"
response = client.get("/protected")
assert response.status_code == 200
assert b"Protected content" in response.data
def test_public_route_accessible(self, client):
"""Test that public routes are accessible without auth."""
response = client.get("/public")
assert response.status_code == 200
assert b"Public content" in response.data
def test_current_user_function(self, client, monkeypatch):
"""Test the current_user helper function."""
# Mock the database manager to simulate having a connection
from local_deep_research.database.encrypted_db import db_manager
from unittest.mock import MagicMock
# Mock for logged in user
mock_connections = {"testuser": MagicMock()}
monkeypatch.setattr(db_manager, "connections", mock_connections)
mock_session = MagicMock()
monkeypatch.setattr(
db_manager,
"get_session",
lambda username: mock_session if username == "testuser" else None,
)
# Test not logged in scenario
with client.session_transaction() as sess:
assert "username" not in sess
# Make a request to establish request context
response = client.get("/public")
assert response.status_code == 200
# Test current_user during a request
with client.application.test_request_context("/"):
# Set up an empty session to simulate not logged in
with client.session_transaction() as sess:
sess.clear()
assert current_user() is None
# Test logged in scenario
with client.session_transaction() as sess:
sess["username"] = "testuser"
response = client.get("/user-info")
assert response.status_code == 200
assert b"User: testuser" in response.data
def test_inject_current_user(self, app, client, monkeypatch):
"""Test that current user is injected into g."""
# Mock the database manager
from local_deep_research.database.encrypted_db import db_manager
from unittest.mock import MagicMock
# Mock for logged in user
mock_connections = {"testuser": MagicMock()}
monkeypatch.setattr(db_manager, "connections", mock_connections)
mock_session = MagicMock()
monkeypatch.setattr(
db_manager,
"get_session",
lambda username: mock_session if username == "testuser" else None,
)
# Test not logged in
with app.test_request_context("/"):
# Ensure session is empty
if "username" in session:
session.pop("username")
inject_current_user()
assert g.current_user is None
assert g.db_session is None
# Test logged in
with app.test_request_context("/"):
# Set session data directly within request context
session["username"] = "testuser"
inject_current_user()
assert g.current_user == "testuser"
# Session is now created lazily, not eagerly in inject_current_user
assert g.db_session is None
def test_login_required_with_missing_db_connection(
self, client, monkeypatch
):
"""Test login_required when database connection is missing."""
# Mock db_manager to simulate missing connection
class MockDbManager:
def is_user_connected(self, username):
return False
import local_deep_research.web.auth.decorators as decorators
monkeypatch.setattr(decorators, "db_manager", MockDbManager())
with client.session_transaction() as sess:
sess["username"] = "testuser"
response = client.get("/protected")
assert response.status_code == 302
assert "/auth/login" in response.location
# Session should be cleared
with client.session_transaction() as sess:
assert "username" not in sess
+311
View File
@@ -0,0 +1,311 @@
"""
Integration tests for the complete authentication system.
"""
import os
import shutil
import tempfile
from pathlib import Path
import pytest
from local_deep_research.database.auth_db import (
get_auth_db_session,
init_auth_database,
)
from local_deep_research.database.models.auth import User
from local_deep_research.web.app_factory import create_app
@pytest.fixture
def temp_data_dir():
"""Create a temporary data directory for testing."""
temp_dir = tempfile.mkdtemp()
yield Path(temp_dir)
shutil.rmtree(temp_dir)
@pytest.fixture
def app(temp_data_dir, monkeypatch):
"""Create a Flask app configured for testing."""
monkeypatch.setenv("LDR_DATA_DIR", str(temp_data_dir))
# Clear database manager state before creating app
from local_deep_research.database.encrypted_db import db_manager
db_manager.close_all_databases()
# Reset db_manager's data directory to temp directory
db_manager.data_dir = temp_data_dir / "encrypted_databases"
db_manager.data_dir.mkdir(parents=True, exist_ok=True)
# Remove any existing user databases in the temp directory
encrypted_db_dir = temp_data_dir / "encrypted_databases"
if encrypted_db_dir.exists():
shutil.rmtree(encrypted_db_dir)
encrypted_db_dir.mkdir(parents=True, exist_ok=True)
# Initialize fresh auth database
init_auth_database()
# Clean up any existing test users before starting
auth_db = get_auth_db_session()
auth_db.query(User).filter(User.username.like("integrationtest%")).delete()
auth_db.query(User).filter(User.username.like("testuser%")).delete()
auth_db.commit()
auth_db.close()
app, _ = create_app()
app.config["TESTING"] = True
app.config["WTF_CSRF_ENABLED"] = False
app.config["SESSION_COOKIE_SECURE"] = False
yield app
# Cleanup after test
db_manager.close_all_databases()
# Clean up test users from auth database
auth_db = get_auth_db_session()
auth_db.query(User).filter(User.username.like("integrationtest%")).delete()
auth_db.query(User).filter(User.username.like("testuser%")).delete()
auth_db.commit()
auth_db.close()
@pytest.fixture
def client(app):
"""Create a test client."""
return app.test_client()
class TestAuthIntegration:
"""Integration tests for authentication system."""
def test_full_user_lifecycle(self, client):
"""Test complete user lifecycle: register, login, use app, logout."""
# 1. Start unauthenticated - should redirect to login
response = client.get("/")
assert response.status_code == 302
assert "/auth/login" in response.location
# 2. Register new user
response = client.post(
"/auth/register",
data={
"username": "integrationtest",
"password": "TestPass123", # pragma: allowlist secret
"confirm_password": "TestPass123", # pragma: allowlist secret
"acknowledge": "true",
},
follow_redirects=True,
)
assert response.status_code == 200
# 3. Should be logged in automatically after registration
response = client.get("/")
assert response.status_code == 200
# The main page should show we're logged in (username might be displayed)
# 4. Access protected routes
response = client.get("/history")
assert response.status_code == 200
response = client.get("/settings")
assert response.status_code == 200
# 5. Check auth status
response = client.get("/auth/check")
assert response.status_code == 200
data = response.get_json()
assert data["authenticated"] is True
assert data["username"] == "integrationtest"
# 6. Logout
response = client.post("/auth/logout", follow_redirects=True)
assert response.status_code == 200
assert b"You have been logged out successfully" in response.data
# 7. Should not be able to access protected routes
response = client.get("/history")
assert response.status_code == 302
assert "/auth/login" in response.location
# 8. Login again
response = client.post(
"/auth/login",
data={"username": "integrationtest", "password": "TestPass123"},
follow_redirects=True,
)
assert response.status_code == 200
# 9. Should be able to access protected routes again
response = client.get("/history")
assert response.status_code == 200
def test_multiple_users(self, client):
"""Test that multiple users can register and have separate sessions."""
# Register first user
client.post(
"/auth/register",
data={
"username": "user1",
"password": "Password1X",
"confirm_password": "Password1X",
"acknowledge": "true",
},
)
# Check logged in as user1
response = client.get("/auth/check")
data = response.get_json()
assert data["username"] == "user1"
# Logout
client.post("/auth/logout")
# Register second user
client.post(
"/auth/register",
data={
"username": "user2",
"password": "Password2X",
"confirm_password": "Password2X",
"acknowledge": "true",
},
)
# Check logged in as user2
response = client.get("/auth/check")
data = response.get_json()
assert data["username"] == "user2"
# Verify both users exist in auth database
auth_db = get_auth_db_session()
users = auth_db.query(User).all()
assert len(users) == 2
usernames = [user.username for user in users]
assert "user1" in usernames
assert "user2" in usernames
auth_db.close()
def test_session_persistence(self, client):
"""Test that sessions persist across requests."""
# Register and login
client.post(
"/auth/register",
data={
"username": "sessiontest",
"password": "TestPass123", # pragma: allowlist secret
"confirm_password": "TestPass123", # pragma: allowlist secret
"acknowledge": "true",
},
)
# Make multiple requests
for _ in range(5):
response = client.get("/history")
assert response.status_code == 200
response = client.get("/auth/check")
data = response.get_json()
assert data["username"] == "sessiontest"
def test_protected_api_endpoints(self, client):
"""Test that API endpoints require authentication."""
# Test various API endpoints without auth
endpoints = [
("/api/start_research", "POST"),
("/api/history", "GET"),
("/settings/api", "GET"),
("/history/api", "GET"),
("/metrics/api/metrics", "GET"),
]
for endpoint, method in endpoints:
if method == "GET":
response = client.get(endpoint)
else:
response = client.post(endpoint, json={})
# All these endpoints contain the slash-bounded `api` segment
# (either /api/ in the middle or trailing /api), so the auth
# decorator returns JSON 401 for unauthenticated callers.
assert response.status_code == 401
assert response.get_json()["error"] == "Authentication required"
# Register and try again
client.post(
"/auth/register",
data={
"username": "apitest",
"password": "TestPass123", # pragma: allowlist secret
"confirm_password": "TestPass123", # pragma: allowlist secret
"acknowledge": "true",
},
)
# Now endpoints should be accessible (may return errors but not redirects)
for endpoint, method in endpoints:
if method == "GET":
response = client.get(endpoint)
else:
response = client.post(endpoint, json={})
assert response.status_code != 302 # Not a redirect
@pytest.mark.skipif(
os.environ.get("CI") == "true"
or os.environ.get("GITHUB_ACTIONS") == "true",
reason="Password change with encrypted DB re-keying is complex to test in CI",
)
def test_password_change_flow(self, client):
"""Test the complete password change flow."""
# Register user
client.post(
"/auth/register",
data={
"username": "changetest",
"password": "OldPass123",
"confirm_password": "OldPass123",
"acknowledge": "true",
},
)
# Change password
response = client.post(
"/auth/change-password",
data={
"current_password": "OldPass123",
"new_password": "NewPass456", # pragma: allowlist secret
"confirm_password": "NewPass456",
},
follow_redirects=True,
)
assert b"Password changed successfully" in response.data
assert b"Please login with your new password" in response.data
# Should be logged out
response = client.get("/auth/check")
assert response.status_code == 401
# Old password should fail
response = client.post(
"/auth/login",
data={"username": "changetest", "password": "OldPass123"},
)
assert response.status_code == 401
# New password should work
response = client.post(
"/auth/login",
data={"username": "changetest", "password": "NewPass456"},
follow_redirects=True,
)
assert response.status_code == 200
# Verify logged in
response = client.get("/auth/check")
data = response.get_json()
assert data["username"] == "changetest"
+518
View File
@@ -0,0 +1,518 @@
"""
Tests for authentication rate limiting (login and registration endpoints).
Tests that brute force protection is working correctly.
"""
import shutil
import tempfile
from pathlib import Path
import pytest
class TestAuthRateLimiting:
"""Test rate limiting on authentication endpoints."""
@pytest.fixture
def temp_data_dir(self):
"""Create a temporary data directory for testing."""
temp_dir = tempfile.mkdtemp()
yield Path(temp_dir)
shutil.rmtree(temp_dir, ignore_errors=True)
@pytest.fixture
def app(self, temp_data_dir, monkeypatch):
"""Create a test Flask app with rate limiting."""
monkeypatch.setenv("LDR_DATA_DIR", str(temp_data_dir))
from local_deep_research.database.auth_db import init_auth_database
from local_deep_research.database.encrypted_db import db_manager
from local_deep_research.web.app_factory import create_app
from local_deep_research.security.rate_limiter import limiter
# Reset db_manager state
db_manager.close_all_databases()
db_manager.data_dir = temp_data_dir / "encrypted_databases"
db_manager.data_dir.mkdir(parents=True, exist_ok=True)
# Initialize auth database
init_auth_database()
app, _ = create_app()
app.config["TESTING"] = True
app.config["WTF_CSRF_ENABLED"] = False # Disable CSRF for testing
# In CI, rate limiting is disabled at startup so init_app() returns
# early without initializing storage or registering error handlers.
# Re-call init_app() with RATELIMIT_ENABLED=True to fully initialize.
app.config["RATELIMIT_ENABLED"] = True
limiter.enabled = True
limiter.init_app(app)
# Reset limiter storage between tests to prevent cross-test pollution
limiter.reset()
yield app
# Restore original state
limiter.enabled = True
db_manager.close_all_databases()
@pytest.fixture
def client(self, app):
"""Create a test client."""
return app.test_client()
def test_login_rate_limit_allows_5_attempts(self, client):
"""Test that login allows 5 attempts before rate limiting."""
# Make 5 login attempts - should all be allowed
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: tighten to assert status_code == 401 (invalid creds), not membership of 3 codes).
for i in range(5):
response = client.post(
"/auth/login",
data={"username": f"testuser{i}", "password": "wrongpassword"},
follow_redirects=False,
)
# Should get 401 (invalid credentials), NOT 429 (rate limit).
# Tightened from `status_code in [200, 401, 400]` (PUNCHLIST
# H8_STATUS_OR) — that broad list masked bugs where the auth
# path silently returned 400 / 200 for wrong credentials.
assert response.status_code == 401, (
f"Attempt {i + 1}: wrong-credential login must return 401, "
f"got {response.status_code}"
)
def test_login_rate_limit_blocks_6th_attempt(self, client):
"""Test that login blocks the 6th attempt within 15 minutes."""
# Make 5 login attempts
for i in range(5):
client.post(
"/auth/login",
data={"username": f"testuser{i}", "password": "wrongpassword"},
)
# 6th attempt should be rate limited
response = client.post(
"/auth/login",
data={"username": "testuser6", "password": "wrongpassword"},
)
assert response.status_code == 429, "6th attempt should be rate limited"
def test_login_rate_limit_returns_proper_error(self, client):
"""Test that rate limit returns proper JSON error response."""
# Trigger rate limit
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: remove conditional — assert response.status_code == 429 unconditionally before checking body).
for i in range(6):
response = client.post(
"/auth/login",
data={"username": f"testuser{i}", "password": "wrongpassword"},
)
# Confirm rate limit actually fired before inspecting the body.
# Previously the body checks were gated by `if response.status_code == 429`,
# which silently passed when rate limiting didn't fire — masking real bugs.
assert response.status_code == 429, (
"6th attempt must be rate-limited; "
f"got status {response.status_code}"
)
data = response.get_json()
assert "error" in data
assert "message" in data
assert "Too many" in data["message"] or "Too many" in data["error"]
def test_login_rate_limit_includes_retry_after_header(self, client):
"""Test that 429 response includes Retry-After header."""
# Trigger rate limit
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: remove conditional and assert 429 first).
for i in range(6):
response = client.post(
"/auth/login",
data={"username": f"testuser{i}", "password": "wrongpassword"},
)
# Confirm rate limit actually fired before inspecting headers.
# Previously the header check was gated by `if response.status_code == 429`,
# which silently passed when rate limiting didn't fire.
assert response.status_code == 429, (
"6th attempt must be rate-limited; "
f"got status {response.status_code}"
)
assert (
"Retry-After" in response.headers
or "X-RateLimit-Reset" in response.headers
), "Rate limit response should include retry timing header"
def test_registration_rate_limit_allows_3_attempts(self, client):
"""Test that registration allows 3 attempts before rate limiting."""
# Make 3 registration attempts - should all be allowed
for i in range(3):
response = client.post(
"/auth/register",
data={
"username": f"newuser{i}",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
follow_redirects=False,
)
# Should get 200/302 (success/redirect) or 400 (validation error)
# but not 429 (rate limit)
assert response.status_code in [
200,
302,
400,
], f"Attempt {i + 1} should not be rate limited"
def test_registration_rate_limit_blocks_4th_attempt(self, client):
"""Test that registration blocks the 4th attempt within 1 hour."""
# Make 3 registration attempts
for i in range(3):
client.post(
"/auth/register",
data={
"username": f"newuser{i}",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
# 4th attempt should be rate limited
response = client.post(
"/auth/register",
data={
"username": "newuser4",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
assert response.status_code == 429, "4th attempt should be rate limited"
def test_password_change_rate_limit_blocks_6th_attempt(self, client, app):
"""Test that password change blocks the 6th attempt."""
# Disable exception propagation so template rendering errors
# (e.g. missing 'research.index' endpoint) return 500 instead
# of crashing the test. Rate limiting fires before the handler,
# so non-429 responses still count toward the limit.
app.config["PROPAGATE_EXCEPTIONS"] = False
app.config["TESTING"] = False
with client.session_transaction() as sess:
sess["username"] = "testuser"
for i in range(5):
response = client.post(
"/auth/change-password",
data={
"current_password": "",
"new_password": "NewStrongP4ss!",
"confirm_password": "NewStrongP4ss!",
},
)
assert response.status_code != 429, (
f"Attempt {i + 1} should not be rate limited"
)
# 6th attempt should be rate limited
response = client.post(
"/auth/change-password",
data={
"current_password": "",
"new_password": "NewStrongP4ss!",
"confirm_password": "NewStrongP4ss!",
},
)
assert response.status_code == 429, (
"6th password change attempt should be rate limited"
)
def test_password_change_has_separate_limit_from_login(self, client, app):
"""Test that login and password change have independent rate limits."""
app.config["PROPAGATE_EXCEPTIONS"] = False
app.config["TESTING"] = False
# Exhaust login limit (5 attempts)
for i in range(5):
client.post(
"/auth/login",
data={"username": f"testuser{i}", "password": "wrongpassword"},
)
# Verify login is rate limited
response = client.post(
"/auth/login",
data={"username": "testuser6", "password": "wrongpassword"},
)
assert response.status_code == 429, "Login should be rate limited"
# Password change should still work (separate scope)
with client.session_transaction() as sess:
sess["username"] = "testuser"
response = client.post(
"/auth/change-password",
data={
"current_password": "",
"new_password": "NewStrongP4ss!",
"confirm_password": "NewStrongP4ss!",
},
)
assert response.status_code != 429, (
"Password change should have separate rate limit from login"
)
def test_different_endpoints_have_separate_limits(self, client):
"""Test that login and registration have independent rate limits."""
# Exhaust login limit (5 attempts)
for i in range(5):
client.post(
"/auth/login",
data={"username": f"testuser{i}", "password": "wrongpassword"},
)
# Registration should still work (separate limit)
response = client.post(
"/auth/register",
data={
"username": "newuser1",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
assert response.status_code != 429, (
"Registration should have separate rate limit from login"
)
def test_rate_limit_is_per_ip(self, client, app):
"""Test that rate limiting is applied per IP address."""
# Make 5 requests from "IP 1"
for i in range(5):
with app.test_request_context(
"/auth/login",
method="POST",
environ_base={"REMOTE_ADDR": "192.168.1.1"},
):
client.post(
"/auth/login",
data={
"username": f"testuser{i}",
"password": "wrongpassword",
},
environ_base={"REMOTE_ADDR": "192.168.1.1"},
)
# 6th request from "IP 1" should be rate limited
response1 = client.post(
"/auth/login",
data={"username": "testuser6", "password": "wrongpassword"},
environ_base={"REMOTE_ADDR": "192.168.1.1"},
)
# Request from "IP 2" should still work (different IP)
response2 = client.post(
"/auth/login",
data={"username": "testuser7", "password": "wrongpassword"},
environ_base={"REMOTE_ADDR": "192.168.1.2"},
)
assert response1.status_code == 429, "IP 1 should be rate limited"
assert response2.status_code != 429, (
"IP 2 should not be rate limited (different IP)"
)
def test_proxy_headers_are_respected(self, client):
"""Test that X-Forwarded-For headers are used for rate limiting."""
# Make 5 requests with same X-Forwarded-For header
for i in range(5):
client.post(
"/auth/login",
data={"username": f"testuser{i}", "password": "wrongpassword"},
headers={"X-Forwarded-For": "10.0.0.1"},
)
# 6th request with same X-Forwarded-For should be rate limited
response = client.post(
"/auth/login",
data={"username": "testuser6", "password": "wrongpassword"},
headers={"X-Forwarded-For": "10.0.0.1"},
)
assert response.status_code == 429, (
"Requests from same X-Forwarded-For IP should be rate limited"
)
def test_successful_login_still_counts_toward_limit(self, client):
"""Test that successful logins also count toward rate limit."""
# This prevents attackers from resetting the limit with valid credentials
# Create a test user first (this uses the programmatic API, not the web endpoint)
from local_deep_research.database.encrypted_db import db_manager
test_username = "ratelimituser"
test_password = "TestPass123"
# Create user if doesn't exist
if not db_manager.user_exists(test_username):
db_manager.create_user_database(test_username, test_password)
# Make 5 successful login attempts
for i in range(5):
client.post(
"/auth/login",
data={"username": test_username, "password": test_password},
)
# 6th attempt should still be rate limited, even with valid credentials
response = client.post(
"/auth/login",
data={"username": test_username, "password": test_password},
)
assert response.status_code == 429, (
"Even successful logins should count toward rate limit"
)
# Clean up: delete test user
db_manager.close_user_database(test_username)
def test_account_enumeration_prevented(self, client):
"""Test that registration errors don't reveal username existence."""
# First, register a real user so the second attempt below collides.
# The `app` fixture scope is `function`, so the DB is fresh; the user
# this test relies on must be created here rather than depending on
# any other test's side effects.
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: unconditionally assert both responses == 400 before content checks).
existing_username = "enum_target_user"
setup_response = client.post(
"/auth/register",
data={
"username": existing_username,
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
# Registration must succeed for the rest of the test to be meaningful.
# Accept 200 or 302 (some flows redirect after success).
assert setup_response.status_code in (200, 201, 302), (
"Setup user registration must succeed; "
f"got {setup_response.status_code}"
)
# Try to register with a username that definitely doesn't exist
# but with an invalid password so validation rejects it.
response1 = client.post(
"/auth/register",
data={
"username": "definitelynonexistentuser12345",
"password": "short", # Will fail validation
"confirm_password": "short",
"acknowledge": "true",
},
)
# Try to register with the username that now exists (collision)
response2 = client.post(
"/auth/register",
data={
"username": existing_username,
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
# Both should return generic errors, not revealing if username exists.
# Previously the body checks were gated by
# `if response1.status_code == 400 and response2.status_code == 400`,
# which silently passed if either response was anything else — masking
# account-enumeration regressions (the whole point of this test). Worse,
# the test relied on a leftover user from a sibling test, which never
# existed because the fixture is function-scoped, so response2 was
# always a successful registration and the body checks were always
# skipped.
assert response1.status_code == 400, (
"Short-password registration must return 400; "
f"got {response1.status_code}"
)
assert response2.status_code == 400, (
"Duplicate-username registration must return 400; "
f"got {response2.status_code}"
)
# Check that error messages are generic
data2 = response2.get_data(as_text=True)
# Should NOT contain "Username already exists"
assert "Username already exists" not in data2, (
"Error should not reveal username existence"
)
# Should contain generic message
assert (
"Registration failed" in data2
or "try a different username" in data2
), "Should use generic error message"
class TestRateLimitReset:
"""Test that rate limits reset after the time window."""
@pytest.fixture
def temp_data_dir(self):
"""Create a temporary data directory for testing."""
temp_dir = tempfile.mkdtemp()
yield Path(temp_dir)
shutil.rmtree(temp_dir, ignore_errors=True)
@pytest.fixture
def app(self, temp_data_dir, monkeypatch):
"""Create a test Flask app with rate limiting."""
monkeypatch.setenv("LDR_DATA_DIR", str(temp_data_dir))
from local_deep_research.database.auth_db import init_auth_database
from local_deep_research.database.encrypted_db import db_manager
from local_deep_research.web.app_factory import create_app
from local_deep_research.security.rate_limiter import limiter
db_manager.close_all_databases()
db_manager.data_dir = temp_data_dir / "encrypted_databases"
db_manager.data_dir.mkdir(parents=True, exist_ok=True)
init_auth_database()
app, _ = create_app()
app.config["TESTING"] = True
app.config["WTF_CSRF_ENABLED"] = False
# Enable rate limiting AFTER init_app (CI sets LDR_DISABLE_RATE_LIMITING=true)
app.config["RATELIMIT_ENABLED"] = True
limiter.enabled = True
limiter.init_app(app)
limiter.reset()
yield app
db_manager.close_all_databases()
@pytest.fixture
def client(self, app):
"""Create a test client."""
return app.test_client()
@pytest.mark.slow
def test_rate_limit_resets_after_time_window(self, client):
"""Test that rate limit resets after 15 minutes (for login)."""
# Note: This test would take 15 minutes to run in real time
# In practice, you'd mock time or use a shorter limit for testing
pytest.skip(
"This test requires mocking time or waiting 15 minutes - "
"implement with time mocking if needed"
)
# Implementation would look like:
# 1. Make 5 login attempts
# 2. Verify 6th attempt is blocked
# 3. Fast-forward time by 15 minutes (using time mocking)
# 4. Verify new attempt is allowed
+870
View File
@@ -0,0 +1,870 @@
"""
Test authentication routes including login, register, and logout.
"""
import os
import shutil
import tempfile
from pathlib import Path
import pytest
from local_deep_research.database.auth_db import (
dispose_auth_engine,
get_auth_db_session,
init_auth_database,
)
from local_deep_research.database.encrypted_db import db_manager
from local_deep_research.database.models.auth import User
from local_deep_research.web.app_factory import create_app
@pytest.fixture
def temp_data_dir():
"""Create a temporary data directory for testing."""
temp_dir = tempfile.mkdtemp()
yield Path(temp_dir)
shutil.rmtree(temp_dir, ignore_errors=True)
@pytest.fixture
def app(temp_data_dir, monkeypatch):
"""Create a Flask app configured for testing."""
# Override data directory
monkeypatch.setenv("LDR_DATA_DIR", str(temp_data_dir))
# Disable rate limiting for tests
monkeypatch.setenv("LDR_DISABLE_RATE_LIMITING", "true")
# Use fast KDF iterations for tests (default 256000 is too slow in CI
# after lru_cache removal from _get_key_from_password)
monkeypatch.setenv("LDR_DB_CONFIG_KDF_ITERATIONS", "1000")
# Clear database manager state
db_manager.close_all_databases()
# Reset db_manager's data directory to temp directory
db_manager.data_dir = temp_data_dir / "encrypted_databases"
db_manager.data_dir.mkdir(parents=True, exist_ok=True)
# Create app with testing config
app, _ = create_app()
app.config["TESTING"] = True
app.config["WTF_CSRF_ENABLED"] = False
app.config["SESSION_COOKIE_SECURE"] = False # For testing without HTTPS
# Initialize auth database
init_auth_database()
# Clean up any existing test users
auth_db = get_auth_db_session()
auth_db.query(User).filter(User.username.like("testuser%")).delete()
auth_db.commit()
auth_db.close()
yield app
# Cleanup after test
db_manager.close_all_databases()
dispose_auth_engine()
@pytest.fixture
def client(app):
"""Create a test client."""
return app.test_client()
class TestAuthRoutes:
"""Test authentication routes."""
def test_root_redirects_to_login(self, client):
"""Test that unauthenticated users are redirected to login."""
response = client.get("/")
assert response.status_code == 302
assert "/auth/login" in response.location
def test_login_page_loads(self, client):
"""Test that login page loads successfully."""
response = client.get("/auth/login")
assert response.status_code == 200
assert b"Local Deep Research" in response.data
assert b"Your data is encrypted" in response.data
def test_register_page_loads(self, client):
"""Test that register page loads successfully."""
response = client.get("/auth/register")
assert response.status_code == 200
assert b"Create Account" in response.data
assert b"NO way to recover your data" in response.data
def test_successful_registration(self, client):
"""Test successful user registration."""
response = client.post(
"/auth/register",
data={
"username": "testuser",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
follow_redirects=True,
)
assert response.status_code == 200
# Check user was created in auth database
auth_db = get_auth_db_session()
user = auth_db.query(User).filter_by(username="testuser").first()
assert user is not None
assert user.username == "testuser"
auth_db.close()
# Check user database exists
assert db_manager.user_exists("testuser")
def test_registration_validation(self, client):
"""Test registration form validation."""
# Test missing fields
response = client.post("/auth/register", data={})
assert response.status_code == 400
assert b"Username is required" in response.data
# Test username with invalid characters (special chars not allowed)
response = client.post(
"/auth/register",
data={
"username": "@invalid!user",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
assert response.status_code == 400
assert b"Username can only contain" in response.data
# Test password mismatch
response = client.post(
"/auth/register",
data={
"username": "testuser",
"password": "password1",
"confirm_password": "password2",
"acknowledge": "true",
},
)
assert response.status_code == 400
assert b"Passwords do not match" in response.data
# Test missing acknowledgment
response = client.post(
"/auth/register",
data={
"username": "testuser",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "false",
},
)
assert response.status_code == 400
assert b"You must acknowledge" in response.data
def test_single_character_username(self, client):
"""Test that single character usernames are rejected (min 3 chars)."""
response = client.post(
"/auth/register",
data={
"username": "x",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
assert response.status_code == 400
assert b"Username must be at least 3 characters" in response.data
def test_empty_username_rejected(self, client):
"""Test that empty or whitespace-only usernames are rejected."""
# Test empty username
response = client.post(
"/auth/register",
data={
"username": "",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
assert response.status_code == 400
assert b"Username is required" in response.data
# Test whitespace-only username
response = client.post(
"/auth/register",
data={
"username": " ",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
assert response.status_code == 400
# Whitespace gets trimmed, so it's treated as empty
assert (
b"Username is required" in response.data
or b"Username can only contain" in response.data
)
def test_duplicate_username(self, client):
"""Test that duplicate usernames are rejected."""
# Register first user
client.post(
"/auth/register",
data={
"username": "testuser",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
# Logout so we can try registering again
client.post("/auth/logout")
# Try to register same username
response = client.post(
"/auth/register",
data={
"username": "testuser",
"password": "OtherPass123",
"confirm_password": "OtherPass123",
"acknowledge": "true",
},
)
assert response.status_code == 400
# Error message uses generic text to prevent account enumeration
assert (
b"Registration failed. Please try a different username"
in response.data
)
def test_orphaned_auth_row_cleaned_up_on_db_creation_failure(
self, client, monkeypatch
):
"""Registration must not leave a bricked account behind.
If ``create_user_database()`` fails after the auth-DB ``User`` row
has been committed, that orphaned row must be deleted. Otherwise the
username stays taken (``user_exists()`` blocks re-registration) while
login also fails (no encrypted DB to open) — a permanently unusable
account. Regression test for PR #3142.
"""
username = "testuser_orphan"
row_existed_at_failure = {}
def _fail_create(uname, password):
# The auth row is committed before create_user_database() runs.
# Record its presence here so the test proves the row was
# created-then-cleaned, not merely never created.
check_db = get_auth_db_session()
row_existed_at_failure["value"] = (
check_db.query(User).filter_by(username=uname).first()
is not None
)
check_db.close()
raise RuntimeError("simulated encrypted DB creation failure")
monkeypatch.setattr(db_manager, "create_user_database", _fail_create)
response = client.post(
"/auth/register",
data={
"username": username,
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
# Registration reports failure to the user...
assert response.status_code == 500
assert b"Registration failed" in response.data
# ...but only after the auth row was actually committed...
assert row_existed_at_failure.get("value") is True
# ...and the orphaned row must have been cleaned up, freeing the
# username so the user can retry registration.
auth_db = get_auth_db_session()
orphan = auth_db.query(User).filter_by(username=username).first()
auth_db.close()
assert orphan is None
def test_partial_db_creation_failure_leaves_username_reusable(
self, client, monkeypatch
):
"""A real mid-flight ``create_user_database()`` failure must leave no
on-disk residue that blocks re-registration.
``create_user_database()`` writes a per-database ``.salt`` file before
the DB itself. If a later step fails and only the ``.db`` is removed,
the orphaned salt makes ``create_database_salt()`` raise
``FileExistsError`` on every retry — so the username stays permanently
un-registerable even after the auth row is cleaned up. This drives a
genuine failure *after* the salt is written (by making the first
SQLCipher connection raise) and asserts the username can be registered
afterwards. Complements the stubbed auth-row test above by exercising
the real filesystem cleanup end-to-end.
"""
if not db_manager.has_encryption:
pytest.skip(
"Salt files only exist on the encrypted (SQLCipher) path"
)
import local_deep_research.database.encrypted_db as encrypted_db
from local_deep_research.database.sqlcipher_utils import (
get_salt_file_path,
)
username = "testuser_partial"
real_create_conn = encrypted_db.create_sqlcipher_connection
calls = {"n": 0}
def _flaky_conn(*args, **kwargs):
calls["n"] += 1
if calls["n"] == 1:
# Fail the first creation attempt, AFTER the real salt file has
# already been written by create_database_salt().
raise RuntimeError("simulated SQLCipher failure")
return real_create_conn(*args, **kwargs)
monkeypatch.setattr(
encrypted_db, "create_sqlcipher_connection", _flaky_conn
)
register_data = {
"username": username,
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
}
# First attempt fails mid-creation.
first = client.post("/auth/register", data=register_data)
assert first.status_code == 500
# The injected failure actually fired (not some unrelated 500).
assert calls["n"] >= 1
# The salt file (and DB) must have been cleaned up, not orphaned —
# otherwise the retry below would trip create_database_salt()'s
# FileExistsError.
salt_path = get_salt_file_path(db_manager._get_user_db_path(username))
assert not salt_path.exists()
# The username must now be genuinely reusable: a retry (which no longer
# trips the injected failure) succeeds end-to-end and actually creates
# the encrypted DB file (user_exists() only checks the auth DB).
second = client.post(
"/auth/register", data=register_data, follow_redirects=True
)
assert second.status_code == 200
assert db_manager.user_exists(username)
assert db_manager._get_user_db_path(username).exists()
def test_migration_failure_leaves_username_reusable(
self, client, monkeypatch
):
"""The *migration-path* cleanup site must also leave no on-disk residue.
``create_user_database()`` has two failure-cleanup sites: the SQLCipher
structure-creation step (exercised by the test above) and the alembic
migration step (``initialize_database``). The migration failure — e.g.
a world-writable migrations dir — is the historically realistic one
(see the "no such table: papers" / #3635 lineage). This drives a failure
there, *after* the real ``.db`` and salt have been written, and asserts
both are removed and the username is reusable — guarding the second
cleanup call site, which the SQLCipher-path test cannot reach.
"""
if not db_manager.has_encryption:
pytest.skip(
"Salt files only exist on the encrypted (SQLCipher) path"
)
import local_deep_research.database.initialize as initialize_mod
from local_deep_research.database.sqlcipher_utils import (
get_salt_file_path,
)
username = "testuser_migfail"
real_initialize = initialize_mod.initialize_database
calls = {"n": 0}
def _flaky_initialize(*args, **kwargs):
calls["n"] += 1
if calls["n"] == 1:
# Fail the first attempt at the migration step, AFTER the real
# .db + salt were created by the SQLCipher structure step.
raise RuntimeError("simulated migration failure")
return real_initialize(*args, **kwargs)
# Patch on the source module (not encrypted_db): create_user_database
# does a *function-level* `from .initialize import initialize_database`
# that re-reads the module attribute at call time, so patching
# initialize_mod.initialize_database takes effect. (A module-level
# import in encrypted_db would need the patch applied there instead.)
monkeypatch.setattr(
initialize_mod, "initialize_database", _flaky_initialize
)
register_data = {
"username": username,
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
}
db_path = db_manager._get_user_db_path(username)
# First attempt fails at the migration step — after .db + salt exist.
first = client.post("/auth/register", data=register_data)
assert first.status_code == 500
assert calls["n"] >= 1 # the injected migration failure actually fired
# The migration-path cleanup must remove BOTH the .db and its salt
# (the pre-fix code unlinked only the .db).
assert not db_path.exists()
assert not get_salt_file_path(db_path).exists()
# The username is reusable: the retry (no longer failing) succeeds.
second = client.post(
"/auth/register", data=register_data, follow_redirects=True
)
assert second.status_code == 200
assert db_manager.user_exists(username)
assert db_path.exists()
def test_registration_recovers_from_orphaned_salt(self, client):
"""A pre-existing salt with no matching database must not permanently
block registration.
A create that dies before completing — a hard process kill mid-creation,
or the pre-#4934 cleanup that removed only the ``.db`` — can leave a
``.salt`` file with no ``.db``. ``create_database_salt()`` refuses to
overwrite it (``FileExistsError``), so without recovery the username is
permanently un-registerable. This simulates that orphan and asserts
registration heals it (removes the stale salt, creates fresh) and
succeeds. Fails without the salt-creation recovery (the first
registration 500s on the FileExistsError).
The 200 alone proves the DB was created and opens with the *fresh*
salt: create_user_database() runs initialize_database() against the
newly-keyed DB, which 500s if the salt/key are inconsistent. We also
assert the on-disk salt bytes changed, to prove regenerate-not-reuse.
"""
if not db_manager.has_encryption:
pytest.skip(
"Salt files only exist on the encrypted (SQLCipher) path"
)
from local_deep_research.database.sqlcipher_utils import (
create_database_salt,
get_salt_file_path,
)
username = "testuser_saltorphan"
db_path = db_manager._get_user_db_path(username)
db_path.parent.mkdir(parents=True, exist_ok=True)
# Simulate the orphan: a salt file with no matching database.
create_database_salt(db_path)
salt_path = get_salt_file_path(db_path)
assert salt_path.exists()
assert not db_path.exists()
planted_salt = salt_path.read_bytes()
# Registration must recover (remove the orphan, create fresh) and
# succeed end-to-end rather than 500 on the salt collision.
response = client.post(
"/auth/register",
data={
"username": username,
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
follow_redirects=True,
)
assert response.status_code == 200
assert db_manager.user_exists(username)
assert db_path.exists()
# A freshly-generated salt now backs the real database — the recovery
# removed and recreated it, it did not reuse the planted orphan.
assert salt_path.exists()
assert salt_path.read_bytes() != planted_salt
def test_engine_build_failure_leaves_username_reusable(
self, client, monkeypatch
):
"""A failure building the SQLAlchemy engine — the step BETWEEN the
structure-creation and migration cleanup blocks — must still clean up
the already-created .db and salt.
Without cleanup at this step a failure here orphans the .db, and unlike
the salt path it is NOT self-healing: the retry trips
create_user_database's ``db_path.exists()`` guard and bricks the
username permanently. This injects an engine-build failure after the
structure step has written the .db + salt and asserts both are removed
and the username is reusable.
"""
if not db_manager.has_encryption:
pytest.skip(
"Salt files only exist on the encrypted (SQLCipher) path"
)
import local_deep_research.database.encrypted_db as encrypted_db
from local_deep_research.database.sqlcipher_utils import (
get_salt_file_path,
)
username = "testuser_engfail"
real_create_engine = encrypted_db.create_engine
calls = {"n": 0}
def _flaky_create_engine(*args, **kwargs):
calls["n"] += 1
if calls["n"] == 1:
# Fail the first engine build, AFTER the structure step wrote
# the real .db + salt.
raise RuntimeError("simulated engine build failure")
return real_create_engine(*args, **kwargs)
monkeypatch.setattr(encrypted_db, "create_engine", _flaky_create_engine)
register_data = {
"username": username,
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
}
db_path = db_manager._get_user_db_path(username)
first = client.post("/auth/register", data=register_data)
assert first.status_code == 500
# Exactly one create_engine call fired (the F1a engine build) and it
# raised — pins the failure to that site, not some earlier 500.
assert calls["n"] == 1
# Both the .db and its salt must have been removed, not orphaned.
assert not db_path.exists()
assert not get_salt_file_path(db_path).exists()
# The username is reusable: the retry (no longer failing) succeeds.
second = client.post(
"/auth/register", data=register_data, follow_redirects=True
)
assert second.status_code == 200
assert db_manager.user_exists(username)
assert db_path.exists()
def test_post_creation_session_failure_does_not_leave_user_logged_in(
self, client, monkeypatch
):
"""If the post-creation session setup fails, the user must not be left
half-logged-in.
create_user_database() succeeds (the account is legitimately created),
but a later step in _create_user_session — storing the session password
— fails. Registration returns 500, and the Flask session must NOT retain
the auth keys: otherwise the partially-set cookie would effectively log
the user in on their next request despite the reported failure.
"""
from local_deep_research.database.session_passwords import (
session_password_store,
)
from local_deep_research.web.auth.session_manager import (
session_manager,
)
username = "testuser_sessfail"
captured = {}
def _boom(*args, **kwargs):
# store_session_password(username, session_id, password) — capture
# the server-side session_id so we can assert it was torn down.
captured["session_id"] = (
args[1] if len(args) > 1 else kwargs.get("session_id")
)
raise RuntimeError("simulated post-creation session failure")
# Fails AFTER _create_user_session has already set session_id/username/
# temp_auth_token in the cookie AND created the server-side session —
# exactly the half-logged-in window.
monkeypatch.setattr(
session_password_store, "store_session_password", _boom
)
response = client.post(
"/auth/register",
data={
"username": username,
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
assert response.status_code == 500
assert captured.get("session_id") # the failing step actually ran
# 1. Cookie rolled back: no auth material survives (session_id/username
# gate login; temp_auth_token is credential-lookup material).
with client.session_transaction() as sess:
assert "session_id" not in sess
assert "username" not in sess
assert "temp_auth_token" not in sess
# 2. Server-side state torn down too (not just the cookie): the session
# and its password-store entry must be gone, or they leak.
assert session_manager.validate_session(captured["session_id"]) is None
assert (
session_password_store.get_session_password(
username, captured["session_id"]
)
is None
)
# 3. End-to-end: the user is genuinely not authenticated — a protected
# route redirects to login.
protected = client.get("/")
assert protected.status_code == 302
assert "/auth/login" in protected.location
def test_successful_login(self, client):
"""Test successful login."""
# Register user first
client.post(
"/auth/register",
data={
"username": "testuser",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
# Logout
client.post("/auth/logout")
# Login
response = client.post(
"/auth/login",
data={"username": "testuser", "password": "TestPass123"},
follow_redirects=True,
)
assert response.status_code == 200
# Check session
with client.session_transaction() as sess:
assert "username" in sess
assert sess["username"] == "testuser"
def test_invalid_login(self, client):
"""Test login with invalid credentials."""
response = client.post(
"/auth/login",
data={"username": "nonexistent", "password": "wrongpassword"},
)
assert response.status_code == 401
assert b"Invalid username or password" in response.data
@pytest.mark.skipif(
os.environ.get("CI") == "true"
or os.environ.get("GITHUB_ACTIONS") == "true",
reason="Encrypted DB registration can hit sqlcipher hmac errors in CI",
)
def test_logout(self, client):
"""Test logout functionality."""
# Register and login
reg_response = client.post(
"/auth/register",
data={
"username": "testuser",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
follow_redirects=False,
)
assert reg_response.status_code == 302, (
f"Registration failed with status {reg_response.status_code}"
)
# Verify logged in
with client.session_transaction() as sess:
assert sess.get("username") == "testuser"
# Logout
response = client.post("/auth/logout", follow_redirects=False)
assert response.status_code == 302
assert "/auth/login" in response.location
# Check session is cleared
with client.session_transaction() as sess:
assert "username" not in sess
@pytest.mark.skipif(
os.environ.get("CI") == "true"
or os.environ.get("GITHUB_ACTIONS") == "true",
reason="Password change with encrypted DB re-keying is complex to test in CI",
)
def test_change_password(self, client):
"""Test password change functionality."""
# Register user
client.post(
"/auth/register",
data={
"username": "testuser",
"password": "OldPass123",
"confirm_password": "OldPass123",
"acknowledge": "true",
},
)
# Change password - don't follow redirects to check status
response = client.post(
"/auth/change-password",
data={
"current_password": "OldPass123",
"new_password": "NewPass456",
"confirm_password": "NewPass456",
},
follow_redirects=False,
)
# Should redirect to login after successful password change
assert response.status_code == 302
assert "/auth/login" in response.location
# Now follow the redirect to login page
response = client.get("/auth/login")
assert response.status_code == 200
# Try to login with new password
response = client.post(
"/auth/login",
data={"username": "testuser", "password": "NewPass456"},
follow_redirects=True,
)
assert response.status_code == 200
def test_remember_me(self, client):
"""Test remember me functionality."""
# Register user
client.post(
"/auth/register",
data={
"username": "testuser",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
# Logout
client.post("/auth/logout")
# Login with remember me
client.post(
"/auth/login",
data={
"username": "testuser",
"password": "TestPass123",
"remember": "true",
},
)
with client.session_transaction() as sess:
assert sess.permanent is True
@pytest.mark.skipif(
os.environ.get("CI") == "true"
or os.environ.get("GITHUB_ACTIONS") == "true",
reason="Encrypted DB registration can hit sqlcipher hmac errors in CI",
)
def test_auth_check_endpoint(self, client):
"""Test the authentication check endpoint."""
# Not logged in
response = client.get("/auth/check")
assert response.status_code == 401
data = response.get_json()
assert data["authenticated"] is False
# Register and check
reg_response = client.post(
"/auth/register",
data={
"username": "testuser",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
follow_redirects=False,
)
assert reg_response.status_code == 302, (
f"Registration failed with status {reg_response.status_code}"
)
response = client.get("/auth/check")
assert response.status_code == 200
data = response.get_json()
assert data["authenticated"] is True
assert data["username"] == "testuser"
def test_blocked_registration_get(self, client, monkeypatch):
"""Test that GET register redirects when registrations are disabled."""
def mock_load_config():
return {"allow_registrations": False}
monkeypatch.setattr(
"local_deep_research.web.auth.routes.load_server_config",
mock_load_config,
)
response = client.get("/auth/register", follow_redirects=False)
assert response.status_code == 302
assert "/auth/login" in response.location
def test_blocked_registration_post(self, client, monkeypatch):
"""Test that POST register redirects and doesn't create user when disabled."""
def mock_load_config():
return {"allow_registrations": False}
monkeypatch.setattr(
"local_deep_research.web.auth.routes.load_server_config",
mock_load_config,
)
response = client.post(
"/auth/register",
data={
"username": "testuser_blocked",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
follow_redirects=False,
)
assert response.status_code == 302
assert "/auth/login" in response.location
# Check no user was created
auth_db = get_auth_db_session()
user = (
auth_db.query(User).filter_by(username="testuser_blocked").first()
)
assert user is None
auth_db.close()
+446
View File
@@ -0,0 +1,446 @@
"""
Test encrypted database management.
"""
import os
import shutil
import tempfile
from pathlib import Path
import pytest
from sqlalchemy import inspect, text
from local_deep_research.database.auth_db import get_auth_db_session
from local_deep_research.database.encrypted_db import DatabaseManager
from local_deep_research.database.models.auth import User
@pytest.fixture
def temp_data_dir():
"""Create a temporary data directory for testing."""
temp_dir = tempfile.mkdtemp()
yield Path(temp_dir)
shutil.rmtree(temp_dir)
@pytest.fixture
def db_manager(temp_data_dir, monkeypatch):
"""Create a DatabaseManager with test configuration."""
monkeypatch.setenv("LDR_DATA_DIR", str(temp_data_dir))
manager = DatabaseManager()
manager.data_dir = temp_data_dir / "encrypted_databases"
manager.data_dir.mkdir(parents=True, exist_ok=True)
return manager
@pytest.fixture
def auth_user(temp_data_dir, monkeypatch):
"""Create a test user in auth database."""
monkeypatch.setenv("LDR_DATA_DIR", str(temp_data_dir))
# Initialize auth database
from local_deep_research.database.auth_db import init_auth_database
init_auth_database()
# Create user
auth_db = get_auth_db_session()
user = User(username="testuser")
auth_db.add(user)
auth_db.commit()
auth_db.close()
return user
class TestDatabaseManager:
"""Test the DatabaseManager class."""
def test_user_db_path(self, db_manager):
"""Test generating user database paths."""
path = db_manager._get_user_db_path("testuser")
assert path.parent == db_manager.data_dir
assert path.name.startswith("ldr_user_")
assert path.name.endswith(".db")
# Same username should generate same path
path2 = db_manager._get_user_db_path("testuser")
assert path == path2
# Different username should generate different path
path3 = db_manager._get_user_db_path("otheruser")
assert path != path3
def test_create_user_database(self, db_manager, auth_user):
"""Test creating an encrypted database for a user."""
engine = db_manager.create_user_database("testuser", "testpassword123")
assert engine is not None
assert db_manager.is_user_connected("testuser")
# Test that database is encrypted and accessible
with engine.connect() as conn:
result = conn.execute(
text("SELECT name FROM sqlite_master WHERE type='table'")
)
tables = [row[0] for row in result]
assert len(tables) > 0 # Should have created tables
# Database file should exist
db_path = db_manager._get_user_db_path("testuser")
assert db_path.exists()
def test_create_duplicate_database(self, db_manager, auth_user):
"""Test that creating duplicate database fails."""
db_manager.create_user_database("testuser", "testpassword123")
with pytest.raises(ValueError, match="Database already exists"):
db_manager.create_user_database("testuser", "differentpassword")
def test_open_user_database(self, db_manager, auth_user):
"""Test opening an existing encrypted database."""
# Create database first
db_manager.create_user_database("testuser", "testpassword123")
db_manager.close_user_database("testuser")
# Open it
engine = db_manager.open_user_database("testuser", "testpassword123")
assert engine is not None
# Test access
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
def test_open_with_wrong_password(self, db_manager, auth_user):
"""Test that opening with wrong password fails."""
db_manager.create_user_database("testuser", "correctpassword")
db_manager.close_user_database("testuser")
engine = db_manager.open_user_database("testuser", "wrongpassword")
assert engine is None
def test_open_nonexistent_database(self, db_manager):
"""Test opening a database that doesn't exist."""
engine = db_manager.open_user_database("nonexistent", "password")
assert engine is None
def test_close_user_database(self, db_manager, auth_user):
"""Test closing a user's database connection."""
db_manager.create_user_database("testuser", "testpassword123")
assert db_manager.is_user_connected("testuser")
db_manager.close_user_database("testuser")
assert not db_manager.is_user_connected("testuser")
def test_get_session(self, db_manager, auth_user):
"""Test getting a database session."""
db_manager.create_user_database("testuser", "testpassword123")
session = db_manager.get_session("testuser")
assert session is not None
# get_session returns a new session each time in the current implementation
session2 = db_manager.get_session("testuser")
assert session2 is not None
def test_check_database_integrity(self, db_manager, auth_user):
"""Test checking database integrity."""
db_manager.create_user_database("testuser", "testpassword123")
is_valid = db_manager.check_database_integrity("testuser")
assert isinstance(is_valid, bool)
assert is_valid is True
# Test with non-existent user
is_valid = db_manager.check_database_integrity("nonexistent")
assert is_valid is False
@pytest.mark.skipif(
os.environ.get("CI") == "true"
or os.environ.get("GITHUB_ACTIONS") == "true",
reason="Password change with encrypted DB re-keying is complex to test in CI",
)
def test_change_password(self, db_manager, auth_user):
"""Test changing database encryption password."""
db_manager.create_user_database("testuser", "oldpassword")
# Change password
success = db_manager.change_password(
"testuser", "oldpassword", "newpassword"
)
assert success is True
# Try to open with new password
engine = db_manager.open_user_database("testuser", "newpassword")
assert engine is not None
# Old password should fail
db_manager.close_user_database("testuser")
engine = db_manager.open_user_database("testuser", "oldpassword")
assert engine is None
@pytest.mark.skipif(
os.environ.get("CI") == "true"
or os.environ.get("GITHUB_ACTIONS") == "true",
reason="Password change with encrypted DB re-keying is complex to test in CI",
)
def test_change_password_wrong_old(self, db_manager, auth_user):
"""Test changing password with wrong old password."""
db_manager.create_user_database("testuser", "correctpassword")
success = db_manager.change_password(
"testuser", "wrongpassword", "newpassword"
)
assert success is False
def test_user_exists(self, db_manager, auth_user):
"""Test checking if user exists."""
# User exists in auth DB
assert db_manager.user_exists("testuser") is True
# User doesn't exist
assert db_manager.user_exists("nonexistent") is False
def test_memory_usage(self, db_manager, auth_user):
"""Test getting memory usage statistics."""
stats = db_manager.get_memory_usage()
assert stats["active_connections"] == 0
assert stats["active_sessions"] == 0
assert stats["estimated_memory_mb"] == 0
# Create connections
db_manager.create_user_database("testuser", "password1")
db_manager.get_session("testuser")
stats = db_manager.get_memory_usage()
assert stats["active_connections"] == 1
assert stats["active_sessions"] == 0 # Sessions are not tracked
assert stats["estimated_memory_mb"] == 3.5
def test_pragmas_applied(self, db_manager, auth_user):
"""Test that SQLCipher pragmas are correctly applied."""
engine = db_manager.create_user_database("testuser", "testpassword123")
with engine.connect() as conn:
# Check journal mode
result = conn.execute(text("PRAGMA journal_mode"))
assert result.scalar() == "wal"
# Check cipher settings
result = conn.execute(text("PRAGMA kdf_iter"))
# Default KDF iterations
assert result.scalar() == "256000"
result = conn.execute(text("PRAGMA cipher_page_size"))
# Default page size is 16384 (16KB)
assert result.scalar() == "16384"
def test_is_user_connected(self, db_manager, auth_user):
"""Test the is_user_connected method."""
assert not db_manager.is_user_connected("testuser")
db_manager.create_user_database("testuser", "testpassword123")
assert db_manager.is_user_connected("testuser")
db_manager.close_user_database("testuser")
assert not db_manager.is_user_connected("testuser")
def test_whitespace_password_rejected(self, db_manager, auth_user):
"""Test that whitespace-only passwords are rejected."""
with pytest.raises(
ValueError, match="password cannot be None or empty"
):
db_manager.create_user_database("testuser", " ")
def test_create_database_runs_alembic_migrations(
self, db_manager, auth_user
):
"""create_user_database produces a database with alembic_version at head."""
engine = db_manager.create_user_database("testuser", "testpassword123")
inspector = inspect(engine)
assert "alembic_version" in inspector.get_table_names()
from local_deep_research.database.alembic_runner import (
get_current_revision,
get_head_revision,
)
assert get_current_revision(engine) == get_head_revision()
def test_open_database_runs_alembic_migrations(self, db_manager, auth_user):
"""open_user_database runs migrations on existing encrypted DB."""
db_manager.create_user_database("testuser", "testpassword123")
db_manager.close_user_database("testuser")
engine = db_manager.open_user_database("testuser", "testpassword123")
assert engine is not None
inspector = inspect(engine)
assert "alembic_version" in inspector.get_table_names()
from local_deep_research.database.alembic_runner import (
get_current_revision,
get_head_revision,
)
assert get_current_revision(engine) == get_head_revision()
class TestConnectionVerification:
"""Tests for connection verification functionality."""
def test_verify_connection_correct_password(self, db_manager, auth_user):
"""Verify connection verification returns True with correct password."""
from src.local_deep_research.database.sqlcipher_utils import (
apply_sqlcipher_pragmas,
set_sqlcipher_key,
verify_sqlcipher_connection,
)
from src.local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
# Create database
db_manager.create_user_database("testuser", "testpassword123")
db_path = db_manager._get_user_db_path("testuser")
db_manager.close_user_database("testuser")
# Verify connection with correct password
sqlcipher = get_sqlcipher_module()
conn = sqlcipher.connect(str(db_path))
cursor = conn.cursor()
# Existing database: key first, then cipher_* pragmas
set_sqlcipher_key(cursor, "testpassword123", db_path=db_path)
apply_sqlcipher_pragmas(cursor, creation_mode=False)
assert verify_sqlcipher_connection(cursor) is True
cursor.close()
conn.close()
def test_verify_connection_wrong_password(self, db_manager, auth_user):
"""Verify accessing encrypted data with wrong password fails."""
from src.local_deep_research.database.sqlcipher_utils import (
apply_sqlcipher_pragmas,
set_sqlcipher_key,
)
from src.local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
# Create database
db_manager.create_user_database("testuser", "correctpassword")
db_path = db_manager._get_user_db_path("testuser")
db_manager.close_user_database("testuser")
# Try to access encrypted data with wrong password
sqlcipher = get_sqlcipher_module()
conn = sqlcipher.connect(str(db_path))
cursor = conn.cursor()
set_sqlcipher_key(cursor, "wrongpassword", db_path=db_path)
apply_sqlcipher_pragmas(cursor, creation_mode=False)
cursor.close()
# Accessing actual encrypted data should fail
with pytest.raises(Exception):
conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
conn.close()
def test_all_pragmas_applied_comprehensive(self, db_manager, auth_user):
"""Comprehensive test verifying ALL SQLCipher pragmas are applied."""
from src.local_deep_research.database.sqlcipher_utils import (
get_sqlcipher_settings,
)
settings = get_sqlcipher_settings()
engine = db_manager.create_user_database("testuser", "testpassword123")
with engine.connect() as conn:
# Cipher settings (must be set before key)
page_size = conn.execute(text("PRAGMA cipher_page_size")).scalar()
assert str(page_size) == str(settings["page_size"]), (
f"cipher_page_size mismatch: expected {settings['page_size']}, got {page_size}"
)
hmac_algo = conn.execute(
text("PRAGMA cipher_hmac_algorithm")
).scalar()
assert hmac_algo == settings["hmac_algorithm"], (
f"cipher_hmac_algorithm mismatch: expected {settings['hmac_algorithm']}, got {hmac_algo}"
)
kdf_algo = conn.execute(
text("PRAGMA cipher_kdf_algorithm")
).scalar()
assert kdf_algo == settings["kdf_algorithm"], (
f"cipher_kdf_algorithm mismatch: expected {settings['kdf_algorithm']}, got {kdf_algo}"
)
# Settings that go after key
kdf_iter = conn.execute(text("PRAGMA kdf_iter")).scalar()
assert str(kdf_iter) == str(settings["kdf_iterations"]), (
f"kdf_iter mismatch: expected {settings['kdf_iterations']}, got {kdf_iter}"
)
# Performance settings
journal_mode = conn.execute(text("PRAGMA journal_mode")).scalar()
assert journal_mode.lower() == "wal", (
f"journal_mode should be WAL, got {journal_mode}"
)
temp_store = conn.execute(text("PRAGMA temp_store")).scalar()
assert temp_store == 2, (
f"temp_store should be 2 (MEMORY), got {temp_store}"
)
busy_timeout = conn.execute(text("PRAGMA busy_timeout")).scalar()
assert busy_timeout == 10000, (
f"busy_timeout should be 10000, got {busy_timeout}"
)
synchronous = conn.execute(text("PRAGMA synchronous")).scalar()
assert synchronous == 1, (
f"synchronous should be 1 (NORMAL), got {synchronous}"
)
def test_create_sqlcipher_connection_helper(self, db_manager, auth_user):
"""Test the create_sqlcipher_connection helper function."""
from src.local_deep_research.database.sqlcipher_utils import (
create_sqlcipher_connection,
)
# Create database first
db_manager.create_user_database("testuser", "testpassword123")
db_path = db_manager._get_user_db_path("testuser")
db_manager.close_user_database("testuser")
# Use helper function to open
conn = create_sqlcipher_connection(
str(db_path), password="testpassword123"
)
assert conn is not None
# Verify connection works
result = conn.execute("SELECT 1").fetchone()
assert result == (1,)
conn.close()
def test_create_sqlcipher_connection_wrong_password(
self, db_manager, auth_user
):
"""Test create_sqlcipher_connection fails with wrong password."""
from src.local_deep_research.database.sqlcipher_utils import (
create_sqlcipher_connection,
)
# Create database first
db_manager.create_user_database("testuser", "correctpassword")
db_path = db_manager._get_user_db_path("testuser")
db_manager.close_user_database("testuser")
# Wrong password should raise an error
with pytest.raises(Exception):
create_sqlcipher_connection(str(db_path), password="wrongpassword")
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""Test in-memory storage after encryption removal (issue #593)."""
from freezegun import freeze_time
from local_deep_research.database.session_passwords import (
session_password_store,
)
from local_deep_research.database.temp_auth import temp_auth_store
class TestSessionPasswordStore:
"""Test session password storage without encryption."""
def test_store_and_retrieve_password(self):
"""Test storing and retrieving a password."""
username = "test_user"
session_id = "test_session_123"
password = "test_password_456"
# Store password
session_password_store.store_session_password(
username, session_id, password
)
# Retrieve password
retrieved = session_password_store.get_session_password(
username, session_id
)
assert retrieved == password
def test_wrong_session_returns_none(self):
"""Test that wrong session ID returns None."""
username = "test_user"
session_id = "test_session_123"
password = "test_password_456"
session_password_store.store_session_password(
username, session_id, password
)
# Try with wrong session
wrong_retrieved = session_password_store.get_session_password(
username, "wrong_session"
)
assert wrong_retrieved is None
def test_clear_session(self):
"""Test clearing a session."""
username = "test_user"
session_id = "test_session_123"
password = "test_password_456"
session_password_store.store_session_password(
username, session_id, password
)
# Clear session
session_password_store.clear_session(username, session_id)
# Should return None after clearing
cleared_retrieved = session_password_store.get_session_password(
username, session_id
)
assert cleared_retrieved is None
def test_plain_text_storage(self):
"""Test that passwords are stored in plain text internally."""
username = "internal_test"
session_id = "internal_session"
password = "plain_text_password"
key = f"{username}:{session_id}"
session_password_store._store_credentials(
key, {"username": username, "password": password}
)
# Check internal storage directly
with session_password_store._lock:
stored_entry = session_password_store._store.get(key)
assert stored_entry is not None
assert "password" in stored_entry
assert stored_entry["password"] == password
assert "encrypted_password" not in stored_entry
# Clean up
session_password_store.clear_entry(key)
class TestTemporaryAuthStore:
"""Test temporary auth storage without encryption."""
def test_store_and_retrieve_auth(self):
"""Test storing and retrieving authentication."""
username = "test_user"
password = "test_password_789"
# Store auth
token = temp_auth_store.store_auth(username, password)
assert token is not None
# Retrieve auth (removes it)
retrieved = temp_auth_store.retrieve_auth(token)
assert retrieved == (username, password)
# Should be None after retrieval
retrieved_again = temp_auth_store.retrieve_auth(token)
assert retrieved_again is None
def test_peek_auth(self):
"""Test peeking at auth without removing it."""
username = "test_user"
password = "test_password_789"
token = temp_auth_store.store_auth(username, password)
# Peek at auth (doesn't remove)
peeked = temp_auth_store.peek_auth(token)
assert peeked == (username, password)
# Should still be there
peeked_again = temp_auth_store.peek_auth(token)
assert peeked_again == (username, password)
# Clean up
temp_auth_store.retrieve_auth(token)
def test_expiration(self):
"""Test that auth expires after TTL."""
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: replace with freezegun or monkeypatch on the store's clock).
username = "test_user"
password = "test_password_789"
# Store original TTL
original_ttl = temp_auth_store.ttl
try:
# Set very short TTL
temp_auth_store.ttl = 1 # 1 second
# SUT (credential_store_base) uses time.time() for TTL
# comparison, so freezegun fully mocks the clock —
# no real sleep required.
with freeze_time("2026-01-01 00:00:00") as frozen:
token = temp_auth_store.store_auth(username, password)
# Advance past TTL
frozen.tick(2)
# Should be expired
expired = temp_auth_store.peek_auth(token)
assert expired is None
finally:
# Restore original TTL
temp_auth_store.ttl = original_ttl
def test_plain_text_storage(self):
"""Test that auth is stored in plain text internally."""
username = "internal_test"
password = "plain_text_password"
token = "test_token_123"
temp_auth_store._store_credentials(
token, {"username": username, "password": password}
)
# Check internal storage directly
with temp_auth_store._lock:
stored_entry = temp_auth_store._store.get(token)
assert stored_entry is not None
assert "password" in stored_entry
assert stored_entry["password"] == password
assert "encrypted_password" not in stored_entry
# Clean up
temp_auth_store.clear_entry(token)
class TestEncryptionRemoval:
"""Test that encryption has been properly removed."""
def test_no_cryptography_import(self):
"""Test that cryptography is not imported in the base class."""
import local_deep_research.database.credential_store_base as store_base
# Check that Fernet is not in the module
assert not hasattr(store_base, "Fernet")
# Check that cipher attributes don't exist
test_store = session_password_store
assert not hasattr(test_store, "_cipher")
assert not hasattr(test_store, "_master_key")
def test_storage_format_consistency(self):
"""Test that all stores use the same plain text format."""
# Test data
username = "consistency_test"
password = "test_password"
# Session password store
session_key = f"{username}:session_123"
session_password_store._store_credentials(
session_key, {"username": username, "password": password}
)
# Temp auth store
auth_token = "auth_token_123"
temp_auth_store._store_credentials(
auth_token, {"username": username, "password": password}
)
# Check both have same structure
with session_password_store._lock:
session_entry = session_password_store._store.get(session_key)
assert session_entry["password"] == password
assert "username" in session_entry
assert "expires_at" in session_entry
with temp_auth_store._lock:
auth_entry = temp_auth_store._store.get(auth_token)
assert auth_entry["password"] == password
assert "username" in auth_entry
assert "expires_at" in auth_entry
# Clean up
session_password_store.clear_entry(session_key)
temp_auth_store.clear_entry(auth_token)
+172
View File
@@ -0,0 +1,172 @@
"""
Test session management functionality.
"""
from datetime import datetime
import pytest
from freezegun import freeze_time
from local_deep_research.web.auth.session_manager import SessionManager
class TestSessionManager:
"""Test the SessionManager class."""
@pytest.fixture
def session_manager(self):
"""Create a SessionManager instance."""
return SessionManager()
def test_create_session(self, session_manager):
"""Test creating a new session."""
session_id = session_manager.create_session("testuser")
assert session_id is not None
assert len(session_id) > 20 # Should be a long random string
assert session_id in session_manager.sessions
session_data = session_manager.sessions[session_id]
assert session_data["username"] == "testuser"
assert session_data["remember_me"] is False
assert isinstance(session_data["created_at"], datetime)
assert isinstance(session_data["last_access"], datetime)
def test_create_session_with_remember_me(self, session_manager):
"""Test creating a session with remember me enabled."""
session_id = session_manager.create_session(
"testuser", remember_me=True
)
session_data = session_manager.sessions[session_id]
assert session_data["remember_me"] is True
def test_validate_session_valid(self, session_manager):
"""Test validating a valid session."""
session_id = session_manager.create_session("testuser")
username = session_manager.validate_session(session_id)
assert username == "testuser"
# Check that last_access was updated
session_data = session_manager.sessions[session_id]
assert session_data["last_access"] > session_data["created_at"]
def test_validate_session_invalid(self, session_manager):
"""Test validating an invalid session."""
username = session_manager.validate_session("invalid-session-id")
assert username is None
def test_session_timeout(self, session_manager):
"""Test that sessions timeout after the specified period."""
with freeze_time("2024-01-01 12:00:00") as frozen_time:
session_id = session_manager.create_session("testuser")
# Session should be valid immediately
assert session_manager.validate_session(session_id) == "testuser"
# Move time forward by 1 hour 59 minutes
frozen_time.move_to("2024-01-01 13:59:00")
assert session_manager.validate_session(session_id) == "testuser"
# Move time forward by 2 hours 1 minute from last access (13:59)
# So we need to go to 16:00 for timeout (13:59 + 2:01)
frozen_time.move_to("2024-01-01 16:00:00")
assert session_manager.validate_session(session_id) is None
# Session should be removed
assert session_id not in session_manager.sessions
def test_remember_me_timeout(self, session_manager):
"""Test that remember me sessions have longer timeout."""
with freeze_time("2024-01-01 12:00:00") as frozen_time:
session_id = session_manager.create_session(
"testuser", remember_me=True
)
# Move forward 2 days - should still be valid
frozen_time.move_to("2024-01-03 12:00:00")
assert session_manager.validate_session(session_id) == "testuser"
# Move forward 29 days from start - should still be valid
frozen_time.move_to("2024-01-30 12:00:00")
assert session_manager.validate_session(session_id) == "testuser"
# Move forward 30 days + 1 minute from last access (Jan 30)
# So we need to go to March 1 for timeout
frozen_time.move_to("2024-03-01 12:01:00")
assert session_manager.validate_session(session_id) is None
def test_destroy_session(self, session_manager):
"""Test destroying a session."""
session_id = session_manager.create_session("testuser")
assert session_id in session_manager.sessions
session_manager.destroy_session(session_id)
assert session_id not in session_manager.sessions
# Destroying non-existent session should not raise error
session_manager.destroy_session("non-existent")
def test_cleanup_expired_sessions(self, session_manager):
"""Test cleaning up expired sessions."""
with freeze_time("2024-01-01 12:00:00") as frozen_time:
# Create multiple sessions
session1 = session_manager.create_session("user1")
session2 = session_manager.create_session("user2", remember_me=True)
frozen_time.move_to("2024-01-01 13:00:00")
session3 = session_manager.create_session("user3")
# Move forward 2.5 hours
frozen_time.move_to("2024-01-01 15:30:00")
# Cleanup
session_manager.cleanup_expired_sessions()
# Session 1 should be expired (created 3.5 hours ago)
assert session1 not in session_manager.sessions
# Session 2 should still exist (remember me)
assert session2 in session_manager.sessions
# Session 3 should be expired (created 2.5 hours ago)
assert session3 not in session_manager.sessions
def test_get_active_sessions_count(self, session_manager):
"""Test getting count of active sessions."""
assert session_manager.get_active_sessions_count() == 0
session_manager.create_session("user1")
session_manager.create_session("user2")
assert session_manager.get_active_sessions_count() == 2
# Add expired session
with freeze_time("2024-01-01 12:00:00") as frozen_time:
session_manager.create_session("user3")
frozen_time.move_to("2024-01-01 15:00:00")
# Should cleanup expired sessions and return correct count
assert session_manager.get_active_sessions_count() == 2
def test_get_user_sessions(self, session_manager):
"""Test getting all sessions for a user."""
# Create multiple sessions for same user
session1 = session_manager.create_session("testuser")
session2 = session_manager.create_session("testuser", remember_me=True)
# Create session for different user
session_manager.create_session("otheruser")
user_sessions = session_manager.get_user_sessions("testuser")
assert len(user_sessions) == 2
# Check session info
session_ids = [s["session_id"] for s in user_sessions]
assert session1[:8] + "..." in session_ids
assert session2[:8] + "..." in session_ids
# Check remember_me flags
remember_flags = [s["remember_me"] for s in user_sessions]
assert False in remember_flags
assert True in remember_flags
@@ -0,0 +1,644 @@
"""
Tests for SQLCipher backwards compatibility.
These tests verify that databases can be created, closed, and reopened
correctly across multiple sessions, ensuring data persistence and
settings consistency.
"""
import shutil
import tempfile
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_compat.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.
Uses the correct PRAGMA ordering:
- New DB: cipher_default_* -> key -> kdf_iter -> performance
- Existing DB: key -> cipher_* + kdf_iter -> performance
"""
creation_mode = not db_path.exists()
conn = sqlcipher_module.connect(str(db_path))
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 TestCreateCloseReopen:
"""Tests for create -> close -> reopen cycles."""
def test_create_and_reopen_same_session(
self, sqlcipher_module, temp_db_path
):
"""Verify database can be created and reopened within same test."""
password = "test_password_123"
# Create database
conn1 = create_configured_connection(
sqlcipher_module, temp_db_path, password
)
conn1.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, data TEXT)")
conn1.execute("INSERT INTO test VALUES (1, 'test_data')")
conn1.commit()
conn1.close()
# Reopen database
conn2 = create_configured_connection(
sqlcipher_module, temp_db_path, password
)
result = conn2.execute("SELECT data FROM test WHERE id = 1").fetchone()
assert result[0] == "test_data", (
"Data should persist after close/reopen"
)
conn2.close()
def test_create_reopen_multiple_times(self, sqlcipher_module, temp_db_path):
"""Verify database can be opened and closed multiple times."""
password = "test_password_456"
# 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()
# Open and close 5 times, incrementing counter each time
for i in range(5):
conn = create_configured_connection(
sqlcipher_module, temp_db_path, password
)
conn.execute("UPDATE counter SET count = count + 1 WHERE id = 1")
conn.commit()
conn.close()
# Final open to verify counter
conn = create_configured_connection(
sqlcipher_module, temp_db_path, password
)
result = conn.execute(
"SELECT count FROM counter WHERE id = 1"
).fetchone()
assert result[0] == 5, (
f"Counter should be 5 after 5 increments, got {result[0]}"
)
conn.close()
def test_data_persists_across_sessions(
self, sqlcipher_module, temp_db_path
):
"""Verify complex data persists correctly across sessions."""
password = "persistence_test"
# Session 1: Create database with multiple tables and data
conn1 = create_configured_connection(
sqlcipher_module, temp_db_path, password
)
conn1.execute("""
CREATE TABLE users (
id INTEGER PRIMARY KEY,
username TEXT UNIQUE,
email TEXT
)
""")
conn1.execute("""
CREATE TABLE settings (
user_id INTEGER,
key TEXT,
value TEXT,
FOREIGN KEY(user_id) REFERENCES users(id)
)
""")
# Insert test data
conn1.execute(
"INSERT INTO users VALUES (1, 'alice', 'alice@example.com')"
)
conn1.execute("INSERT INTO users VALUES (2, 'bob', 'bob@example.com')")
conn1.execute("INSERT INTO settings VALUES (1, 'theme', 'dark')")
conn1.execute("INSERT INTO settings VALUES (1, 'language', 'en')")
conn1.execute("INSERT INTO settings VALUES (2, 'theme', 'light')")
conn1.commit()
conn1.close()
# Session 2: Verify all data persists
conn2 = create_configured_connection(
sqlcipher_module, temp_db_path, password
)
# Check users
users = conn2.execute(
"SELECT username, email FROM users ORDER BY id"
).fetchall()
assert users == [
("alice", "alice@example.com"),
("bob", "bob@example.com"),
]
# Check settings
alice_settings = conn2.execute(
"SELECT key, value FROM settings WHERE user_id = 1 ORDER BY key"
).fetchall()
assert alice_settings == [("language", "en"), ("theme", "dark")]
bob_settings = conn2.execute(
"SELECT key, value FROM settings WHERE user_id = 2"
).fetchall()
assert bob_settings == [("theme", "light")]
conn2.close()
def test_settings_match_after_reopen(self, sqlcipher_module, temp_db_path):
"""Verify SQLCipher settings remain consistent after reopen."""
password = "settings_test"
# Create database
conn1 = create_configured_connection(
sqlcipher_module, temp_db_path, password
)
conn1.execute("CREATE TABLE test (id INTEGER)")
conn1.commit()
# Capture settings from first connection
original_page_size = conn1.execute(
"PRAGMA cipher_page_size"
).fetchone()[0]
original_hmac = conn1.execute(
"PRAGMA cipher_hmac_algorithm"
).fetchone()[0]
original_kdf = conn1.execute("PRAGMA cipher_kdf_algorithm").fetchone()[
0
]
conn1.close()
# Reopen and verify settings
conn2 = create_configured_connection(
sqlcipher_module, temp_db_path, password
)
new_page_size = conn2.execute("PRAGMA cipher_page_size").fetchone()[0]
new_hmac = conn2.execute("PRAGMA cipher_hmac_algorithm").fetchone()[0]
new_kdf = conn2.execute("PRAGMA cipher_kdf_algorithm").fetchone()[0]
assert str(new_page_size) == str(original_page_size), (
f"Page size changed: {original_page_size} -> {new_page_size}"
)
assert new_hmac == original_hmac, (
f"HMAC algorithm changed: {original_hmac} -> {new_hmac}"
)
assert new_kdf == original_kdf, (
f"KDF algorithm changed: {original_kdf} -> {new_kdf}"
)
conn2.close()
class TestDatabaseMigration:
"""Tests for database schema changes across sessions."""
def test_schema_migration(self, sqlcipher_module, temp_db_path):
"""Verify schema can be modified across sessions."""
password = "migration_test"
# Session 1: Create initial schema
conn1 = create_configured_connection(
sqlcipher_module, temp_db_path, password
)
conn1.execute("CREATE TABLE data (id INTEGER PRIMARY KEY, value TEXT)")
conn1.execute("INSERT INTO data VALUES (1, 'initial')")
conn1.commit()
conn1.close()
# Session 2: Modify schema (add column)
conn2 = create_configured_connection(
sqlcipher_module, temp_db_path, password
)
conn2.execute("ALTER TABLE data ADD COLUMN created_at TEXT")
conn2.execute("UPDATE data SET created_at = '2024-01-01' WHERE id = 1")
conn2.commit()
conn2.close()
# Session 3: Verify modified schema
conn3 = create_configured_connection(
sqlcipher_module, temp_db_path, password
)
result = conn3.execute(
"SELECT value, created_at FROM data WHERE id = 1"
).fetchone()
assert result == ("initial", "2024-01-01")
conn3.close()
def test_create_index_persists(self, sqlcipher_module, temp_db_path):
"""Verify indexes persist across sessions."""
password = "index_test"
# Session 1: Create table and index
conn1 = create_configured_connection(
sqlcipher_module, temp_db_path, password
)
conn1.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)")
conn1.execute("CREATE INDEX idx_email ON users(email)")
conn1.commit()
conn1.close()
# Session 2: Verify index exists
conn2 = create_configured_connection(
sqlcipher_module, temp_db_path, password
)
indexes = conn2.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='users'"
).fetchall()
index_names = [idx[0] for idx in indexes]
assert "idx_email" in index_names, (
f"Index not found. Indexes: {index_names}"
)
conn2.close()
class TestLargeDataPersistence:
"""Tests for large data persistence."""
def test_large_blob_persists(self, sqlcipher_module, temp_db_path):
"""Verify large binary data persists correctly."""
password = "blob_test"
import os
# Create 1MB of random data
large_data = os.urandom(1024 * 1024)
# Session 1: Store large blob
conn1 = create_configured_connection(
sqlcipher_module, temp_db_path, password
)
conn1.execute("CREATE TABLE blobs (id INTEGER PRIMARY KEY, data BLOB)")
conn1.execute("INSERT INTO blobs VALUES (1, ?)", (large_data,))
conn1.commit()
conn1.close()
# Session 2: Retrieve and verify
conn2 = create_configured_connection(
sqlcipher_module, temp_db_path, password
)
result = conn2.execute("SELECT data FROM blobs WHERE id = 1").fetchone()
assert result[0] == large_data, "Large blob data corrupted or changed"
conn2.close()
def test_many_rows_persist(self, sqlcipher_module, temp_db_path):
"""Verify many rows persist correctly."""
password = "many_rows_test"
# Session 1: Insert 1000 rows
conn1 = create_configured_connection(
sqlcipher_module, temp_db_path, password
)
conn1.execute(
"CREATE TABLE numbers (id INTEGER PRIMARY KEY, value INTEGER)"
)
for i in range(1000):
conn1.execute("INSERT INTO numbers VALUES (?, ?)", (i, i * 2))
conn1.commit()
conn1.close()
# Session 2: Verify all rows
conn2 = create_configured_connection(
sqlcipher_module, temp_db_path, password
)
count = conn2.execute("SELECT COUNT(*) FROM numbers").fetchone()[0]
assert count == 1000, f"Expected 1000 rows, got {count}"
# Verify some specific values
for test_id in [0, 499, 999]:
result = conn2.execute(
"SELECT value FROM numbers WHERE id = ?", (test_id,)
).fetchone()
assert result[0] == test_id * 2, (
f"Row {test_id} has wrong value: {result[0]} != {test_id * 2}"
)
conn2.close()
class TestOldToNewPragmaOrderMigration:
"""
Tests for backwards compatibility when migrating from OLD pragma order
(key first, then cipher settings) to NEW pragma order (cipher_default_*
before key for creation, cipher_* after key for existing).
"""
def _create_connection_old_order(self, sqlcipher_module, db_path, password):
"""
Create a connection using the OLD pragma order (pre-PR behavior).
OLD order: key -> cipher_page_size -> cipher_hmac_algorithm -> kdf_iter
This simulates how databases were created before this PR.
"""
from src.local_deep_research.database.sqlcipher_utils import (
get_sqlcipher_settings,
set_sqlcipher_key,
)
conn = sqlcipher_module.connect(str(db_path))
cursor = conn.cursor()
# OLD ORDER: Set key FIRST
set_sqlcipher_key(cursor, password)
# OLD ORDER: Then set cipher settings AFTER the key
settings = get_sqlcipher_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']}")
cursor.close()
return conn
def _create_connection_new_order(self, sqlcipher_module, db_path, password):
"""
Create a connection using the NEW pragma order (post-PR behavior).
"""
return create_configured_connection(sqlcipher_module, db_path, password)
def test_old_db_opens_with_new_code(self, sqlcipher_module, temp_db_path):
"""
CRITICAL: Verify database created with OLD pragma order can be
opened with NEW pragma order.
"""
password = "old_to_new_migration"
# Create database using OLD pragma order (simulating pre-PR code)
conn1 = self._create_connection_old_order(
sqlcipher_module, temp_db_path, password
)
conn1.execute(
"CREATE TABLE migration_test (id INTEGER PRIMARY KEY, data TEXT)"
)
conn1.execute(
"INSERT INTO migration_test VALUES (1, 'created_with_old_code')"
)
conn1.execute("INSERT INTO migration_test VALUES (2, 'should_persist')")
conn1.commit()
conn1.close()
# Reopen using NEW pragma order (simulating post-PR code)
conn2 = self._create_connection_new_order(
sqlcipher_module, temp_db_path, password
)
# Verify data is accessible
result = conn2.execute(
"SELECT data FROM migration_test WHERE id = 1"
).fetchone()
assert result[0] == "created_with_old_code", (
"Data created with old pragma order should be readable with new order"
)
# Verify all rows
all_rows = conn2.execute(
"SELECT id, data FROM migration_test ORDER BY id"
).fetchall()
assert len(all_rows) == 2, f"Expected 2 rows, got {len(all_rows)}"
assert all_rows[0] == (1, "created_with_old_code")
assert all_rows[1] == (2, "should_persist")
conn2.close()
def test_old_db_can_be_modified_with_new_code(
self, sqlcipher_module, temp_db_path
):
"""
Verify database created with OLD pragma order can be modified
after opening with NEW pragma order.
"""
password = "old_to_new_modify"
# Create database using OLD pragma order
conn1 = self._create_connection_old_order(
sqlcipher_module, temp_db_path, password
)
conn1.execute(
"CREATE TABLE data (id INTEGER PRIMARY KEY, value INTEGER)"
)
conn1.execute("INSERT INTO data VALUES (1, 100)")
conn1.commit()
conn1.close()
# Open with NEW pragma order and modify
conn2 = self._create_connection_new_order(
sqlcipher_module, temp_db_path, password
)
conn2.execute("UPDATE data SET value = 200 WHERE id = 1")
conn2.execute("INSERT INTO data VALUES (2, 300)")
conn2.commit()
conn2.close()
# Reopen with NEW pragma order and verify changes persisted
conn3 = self._create_connection_new_order(
sqlcipher_module, temp_db_path, password
)
results = conn3.execute(
"SELECT id, value FROM data ORDER BY id"
).fetchall()
assert results == [(1, 200), (2, 300)], (
f"Modifications should persist. Got: {results}"
)
conn3.close()
def test_old_db_schema_migration_with_new_code(
self, sqlcipher_module, temp_db_path
):
"""
Verify schema changes work on databases created with OLD pragma order
when opened with NEW pragma order.
"""
password = "old_to_new_schema"
# Create database using OLD pragma order
conn1 = self._create_connection_old_order(
sqlcipher_module, temp_db_path, password
)
conn1.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
conn1.execute("INSERT INTO users VALUES (1, 'Alice')")
conn1.commit()
conn1.close()
# Open with NEW pragma order and modify schema
conn2 = self._create_connection_new_order(
sqlcipher_module, temp_db_path, password
)
conn2.execute("ALTER TABLE users ADD COLUMN email TEXT")
conn2.execute(
"UPDATE users SET email = 'alice@example.com' WHERE id = 1"
)
conn2.execute("CREATE INDEX idx_users_email ON users(email)")
conn2.commit()
conn2.close()
# Verify schema changes persisted
conn3 = self._create_connection_new_order(
sqlcipher_module, temp_db_path, password
)
# Check data
result = conn3.execute(
"SELECT name, email FROM users WHERE id = 1"
).fetchone()
assert result == ("Alice", "alice@example.com")
# Check index exists
indexes = conn3.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='users'"
).fetchall()
index_names = [idx[0] for idx in indexes]
assert "idx_users_email" in index_names, (
f"Index should persist. Found indexes: {index_names}"
)
conn3.close()
def test_settings_consistent_across_migration(
self, sqlcipher_module, temp_db_path
):
"""
Verify SQLCipher settings remain consistent when migrating from
OLD to NEW pragma order.
"""
password = "settings_migration"
# Create database using OLD pragma order
conn1 = self._create_connection_old_order(
sqlcipher_module, temp_db_path, password
)
conn1.execute("CREATE TABLE test (id INTEGER)")
conn1.commit()
# Capture settings from old-order connection
old_page_size = conn1.execute("PRAGMA cipher_page_size").fetchone()[0]
old_hmac = conn1.execute("PRAGMA cipher_hmac_algorithm").fetchone()[0]
conn1.close()
# Open with NEW pragma order
conn2 = self._create_connection_new_order(
sqlcipher_module, temp_db_path, password
)
# Verify settings match
new_page_size = conn2.execute("PRAGMA cipher_page_size").fetchone()[0]
new_hmac = conn2.execute("PRAGMA cipher_hmac_algorithm").fetchone()[0]
assert str(new_page_size) == str(old_page_size), (
f"Page size mismatch after migration: {old_page_size} -> {new_page_size}"
)
assert new_hmac == old_hmac, (
f"HMAC algorithm mismatch after migration: {old_hmac} -> {new_hmac}"
)
conn2.close()
def test_rekey_on_old_db_with_new_code(
self, sqlcipher_module, temp_db_path
):
"""
Verify password change (rekey) works on databases created with
OLD pragma order when using NEW pragma order code.
"""
from src.local_deep_research.database.sqlcipher_utils import (
set_sqlcipher_rekey,
)
old_password = "original_password"
new_password = "changed_password"
# Create database using OLD pragma order
conn1 = self._create_connection_old_order(
sqlcipher_module, temp_db_path, old_password
)
conn1.execute(
"CREATE TABLE secrets (id INTEGER PRIMARY KEY, data TEXT)"
)
conn1.execute("INSERT INTO secrets VALUES (1, 'sensitive_data')")
conn1.commit()
conn1.close()
# Open with NEW pragma order and change password
conn2 = self._create_connection_new_order(
sqlcipher_module, temp_db_path, old_password
)
# Verify data is accessible before rekey
result = conn2.execute(
"SELECT data FROM secrets WHERE id = 1"
).fetchone()
assert result[0] == "sensitive_data"
# Change the password
set_sqlcipher_rekey(conn2, new_password)
conn2.commit()
conn2.close()
# Verify old password no longer works
try:
conn_fail = self._create_connection_new_order(
sqlcipher_module, temp_db_path, old_password
)
# If we get here, try to access data (should fail)
conn_fail.execute("SELECT * FROM secrets").fetchone()
conn_fail.close()
raise AssertionError("Old password should not work after rekey")
except Exception as e:
if isinstance(e, AssertionError):
raise
# Any other exception is expected (file is not a database, etc.)
# Verify new password works
conn3 = self._create_connection_new_order(
sqlcipher_module, temp_db_path, new_password
)
result = conn3.execute(
"SELECT data FROM secrets WHERE id = 1"
).fetchone()
assert result[0] == "sensitive_data", (
"Data should be accessible with new password after rekey"
)
conn3.close()
@@ -0,0 +1,371 @@
"""
Tests for SQLCipher 4.6+ Community Edition compatibility.
These tests verify that the cipher_default_* pragmas are used correctly
and that the PRAGMA ordering (before/after key) is correct.
"""
import shutil
import tempfile
from pathlib import Path
import pytest
from src.local_deep_research.database.encrypted_db import DatabaseManager
from src.local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
from src.local_deep_research.database.sqlcipher_utils import (
apply_cipher_defaults_before_key,
apply_sqlcipher_pragmas,
get_sqlcipher_settings,
set_sqlcipher_key,
)
@pytest.fixture
def temp_data_dir():
"""Create a temporary data directory for testing."""
temp_dir = tempfile.mkdtemp()
yield Path(temp_dir)
shutil.rmtree(temp_dir)
@pytest.fixture
def temp_db_path(temp_data_dir):
"""Create a temporary database path."""
return temp_data_dir / "test_cipher.db"
@pytest.fixture
def sqlcipher_module():
"""Get the SQLCipher module."""
return get_sqlcipher_module()
@pytest.fixture
def db_manager(temp_data_dir, monkeypatch):
"""Create a DatabaseManager with test configuration."""
monkeypatch.setenv("LDR_DATA_DIR", str(temp_data_dir))
manager = DatabaseManager()
manager.data_dir = temp_data_dir / "encrypted_databases"
manager.data_dir.mkdir(parents=True, exist_ok=True)
return manager
@pytest.fixture
def auth_user(temp_data_dir, monkeypatch):
"""Create a test user in auth database."""
monkeypatch.setenv("LDR_DATA_DIR", str(temp_data_dir))
from src.local_deep_research.database.auth_db import (
get_auth_db_session,
init_auth_database,
)
from src.local_deep_research.database.models.auth import User
init_auth_database()
auth_db = get_auth_db_session()
user = User(username="testuser")
auth_db.add(user)
auth_db.commit()
auth_db.close()
return user
class TestSQLCipher46Compatibility:
"""Tests for SQLCipher 4.6+ compatibility with cipher_default_* pragmas."""
def test_cipher_default_page_size_applied(
self, sqlcipher_module, temp_db_path
):
"""Verify cipher_default_page_size pragma is applied correctly."""
settings = get_sqlcipher_settings()
expected_page_size = settings["page_size"]
conn = sqlcipher_module.connect(str(temp_db_path))
cursor = conn.cursor()
# Apply cipher settings BEFORE key (SQLCipher 4.6+ requirement)
apply_cipher_defaults_before_key(cursor)
set_sqlcipher_key(cursor, "testpassword")
cursor.close()
# Verify the page size is applied
result = conn.execute("PRAGMA cipher_page_size").fetchone()
assert result is not None
assert str(result[0]) == str(expected_page_size), (
f"Expected cipher_page_size={expected_page_size}, got {result[0]}"
)
conn.close()
def test_cipher_default_hmac_algorithm_applied(
self, sqlcipher_module, temp_db_path
):
"""Verify cipher_default_hmac_algorithm pragma is applied correctly."""
settings = get_sqlcipher_settings()
expected_hmac = settings["hmac_algorithm"]
conn = sqlcipher_module.connect(str(temp_db_path))
cursor = conn.cursor()
apply_cipher_defaults_before_key(cursor)
set_sqlcipher_key(cursor, "testpassword")
cursor.close()
result = conn.execute("PRAGMA cipher_hmac_algorithm").fetchone()
assert result is not None
assert result[0] == expected_hmac, (
f"Expected cipher_hmac_algorithm={expected_hmac}, got {result[0]}"
)
conn.close()
def test_cipher_default_kdf_algorithm_applied(
self, sqlcipher_module, temp_db_path
):
"""Verify cipher_default_kdf_algorithm pragma is applied correctly."""
settings = get_sqlcipher_settings()
expected_kdf = settings["kdf_algorithm"]
conn = sqlcipher_module.connect(str(temp_db_path))
cursor = conn.cursor()
apply_cipher_defaults_before_key(cursor)
set_sqlcipher_key(cursor, "testpassword")
cursor.close()
result = conn.execute("PRAGMA cipher_kdf_algorithm").fetchone()
assert result is not None
assert result[0] == expected_kdf, (
f"Expected cipher_kdf_algorithm={expected_kdf}, got {result[0]}"
)
conn.close()
def test_pragma_order_cipher_settings_before_key(
self, sqlcipher_module, temp_db_path
):
"""Verify that cipher_default_* pragmas work when applied BEFORE key."""
settings = get_sqlcipher_settings()
conn = sqlcipher_module.connect(str(temp_db_path))
cursor = conn.cursor()
# Correct order: cipher settings BEFORE key
apply_cipher_defaults_before_key(cursor)
set_sqlcipher_key(cursor, "testpassword")
# Should be able to create tables and query
conn.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT)")
conn.execute("INSERT INTO test VALUES (1, 'hello')")
result = conn.execute("SELECT * FROM test").fetchone()
assert result == (1, "hello"), (
"Database should be accessible with correct order"
)
# Verify settings were applied
page_size = conn.execute("PRAGMA cipher_page_size").fetchone()[0]
assert str(page_size) == str(settings["page_size"])
cursor.close()
conn.close()
def test_pragma_order_kdf_iter_after_key(
self, sqlcipher_module, temp_db_path
):
"""Verify that kdf_iter pragma works when applied AFTER key."""
settings = get_sqlcipher_settings()
conn = sqlcipher_module.connect(str(temp_db_path))
cursor = conn.cursor()
# Correct order
apply_cipher_defaults_before_key(cursor)
set_sqlcipher_key(cursor, "testpassword")
apply_sqlcipher_pragmas(cursor, creation_mode=True)
cursor.close()
# Verify kdf_iter was applied
result = conn.execute("PRAGMA kdf_iter").fetchone()
assert result is not None
assert str(result[0]) == str(settings["kdf_iterations"]), (
f"Expected kdf_iter={settings['kdf_iterations']}, got {result[0]}"
)
conn.close()
def test_cipher_default_pragmas_set_correct_defaults(
self, sqlcipher_module, temp_db_path
):
"""
Verify that cipher_default_* pragmas properly configure the database.
This test confirms that using cipher_default_page_size (not cipher_page_size)
correctly sets the page size for new databases in SQLCipher 4.6+.
"""
conn = sqlcipher_module.connect(str(temp_db_path))
cursor = conn.cursor()
# Use cipher_default_page_size (the correct way for SQLCipher 4.6+)
cursor.execute("PRAGMA cipher_default_page_size = 16384")
cursor.execute("PRAGMA key = 'testpassword'")
cursor.close()
# The page size should be 16384 as we set it
result = conn.execute("PRAGMA cipher_page_size").fetchone()
assert result[0] == "16384", (
f"cipher_default_page_size should set page size to 16384, got {result[0]}"
)
# Create a table to ensure database is usable
conn.execute("CREATE TABLE test (id INTEGER PRIMARY KEY)")
conn.execute("INSERT INTO test VALUES (1)")
result = conn.execute("SELECT * FROM test").fetchone()
assert result == (1,), "Database should be functional"
conn.close()
def test_database_created_with_correct_settings(
self, db_manager, auth_user
):
"""Verify database is created with all correct SQLCipher settings."""
from sqlalchemy import text
settings = get_sqlcipher_settings()
engine = db_manager.create_user_database("testuser", "testpassword123")
with engine.connect() as conn:
# Check all cipher settings
page_size = conn.execute(text("PRAGMA cipher_page_size")).scalar()
hmac_algo = conn.execute(
text("PRAGMA cipher_hmac_algorithm")
).scalar()
kdf_algo = conn.execute(
text("PRAGMA cipher_kdf_algorithm")
).scalar()
kdf_iter = conn.execute(text("PRAGMA kdf_iter")).scalar()
assert str(page_size) == str(settings["page_size"]), (
f"cipher_page_size mismatch: expected {settings['page_size']}, got {page_size}"
)
assert hmac_algo == settings["hmac_algorithm"], (
f"cipher_hmac_algorithm mismatch: expected {settings['hmac_algorithm']}, got {hmac_algo}"
)
assert kdf_algo == settings["kdf_algorithm"], (
f"cipher_kdf_algorithm mismatch: expected {settings['kdf_algorithm']}, got {kdf_algo}"
)
assert str(kdf_iter) == str(settings["kdf_iterations"]), (
f"kdf_iter mismatch: expected {settings['kdf_iterations']}, got {kdf_iter}"
)
def test_database_reopened_with_matching_settings(
self, db_manager, auth_user
):
"""Verify database can be reopened and settings remain consistent."""
from sqlalchemy import text
settings = get_sqlcipher_settings()
# Create database
engine = db_manager.create_user_database("testuser", "testpassword123")
# Create a test table and write some data
with engine.connect() as conn:
conn.execute(
text(
"CREATE TABLE IF NOT EXISTS test_data (id INTEGER PRIMARY KEY, value TEXT)"
)
)
conn.execute(
text(
"INSERT INTO test_data (id, value) VALUES (1, 'test_value')"
)
)
conn.commit()
# Close the database
db_manager.close_user_database("testuser")
assert not db_manager.is_user_connected("testuser")
# Reopen the database
engine2 = db_manager.open_user_database("testuser", "testpassword123")
assert engine2 is not None
with engine2.connect() as conn:
# Verify settings are still correct
page_size = conn.execute(text("PRAGMA cipher_page_size")).scalar()
hmac_algo = conn.execute(
text("PRAGMA cipher_hmac_algorithm")
).scalar()
kdf_algo = conn.execute(
text("PRAGMA cipher_kdf_algorithm")
).scalar()
assert str(page_size) == str(settings["page_size"])
assert hmac_algo == settings["hmac_algorithm"]
assert kdf_algo == settings["kdf_algorithm"]
# Verify data is still there
result = conn.execute(
text("SELECT value FROM test_data WHERE id = 1")
).scalar()
assert result == "test_value", "Data should persist after reopen"
class TestSQLCipherVersionDetection:
"""Tests for SQLCipher version detection and compatibility."""
def test_sqlcipher_version_is_4x(self, sqlcipher_module, temp_db_path):
"""Verify we're testing against SQLCipher 4.x."""
conn = sqlcipher_module.connect(str(temp_db_path))
cursor = conn.cursor()
# Set key first (required to query version in some setups)
cursor.execute("PRAGMA key = 'test'")
result = conn.execute("PRAGMA cipher_version").fetchone()
assert result is not None
version = result[0]
# Should be 4.x.x (e.g., "4.6.1 community")
assert version.startswith("4."), (
f"Expected SQLCipher 4.x, got {version}"
)
cursor.close()
conn.close()
def test_cipher_settings_function_returns_valid_settings(self):
"""Verify get_sqlcipher_settings returns all required keys."""
settings = get_sqlcipher_settings()
required_keys = [
"kdf_iterations",
"page_size",
"hmac_algorithm",
"kdf_algorithm",
]
for key in required_keys:
assert key in settings, f"Missing required setting: {key}"
# Verify types
assert isinstance(settings["kdf_iterations"], int)
assert isinstance(settings["page_size"], int)
assert isinstance(settings["hmac_algorithm"], str)
assert isinstance(settings["kdf_algorithm"], str)
# Verify reasonable values
assert settings["kdf_iterations"] >= 100000, (
"KDF iterations should be >= 100000"
)
assert settings["page_size"] in [
1024,
4096,
8192,
16384,
32768,
65536,
], "Page size should be a valid SQLite page size"
@@ -0,0 +1,333 @@
"""
Tests for SQLCipher key derivation functions.
These tests verify that password-to-key derivation is consistent,
deterministic, and secure.
"""
import shutil
import tempfile
from pathlib import Path
import pytest
from src.local_deep_research.database.sqlcipher_utils import (
LEGACY_PBKDF2_SALT,
_get_key_from_password,
get_sqlcipher_settings,
set_sqlcipher_key,
set_sqlcipher_rekey,
)
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_key.db"
yield db_path
shutil.rmtree(temp_dir)
@pytest.fixture
def sqlcipher_module():
"""Get the SQLCipher module."""
return get_sqlcipher_module()
class TestKeyDerivation:
"""Tests for password-to-key derivation."""
def test_key_derivation_deterministic(self):
"""Verify same password always produces same key."""
password = "test_password_123"
key1 = _get_key_from_password(
password,
LEGACY_PBKDF2_SALT,
get_sqlcipher_settings()["kdf_iterations"],
)
key2 = _get_key_from_password(
password,
LEGACY_PBKDF2_SALT,
get_sqlcipher_settings()["kdf_iterations"],
)
assert key1 == key2, "Same password should always produce same key"
def test_key_derivation_different_passwords(self):
"""Verify different passwords produce different keys."""
key1 = _get_key_from_password(
"password1",
LEGACY_PBKDF2_SALT,
get_sqlcipher_settings()["kdf_iterations"],
)
key2 = _get_key_from_password(
"password2",
LEGACY_PBKDF2_SALT,
get_sqlcipher_settings()["kdf_iterations"],
)
assert key1 != key2, "Different passwords should produce different keys"
def test_key_hex_encoding_only_hex_chars(self):
"""Verify derived key only contains hex characters (SQL injection safe)."""
key = _get_key_from_password(
"any_password",
LEGACY_PBKDF2_SALT,
get_sqlcipher_settings()["kdf_iterations"],
)
hex_string = key.hex()
# Verify only hex characters
valid_hex_chars = set("0123456789abcdef")
assert all(c in valid_hex_chars for c in hex_string), (
"Key hex encoding should only contain [0-9a-f]"
)
def test_special_characters_in_password(self):
"""Verify passwords with special characters are handled correctly."""
special_passwords = [
"password with spaces",
"password'with'quotes",
'password"with"double"quotes',
"password\\with\\backslashes",
"password\nwith\nnewlines",
"password\twith\ttabs",
"unicode: 日本語 emoji: 🔐🔑",
"sql_injection: '; DROP TABLE users; --",
"null_byte: \x00test",
]
keys = []
for password in special_passwords:
try:
key = _get_key_from_password(
password,
LEGACY_PBKDF2_SALT,
get_sqlcipher_settings()["kdf_iterations"],
)
keys.append(key)
# Verify key is valid bytes
assert isinstance(key, bytes), (
f"Key should be bytes for: {password}"
)
assert len(key) > 0, f"Key should not be empty for: {password}"
except Exception as e:
pytest.fail(f"Password '{password}' caused error: {e}")
# All keys should be unique
assert len(set(keys)) == len(keys), (
"All special passwords should produce unique keys"
)
def test_key_length_correct(self):
"""Verify derived key has correct length (64 bytes = 512 bits for SHA512)."""
key = _get_key_from_password(
"test_password",
LEGACY_PBKDF2_SALT,
get_sqlcipher_settings()["kdf_iterations"],
)
# SHA512-based PBKDF2 produces 64-byte key (512 bits)
assert len(key) == 64, f"Key should be 64 bytes, got {len(key)}"
def test_empty_password_produces_key(self):
"""Verify empty password still produces a valid key (validation is elsewhere)."""
# Note: Empty password validation happens in DatabaseManager, not in key derivation
# The key derivation function itself should work with any input
key = _get_key_from_password(
"", LEGACY_PBKDF2_SALT, get_sqlcipher_settings()["kdf_iterations"]
)
assert isinstance(key, bytes), (
"Empty password should still produce bytes"
)
assert len(key) == 64, (
"Key length should be consistent (64 bytes for SHA512)"
)
class TestKeyDerivationWithDatabase:
"""Tests for key derivation in actual database operations."""
def test_derived_key_opens_database(self, sqlcipher_module, temp_db_path):
"""Verify derived key can create and open a database."""
from src.local_deep_research.database.sqlcipher_utils import (
apply_cipher_defaults_before_key,
)
password = "test_database_password"
# Create database
conn = sqlcipher_module.connect(str(temp_db_path))
cursor = conn.cursor()
# New database, so use creation_mode=True
apply_cipher_defaults_before_key(cursor)
set_sqlcipher_key(cursor, password)
cursor.close()
conn.execute("CREATE TABLE test (id INTEGER PRIMARY KEY)")
conn.execute("INSERT INTO test VALUES (1)")
conn.commit() # Important: commit before closing
conn.close()
# Reopen with same password
from src.local_deep_research.database.sqlcipher_utils import (
apply_sqlcipher_pragmas,
)
conn2 = sqlcipher_module.connect(str(temp_db_path))
cursor2 = conn2.cursor()
# Existing database: key first, then cipher_* pragmas
set_sqlcipher_key(cursor2, password)
apply_sqlcipher_pragmas(cursor2, creation_mode=False)
cursor2.close()
result = conn2.execute("SELECT * FROM test").fetchone()
assert result == (1,), "Should be able to read data with same password"
conn2.close()
def test_wrong_password_cannot_open(self, sqlcipher_module, temp_db_path):
"""Verify wrong password cannot open database."""
from src.local_deep_research.database.sqlcipher_utils import (
apply_cipher_defaults_before_key,
)
# Create database with password1
conn = sqlcipher_module.connect(str(temp_db_path))
cursor = conn.cursor()
# New database, so use creation_mode=True
apply_cipher_defaults_before_key(cursor)
set_sqlcipher_key(cursor, "password1")
cursor.close()
conn.execute("CREATE TABLE test (id INTEGER PRIMARY KEY)")
conn.commit()
conn.close()
# Try to open with password2
from src.local_deep_research.database.sqlcipher_utils import (
apply_sqlcipher_pragmas,
)
conn2 = sqlcipher_module.connect(str(temp_db_path))
cursor2 = conn2.cursor()
# Existing database: key first, then cipher_* pragmas
set_sqlcipher_key(cursor2, "password2")
apply_sqlcipher_pragmas(cursor2, creation_mode=False)
cursor2.close()
# Should fail when trying to access data
with pytest.raises(Exception):
conn2.execute("SELECT * FROM test").fetchone()
conn2.close()
def test_rekey_uses_same_derivation(self, sqlcipher_module, temp_db_path):
"""Verify rekey produces key compatible with set_sqlcipher_key."""
from src.local_deep_research.database.sqlcipher_utils import (
apply_cipher_defaults_before_key,
)
# Create database
conn = sqlcipher_module.connect(str(temp_db_path))
cursor = conn.cursor()
# New database, so use creation_mode=True
apply_cipher_defaults_before_key(cursor)
set_sqlcipher_key(cursor, "original_password")
cursor.close()
conn.execute("CREATE TABLE test (id INTEGER, value TEXT)")
conn.execute("INSERT INTO test VALUES (1, 'secret_data')")
conn.commit()
# Rekey to new password
set_sqlcipher_rekey(conn, "new_password")
conn.close()
# Open with new password using set_sqlcipher_key
from src.local_deep_research.database.sqlcipher_utils import (
apply_sqlcipher_pragmas,
)
conn2 = sqlcipher_module.connect(str(temp_db_path))
cursor2 = conn2.cursor()
# Existing database: key first, then cipher_* pragmas
set_sqlcipher_key(cursor2, "new_password")
apply_sqlcipher_pragmas(cursor2, creation_mode=False)
cursor2.close()
# Should be able to read data
result = conn2.execute("SELECT value FROM test WHERE id = 1").fetchone()
assert result[0] == "secret_data", "Data should be readable after rekey"
conn2.close()
# Original password should no longer work
conn3 = sqlcipher_module.connect(str(temp_db_path))
cursor3 = conn3.cursor()
# Existing database: key first, then cipher_* pragmas
set_sqlcipher_key(cursor3, "original_password")
apply_sqlcipher_pragmas(cursor3, creation_mode=False)
cursor3.close()
with pytest.raises(Exception):
conn3.execute("SELECT * FROM test").fetchone()
conn3.close()
class TestKeyDerivationSecurity:
"""Security-focused tests for key derivation."""
def test_pbkdf2_iterations_sufficient(self):
"""Verify KDF iterations are set to a secure value."""
settings = get_sqlcipher_settings()
iterations = settings["kdf_iterations"]
# OWASP recommends at least 600,000 for PBKDF2-HMAC-SHA512
# We use 256,000 which is a reasonable balance for performance
assert iterations >= 100000, (
f"KDF iterations should be at least 100000, got {iterations}"
)
def test_key_not_stored_in_plaintext(self):
"""Verify derived key is bytes, not plaintext password."""
password = "my_secret_password"
key = _get_key_from_password(
password,
LEGACY_PBKDF2_SALT,
get_sqlcipher_settings()["kdf_iterations"],
)
# Key should be bytes, not string
assert isinstance(key, bytes), "Key should be bytes"
# Key should not contain the password
assert password.encode() not in key, (
"Key should not contain plaintext password"
)
def test_different_salts_would_produce_different_keys(self):
"""
Document that our fixed salt means same password = same key.
This is intentional for database compatibility - if salt changed,
existing databases would become inaccessible.
"""
# Same password should always produce same key (fixed salt)
key1 = _get_key_from_password(
"test",
LEGACY_PBKDF2_SALT,
get_sqlcipher_settings()["kdf_iterations"],
)
key2 = _get_key_from_password(
"test",
LEGACY_PBKDF2_SALT,
get_sqlcipher_settings()["kdf_iterations"],
)
assert key1 == key2, (
"Fixed salt ensures consistent key derivation for database compatibility"
)
@@ -0,0 +1,278 @@
"""
Tests for SQLCipher performance pragmas.
These tests verify that performance-related PRAGMA settings are
correctly applied for optimal database performance.
"""
import shutil
import tempfile
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_performance.db"
yield db_path
shutil.rmtree(temp_dir)
@pytest.fixture
def sqlcipher_module():
"""Get the SQLCipher module."""
return get_sqlcipher_module()
@pytest.fixture
def configured_connection(sqlcipher_module, temp_db_path):
"""Create a fully configured database connection."""
conn = sqlcipher_module.connect(str(temp_db_path))
cursor = conn.cursor()
# New database: cipher_default_* before key
apply_cipher_defaults_before_key(cursor)
set_sqlcipher_key(cursor, "testpassword")
apply_sqlcipher_pragmas(cursor, creation_mode=True)
apply_performance_pragmas(cursor)
cursor.close()
return conn
class TestWALMode:
"""Tests for WAL (Write-Ahead Logging) mode."""
def test_wal_mode_enabled(self, configured_connection):
"""Verify WAL journal mode is enabled by default."""
result = configured_connection.execute("PRAGMA journal_mode").fetchone()
assert result is not None
assert result[0].lower() == "wal", f"Expected WAL mode, got {result[0]}"
configured_connection.close()
def test_wal_creates_additional_files(self, sqlcipher_module, temp_db_path):
"""Verify WAL mode creates -wal and -shm files."""
conn = sqlcipher_module.connect(str(temp_db_path))
cursor = conn.cursor()
# New database: cipher_default_* before key
apply_cipher_defaults_before_key(cursor)
set_sqlcipher_key(cursor, "testpassword")
apply_performance_pragmas(cursor)
cursor.close()
# Create a table and write data to trigger WAL file creation
conn.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, data TEXT)")
conn.execute("INSERT INTO test VALUES (1, 'test_data')")
conn.commit()
# Note: WAL files (-wal, -shm) may not exist immediately or may be cleaned up
# The main verification is that WAL mode is enabled
assert (
conn.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal"
)
conn.close()
def test_concurrent_reads_with_wal(self, sqlcipher_module, temp_db_path):
"""Verify WAL mode allows concurrent read connections."""
# Create and populate database
conn1 = sqlcipher_module.connect(str(temp_db_path))
cursor1 = conn1.cursor()
# New database: cipher_default_* before key
apply_cipher_defaults_before_key(cursor1)
set_sqlcipher_key(cursor1, "testpassword")
apply_performance_pragmas(cursor1)
cursor1.close()
conn1.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, data TEXT)")
conn1.execute("INSERT INTO test VALUES (1, 'data1')")
conn1.commit()
# Open second read connection while first is open
conn2 = sqlcipher_module.connect(str(temp_db_path))
cursor2 = conn2.cursor()
# Existing database: key first, then cipher_* pragmas
set_sqlcipher_key(cursor2, "testpassword")
apply_sqlcipher_pragmas(cursor2, creation_mode=False)
apply_performance_pragmas(cursor2)
cursor2.close()
# Both connections should be able to read
result1 = conn1.execute("SELECT * FROM test").fetchall()
result2 = conn2.execute("SELECT * FROM test").fetchall()
assert result1 == result2 == [(1, "data1")]
conn2.close()
conn1.close()
class TestCacheSettings:
"""Tests for cache size settings."""
def test_cache_size_applied(self, configured_connection):
"""Verify cache_size pragma is applied."""
result = configured_connection.execute("PRAGMA cache_size").fetchone()
assert result is not None
# Default is 64MB = 65536KB, stored as negative value
assert result[0] < 0, "Cache size should be negative (KB format)"
configured_connection.close()
def test_temp_store_memory(self, configured_connection):
"""Verify temp_store is set to MEMORY."""
result = configured_connection.execute("PRAGMA temp_store").fetchone()
assert result is not None
# 2 = MEMORY
assert result[0] == 2, (
f"Expected temp_store=2 (MEMORY), got {result[0]}"
)
configured_connection.close()
class TestBusyTimeout:
"""Tests for busy timeout setting."""
def test_busy_timeout_applied(self, configured_connection):
"""Verify busy_timeout is set to prevent immediate lock failures."""
result = configured_connection.execute("PRAGMA busy_timeout").fetchone()
assert result is not None
assert result[0] == 10000, (
f"Expected busy_timeout=10000, got {result[0]}"
)
configured_connection.close()
def test_busy_timeout_prevents_immediate_lock_error(
self, sqlcipher_module, temp_db_path
):
"""Verify busy_timeout allows waiting for locks."""
# Create database
conn1 = sqlcipher_module.connect(str(temp_db_path))
cursor1 = conn1.cursor()
# New database: cipher_default_* before key
apply_cipher_defaults_before_key(cursor1)
set_sqlcipher_key(cursor1, "testpassword")
apply_performance_pragmas(cursor1)
cursor1.close()
conn1.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, data TEXT)")
conn1.execute("INSERT INTO test VALUES (1, 'data1')")
conn1.commit()
# The busy_timeout should allow retries
# This is a basic test that the setting is applied
result = conn1.execute("PRAGMA busy_timeout").fetchone()
assert result[0] == 10000
conn1.close()
class TestSynchronousMode:
"""Tests for synchronous mode setting."""
def test_synchronous_mode_normal(self, configured_connection):
"""Verify synchronous mode is set to NORMAL (good balance)."""
result = configured_connection.execute("PRAGMA synchronous").fetchone()
assert result is not None
# Default is NORMAL (1)
assert result[0] == 1, (
f"Expected synchronous=1 (NORMAL), got {result[0]}"
)
configured_connection.close()
class TestPerformanceIntegration:
"""Integration tests for performance settings."""
def test_all_performance_pragmas_applied(self, configured_connection):
"""Verify all performance pragmas are applied correctly."""
pragmas = {
"journal_mode": lambda x: x.lower() == "wal",
"temp_store": lambda x: x == 2,
"busy_timeout": lambda x: x == 10000,
"synchronous": lambda x: x == 1,
}
for pragma, validator in pragmas.items():
result = configured_connection.execute(
f"PRAGMA {pragma}"
).fetchone()
assert result is not None, f"PRAGMA {pragma} returned None"
assert validator(result[0]), (
f"PRAGMA {pragma} has unexpected value: {result[0]}"
)
configured_connection.close()
def test_performance_after_reopen(self, sqlcipher_module, temp_db_path):
"""Verify performance settings persist across connections."""
# Create database with performance pragmas
conn1 = sqlcipher_module.connect(str(temp_db_path))
cursor1 = conn1.cursor()
# New database: cipher_default_* before key
apply_cipher_defaults_before_key(cursor1)
set_sqlcipher_key(cursor1, "testpassword")
apply_performance_pragmas(cursor1)
cursor1.close()
conn1.execute("CREATE TABLE test (id INTEGER)")
conn1.commit()
conn1.close()
# Reopen and verify
conn2 = sqlcipher_module.connect(str(temp_db_path))
cursor2 = conn2.cursor()
# Existing database: key first, then cipher_* pragmas
set_sqlcipher_key(cursor2, "testpassword")
apply_sqlcipher_pragmas(cursor2, creation_mode=False)
apply_performance_pragmas(cursor2)
cursor2.close()
# WAL mode persists (stored in database file)
result = conn2.execute("PRAGMA journal_mode").fetchone()
assert result[0].lower() == "wal"
# Other settings are per-connection but should still be applied
result = conn2.execute("PRAGMA busy_timeout").fetchone()
assert result[0] == 10000
conn2.close()
def test_write_performance_with_settings(self, configured_connection):
"""Verify database performs well with applied settings."""
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: remove timing assertion or mark @pytest.mark.slow; keep the count==100 assertion).
import time
configured_connection.execute(
"CREATE TABLE perf_test (id INTEGER PRIMARY KEY, data TEXT)"
)
# Insert 100 rows and measure time
start = time.time()
for i in range(100):
configured_connection.execute(
f"INSERT INTO perf_test VALUES ({i}, 'test_data_{i}')"
)
configured_connection.commit()
elapsed = time.time() - start
# Should complete in reasonable time (< 5 seconds with WAL)
assert elapsed < 5, f"100 inserts took {elapsed:.2f}s - too slow"
# Verify all data was written
count = configured_connection.execute(
"SELECT COUNT(*) FROM perf_test"
).fetchone()[0]
assert count == 100
configured_connection.close()
+360
View File
@@ -0,0 +1,360 @@
"""
Tests for SQLCipher rekey (password change) functionality.
These tests verify that password changes work correctly and use
consistent key derivation with initial key setup.
"""
import shutil
import tempfile
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from src.local_deep_research.database.sqlcipher_utils import (
LEGACY_PBKDF2_SALT,
apply_cipher_defaults_before_key,
apply_sqlcipher_pragmas,
get_sqlcipher_settings,
set_sqlcipher_key,
set_sqlcipher_rekey,
_get_key_from_password,
)
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_rekey.db"
yield db_path
shutil.rmtree(temp_dir)
@pytest.fixture
def sqlcipher_module():
"""Get the SQLCipher module."""
return get_sqlcipher_module()
@pytest.fixture
def encrypted_db(sqlcipher_module, temp_db_path):
"""Create an encrypted database with test data."""
conn = sqlcipher_module.connect(str(temp_db_path))
cursor = conn.cursor()
# New database, so use creation_mode=True
apply_cipher_defaults_before_key(cursor)
set_sqlcipher_key(cursor, "original_password")
cursor.close()
conn.execute("CREATE TABLE test_data (id INTEGER PRIMARY KEY, value TEXT)")
conn.execute("INSERT INTO test_data VALUES (1, 'secret_value_1')")
conn.execute("INSERT INTO test_data VALUES (2, 'secret_value_2')")
conn.execute("INSERT INTO test_data VALUES (3, 'secret_value_3')")
conn.commit()
return {"conn": conn, "path": temp_db_path, "password": "original_password"}
def _open_existing_db(sqlcipher_module, db_path, password):
"""Helper to open an existing encrypted DB with correct PRAGMA ordering."""
conn = sqlcipher_module.connect(str(db_path))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=False)
cursor.close()
return conn
class TestRekeyFunctionality:
"""Tests for basic rekey operations."""
def test_rekey_changes_password(self, encrypted_db, sqlcipher_module):
"""Verify rekey successfully changes the database password."""
conn = encrypted_db["conn"]
db_path = encrypted_db["path"]
# Change password
set_sqlcipher_rekey(conn, "new_password")
conn.close()
# Old password should fail
conn_old = _open_existing_db(
sqlcipher_module, db_path, "original_password"
)
with pytest.raises(Exception):
conn_old.execute("SELECT * FROM test_data").fetchone()
conn_old.close()
# New password should work
conn_new = _open_existing_db(sqlcipher_module, db_path, "new_password")
result = conn_new.execute("SELECT * FROM test_data").fetchall()
assert len(result) == 3, (
"All data should be accessible with new password"
)
conn_new.close()
def test_rekey_preserves_all_data(self, encrypted_db, sqlcipher_module):
"""Verify all data is preserved after password change."""
conn = encrypted_db["conn"]
db_path = encrypted_db["path"]
# Read data before rekey
original_data = conn.execute(
"SELECT id, value FROM test_data ORDER BY id"
).fetchall()
# Change password
set_sqlcipher_rekey(conn, "new_password")
conn.close()
# Read data after rekey
conn_new = _open_existing_db(sqlcipher_module, db_path, "new_password")
new_data = conn_new.execute(
"SELECT id, value FROM test_data ORDER BY id"
).fetchall()
conn_new.close()
assert original_data == new_data, "Data should be identical after rekey"
def test_rekey_uses_pbkdf2_derivation(self):
"""Verify rekey uses the same PBKDF2 key derivation as initial key."""
# Get derived key for a password
password = "test_password_for_derivation"
kdf_iterations = get_sqlcipher_settings()["kdf_iterations"]
derived_key = _get_key_from_password(
password, LEGACY_PBKDF2_SALT, kdf_iterations
)
# Derive again to verify same result (deterministic)
derived_key_2 = _get_key_from_password(
password, LEGACY_PBKDF2_SALT, kdf_iterations
)
assert derived_key == derived_key_2, (
"Key derivation must be deterministic"
)
def test_rekey_multiple_times(self, encrypted_db, sqlcipher_module):
"""Verify password can be changed multiple times."""
conn = encrypted_db["conn"]
db_path = encrypted_db["path"]
passwords = ["password_1", "password_2", "password_3", "final_password"]
for new_password in passwords:
set_sqlcipher_rekey(conn, new_password)
conn.close()
# Only the final password should work
for wrong_password in passwords[:-1]:
conn_wrong = _open_existing_db(
sqlcipher_module, db_path, wrong_password
)
with pytest.raises(Exception):
conn_wrong.execute("SELECT * FROM test_data").fetchone()
conn_wrong.close()
# Final password works
conn_final = _open_existing_db(
sqlcipher_module, db_path, "final_password"
)
result = conn_final.execute("SELECT * FROM test_data").fetchall()
assert len(result) == 3
conn_final.close()
class TestRekeySpecialPasswords:
"""Tests for rekey with special characters in passwords."""
def test_rekey_with_quotes(self, encrypted_db, sqlcipher_module):
"""Verify rekey works with quotes in password."""
conn = encrypted_db["conn"]
db_path = encrypted_db["path"]
new_password = "pass'word\"with'quotes\""
set_sqlcipher_rekey(conn, new_password)
conn.close()
conn_new = _open_existing_db(sqlcipher_module, db_path, new_password)
result = conn_new.execute("SELECT * FROM test_data").fetchone()
assert result is not None, (
"Database should be accessible with quoted password"
)
conn_new.close()
def test_rekey_with_unicode(self, encrypted_db, sqlcipher_module):
"""Verify rekey works with unicode characters."""
conn = encrypted_db["conn"]
db_path = encrypted_db["path"]
new_password = "パスワード安全"
set_sqlcipher_rekey(conn, new_password)
conn.close()
conn_new = _open_existing_db(sqlcipher_module, db_path, new_password)
result = conn_new.execute("SELECT * FROM test_data").fetchone()
assert result is not None, (
"Database should be accessible with unicode password"
)
conn_new.close()
def test_rekey_with_backslashes(self, encrypted_db, sqlcipher_module):
"""Verify rekey works with backslashes in password."""
conn = encrypted_db["conn"]
db_path = encrypted_db["path"]
new_password = "pass\\word\\with\\backslashes"
set_sqlcipher_rekey(conn, new_password)
conn.close()
conn_new = _open_existing_db(sqlcipher_module, db_path, new_password)
result = conn_new.execute("SELECT * FROM test_data").fetchone()
assert result is not None, (
"Database should be accessible with backslash password"
)
conn_new.close()
def test_rekey_with_sql_injection_attempt(
self, encrypted_db, sqlcipher_module
):
"""Verify rekey is safe against SQL injection attempts."""
conn = encrypted_db["conn"]
db_path = encrypted_db["path"]
new_password = "'; DROP TABLE test_data; --"
set_sqlcipher_rekey(conn, new_password)
conn.close()
conn_new = _open_existing_db(sqlcipher_module, db_path, new_password)
result = conn_new.execute("SELECT COUNT(*) FROM test_data").fetchone()
assert result[0] == 3, (
"Table should exist and have all data after 'injection' password"
)
conn_new.close()
class TestRekeyWithSQLAlchemy:
"""Tests for rekey via SQLAlchemy connections."""
def test_rekey_via_sqlalchemy(self, temp_db_path):
"""Verify rekey works through SQLAlchemy connection."""
from sqlalchemy import create_engine, event, text
from src.local_deep_research.database.sqlcipher_utils import (
apply_cipher_defaults_before_key,
apply_sqlcipher_pragmas,
set_sqlcipher_key,
set_sqlcipher_rekey,
)
from src.local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
sqlcipher_module = get_sqlcipher_module()
# Create database with raw connection first
raw_conn = sqlcipher_module.connect(str(temp_db_path))
cursor = raw_conn.cursor()
apply_cipher_defaults_before_key(cursor)
set_sqlcipher_key(cursor, "original_password")
cursor.close()
raw_conn.execute(
"CREATE TABLE test (id INTEGER PRIMARY KEY, data TEXT)"
)
raw_conn.execute("INSERT INTO test VALUES (1, 'test_data')")
raw_conn.commit()
raw_conn.close()
# Open with SQLAlchemy and rekey
engine = create_engine(
f"sqlite:///{temp_db_path}",
module=sqlcipher_module,
)
@event.listens_for(engine, "connect")
def set_cipher(dbapi_conn, connection_record):
cursor = dbapi_conn.cursor()
set_sqlcipher_key(cursor, "original_password")
apply_sqlcipher_pragmas(cursor, creation_mode=False)
cursor.close()
with engine.connect() as conn:
# Verify data is accessible
result = conn.execute(
text("SELECT data FROM test WHERE id = 1")
).scalar()
assert result == "test_data"
# Rekey via SQLAlchemy connection
set_sqlcipher_rekey(conn, "new_password")
conn.commit()
engine.dispose()
# Verify new password works with raw connection
raw_conn2 = _open_existing_db(
sqlcipher_module, temp_db_path, "new_password"
)
result = raw_conn2.execute(
"SELECT data FROM test WHERE id = 1"
).fetchone()
assert result[0] == "test_data", (
"Data should be accessible with new password"
)
raw_conn2.close()
class TestSetSqlcipherRekey:
"""Unit tests for set_sqlcipher_rekey verifying it uses PBKDF2."""
def test_rekey_calls_get_key_from_password(self):
"""Verify rekey uses get_key_from_password (PBKDF2), not raw encoding."""
mock_conn = Mock()
mock_conn.execute.side_effect = [TypeError("not sqlalchemy"), None]
with patch(
"src.local_deep_research.database.sqlcipher_utils.get_key_from_password",
return_value=b"\xab\xcd\xef",
) as mock_get_key:
set_sqlcipher_rekey(mock_conn, "new_password")
mock_get_key.assert_called_once_with("new_password", db_path=None)
def test_rekey_does_not_use_raw_utf8_hex(self):
"""Verify rekey does NOT use password.encode().hex() (raw UTF-8)."""
mock_conn = Mock()
mock_conn.execute.side_effect = [TypeError("not sqlalchemy"), None]
with patch(
"src.local_deep_research.database.sqlcipher_utils.get_key_from_password",
return_value=b"\xab\xcd\xef",
):
set_sqlcipher_rekey(mock_conn, "test_password")
# The SQL should contain the PBKDF2-derived hex, not raw password hex
rekey_call = mock_conn.execute.call_args_list[-1][0][0]
raw_hex = "test_password".encode().hex()
assert raw_hex not in rekey_call, (
"Rekey should NOT use raw UTF-8 hex encoding"
)
assert "abcdef" in rekey_call, "Rekey should use PBKDF2-derived hex key"
def test_rekey_works_with_sqlalchemy_connection(self):
"""Verify rekey works through SQLAlchemy connection (text() wrapper)."""
mock_conn = Mock()
with patch(
"src.local_deep_research.database.sqlcipher_utils.get_key_from_password",
return_value=b"\xab\xcd\xef",
):
set_sqlcipher_rekey(mock_conn, "new_password")
mock_conn.execute.assert_called_once()
+304
View File
@@ -0,0 +1,304 @@
"""
Tests for SQLCipher settings and environment variable overrides.
These tests verify that default settings are correct and that
environment variables properly override them.
"""
import shutil
import tempfile
from pathlib import Path
import pytest
from src.local_deep_research.database.sqlcipher_utils import (
DEFAULT_KDF_ITERATIONS,
DEFAULT_PAGE_SIZE,
DEFAULT_HMAC_ALGORITHM,
DEFAULT_KDF_ALGORITHM,
get_sqlcipher_settings,
apply_cipher_defaults_before_key,
apply_performance_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_settings.db"
yield db_path
shutil.rmtree(temp_dir)
@pytest.fixture
def sqlcipher_module():
"""Get the SQLCipher module."""
return get_sqlcipher_module()
@pytest.fixture
def clean_env(monkeypatch):
"""Remove all LDR_DB_* environment variables."""
env_vars = [
"LDR_DB_KDF_ITERATIONS",
"LDR_DB_PAGE_SIZE",
"LDR_DB_HMAC_ALGORITHM",
"LDR_DB_KDF_ALGORITHM",
"LDR_DB_CACHE_SIZE_MB",
"LDR_DB_JOURNAL_MODE",
"LDR_DB_SYNCHRONOUS",
]
for var in env_vars:
monkeypatch.delenv(var, raising=False)
return monkeypatch
class TestDefaultSettings:
"""Tests for default SQLCipher settings."""
def test_default_kdf_iterations(self, clean_env):
"""Verify default KDF iterations value."""
settings = get_sqlcipher_settings()
assert settings["kdf_iterations"] == DEFAULT_KDF_ITERATIONS
assert settings["kdf_iterations"] == 256000, "Default should be 256000"
def test_default_page_size(self, clean_env):
"""Verify default page size value."""
settings = get_sqlcipher_settings()
assert settings["page_size"] == DEFAULT_PAGE_SIZE
assert settings["page_size"] == 16384, "Default should be 16384 (16KB)"
def test_default_hmac_algorithm(self, clean_env):
"""Verify default HMAC algorithm value."""
settings = get_sqlcipher_settings()
assert settings["hmac_algorithm"] == DEFAULT_HMAC_ALGORITHM
assert settings["hmac_algorithm"] == "HMAC_SHA512"
def test_default_kdf_algorithm(self, clean_env):
"""Verify default KDF algorithm value."""
settings = get_sqlcipher_settings()
assert settings["kdf_algorithm"] == DEFAULT_KDF_ALGORITHM
assert settings["kdf_algorithm"] == "PBKDF2_HMAC_SHA512"
def test_settings_returns_all_required_keys(self, clean_env):
"""Verify settings dictionary has all required keys."""
settings = get_sqlcipher_settings()
required_keys = [
"kdf_iterations",
"page_size",
"hmac_algorithm",
"kdf_algorithm",
]
for key in required_keys:
assert key in settings, f"Missing key: {key}"
def test_settings_values_are_correct_types(self, clean_env):
"""Verify settings values have correct types."""
settings = get_sqlcipher_settings()
assert isinstance(settings["kdf_iterations"], int)
assert isinstance(settings["page_size"], int)
assert isinstance(settings["hmac_algorithm"], str)
assert isinstance(settings["kdf_algorithm"], str)
class TestEnvironmentOverrides:
"""Tests for environment variable overrides."""
def test_kdf_iterations_override(self, monkeypatch):
"""Verify LDR_DB_KDF_ITERATIONS environment variable works."""
monkeypatch.setenv("LDR_DB_KDF_ITERATIONS", "500000")
settings = get_sqlcipher_settings()
assert settings["kdf_iterations"] == 500000
def test_page_size_override(self, monkeypatch):
"""Verify LDR_DB_PAGE_SIZE environment variable works."""
monkeypatch.setenv("LDR_DB_PAGE_SIZE", "8192")
settings = get_sqlcipher_settings()
assert settings["page_size"] == 8192
def test_hmac_algorithm_override(self, monkeypatch):
"""Verify LDR_DB_HMAC_ALGORITHM environment variable works."""
monkeypatch.setenv("LDR_DB_HMAC_ALGORITHM", "HMAC_SHA256")
settings = get_sqlcipher_settings()
assert settings["hmac_algorithm"] == "HMAC_SHA256"
def test_kdf_algorithm_override(self, monkeypatch):
"""Verify LDR_DB_KDF_ALGORITHM environment variable works."""
monkeypatch.setenv("LDR_DB_KDF_ALGORITHM", "PBKDF2_HMAC_SHA256")
settings = get_sqlcipher_settings()
assert settings["kdf_algorithm"] == "PBKDF2_HMAC_SHA256"
class TestPerformancePragmaSettings:
"""Tests for performance pragma environment variable overrides."""
def test_cache_size_override(
self, sqlcipher_module, temp_db_path, monkeypatch
):
"""Verify LDR_DB_CACHE_SIZE_MB environment variable works."""
monkeypatch.setenv("LDR_DB_CACHE_SIZE_MB", "128")
conn = sqlcipher_module.connect(str(temp_db_path))
cursor = conn.cursor()
apply_cipher_defaults_before_key(cursor)
set_sqlcipher_key(cursor, "testpassword")
apply_performance_pragmas(cursor)
cursor.close()
# cache_size in KB (negative means KB)
# 128 MB = 131072 KB, stored as -131072
result = conn.execute("PRAGMA cache_size").fetchone()
assert result is not None
assert result[0] == -131072, (
f"Expected -131072 (-128*1024), got {result[0]}"
)
conn.close()
def test_journal_mode_override(
self, sqlcipher_module, temp_db_path, monkeypatch
):
"""Verify LDR_DB_JOURNAL_MODE environment variable works."""
monkeypatch.setenv("LDR_DB_JOURNAL_MODE", "DELETE")
conn = sqlcipher_module.connect(str(temp_db_path))
cursor = conn.cursor()
apply_cipher_defaults_before_key(cursor)
set_sqlcipher_key(cursor, "testpassword")
apply_performance_pragmas(cursor)
cursor.close()
result = conn.execute("PRAGMA journal_mode").fetchone()
assert result is not None
assert result[0].lower() == "delete"
conn.close()
def test_synchronous_override(
self, sqlcipher_module, temp_db_path, monkeypatch
):
"""Verify LDR_DB_SYNCHRONOUS environment variable works."""
monkeypatch.setenv("LDR_DB_SYNCHRONOUS", "FULL")
conn = sqlcipher_module.connect(str(temp_db_path))
cursor = conn.cursor()
apply_cipher_defaults_before_key(cursor)
set_sqlcipher_key(cursor, "testpassword")
apply_performance_pragmas(cursor)
cursor.close()
result = conn.execute("PRAGMA synchronous").fetchone()
assert result is not None
# SQLite returns synchronous as integer: 0=OFF, 1=NORMAL, 2=FULL, 3=EXTRA
assert result[0] == 2, f"Expected 2 (FULL), got {result[0]}"
conn.close()
def test_default_journal_mode_is_wal(
self, sqlcipher_module, temp_db_path, clean_env
):
"""Verify default journal mode is WAL."""
conn = sqlcipher_module.connect(str(temp_db_path))
cursor = conn.cursor()
apply_cipher_defaults_before_key(cursor)
set_sqlcipher_key(cursor, "testpassword")
apply_performance_pragmas(cursor)
cursor.close()
result = conn.execute("PRAGMA journal_mode").fetchone()
assert result is not None
assert result[0].lower() == "wal", f"Expected WAL, got {result[0]}"
conn.close()
def test_default_temp_store_is_memory(
self, sqlcipher_module, temp_db_path, clean_env
):
"""Verify temp_store is always set to MEMORY."""
conn = sqlcipher_module.connect(str(temp_db_path))
cursor = conn.cursor()
apply_cipher_defaults_before_key(cursor)
set_sqlcipher_key(cursor, "testpassword")
apply_performance_pragmas(cursor)
cursor.close()
result = conn.execute("PRAGMA temp_store").fetchone()
assert result is not None
# temp_store: 0=DEFAULT, 1=FILE, 2=MEMORY
assert result[0] == 2, f"Expected 2 (MEMORY), got {result[0]}"
conn.close()
def test_default_busy_timeout(
self, sqlcipher_module, temp_db_path, clean_env
):
"""Verify busy_timeout is set to 10000ms."""
conn = sqlcipher_module.connect(str(temp_db_path))
cursor = conn.cursor()
apply_cipher_defaults_before_key(cursor)
set_sqlcipher_key(cursor, "testpassword")
apply_performance_pragmas(cursor)
cursor.close()
result = conn.execute("PRAGMA busy_timeout").fetchone()
assert result is not None
assert result[0] == 10000, f"Expected 10000, got {result[0]}"
conn.close()
class TestSettingsAppliedToDatabase:
"""Tests verifying settings are actually applied to databases."""
def test_cipher_settings_applied_before_key(
self, sqlcipher_module, temp_db_path, clean_env
):
"""Verify cipher_default_* settings are applied before key."""
settings = get_sqlcipher_settings()
conn = sqlcipher_module.connect(str(temp_db_path))
cursor = conn.cursor()
# Apply settings before key
apply_cipher_defaults_before_key(cursor)
set_sqlcipher_key(cursor, "testpassword")
cursor.close()
# Verify all cipher settings
page_size = conn.execute("PRAGMA cipher_page_size").fetchone()[0]
hmac_algo = conn.execute("PRAGMA cipher_hmac_algorithm").fetchone()[0]
kdf_algo = conn.execute("PRAGMA cipher_kdf_algorithm").fetchone()[0]
assert str(page_size) == str(settings["page_size"])
assert hmac_algo == settings["hmac_algorithm"]
assert kdf_algo == settings["kdf_algorithm"]
conn.close()
def test_multiple_env_overrides_work_together(
self, sqlcipher_module, temp_db_path, monkeypatch
):
"""Verify multiple environment variable overrides work together."""
monkeypatch.setenv("LDR_DB_PAGE_SIZE", "8192")
monkeypatch.setenv("LDR_DB_KDF_ITERATIONS", "100000")
monkeypatch.setenv("LDR_DB_CACHE_SIZE_MB", "32")
settings = get_sqlcipher_settings()
assert settings["page_size"] == 8192
assert settings["kdf_iterations"] == 100000
conn = sqlcipher_module.connect(str(temp_db_path))
cursor = conn.cursor()
apply_cipher_defaults_before_key(cursor)
set_sqlcipher_key(cursor, "testpassword")
apply_performance_pragmas(cursor)
cursor.close()
# Verify page size
page_size = conn.execute("PRAGMA cipher_page_size").fetchone()[0]
assert str(page_size) == "8192"
# Verify cache size (32MB = 32768KB = -32768)
cache_size = conn.execute("PRAGMA cache_size").fetchone()[0]
assert cache_size == -32768
conn.close()
+387
View File
@@ -0,0 +1,387 @@
"""
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()