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
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:
@@ -0,0 +1 @@
|
||||
"""Authentication tests for LDR."""
|
||||
@@ -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
|
||||
Executable
+69
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user