diff --git a/.gitignore b/.gitignore index 702fe2f1..d64fb5d5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ __pycache__/ .env.local .env.*.local +# Dynaconf (ADR-024) +.secrets.toml +settings.local.toml + # Git worktrees/ diff --git a/.secrets.toml.example b/.secrets.toml.example new file mode 100644 index 00000000..2e31cfe9 --- /dev/null +++ b/.secrets.toml.example @@ -0,0 +1,13 @@ +# Secrets configuration for Nextcloud MCP Server. +# Copy this file to .secrets.toml and fill in your values. +# .secrets.toml is gitignored — never commit real secrets. +# +# See docs/ADR-024-dynaconf-configuration-management.md for details. + +[default] +token_encryption_key = "" +nextcloud_password = "" +nextcloud_oidc_client_secret = "" +qdrant_api_key = "" +openai_api_key = "" +custom_processor_api_key = "" diff --git a/nextcloud_mcp_server/config.py b/nextcloud_mcp_server/config.py index cc4aa50c..1f616d7a 100644 --- a/nextcloud_mcp_server/config.py +++ b/nextcloud_mcp_server/config.py @@ -4,8 +4,65 @@ import os import socket import ssl from dataclasses import dataclass +from pathlib import Path from typing import Any +from dynaconf import Dynaconf, Validator + +# Dynaconf instance — loads settings.toml + .secrets.toml + env vars. +# Env vars always win (12-factor). See ADR-024 for architecture. +_dynaconf = Dynaconf( + settings_files=["settings.toml", ".secrets.toml"], + environments=True, + envvar_prefix=False, + env_switcher="MCP_DEPLOYMENT_MODE", + ignore_unknown_envvars=True, + root_path=str(Path(__file__).parent.parent), + load_dotenv=False, + validators=[ + # Port ranges + Validator("METRICS_PORT", gte=1, lte=65535), + # Positive integers + Validator("VECTOR_SYNC_SCAN_INTERVAL", gte=1), + Validator("VECTOR_SYNC_PROCESSOR_WORKERS", gte=1), + Validator("VECTOR_SYNC_QUEUE_MAX_SIZE", gte=1), + Validator("VECTOR_SYNC_USER_POLL_INTERVAL", gte=1), + Validator("DOCUMENT_CHUNK_SIZE", gte=1), + # Non-negative + Validator("DOCUMENT_CHUNK_OVERLAP", gte=0), + # Enum constraints + Validator("LOG_FORMAT", is_in=["text", "json"]), + Validator( + "LOG_LEVEL", + is_in=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + ), + Validator( + "OTEL_TRACES_SAMPLER", + is_in=[ + "always_on", + "always_off", + "traceidratio", + "parentbased_always_on", + "parentbased_always_off", + "parentbased_traceidratio", + ], + ), + # Float ranges + Validator("OTEL_TRACES_SAMPLER_ARG", gte=0.0, lte=1.0), + ], +) + + +def _reload_config(): + """Reload dynaconf settings from files and environment. + + Call this in tests after modifying os.environ to refresh the cache. + Re-validates all validators since reload() only checks unchecked ones. + """ + _dynaconf.reload() + _dynaconf.validators.validate_all() + + LOGGING_CONFIG = { "version": 1, "disable_existing_loggers": False, @@ -63,7 +120,7 @@ def setup_logging(): def get_document_processor_config() -> dict[str, Any]: - """Get document processor configuration from environment. + """Get document processor configuration from dynaconf. Returns: Dict with processor configs: @@ -78,54 +135,54 @@ def get_document_processor_config() -> dict[str, Any]: } """ config: dict[str, Any] = { - "enabled": os.getenv("ENABLE_DOCUMENT_PROCESSING", "false").lower() == "true", - "default_processor": os.getenv("DOCUMENT_PROCESSOR", "unstructured"), + "enabled": _dynaconf.get("ENABLE_DOCUMENT_PROCESSING"), + "default_processor": _dynaconf.get("DOCUMENT_PROCESSOR"), "processors": {}, } # Unstructured configuration - if os.getenv("ENABLE_UNSTRUCTURED", "false").lower() == "true": + if _dynaconf.get("ENABLE_UNSTRUCTURED"): + languages_str = _dynaconf.get("UNSTRUCTURED_LANGUAGES") config["processors"]["unstructured"] = { - "api_url": os.getenv("UNSTRUCTURED_API_URL", "http://unstructured:8000"), - "timeout": int(os.getenv("UNSTRUCTURED_TIMEOUT", "120")), - "strategy": os.getenv("UNSTRUCTURED_STRATEGY", "auto"), + "api_url": _dynaconf.get("UNSTRUCTURED_API_URL"), + "timeout": _dynaconf.get("UNSTRUCTURED_TIMEOUT"), + "strategy": _dynaconf.get("UNSTRUCTURED_STRATEGY"), "languages": [ - lang.strip() - for lang in os.getenv("UNSTRUCTURED_LANGUAGES", "eng,deu").split(",") - if lang.strip() + lang.strip() for lang in languages_str.split(",") if lang.strip() ], - "progress_interval": int(os.getenv("PROGRESS_INTERVAL", "10")), + "progress_interval": _dynaconf.get("PROGRESS_INTERVAL"), } # Tesseract configuration - if os.getenv("ENABLE_TESSERACT", "false").lower() == "true": + if _dynaconf.get("ENABLE_TESSERACT"): config["processors"]["tesseract"] = { - "tesseract_cmd": os.getenv("TESSERACT_CMD"), # None = auto-detect - "lang": os.getenv("TESSERACT_LANG", "eng"), + "tesseract_cmd": _dynaconf.get("TESSERACT_CMD"), # None = auto-detect + "lang": _dynaconf.get("TESSERACT_LANG"), } # PyMuPDF configuration (local PDF processing) - if os.getenv("ENABLE_PYMUPDF", "true").lower() == "true": # Enabled by default + if _dynaconf.get("ENABLE_PYMUPDF"): # Enabled by default config["processors"]["pymupdf"] = { - "extract_images": os.getenv("PYMUPDF_EXTRACT_IMAGES", "true").lower() - == "true", - "image_dir": os.getenv("PYMUPDF_IMAGE_DIR"), # None = use temp directory + "extract_images": _dynaconf.get("PYMUPDF_EXTRACT_IMAGES"), + "image_dir": _dynaconf.get( + "PYMUPDF_IMAGE_DIR" + ), # None = use temp directory } # Custom processor (via HTTP API) - if os.getenv("ENABLE_CUSTOM_PROCESSOR", "false").lower() == "true": - custom_url = os.getenv("CUSTOM_PROCESSOR_URL") + if _dynaconf.get("ENABLE_CUSTOM_PROCESSOR"): + custom_url = _dynaconf.get("CUSTOM_PROCESSOR_URL") if custom_url: - supported_types_str = os.getenv("CUSTOM_PROCESSOR_TYPES", "application/pdf") + supported_types_str = _dynaconf.get("CUSTOM_PROCESSOR_TYPES") supported_types = { t.strip() for t in supported_types_str.split(",") if t.strip() } config["processors"]["custom"] = { - "name": os.getenv("CUSTOM_PROCESSOR_NAME", "custom"), + "name": _dynaconf.get("CUSTOM_PROCESSOR_NAME"), "api_url": custom_url, - "api_key": os.getenv("CUSTOM_PROCESSOR_API_KEY"), - "timeout": int(os.getenv("CUSTOM_PROCESSOR_TIMEOUT", "60")), + "api_key": _dynaconf.get("CUSTOM_PROCESSOR_API_KEY"), + "timeout": _dynaconf.get("CUSTOM_PROCESSOR_TIMEOUT"), "supported_types": supported_types, } @@ -277,11 +334,6 @@ class Settings: f"Smaller chunks may lose context. Consider using at least 1024 characters." ) - if self.document_chunk_overlap < 0: - raise ValueError( - f"DOCUMENT_CHUNK_OVERLAP ({self.document_chunk_overlap}) cannot be negative." - ) - def get_embedding_model_name(self) -> str: """ Get the active embedding model name based on provider priority. @@ -371,8 +423,8 @@ def _get_semantic_search_enabled() -> bool: """ logger = logging.getLogger(__name__) - new_value = os.getenv("ENABLE_SEMANTIC_SEARCH", "").lower() == "true" - old_value = os.getenv("VECTOR_SYNC_ENABLED", "").lower() == "true" + new_value = _dynaconf.get("ENABLE_SEMANTIC_SEARCH", False) + old_value = _dynaconf.get("VECTOR_SYNC_ENABLED", False) if new_value and old_value: logger.warning( @@ -405,16 +457,16 @@ def _is_multi_user_mode() -> bool: True if multi-user mode detected """ # Multi-user BasicAuth explicitly enabled - if os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true": + if _dynaconf.get("ENABLE_MULTI_USER_BASIC_AUTH", False): return True # Token exchange implies OAuth multi-user - if os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true": + if _dynaconf.get("ENABLE_TOKEN_EXCHANGE", False): return True # If both username and password are set, it's single-user BasicAuth - has_username = bool(os.getenv("NEXTCLOUD_USERNAME")) - has_password = bool(os.getenv("NEXTCLOUD_PASSWORD")) + has_username = bool(_dynaconf.get("NEXTCLOUD_USERNAME")) + has_password = bool(_dynaconf.get("NEXTCLOUD_PASSWORD")) if has_username and has_password: return False @@ -436,8 +488,8 @@ def _get_background_operations_enabled() -> bool: logger = logging.getLogger(__name__) # Check new and old variable names - explicit = os.getenv("ENABLE_BACKGROUND_OPERATIONS", "").lower() == "true" - legacy = os.getenv("ENABLE_OFFLINE_ACCESS", "").lower() == "true" + explicit = _dynaconf.get("ENABLE_BACKGROUND_OPERATIONS", False) + legacy = _dynaconf.get("ENABLE_OFFLINE_ACCESS", False) if explicit and legacy: logger.warning( @@ -467,7 +519,14 @@ def _get_background_operations_enabled() -> bool: def get_settings() -> Settings: - """Get application settings from environment variables. + """Get application settings from dynaconf configuration. + + Settings are loaded from (last wins): + 1. settings.toml [default] section + 2. settings.toml [] section (via MCP_DEPLOYMENT_MODE) + 3. .secrets.toml (if present) + 4. settings.local.toml (if present) + 5. Environment variables (highest priority) Returns: Settings object with configuration values @@ -478,83 +537,69 @@ def get_settings() -> Settings: return Settings( # Deployment mode (ADR-021) - deployment_mode=os.getenv("MCP_DEPLOYMENT_MODE"), + deployment_mode=_dynaconf.get("MCP_DEPLOYMENT_MODE"), # OAuth/OIDC settings - oidc_discovery_url=os.getenv("OIDC_DISCOVERY_URL"), - oidc_client_id=os.getenv("NEXTCLOUD_OIDC_CLIENT_ID"), - oidc_client_secret=os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET"), - oidc_issuer=os.getenv("OIDC_ISSUER"), + oidc_discovery_url=_dynaconf.get("OIDC_DISCOVERY_URL"), + oidc_client_id=_dynaconf.get("NEXTCLOUD_OIDC_CLIENT_ID"), + oidc_client_secret=_dynaconf.get("NEXTCLOUD_OIDC_CLIENT_SECRET"), + oidc_issuer=_dynaconf.get("OIDC_ISSUER"), # Nextcloud settings - nextcloud_host=os.getenv("NEXTCLOUD_HOST"), - nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"), - nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"), - nextcloud_app_password=os.getenv("NEXTCLOUD_APP_PASSWORD"), + nextcloud_host=_dynaconf.get("NEXTCLOUD_HOST"), + nextcloud_username=_dynaconf.get("NEXTCLOUD_USERNAME"), + nextcloud_password=_dynaconf.get("NEXTCLOUD_PASSWORD"), + nextcloud_app_password=_dynaconf.get("NEXTCLOUD_APP_PASSWORD"), # Nextcloud SSL/TLS settings - nextcloud_verify_ssl=( - os.getenv("NEXTCLOUD_VERIFY_SSL", "true").lower() == "true" - ), - nextcloud_ca_bundle=os.getenv("NEXTCLOUD_CA_BUNDLE"), + nextcloud_verify_ssl=_dynaconf.get("NEXTCLOUD_VERIFY_SSL"), + nextcloud_ca_bundle=_dynaconf.get("NEXTCLOUD_CA_BUNDLE"), # ADR-005: Token Audience Validation - nextcloud_mcp_server_url=os.getenv("NEXTCLOUD_MCP_SERVER_URL"), - nextcloud_resource_uri=os.getenv("NEXTCLOUD_RESOURCE_URI"), + nextcloud_mcp_server_url=_dynaconf.get("NEXTCLOUD_MCP_SERVER_URL"), + nextcloud_resource_uri=_dynaconf.get("NEXTCLOUD_RESOURCE_URI"), # Token verification endpoints - jwks_uri=os.getenv("JWKS_URI"), - introspection_uri=os.getenv("INTROSPECTION_URI"), - userinfo_uri=os.getenv("USERINFO_URI"), + jwks_uri=_dynaconf.get("JWKS_URI"), + introspection_uri=_dynaconf.get("INTROSPECTION_URI"), + userinfo_uri=_dynaconf.get("USERINFO_URI"), # Progressive Consent settings (always enabled) enable_offline_access=enable_background_operations, # Smart dependency resolution # Multi-user BasicAuth pass-through mode - enable_multi_user_basic_auth=( - os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true" - ), + enable_multi_user_basic_auth=_dynaconf.get("ENABLE_MULTI_USER_BASIC_AUTH"), # Login Flow v2 settings (ADR-022) - enable_login_flow=(os.getenv("ENABLE_LOGIN_FLOW", "false").lower() == "true"), + enable_login_flow=_dynaconf.get("ENABLE_LOGIN_FLOW"), # Token and webhook storage settings (encryption key optional for webhook-only usage) - token_encryption_key=os.getenv("TOKEN_ENCRYPTION_KEY"), - token_storage_db=os.getenv("TOKEN_STORAGE_DB", "/tmp/tokens.db"), + token_encryption_key=_dynaconf.get("TOKEN_ENCRYPTION_KEY"), + token_storage_db=_dynaconf.get("TOKEN_STORAGE_DB"), # Vector sync settings (ADR-007) vector_sync_enabled=enable_semantic_search, # Smart dependency resolution - vector_sync_scan_interval=int(os.getenv("VECTOR_SYNC_SCAN_INTERVAL", "300")), - vector_sync_processor_workers=int( - os.getenv("VECTOR_SYNC_PROCESSOR_WORKERS", "3") - ), - vector_sync_queue_max_size=int( - os.getenv("VECTOR_SYNC_QUEUE_MAX_SIZE", "10000") - ), - vector_sync_user_poll_interval=int( - os.getenv("VECTOR_SYNC_USER_POLL_INTERVAL", "60") - ), + vector_sync_scan_interval=_dynaconf.get("VECTOR_SYNC_SCAN_INTERVAL"), + vector_sync_processor_workers=_dynaconf.get("VECTOR_SYNC_PROCESSOR_WORKERS"), + vector_sync_queue_max_size=_dynaconf.get("VECTOR_SYNC_QUEUE_MAX_SIZE"), + vector_sync_user_poll_interval=_dynaconf.get("VECTOR_SYNC_USER_POLL_INTERVAL"), # Qdrant settings - qdrant_url=os.getenv("QDRANT_URL"), - qdrant_location=os.getenv("QDRANT_LOCATION"), - qdrant_api_key=os.getenv("QDRANT_API_KEY"), - qdrant_collection=os.getenv("QDRANT_COLLECTION", "nextcloud_content"), + qdrant_url=_dynaconf.get("QDRANT_URL"), + qdrant_location=_dynaconf.get("QDRANT_LOCATION"), + qdrant_api_key=_dynaconf.get("QDRANT_API_KEY"), + qdrant_collection=_dynaconf.get("QDRANT_COLLECTION"), # Ollama settings - ollama_base_url=os.getenv("OLLAMA_BASE_URL"), - ollama_embedding_model=os.getenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text"), - ollama_verify_ssl=os.getenv("OLLAMA_VERIFY_SSL", "true").lower() == "true", + ollama_base_url=_dynaconf.get("OLLAMA_BASE_URL"), + ollama_embedding_model=_dynaconf.get("OLLAMA_EMBEDDING_MODEL"), + ollama_verify_ssl=_dynaconf.get("OLLAMA_VERIFY_SSL"), # OpenAI settings - openai_api_key=os.getenv("OPENAI_API_KEY"), - openai_base_url=os.getenv("OPENAI_BASE_URL"), - openai_embedding_model=os.getenv( - "OPENAI_EMBEDDING_MODEL", "text-embedding-3-small" - ), + openai_api_key=_dynaconf.get("OPENAI_API_KEY"), + openai_base_url=_dynaconf.get("OPENAI_BASE_URL"), + openai_embedding_model=_dynaconf.get("OPENAI_EMBEDDING_MODEL"), # Document chunking settings - document_chunk_size=int(os.getenv("DOCUMENT_CHUNK_SIZE", "2048")), - document_chunk_overlap=int(os.getenv("DOCUMENT_CHUNK_OVERLAP", "200")), + document_chunk_size=_dynaconf.get("DOCUMENT_CHUNK_SIZE"), + document_chunk_overlap=_dynaconf.get("DOCUMENT_CHUNK_OVERLAP"), # Observability settings - metrics_enabled=os.getenv("METRICS_ENABLED", "true").lower() == "true", - metrics_port=int(os.getenv("METRICS_PORT", "9090")), - otel_exporter_otlp_endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"), - otel_exporter_verify_ssl=os.getenv("OTEL_EXPORTER_VERIFY_SSL", "false").lower() - == "true", - otel_service_name=os.getenv("OTEL_SERVICE_NAME", "nextcloud-mcp-server"), - otel_traces_sampler=os.getenv("OTEL_TRACES_SAMPLER", "always_on"), - otel_traces_sampler_arg=float(os.getenv("OTEL_TRACES_SAMPLER_ARG", "1.0")), - log_format=os.getenv("LOG_FORMAT", "text"), - log_level=os.getenv("LOG_LEVEL", "INFO"), - log_include_trace_context=os.getenv("LOG_INCLUDE_TRACE_CONTEXT", "true").lower() - == "true", + metrics_enabled=_dynaconf.get("METRICS_ENABLED"), + metrics_port=_dynaconf.get("METRICS_PORT"), + otel_exporter_otlp_endpoint=_dynaconf.get("OTEL_EXPORTER_OTLP_ENDPOINT"), + otel_exporter_verify_ssl=_dynaconf.get("OTEL_EXPORTER_VERIFY_SSL"), + otel_service_name=_dynaconf.get("OTEL_SERVICE_NAME"), + otel_traces_sampler=_dynaconf.get("OTEL_TRACES_SAMPLER"), + otel_traces_sampler_arg=_dynaconf.get("OTEL_TRACES_SAMPLER_ARG"), + log_format=_dynaconf.get("LOG_FORMAT"), + log_level=_dynaconf.get("LOG_LEVEL"), + log_include_trace_context=_dynaconf.get("LOG_INCLUDE_TRACE_CONTEXT"), ) diff --git a/settings.toml b/settings.toml new file mode 100644 index 00000000..b4cccac1 --- /dev/null +++ b/settings.toml @@ -0,0 +1,131 @@ +# Nextcloud MCP Server Configuration +# This file defines all configuration keys with their default values. +# Environment variables always override values from this file. +# +# Loading priority (last wins): +# 1. settings.toml [default] section +# 2. settings.toml [] section (via MCP_DEPLOYMENT_MODE) +# 3. .secrets.toml [default] section +# 4. .secrets.toml [] section +# 5. settings.local.toml (all sections, gitignored) +# 6. Environment variables (highest priority) +# +# See docs/ADR-024-dynaconf-configuration-management.md for details. + +[default] + +# --- Deployment mode (ADR-021) --- +# Valid: single_user_basic, multi_user_basic, login_flow, keycloak, oauth_single_audience +# If unset, mode is auto-detected from other settings. +mcp_deployment_mode = "@none" + +# --- Nextcloud core --- +nextcloud_host = "@none" +nextcloud_username = "@none" +nextcloud_password = "@none" +nextcloud_app_password = "@none" +nextcloud_verify_ssl = true +nextcloud_ca_bundle = "@none" +nextcloud_mcp_server_url = "@none" +nextcloud_resource_uri = "@none" + +# --- OAuth/OIDC --- +oidc_discovery_url = "@none" +nextcloud_oidc_client_id = "@none" +nextcloud_oidc_client_secret = "@none" +oidc_issuer = "@none" +jwks_uri = "@none" +introspection_uri = "@none" +userinfo_uri = "@none" + +# --- Mode flags --- +enable_multi_user_basic_auth = false +enable_login_flow = false +enable_semantic_search = false +enable_background_operations = false + +# Deprecated aliases (declared so env var overrides work) +vector_sync_enabled = false +enable_offline_access = false +enable_token_exchange = false + +# --- Token storage --- +token_encryption_key = "@none" +token_storage_db = "/tmp/tokens.db" + +# --- Vector sync --- +vector_sync_scan_interval = 300 +vector_sync_processor_workers = 3 +vector_sync_queue_max_size = 10000 +vector_sync_user_poll_interval = 60 + +# --- Qdrant --- +# No default for qdrant_location — conditional default (:memory:) is in Settings.__post_init__ +qdrant_url = "@none" +qdrant_location = "@none" +qdrant_api_key = "@none" +qdrant_collection = "nextcloud_content" + +# --- Ollama --- +ollama_base_url = "@none" +ollama_embedding_model = "nomic-embed-text" +ollama_verify_ssl = true + +# --- OpenAI --- +openai_api_key = "@none" +openai_base_url = "@none" +openai_embedding_model = "text-embedding-3-small" + +# --- Document chunking --- +document_chunk_size = 2048 +document_chunk_overlap = 200 + +# --- Observability --- +metrics_enabled = true +metrics_port = 9090 +otel_exporter_otlp_endpoint = "@none" +otel_exporter_verify_ssl = false +otel_service_name = "nextcloud-mcp-server" +otel_traces_sampler = "always_on" +otel_traces_sampler_arg = 1.0 +log_format = "text" +log_level = "INFO" +log_include_trace_context = true + +# --- Document processing --- +enable_document_processing = false +document_processor = "unstructured" +enable_unstructured = false +unstructured_api_url = "http://unstructured:8000" +unstructured_timeout = 120 +unstructured_strategy = "auto" +unstructured_languages = "eng,deu" +progress_interval = 10 +enable_tesseract = false +tesseract_cmd = "@none" +tesseract_lang = "eng" +enable_pymupdf = true +pymupdf_extract_images = true +pymupdf_image_dir = "@none" +enable_custom_processor = false +custom_processor_url = "@none" +custom_processor_types = "application/pdf" +custom_processor_name = "custom" +custom_processor_api_key = "@none" +custom_processor_timeout = 60 + + +# --- Deployment mode sections --- +# Keys here override [default] when MCP_DEPLOYMENT_MODE matches. + +[single_user_basic] + +[multi_user_basic] +enable_multi_user_basic_auth = true + +[login_flow] +enable_login_flow = true + +[keycloak] + +[oauth_single_audience] diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 00000000..9eb01e72 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,21 @@ +"""Unit test configuration — shared fixtures for all unit tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def _reload_dynaconf_after_test(): + """Ensure dynaconf cache is clean between tests. + + Dynaconf caches env var values at load time. Tests that modify os.environ + must call _reload_config() to refresh the cache. This fixture reloads + after each test to prevent leaked state. + + Uses _dynaconf.reload() directly (without validate_all) since the + real env may have values that don't pass validators. Tests that need + validation should call _reload_config() explicitly. + """ + yield + from nextcloud_mcp_server.config import _dynaconf + + _dynaconf.reload() diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 345107ce..2a73c1ae 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -6,7 +6,7 @@ from unittest.mock import patch import pytest -from nextcloud_mcp_server.config import Settings, get_settings +from nextcloud_mcp_server.config import Settings, _reload_config, get_settings class TestQdrantConfigValidation: @@ -74,6 +74,7 @@ class TestGetSettings: @patch.dict(os.environ, {}, clear=True) def test_get_settings_defaults_to_memory(self): """Test get_settings() defaults to :memory: when no env vars set.""" + _reload_config() settings = get_settings() assert settings.qdrant_location == ":memory:" assert settings.qdrant_url is None @@ -88,6 +89,7 @@ class TestGetSettings: ) def test_get_settings_network_mode(self): """Test get_settings() with network mode env vars.""" + _reload_config() settings = get_settings() assert settings.qdrant_url == "http://qdrant:6333" assert settings.qdrant_api_key == "test-key" @@ -100,6 +102,7 @@ class TestGetSettings: ) def test_get_settings_persistent_mode(self): """Test get_settings() with persistent local mode env vars.""" + _reload_config() settings = get_settings() assert settings.qdrant_location == "/app/data/qdrant" assert settings.qdrant_url is None @@ -111,6 +114,7 @@ class TestGetSettings: ) def test_get_settings_explicit_memory(self): """Test get_settings() with explicit :memory: env var.""" + _reload_config() settings = get_settings() assert settings.qdrant_location == ":memory:" assert settings.qdrant_url is None @@ -125,6 +129,7 @@ class TestGetSettings: ) def test_get_settings_mutual_exclusion_error(self): """Test get_settings() raises error when both URL and location set.""" + _reload_config() with pytest.raises( ValueError, match="Cannot set both QDRANT_URL and QDRANT_LOCATION", @@ -144,6 +149,7 @@ class TestGetSettings: ) def test_get_settings_vector_sync_config(self): """Test get_settings() with vector sync configuration.""" + _reload_config() settings = get_settings() assert settings.qdrant_collection == "test_collection" assert settings.vector_sync_enabled is True @@ -192,16 +198,17 @@ class TestChunkConfigValidation: document_chunk_overlap=300, ) + @patch.dict( + os.environ, + {"DOCUMENT_CHUNK_OVERLAP": "-10"}, + clear=True, + ) def test_negative_overlap_raises_error(self): - """Test that negative overlap raises ValueError.""" - with pytest.raises( - ValueError, - match="DOCUMENT_CHUNK_OVERLAP .* cannot be negative", - ): - Settings( - document_chunk_size=512, - document_chunk_overlap=-10, - ) + """Test that negative overlap raises ValidationError via dynaconf.""" + from dynaconf import ValidationError + + with pytest.raises(ValidationError, match="DOCUMENT_CHUNK_OVERLAP"): + _reload_config() def test_small_chunk_size_warning(self, caplog): """Test that chunk size < 512 triggers warning.""" @@ -237,6 +244,7 @@ class TestChunkConfigValidation: ) def test_get_settings_chunk_config(self): """Test get_settings() with chunk configuration.""" + _reload_config() settings = get_settings() assert settings.document_chunk_size == 1024 assert settings.document_chunk_overlap == 102 @@ -251,6 +259,7 @@ class TestChunkConfigValidation: ) def test_get_settings_invalid_chunk_config_raises_error(self): """Test get_settings() raises error for invalid chunk config.""" + _reload_config() with pytest.raises( ValueError, match="DOCUMENT_CHUNK_OVERLAP .* must be less than DOCUMENT_CHUNK_SIZE", @@ -294,6 +303,7 @@ class TestEmbeddingModelName: ) def test_get_settings_openai_model(self): """Test get_settings() loads OpenAI embedding model.""" + _reload_config() settings = get_settings() assert settings.openai_api_key == "test-openai-key" assert settings.openai_embedding_model == "openai/text-embedding-3-small" @@ -342,3 +352,85 @@ class TestCollectionNameWithProviders: openai_embedding_model="text-embedding-3-large", ) assert settings.get_collection_name() == "custom-collection" + + +class TestDynaconfValidators: + """Test dynaconf declarative validators (ADR-024 Phase 3).""" + + @patch.dict(os.environ, {"METRICS_PORT": "0"}, clear=True) + def test_metrics_port_too_low(self): + """Test METRICS_PORT below minimum raises ValidationError.""" + from dynaconf import ValidationError + + with pytest.raises(ValidationError, match="METRICS_PORT"): + _reload_config() + + @patch.dict(os.environ, {"METRICS_PORT": "99999"}, clear=True) + def test_metrics_port_too_high(self): + """Test METRICS_PORT above maximum raises ValidationError.""" + from dynaconf import ValidationError + + with pytest.raises(ValidationError, match="METRICS_PORT"): + _reload_config() + + @patch.dict(os.environ, {"LOG_FORMAT": "xml"}, clear=True) + def test_invalid_log_format(self): + """Test invalid LOG_FORMAT raises ValidationError.""" + from dynaconf import ValidationError + + with pytest.raises(ValidationError, match="LOG_FORMAT"): + _reload_config() + + @patch.dict(os.environ, {"LOG_LEVEL": "VERBOSE"}, clear=True) + def test_invalid_log_level(self): + """Test invalid LOG_LEVEL raises ValidationError.""" + from dynaconf import ValidationError + + with pytest.raises(ValidationError, match="LOG_LEVEL"): + _reload_config() + + @patch.dict(os.environ, {"OTEL_TRACES_SAMPLER": "random"}, clear=True) + def test_invalid_otel_sampler(self): + """Test invalid OTEL_TRACES_SAMPLER raises ValidationError.""" + from dynaconf import ValidationError + + with pytest.raises(ValidationError, match="OTEL_TRACES_SAMPLER"): + _reload_config() + + @patch.dict(os.environ, {"OTEL_TRACES_SAMPLER_ARG": "2.0"}, clear=True) + def test_sampler_arg_too_high(self): + """Test OTEL_TRACES_SAMPLER_ARG above 1.0 raises ValidationError.""" + from dynaconf import ValidationError + + with pytest.raises(ValidationError, match="OTEL_TRACES_SAMPLER_ARG"): + _reload_config() + + @patch.dict(os.environ, {"VECTOR_SYNC_SCAN_INTERVAL": "0"}, clear=True) + def test_vector_sync_interval_zero(self): + """Test zero VECTOR_SYNC_SCAN_INTERVAL raises ValidationError.""" + from dynaconf import ValidationError + + with pytest.raises(ValidationError, match="VECTOR_SYNC_SCAN_INTERVAL"): + _reload_config() + + @patch.dict(os.environ, {"DOCUMENT_CHUNK_SIZE": "0"}, clear=True) + def test_chunk_size_zero(self): + """Test zero DOCUMENT_CHUNK_SIZE raises ValidationError.""" + from dynaconf import ValidationError + + with pytest.raises(ValidationError, match="DOCUMENT_CHUNK_SIZE"): + _reload_config() + + @patch.dict(os.environ, {"METRICS_PORT": "8080"}, clear=True) + def test_valid_metrics_port(self): + """Test valid METRICS_PORT passes validation.""" + _reload_config() + settings = get_settings() + assert settings.metrics_port == 8080 + + @patch.dict(os.environ, {"LOG_FORMAT": "json"}, clear=True) + def test_valid_log_format_json(self): + """Test valid LOG_FORMAT=json passes validation.""" + _reload_config() + settings = get_settings() + assert settings.log_format == "json" diff --git a/tests/unit/test_config_validators.py b/tests/unit/test_config_validators.py index ad242c14..3aad9eaa 100644 --- a/tests/unit/test_config_validators.py +++ b/tests/unit/test_config_validators.py @@ -10,7 +10,7 @@ Tests cover: import os from unittest.mock import patch -from nextcloud_mcp_server.config import Settings +from nextcloud_mcp_server.config import Settings, _reload_config from nextcloud_mcp_server.config_validators import ( AuthMode, detect_auth_mode, @@ -274,6 +274,7 @@ class TestMultiUserBasicValidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() mode, errors = validate_configuration(settings) @@ -372,6 +373,7 @@ class TestOAuthSingleAudienceValidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() mode, errors = validate_configuration(settings) @@ -459,6 +461,7 @@ class TestConfigurationConsolidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() assert settings.vector_sync_enabled is True @@ -474,6 +477,7 @@ class TestConfigurationConsolidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() assert settings.vector_sync_enabled is True @@ -490,6 +494,7 @@ class TestConfigurationConsolidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() assert settings.enable_offline_access is True @@ -506,6 +511,7 @@ class TestConfigurationConsolidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() assert settings.enable_offline_access is True @@ -525,6 +531,7 @@ class TestConfigurationConsolidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() # Semantic search enabled @@ -549,6 +556,7 @@ class TestConfigurationConsolidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() # Semantic search enabled @@ -572,6 +580,7 @@ class TestConfigurationConsolidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() # Semantic search NOT enabled @@ -593,6 +602,7 @@ class TestConfigurationConsolidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() # Should use new name value (true) @@ -612,6 +622,7 @@ class TestConfigurationConsolidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() # Should use new name value (true) @@ -637,6 +648,7 @@ class TestConfigurationConsolidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() mode, errors = validate_configuration(settings) @@ -671,6 +683,7 @@ class TestExplicitModeSelection: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() mode = detect_auth_mode(settings) @@ -688,6 +701,7 @@ class TestExplicitModeSelection: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() mode = detect_auth_mode(settings) @@ -705,6 +719,7 @@ class TestExplicitModeSelection: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() mode = detect_auth_mode(settings) @@ -722,6 +737,7 @@ class TestExplicitModeSelection: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() # Should raise ValueError with clear message @@ -747,6 +763,7 @@ class TestExplicitModeSelection: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() mode = detect_auth_mode(settings) @@ -765,6 +782,7 @@ class TestExplicitModeSelection: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() mode = detect_auth_mode(settings) @@ -782,6 +800,7 @@ class TestExplicitModeSelection: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() mode = detect_auth_mode(settings) diff --git a/tests/unit/test_document_processor_config.py b/tests/unit/test_document_processor_config.py index 6f55fbfd..29cc91be 100644 --- a/tests/unit/test_document_processor_config.py +++ b/tests/unit/test_document_processor_config.py @@ -4,6 +4,8 @@ import os import pytest +from nextcloud_mcp_server.config import _reload_config, get_document_processor_config + pytestmark = pytest.mark.unit @@ -12,18 +14,16 @@ class TestDocumentProcessorConfig: def test_config_disabled_by_default(self): """Test that document processing is disabled by default.""" - from nextcloud_mcp_server.config import get_document_processor_config - os.environ.pop("ENABLE_DOCUMENT_PROCESSING", None) + _reload_config() config = get_document_processor_config() assert config["enabled"] is False def test_config_enabled(self): """Test enabling document processing.""" - from nextcloud_mcp_server.config import get_document_processor_config - os.environ["ENABLE_DOCUMENT_PROCESSING"] = "true" try: + _reload_config() config = get_document_processor_config() assert config["enabled"] is True finally: @@ -31,8 +31,6 @@ class TestDocumentProcessorConfig: def test_unstructured_processor_config(self): """Test Unstructured processor configuration.""" - from nextcloud_mcp_server.config import get_document_processor_config - os.environ["ENABLE_UNSTRUCTURED"] = "true" os.environ["UNSTRUCTURED_API_URL"] = "http://test:8000" os.environ["UNSTRUCTURED_STRATEGY"] = "hi_res" @@ -40,6 +38,7 @@ class TestDocumentProcessorConfig: os.environ["UNSTRUCTURED_TIMEOUT"] = "60" try: + _reload_config() config = get_document_processor_config() assert "unstructured" in config["processors"] unst_config = config["processors"]["unstructured"] @@ -56,13 +55,12 @@ class TestDocumentProcessorConfig: def test_tesseract_processor_config(self): """Test Tesseract processor configuration.""" - from nextcloud_mcp_server.config import get_document_processor_config - os.environ["ENABLE_TESSERACT"] = "true" os.environ["TESSERACT_LANG"] = "eng+deu" os.environ["TESSERACT_CMD"] = "/usr/local/bin/tesseract" try: + _reload_config() config = get_document_processor_config() assert "tesseract" in config["processors"] tess_config = config["processors"]["tesseract"] @@ -75,8 +73,6 @@ class TestDocumentProcessorConfig: def test_custom_processor_config(self): """Test custom processor configuration.""" - from nextcloud_mcp_server.config import get_document_processor_config - os.environ["ENABLE_CUSTOM_PROCESSOR"] = "true" os.environ["CUSTOM_PROCESSOR_NAME"] = "my_ocr" os.environ["CUSTOM_PROCESSOR_URL"] = "http://localhost:9000/process" @@ -85,6 +81,7 @@ class TestDocumentProcessorConfig: os.environ["CUSTOM_PROCESSOR_TYPES"] = "application/pdf,image/jpeg" try: + _reload_config() config = get_document_processor_config() assert "custom" in config["processors"] custom_config = config["processors"]["custom"] @@ -104,13 +101,12 @@ class TestDocumentProcessorConfig: def test_multiple_processors(self): """Test configuration with multiple processors enabled.""" - from nextcloud_mcp_server.config import get_document_processor_config - os.environ["ENABLE_DOCUMENT_PROCESSING"] = "true" os.environ["ENABLE_UNSTRUCTURED"] = "true" os.environ["ENABLE_TESSERACT"] = "true" try: + _reload_config() config = get_document_processor_config() assert config["enabled"] is True assert "unstructured" in config["processors"] @@ -122,14 +118,14 @@ class TestDocumentProcessorConfig: def test_default_processor_selection(self): """Test default processor configuration.""" - from nextcloud_mcp_server.config import get_document_processor_config - os.environ.pop("DOCUMENT_PROCESSOR", None) + _reload_config() config = get_document_processor_config() assert config["default_processor"] == "unstructured" os.environ["DOCUMENT_PROCESSOR"] = "tesseract" try: + _reload_config() config = get_document_processor_config() assert config["default_processor"] == "tesseract" finally: diff --git a/tests/unit/test_ssl_config.py b/tests/unit/test_ssl_config.py index a03d5c47..0aca6f70 100644 --- a/tests/unit/test_ssl_config.py +++ b/tests/unit/test_ssl_config.py @@ -9,7 +9,12 @@ import certifi import httpx import pytest -from nextcloud_mcp_server.config import Settings, get_nextcloud_ssl_verify, get_settings +from nextcloud_mcp_server.config import ( + Settings, + _reload_config, + get_nextcloud_ssl_verify, + get_settings, +) from nextcloud_mcp_server.http import nextcloud_httpx_client, nextcloud_httpx_transport @@ -50,7 +55,7 @@ class TestGetNextcloudSSLVerify: "NEXTCLOUD_VERIFY_SSL": "true", } with patch.dict(os.environ, env, clear=False): - # Clear any cached settings + _reload_config() result = get_nextcloud_ssl_verify() assert result is True @@ -110,12 +115,14 @@ class TestGetSettingsSSLEnvVars: def test_verify_ssl_env_true(self): env = {"NEXTCLOUD_VERIFY_SSL": "true"} with patch.dict(os.environ, env, clear=False): + _reload_config() settings = get_settings() assert settings.nextcloud_verify_ssl is True def test_verify_ssl_env_false(self): env = {"NEXTCLOUD_VERIFY_SSL": "false"} with patch.dict(os.environ, env, clear=False): + _reload_config() settings = get_settings() assert settings.nextcloud_verify_ssl is False @@ -123,6 +130,7 @@ class TestGetSettingsSSLEnvVars: with patch.dict(os.environ, {}, clear=False): # Remove NEXTCLOUD_VERIFY_SSL if it exists os.environ.pop("NEXTCLOUD_VERIFY_SSL", None) + _reload_config() settings = get_settings() assert settings.nextcloud_verify_ssl is True @@ -133,6 +141,7 @@ class TestGetSettingsSSLEnvVars: ) env = {"NEXTCLOUD_CA_BUNDLE": str(ca_file)} with patch.dict(os.environ, env, clear=False): + _reload_config() settings = get_settings() assert settings.nextcloud_ca_bundle == str(ca_file)