7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
473 lines
15 KiB
Python
473 lines
15 KiB
Python
"""Tests for database initialization and encryption functionality."""
|
|
|
|
import shutil
|
|
import tempfile
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from sqlalchemy import create_engine, inspect
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from local_deep_research.database.models import (
|
|
Base,
|
|
ResearchHistory,
|
|
Setting,
|
|
User,
|
|
)
|
|
|
|
|
|
class TestDatabaseInitialization:
|
|
"""Test suite for database initialization and setup."""
|
|
|
|
@pytest.fixture
|
|
def temp_dir(self):
|
|
"""Create a temporary directory for test databases."""
|
|
temp_dir = tempfile.mkdtemp()
|
|
yield temp_dir
|
|
shutil.rmtree(temp_dir)
|
|
|
|
@pytest.fixture
|
|
def db_engine(self, temp_dir):
|
|
"""Create and dispose a SQLite engine with all tables."""
|
|
db_path = str(Path(temp_dir) / "test.db")
|
|
engine = create_engine(f"sqlite:///{db_path}")
|
|
Base.metadata.create_all(engine)
|
|
yield engine
|
|
engine.dispose()
|
|
|
|
@pytest.fixture
|
|
def db_session(self, db_engine):
|
|
"""Create a session bound to db_engine, closed on teardown."""
|
|
Session = sessionmaker(bind=db_engine)
|
|
session = Session()
|
|
yield session
|
|
session.close()
|
|
|
|
def test_basic_database_creation(self, temp_dir):
|
|
"""Test creating a basic SQLite database."""
|
|
db_path = str(Path(temp_dir) / "test.db")
|
|
engine = create_engine(f"sqlite:///{db_path}")
|
|
Base.metadata.create_all(engine)
|
|
|
|
# Verify database file exists
|
|
assert Path(db_path).exists()
|
|
|
|
# Verify tables were created
|
|
inspector = inspect(engine)
|
|
tables = inspector.get_table_names()
|
|
|
|
# Check for essential tables
|
|
assert "users" in tables
|
|
assert "research_history" in tables
|
|
assert "settings" in tables
|
|
assert "research_resources" in tables
|
|
assert "token_usage" in tables
|
|
|
|
engine.dispose()
|
|
|
|
def test_database_creation_with_function(self, temp_dir):
|
|
"""Test database creation through standard SQLAlchemy."""
|
|
db_path = str(Path(temp_dir) / "test_user.db")
|
|
|
|
# Create engine and initialize database
|
|
engine = create_engine(f"sqlite:///{db_path}")
|
|
Base.metadata.create_all(engine)
|
|
|
|
# Verify engine is created
|
|
assert engine is not None
|
|
|
|
# Verify database exists
|
|
assert Path(db_path).exists()
|
|
|
|
# Test connection
|
|
from sqlalchemy import text
|
|
|
|
with engine.connect() as conn:
|
|
result = conn.execute(text("SELECT 1"))
|
|
assert result.fetchone()[0] == 1
|
|
|
|
engine.dispose()
|
|
|
|
def test_encrypted_database_creation(self, temp_dir, monkeypatch):
|
|
"""A user database created via DatabaseManager is real SQLCipher-encrypted.
|
|
|
|
Uses the production creation path (DatabaseManager + sqlcipher3), not
|
|
the obsolete ``sqlite+pysqlcipher://`` dialect, and skips only when a
|
|
functional SQLCipher backend is genuinely unavailable -- checked via
|
|
``DatabaseManager.has_encryption`` rather than guessing a package name.
|
|
The previous version probed ``pysqlcipher3`` (which this project does
|
|
not install -- it uses ``sqlcipher3``), so it silently skipped
|
|
everywhere, including CI.
|
|
"""
|
|
from sqlalchemy import text
|
|
from sqlalchemy.exc import DatabaseError
|
|
|
|
from local_deep_research.database.encrypted_db import DatabaseManager
|
|
|
|
monkeypatch.setattr(
|
|
"local_deep_research.database.encrypted_db.get_data_directory",
|
|
lambda: Path(temp_dir),
|
|
)
|
|
manager = DatabaseManager()
|
|
if not manager.has_encryption:
|
|
pytest.skip("Functional SQLCipher backend not available")
|
|
|
|
username, password = "encuser", "TestPassword123!"
|
|
engine = manager.create_user_database(username, password)
|
|
plain_engine = None
|
|
try:
|
|
# The encrypted engine is functional.
|
|
with engine.connect() as conn:
|
|
assert conn.execute(text("SELECT 1")).scalar() == 1
|
|
|
|
db_path = manager._get_user_db_path(username)
|
|
assert db_path.exists()
|
|
|
|
# Opening the same file as PLAINTEXT SQLite must fail with "file is
|
|
# not a database": the bytes are encrypted, so SQLite cannot parse
|
|
# the header. DatabaseError (SQLAlchemy's OperationalError subclasses
|
|
# it) is specific enough that the test can't pass for an unrelated
|
|
# reason -- the actual proof that encryption was applied.
|
|
plain_engine = create_engine(f"sqlite:///{db_path}")
|
|
with pytest.raises(DatabaseError):
|
|
with plain_engine.connect() as conn:
|
|
conn.execute(text("SELECT name FROM sqlite_master"))
|
|
finally:
|
|
if plain_engine is not None:
|
|
plain_engine.dispose()
|
|
manager.close_user_database(username)
|
|
|
|
def test_database_schema_completeness(self, db_engine):
|
|
"""Test that all expected tables and columns are created."""
|
|
inspector = inspect(db_engine)
|
|
|
|
# Test ResearchHistory table schema
|
|
research_columns = {
|
|
col["name"] for col in inspector.get_columns("research_history")
|
|
}
|
|
expected_columns = {
|
|
"id",
|
|
"query",
|
|
"mode",
|
|
"status",
|
|
"created_at",
|
|
"completed_at",
|
|
"duration_seconds",
|
|
"progress",
|
|
"report_path",
|
|
"report_content",
|
|
"title",
|
|
"progress_log",
|
|
"research_meta",
|
|
}
|
|
assert expected_columns.issubset(research_columns)
|
|
|
|
# Test User table schema
|
|
user_columns = {col["name"] for col in inspector.get_columns("users")}
|
|
expected_user_columns = {
|
|
"id",
|
|
"username",
|
|
"created_at",
|
|
"last_login",
|
|
"database_version",
|
|
}
|
|
assert expected_user_columns.issubset(user_columns), (
|
|
f"Missing columns: {expected_user_columns - user_columns}"
|
|
)
|
|
|
|
# Test Settings table schema
|
|
settings_columns = {
|
|
col["name"] for col in inspector.get_columns("settings")
|
|
}
|
|
expected_settings_columns = {
|
|
"id",
|
|
"key",
|
|
"value",
|
|
"type",
|
|
"category",
|
|
"description",
|
|
"name",
|
|
"ui_element",
|
|
"options",
|
|
"min_value",
|
|
"max_value",
|
|
"step",
|
|
"visible",
|
|
"editable",
|
|
"created_at",
|
|
"updated_at",
|
|
}
|
|
assert expected_settings_columns.issubset(settings_columns)
|
|
|
|
def test_database_indexes(self, db_engine):
|
|
"""Test that proper indexes are created."""
|
|
inspector = inspect(db_engine)
|
|
|
|
# Check indexes on research_history
|
|
research_indexes = inspector.get_indexes("research_history")
|
|
# Should have indexes on commonly queried fields
|
|
index_columns = set()
|
|
for idx in research_indexes:
|
|
index_columns.update(idx["column_names"])
|
|
|
|
# Status should be indexed for filtering
|
|
# Created_at should be indexed for sorting
|
|
# These might be part of composite indexes
|
|
|
|
# Check unique constraints
|
|
# Username and email should have unique constraints in users table
|
|
|
|
def test_database_foreign_keys(self, db_engine, db_session):
|
|
"""Test that foreign key relationships work correctly."""
|
|
# Create a research record
|
|
research = ResearchHistory(
|
|
id=str(uuid.uuid4()),
|
|
query="Test query",
|
|
mode="quick",
|
|
status="completed",
|
|
created_at="2024-01-01T00:00:00",
|
|
)
|
|
db_session.add(research)
|
|
db_session.commit()
|
|
|
|
# Create related records
|
|
from local_deep_research.database.models import (
|
|
ResearchResource,
|
|
TokenUsage,
|
|
)
|
|
|
|
# Add a resource
|
|
resource = ResearchResource(
|
|
research_id=research.id,
|
|
title="Test Resource",
|
|
url="https://example.com",
|
|
created_at="2024-01-01T00:01:00",
|
|
)
|
|
db_session.add(resource)
|
|
|
|
# Add token usage
|
|
usage = TokenUsage(
|
|
research_id=str(research.id),
|
|
model_provider="openai",
|
|
model_name="gpt-4",
|
|
prompt_tokens=80,
|
|
completion_tokens=20,
|
|
total_tokens=100,
|
|
)
|
|
db_session.add(usage)
|
|
|
|
db_session.commit()
|
|
|
|
# Verify relationships
|
|
assert resource.research_id == research.id
|
|
assert usage.research_id == str(research.id)
|
|
|
|
def test_database_cascade_deletes(self, db_engine, db_session):
|
|
"""Test cascade delete behavior."""
|
|
# Create a benchmark run with results
|
|
from local_deep_research.database.models import (
|
|
BenchmarkResult,
|
|
BenchmarkRun,
|
|
DatasetType,
|
|
)
|
|
|
|
run = BenchmarkRun(
|
|
config_hash="test123",
|
|
query_hash_list=[],
|
|
search_config={},
|
|
evaluation_config={},
|
|
datasets_config={},
|
|
)
|
|
db_session.add(run)
|
|
db_session.commit()
|
|
|
|
# Add results
|
|
for i in range(3):
|
|
result = BenchmarkResult(
|
|
benchmark_run_id=run.id,
|
|
example_id=f"test_{i}",
|
|
query_hash=f"hash_{i}",
|
|
dataset_type=DatasetType.SIMPLEQA,
|
|
question=f"Question {i}",
|
|
correct_answer=f"Answer {i}",
|
|
)
|
|
db_session.add(result)
|
|
|
|
db_session.commit()
|
|
|
|
# Verify results exist
|
|
result_count = (
|
|
db_session.query(BenchmarkResult)
|
|
.filter_by(benchmark_run_id=run.id)
|
|
.count()
|
|
)
|
|
assert result_count == 3
|
|
|
|
# Delete the run
|
|
db_session.delete(run)
|
|
db_session.commit()
|
|
|
|
# Verify cascade delete worked
|
|
result_count = (
|
|
db_session.query(BenchmarkResult)
|
|
.filter_by(benchmark_run_id=run.id)
|
|
.count()
|
|
)
|
|
assert result_count == 0
|
|
|
|
def test_database_transactions(self, db_engine, db_session):
|
|
"""Test transaction rollback behavior."""
|
|
# Add a user
|
|
user = User(username="testuser")
|
|
db_session.add(user)
|
|
db_session.commit()
|
|
|
|
# Start a transaction that will fail
|
|
try:
|
|
# Add another user with duplicate username (should fail)
|
|
user2 = User(username="testuser")
|
|
db_session.add(user2)
|
|
|
|
# Add a valid setting
|
|
setting = Setting(
|
|
key="test.setting",
|
|
value="test_value",
|
|
type="string",
|
|
category="test",
|
|
)
|
|
db_session.add(setting)
|
|
|
|
# This should fail due to unique constraint
|
|
db_session.commit()
|
|
|
|
except Exception:
|
|
db_session.rollback()
|
|
|
|
# Verify rollback worked - setting should not exist
|
|
setting_count = (
|
|
db_session.query(Setting).filter_by(key="test.setting").count()
|
|
)
|
|
assert setting_count == 0
|
|
|
|
# Original user should still exist
|
|
user_count = db_session.query(User).count()
|
|
assert user_count == 1
|
|
|
|
def test_database_performance_with_large_dataset(
|
|
self, db_engine, db_session
|
|
):
|
|
"""Test database performance with larger datasets."""
|
|
# Add many research records
|
|
research_count = 1000
|
|
for i in range(research_count):
|
|
research = ResearchHistory(
|
|
id=str(uuid.uuid4()),
|
|
query=f"Test query {i}",
|
|
mode="quick",
|
|
status="completed" if i % 2 == 0 else "failed",
|
|
created_at=f"2024-01-{(i % 28) + 1:02d}T00:00:00",
|
|
duration_seconds=100 + i % 500,
|
|
progress=100 if i % 2 == 0 else 50,
|
|
)
|
|
db_session.add(research)
|
|
|
|
# Commit in batches
|
|
if i % 100 == 0:
|
|
db_session.commit()
|
|
|
|
db_session.commit()
|
|
|
|
# Test query performance
|
|
import time
|
|
|
|
# Query completed research
|
|
start = time.time()
|
|
completed = (
|
|
db_session.query(ResearchHistory)
|
|
.filter_by(status="completed")
|
|
.count()
|
|
)
|
|
query_time = time.time() - start
|
|
|
|
assert completed == 500
|
|
assert query_time < 0.1 # Should be fast with indexes
|
|
|
|
# Test ordering
|
|
start = time.time()
|
|
recent = (
|
|
db_session.query(ResearchHistory)
|
|
.order_by(ResearchHistory.created_at.desc())
|
|
.limit(10)
|
|
.all()
|
|
)
|
|
order_time = time.time() - start
|
|
|
|
assert len(recent) == 10
|
|
assert order_time < 0.1
|
|
|
|
def test_user_specific_database_path(self, temp_dir):
|
|
"""Test user-specific database paths for multi-user support."""
|
|
# Test database path generation for different users
|
|
user1_path = str(Path(temp_dir) / "user1" / "user1_encrypted.db")
|
|
user2_path = str(Path(temp_dir) / "user2" / "user2_encrypted.db")
|
|
|
|
# Create directories
|
|
Path(user1_path).parent.mkdir(parents=True, exist_ok=True)
|
|
Path(user2_path).parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Create separate databases
|
|
engine1 = create_engine(f"sqlite:///{user1_path}")
|
|
engine2 = create_engine(f"sqlite:///{user2_path}")
|
|
|
|
Base.metadata.create_all(engine1)
|
|
Base.metadata.create_all(engine2)
|
|
|
|
# Add data to each
|
|
Session1 = sessionmaker(bind=engine1)
|
|
Session2 = sessionmaker(bind=engine2)
|
|
|
|
session1 = Session1()
|
|
session2 = Session2()
|
|
|
|
# User 1 research
|
|
research1 = ResearchHistory(
|
|
id=str(uuid.uuid4()),
|
|
query="User 1 research",
|
|
mode="quick",
|
|
status="completed",
|
|
created_at="2024-01-01T00:00:00",
|
|
)
|
|
session1.add(research1)
|
|
session1.commit()
|
|
|
|
# User 2 research
|
|
research2 = ResearchHistory(
|
|
id=str(uuid.uuid4()),
|
|
query="User 2 research",
|
|
mode="quick",
|
|
status="completed",
|
|
created_at="2024-01-01T00:00:00",
|
|
)
|
|
session2.add(research2)
|
|
session2.commit()
|
|
|
|
# Verify isolation
|
|
user1_count = session1.query(ResearchHistory).count()
|
|
user2_count = session2.query(ResearchHistory).count()
|
|
|
|
assert user1_count == 1
|
|
assert user2_count == 1
|
|
|
|
# Verify different content
|
|
user1_research = session1.query(ResearchHistory).first()
|
|
user2_research = session2.query(ResearchHistory).first()
|
|
|
|
assert user1_research.query == "User 1 research"
|
|
assert user2_research.query == "User 2 research"
|
|
|
|
session1.close()
|
|
session2.close()
|
|
engine1.dispose()
|
|
engine2.dispose()
|