a2592629db
Add 64 previously missing settings from config.py to SETTING_METADATA, making them all configurable via the settings page UI. New categories: PDF/A Archival, Security Enhanced UX: - Slider inputs for threshold values (text_quality_threshold, near_duplicate_threshold) - Dropdown selects for s3_storage_class, s3_acl, pdfa_format, security_header_x_frame_options_value - All 8 PDF/A settings now configurable via the UI - Security headers, audit logging, rate limiting, CORS settings added - IMAP 1 & 2, S3, Meilisearch, Deduplication, Embedding settings added - Task retry, step timeout, file size limits settings added Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
412 lines
16 KiB
Python
412 lines
16 KiB
Python
"""
|
|
Tests for application settings management.
|
|
"""
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models import ApplicationSettings
|
|
from app.utils.config_loader import convert_setting_value, load_settings_from_db
|
|
from app.utils.settings_service import (
|
|
SETTING_METADATA,
|
|
delete_setting_from_db,
|
|
get_all_settings_from_db,
|
|
get_setting_from_db,
|
|
get_setting_metadata,
|
|
get_settings_by_category,
|
|
save_setting_to_db,
|
|
validate_setting_value,
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSettingsService:
|
|
"""Test settings service functions"""
|
|
|
|
def test_save_and_get_setting(self, db_session: Session):
|
|
"""Test saving and retrieving a setting from database"""
|
|
# Save a setting
|
|
result = save_setting_to_db(db_session, "test_key", "test_value")
|
|
assert result is True
|
|
|
|
# Retrieve the setting
|
|
value = get_setting_from_db(db_session, "test_key")
|
|
assert value == "test_value"
|
|
|
|
def test_update_existing_setting(self, db_session: Session):
|
|
"""Test updating an existing setting"""
|
|
# Save initial value
|
|
save_setting_to_db(db_session, "test_key", "initial_value")
|
|
|
|
# Update the value
|
|
result = save_setting_to_db(db_session, "test_key", "updated_value")
|
|
assert result is True
|
|
|
|
# Verify update
|
|
value = get_setting_from_db(db_session, "test_key")
|
|
assert value == "updated_value"
|
|
|
|
def test_get_nonexistent_setting(self, db_session: Session):
|
|
"""Test retrieving a setting that doesn't exist"""
|
|
value = get_setting_from_db(db_session, "nonexistent_key")
|
|
assert value is None
|
|
|
|
def test_get_all_settings(self, db_session: Session):
|
|
"""Test retrieving all settings from database"""
|
|
# Save multiple settings
|
|
save_setting_to_db(db_session, "key1", "value1")
|
|
save_setting_to_db(db_session, "key2", "value2")
|
|
save_setting_to_db(db_session, "key3", "value3")
|
|
|
|
# Get all settings
|
|
all_settings = get_all_settings_from_db(db_session)
|
|
assert len(all_settings) == 3
|
|
assert all_settings["key1"] == "value1"
|
|
assert all_settings["key2"] == "value2"
|
|
assert all_settings["key3"] == "value3"
|
|
|
|
def test_delete_setting(self, db_session: Session):
|
|
"""Test deleting a setting from database"""
|
|
# Save a setting
|
|
save_setting_to_db(db_session, "test_key", "test_value")
|
|
|
|
# Delete the setting
|
|
result = delete_setting_from_db(db_session, "test_key")
|
|
assert result is True
|
|
|
|
# Verify deletion
|
|
value = get_setting_from_db(db_session, "test_key")
|
|
assert value is None
|
|
|
|
def test_delete_nonexistent_setting(self, db_session: Session):
|
|
"""Test deleting a setting that doesn't exist"""
|
|
result = delete_setting_from_db(db_session, "nonexistent_key")
|
|
assert result is False
|
|
|
|
def test_validate_setting_value_boolean(self):
|
|
"""Test validation of boolean settings"""
|
|
# Valid boolean values
|
|
is_valid, error = validate_setting_value("debug", "true")
|
|
assert is_valid is True
|
|
assert error is None
|
|
|
|
is_valid, error = validate_setting_value("debug", "false")
|
|
assert is_valid is True
|
|
|
|
# Invalid boolean value
|
|
is_valid, error = validate_setting_value("debug", "maybe")
|
|
assert is_valid is False
|
|
assert "boolean" in error.lower()
|
|
|
|
def test_validate_session_secret_length(self):
|
|
"""Test validation of session_secret minimum length"""
|
|
# Too short
|
|
is_valid, error = validate_setting_value("session_secret", "short")
|
|
assert is_valid is False
|
|
assert "32 characters" in error
|
|
|
|
# Long enough
|
|
long_secret = "a" * 32
|
|
is_valid, error = validate_setting_value("session_secret", long_secret)
|
|
assert is_valid is True
|
|
|
|
def test_get_setting_metadata(self):
|
|
"""Test retrieving setting metadata"""
|
|
metadata = get_setting_metadata("database_url")
|
|
assert metadata["category"] == "Core"
|
|
assert metadata["type"] == "string"
|
|
assert metadata["required"] is True
|
|
assert metadata["restart_required"] is True
|
|
|
|
# Test unknown setting
|
|
metadata = get_setting_metadata("unknown_setting")
|
|
assert metadata["category"] == "Other"
|
|
|
|
def test_get_settings_by_category(self):
|
|
"""Test getting settings organized by category"""
|
|
categories = get_settings_by_category()
|
|
assert "Core" in categories
|
|
assert "Authentication" in categories
|
|
assert "AI Services" in categories
|
|
assert "database_url" in categories["Core"]
|
|
assert "auth_enabled" in categories["Authentication"]
|
|
|
|
def test_setting_metadata_completeness(self):
|
|
"""Test that all major settings have metadata"""
|
|
# Check that we have a good number of settings defined
|
|
assert len(SETTING_METADATA) > 50, "Should have metadata for at least 50 settings"
|
|
|
|
# Check critical settings are present
|
|
critical_settings = [
|
|
"database_url",
|
|
"redis_url",
|
|
"workdir",
|
|
"debug",
|
|
"openai_api_key",
|
|
"azure_ai_key",
|
|
"auth_enabled",
|
|
"session_secret",
|
|
]
|
|
for setting in critical_settings:
|
|
assert setting in SETTING_METADATA, f"Missing metadata for {setting}"
|
|
|
|
def test_all_config_settings_have_metadata(self):
|
|
"""Test that every setting in config.py's Settings class has a SETTING_METADATA entry."""
|
|
from app.config import Settings
|
|
|
|
# Get all annotated fields from the Settings class
|
|
settings_fields = set(Settings.model_fields.keys())
|
|
|
|
metadata_keys = set(SETTING_METADATA.keys())
|
|
missing = settings_fields - metadata_keys
|
|
|
|
assert not missing, (
|
|
f"The following config.py settings are missing from SETTING_METADATA "
|
|
f"and will not appear on the settings page: {sorted(missing)}"
|
|
)
|
|
|
|
def test_setting_metadata_has_required_fields(self):
|
|
"""Test that every SETTING_METADATA entry has the required keys."""
|
|
required_keys = {"category", "description", "type", "sensitive", "required", "restart_required"}
|
|
for key, meta in SETTING_METADATA.items():
|
|
missing = required_keys - set(meta.keys())
|
|
assert not missing, f"SETTING_METADATA['{key}'] is missing keys: {missing}"
|
|
|
|
def test_pdfa_settings_in_metadata(self):
|
|
"""Test that all 8 PDF/A settings from the issue are present in SETTING_METADATA."""
|
|
pdfa_settings = [
|
|
"enable_pdfa_conversion",
|
|
"pdfa_format",
|
|
"pdfa_upload_original",
|
|
"pdfa_upload_processed",
|
|
"pdfa_upload_folder",
|
|
"google_drive_pdfa_folder_id",
|
|
"pdfa_timestamp_enabled",
|
|
"pdfa_timestamp_url",
|
|
]
|
|
for setting in pdfa_settings:
|
|
assert setting in SETTING_METADATA, f"PDF/A setting '{setting}' missing from SETTING_METADATA"
|
|
|
|
def test_pdfa_format_has_dropdown_options(self):
|
|
"""Test that pdfa_format uses a dropdown with valid PDF/A format options."""
|
|
meta = SETTING_METADATA["pdfa_format"]
|
|
assert "options" in meta, "pdfa_format should have dropdown options"
|
|
assert set(meta["options"]) == {"1", "2", "3"}, "pdfa_format options should be 1, 2, 3"
|
|
|
|
def test_slider_type_settings_have_range(self):
|
|
"""Test that slider-type settings have min, max, and step defined."""
|
|
for key, meta in SETTING_METADATA.items():
|
|
if meta.get("type") == "slider":
|
|
assert "min" in meta, f"Slider setting '{key}' missing 'min'"
|
|
assert "max" in meta, f"Slider setting '{key}' missing 'max'"
|
|
assert "step" in meta, f"Slider setting '{key}' missing 'step'"
|
|
|
|
def test_validate_slider_value(self):
|
|
"""Test validation of slider type values."""
|
|
# near_duplicate_threshold is a slider (0-1)
|
|
is_valid, error = validate_setting_value("near_duplicate_threshold", "0.85")
|
|
assert is_valid is True
|
|
|
|
is_valid, error = validate_setting_value("near_duplicate_threshold", "1.5")
|
|
assert is_valid is False
|
|
assert "must be <= 1" in error
|
|
|
|
is_valid, error = validate_setting_value("near_duplicate_threshold", "-0.1")
|
|
assert is_valid is False
|
|
assert "must be >= 0" in error
|
|
|
|
is_valid, error = validate_setting_value("near_duplicate_threshold", "not_a_number")
|
|
assert is_valid is False
|
|
assert "must be a number" in error
|
|
|
|
def test_s3_storage_class_has_dropdown_options(self):
|
|
"""Test that s3_storage_class uses a dropdown with valid options."""
|
|
meta = SETTING_METADATA["s3_storage_class"]
|
|
assert "options" in meta, "s3_storage_class should have dropdown options"
|
|
assert "STANDARD" in meta["options"]
|
|
assert "GLACIER" in meta["options"]
|
|
|
|
def test_s3_acl_has_dropdown_options(self):
|
|
"""Test that s3_acl uses a dropdown with valid options."""
|
|
meta = SETTING_METADATA["s3_acl"]
|
|
assert "options" in meta, "s3_acl should have dropdown options"
|
|
assert "private" in meta["options"]
|
|
assert "public-read" in meta["options"]
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestConfigLoader:
|
|
"""Test configuration loader functions"""
|
|
|
|
def test_convert_boolean_value(self):
|
|
"""Test converting string to boolean"""
|
|
assert convert_setting_value("true", bool) is True
|
|
assert convert_setting_value("false", bool) is False
|
|
assert convert_setting_value("1", bool) is True
|
|
assert convert_setting_value("0", bool) is False
|
|
assert convert_setting_value("yes", bool) is True
|
|
assert convert_setting_value("no", bool) is False
|
|
|
|
def test_convert_integer_value(self):
|
|
"""Test converting string to integer"""
|
|
assert convert_setting_value("42", int) == 42
|
|
assert convert_setting_value("0", int) == 0
|
|
assert convert_setting_value("-5", int) == -5
|
|
|
|
# Invalid integer
|
|
assert convert_setting_value("not_a_number", int) == 0
|
|
|
|
def test_convert_string_value(self):
|
|
"""Test converting to string (default)"""
|
|
assert convert_setting_value("hello", str) == "hello"
|
|
assert convert_setting_value("123", str) == "123"
|
|
|
|
def test_convert_none_value(self):
|
|
"""Test handling None values"""
|
|
assert convert_setting_value(None, str) is None
|
|
assert convert_setting_value(None, int) is None
|
|
assert convert_setting_value(None, bool) is None
|
|
|
|
def test_convert_list_value(self):
|
|
"""Test converting comma-separated string to list"""
|
|
assert convert_setting_value("a,b,c", list) == ["a", "b", "c"]
|
|
assert convert_setting_value("single", list) == ["single"]
|
|
assert convert_setting_value("", list) == []
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.requires_db
|
|
class TestSettingsAPI:
|
|
"""Test settings API endpoints"""
|
|
|
|
def test_get_settings_requires_admin(self, client: TestClient):
|
|
"""Test that settings endpoint requires admin privileges"""
|
|
# With AUTH_ENABLED=False in test environment, this test verifies
|
|
# the admin check functionality. In production with AUTH_ENABLED=True,
|
|
# both authentication and admin checks are enforced.
|
|
response = client.get("/api/settings/")
|
|
# Should return 403 (no admin session) or redirect
|
|
# Note: Test environment has AUTH_ENABLED=False
|
|
assert response.status_code in [200, 302, 403]
|
|
|
|
def test_settings_page_structure(self, client: TestClient):
|
|
"""Test that settings page has expected structure"""
|
|
# Verify the endpoint exists and returns expected status codes
|
|
response = client.get("/settings", follow_redirects=False)
|
|
# Should redirect or return 403 since no admin session
|
|
assert response.status_code in [200, 302, 403]
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.requires_db
|
|
class TestSettingsPrecedence:
|
|
"""Test settings precedence (DB > env > defaults)"""
|
|
|
|
def test_db_overrides_default(self, db_session: Session):
|
|
"""Test that database settings override default values"""
|
|
# Create a minimal test settings object
|
|
|
|
from pydantic_settings import BaseSettings
|
|
|
|
class TestSettings(BaseSettings):
|
|
test_value: str = "default"
|
|
test_bool: bool = False
|
|
|
|
class Config:
|
|
env_file = None
|
|
|
|
# Create settings with defaults
|
|
test_settings = TestSettings()
|
|
assert test_settings.test_value == "default"
|
|
assert test_settings.test_bool is False
|
|
|
|
# Save to database
|
|
save_setting_to_db(db_session, "test_value", "from_database")
|
|
save_setting_to_db(db_session, "test_bool", "true")
|
|
|
|
# Load from database
|
|
load_settings_from_db(test_settings, db_session)
|
|
|
|
# Verify database values take precedence
|
|
assert test_settings.test_value == "from_database"
|
|
assert test_settings.test_bool is True
|
|
|
|
def test_load_settings_handles_missing_db_settings(self, db_session: Session):
|
|
"""Test that loading settings works when no DB settings exist"""
|
|
from pydantic_settings import BaseSettings
|
|
|
|
class TestSettings(BaseSettings):
|
|
test_value: str = "default"
|
|
|
|
class Config:
|
|
env_file = None
|
|
|
|
test_settings = TestSettings()
|
|
|
|
# Load from empty database - should not crash
|
|
load_settings_from_db(test_settings, db_session)
|
|
|
|
# Should still have default value
|
|
assert test_settings.test_value == "default"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestApplicationSettingsModel:
|
|
"""Test the ApplicationSettings database model"""
|
|
|
|
def test_create_setting_record(self, db_session: Session):
|
|
"""Test creating an ApplicationSettings record"""
|
|
setting = ApplicationSettings(key="test_key", value="test_value")
|
|
db_session.add(setting)
|
|
db_session.commit()
|
|
|
|
# Retrieve and verify
|
|
retrieved = db_session.query(ApplicationSettings).filter_by(key="test_key").first()
|
|
assert retrieved is not None
|
|
assert retrieved.key == "test_key"
|
|
assert retrieved.value == "test_value"
|
|
assert retrieved.created_at is not None
|
|
assert retrieved.updated_at is not None
|
|
|
|
def test_unique_key_constraint(self, db_session: Session):
|
|
"""Test that key field has unique constraint"""
|
|
# Create first setting
|
|
setting1 = ApplicationSettings(key="unique_key", value="value1")
|
|
db_session.add(setting1)
|
|
db_session.commit()
|
|
|
|
# Try to create duplicate - should fail
|
|
setting2 = ApplicationSettings(key="unique_key", value="value2")
|
|
db_session.add(setting2)
|
|
|
|
with pytest.raises(Exception): # SQLAlchemy will raise an exception
|
|
db_session.commit()
|
|
|
|
@pytest.mark.skipif(
|
|
True, # Skip for all databases - timestamp update behavior varies
|
|
reason="Timestamp update behavior varies by database backend",
|
|
)
|
|
def test_update_timestamp(self, db_session: Session):
|
|
"""Test that updated_at timestamp is updated on modification"""
|
|
import time
|
|
|
|
# Create setting
|
|
setting = ApplicationSettings(key="test_key", value="initial")
|
|
db_session.add(setting)
|
|
db_session.commit()
|
|
|
|
initial_updated_at = setting.updated_at
|
|
|
|
# Small delay to ensure timestamp difference
|
|
time.sleep(0.1)
|
|
|
|
# Update setting
|
|
setting.value = "updated"
|
|
db_session.commit()
|
|
|
|
# Verify updated_at changed
|
|
# Note: SQLite doesn't automatically update onupdate timestamps
|
|
# This test is skipped as behavior varies by database backend
|
|
assert setting.updated_at is not None
|