feat: implement dynaconf configuration management (ADR-024 phases 1-3)

Replace ~80 manual os.getenv() calls in config.py with dynaconf-backed
configuration, enabling TOML file-based config alongside existing env
var support. Zero breaking changes — Settings dataclass interface and
all consumers unchanged.

Phase 1: Create settings.toml with all config keys and defaults,
.secrets.toml.example template, update .gitignore, initialize Dynaconf
instance with envvar_prefix=False and environment section switching.

Phase 2: Wire adapter — replace os.getenv() with _dynaconf.get() in
get_settings(), get_document_processor_config(), and deprecation/
dependency resolution helpers. Automatic type coercion eliminates ~30
manual int()/float()/.lower()=="true" patterns.

Phase 3: Add 12 declarative validators for port ranges, positive
integers, enum constraints, and float ranges. Remove redundant negative
overlap check from Settings.__post_init__.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-04-07 09:22:19 +02:00
co-authored by Claude Opus 4.6
parent f34c74afbc
commit c8e4cbe825
9 changed files with 455 additions and 125 deletions
+4
View File
@@ -5,6 +5,10 @@ __pycache__/
.env.local .env.local
.env.*.local .env.*.local
# Dynaconf (ADR-024)
.secrets.toml
settings.local.toml
# Git # Git
worktrees/ worktrees/
+13
View File
@@ -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 = ""
+143 -98
View File
@@ -4,8 +4,65 @@ import os
import socket import socket
import ssl import ssl
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from typing import Any 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 = { LOGGING_CONFIG = {
"version": 1, "version": 1,
"disable_existing_loggers": False, "disable_existing_loggers": False,
@@ -63,7 +120,7 @@ def setup_logging():
def get_document_processor_config() -> dict[str, Any]: def get_document_processor_config() -> dict[str, Any]:
"""Get document processor configuration from environment. """Get document processor configuration from dynaconf.
Returns: Returns:
Dict with processor configs: Dict with processor configs:
@@ -78,54 +135,54 @@ def get_document_processor_config() -> dict[str, Any]:
} }
""" """
config: dict[str, Any] = { config: dict[str, Any] = {
"enabled": os.getenv("ENABLE_DOCUMENT_PROCESSING", "false").lower() == "true", "enabled": _dynaconf.get("ENABLE_DOCUMENT_PROCESSING"),
"default_processor": os.getenv("DOCUMENT_PROCESSOR", "unstructured"), "default_processor": _dynaconf.get("DOCUMENT_PROCESSOR"),
"processors": {}, "processors": {},
} }
# Unstructured configuration # Unstructured configuration
if os.getenv("ENABLE_UNSTRUCTURED", "false").lower() == "true": if _dynaconf.get("ENABLE_UNSTRUCTURED"):
languages_str = _dynaconf.get("UNSTRUCTURED_LANGUAGES")
config["processors"]["unstructured"] = { config["processors"]["unstructured"] = {
"api_url": os.getenv("UNSTRUCTURED_API_URL", "http://unstructured:8000"), "api_url": _dynaconf.get("UNSTRUCTURED_API_URL"),
"timeout": int(os.getenv("UNSTRUCTURED_TIMEOUT", "120")), "timeout": _dynaconf.get("UNSTRUCTURED_TIMEOUT"),
"strategy": os.getenv("UNSTRUCTURED_STRATEGY", "auto"), "strategy": _dynaconf.get("UNSTRUCTURED_STRATEGY"),
"languages": [ "languages": [
lang.strip() lang.strip() for lang in languages_str.split(",") if lang.strip()
for lang in os.getenv("UNSTRUCTURED_LANGUAGES", "eng,deu").split(",")
if lang.strip()
], ],
"progress_interval": int(os.getenv("PROGRESS_INTERVAL", "10")), "progress_interval": _dynaconf.get("PROGRESS_INTERVAL"),
} }
# Tesseract configuration # Tesseract configuration
if os.getenv("ENABLE_TESSERACT", "false").lower() == "true": if _dynaconf.get("ENABLE_TESSERACT"):
config["processors"]["tesseract"] = { config["processors"]["tesseract"] = {
"tesseract_cmd": os.getenv("TESSERACT_CMD"), # None = auto-detect "tesseract_cmd": _dynaconf.get("TESSERACT_CMD"), # None = auto-detect
"lang": os.getenv("TESSERACT_LANG", "eng"), "lang": _dynaconf.get("TESSERACT_LANG"),
} }
# PyMuPDF configuration (local PDF processing) # 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"] = { config["processors"]["pymupdf"] = {
"extract_images": os.getenv("PYMUPDF_EXTRACT_IMAGES", "true").lower() "extract_images": _dynaconf.get("PYMUPDF_EXTRACT_IMAGES"),
== "true", "image_dir": _dynaconf.get(
"image_dir": os.getenv("PYMUPDF_IMAGE_DIR"), # None = use temp directory "PYMUPDF_IMAGE_DIR"
), # None = use temp directory
} }
# Custom processor (via HTTP API) # Custom processor (via HTTP API)
if os.getenv("ENABLE_CUSTOM_PROCESSOR", "false").lower() == "true": if _dynaconf.get("ENABLE_CUSTOM_PROCESSOR"):
custom_url = os.getenv("CUSTOM_PROCESSOR_URL") custom_url = _dynaconf.get("CUSTOM_PROCESSOR_URL")
if custom_url: if custom_url:
supported_types_str = os.getenv("CUSTOM_PROCESSOR_TYPES", "application/pdf") supported_types_str = _dynaconf.get("CUSTOM_PROCESSOR_TYPES")
supported_types = { supported_types = {
t.strip() for t in supported_types_str.split(",") if t.strip() t.strip() for t in supported_types_str.split(",") if t.strip()
} }
config["processors"]["custom"] = { config["processors"]["custom"] = {
"name": os.getenv("CUSTOM_PROCESSOR_NAME", "custom"), "name": _dynaconf.get("CUSTOM_PROCESSOR_NAME"),
"api_url": custom_url, "api_url": custom_url,
"api_key": os.getenv("CUSTOM_PROCESSOR_API_KEY"), "api_key": _dynaconf.get("CUSTOM_PROCESSOR_API_KEY"),
"timeout": int(os.getenv("CUSTOM_PROCESSOR_TIMEOUT", "60")), "timeout": _dynaconf.get("CUSTOM_PROCESSOR_TIMEOUT"),
"supported_types": supported_types, "supported_types": supported_types,
} }
@@ -277,11 +334,6 @@ class Settings:
f"Smaller chunks may lose context. Consider using at least 1024 characters." 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: def get_embedding_model_name(self) -> str:
""" """
Get the active embedding model name based on provider priority. Get the active embedding model name based on provider priority.
@@ -371,8 +423,8 @@ def _get_semantic_search_enabled() -> bool:
""" """
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
new_value = os.getenv("ENABLE_SEMANTIC_SEARCH", "").lower() == "true" new_value = _dynaconf.get("ENABLE_SEMANTIC_SEARCH", False)
old_value = os.getenv("VECTOR_SYNC_ENABLED", "").lower() == "true" old_value = _dynaconf.get("VECTOR_SYNC_ENABLED", False)
if new_value and old_value: if new_value and old_value:
logger.warning( logger.warning(
@@ -405,16 +457,16 @@ def _is_multi_user_mode() -> bool:
True if multi-user mode detected True if multi-user mode detected
""" """
# Multi-user BasicAuth explicitly enabled # 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 return True
# Token exchange implies OAuth multi-user # Token exchange implies OAuth multi-user
if os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true": if _dynaconf.get("ENABLE_TOKEN_EXCHANGE", False):
return True return True
# If both username and password are set, it's single-user BasicAuth # If both username and password are set, it's single-user BasicAuth
has_username = bool(os.getenv("NEXTCLOUD_USERNAME")) has_username = bool(_dynaconf.get("NEXTCLOUD_USERNAME"))
has_password = bool(os.getenv("NEXTCLOUD_PASSWORD")) has_password = bool(_dynaconf.get("NEXTCLOUD_PASSWORD"))
if has_username and has_password: if has_username and has_password:
return False return False
@@ -436,8 +488,8 @@ def _get_background_operations_enabled() -> bool:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Check new and old variable names # Check new and old variable names
explicit = os.getenv("ENABLE_BACKGROUND_OPERATIONS", "").lower() == "true" explicit = _dynaconf.get("ENABLE_BACKGROUND_OPERATIONS", False)
legacy = os.getenv("ENABLE_OFFLINE_ACCESS", "").lower() == "true" legacy = _dynaconf.get("ENABLE_OFFLINE_ACCESS", False)
if explicit and legacy: if explicit and legacy:
logger.warning( logger.warning(
@@ -467,7 +519,14 @@ def _get_background_operations_enabled() -> bool:
def get_settings() -> Settings: 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 [<mode>] section (via MCP_DEPLOYMENT_MODE)
3. .secrets.toml (if present)
4. settings.local.toml (if present)
5. Environment variables (highest priority)
Returns: Returns:
Settings object with configuration values Settings object with configuration values
@@ -478,83 +537,69 @@ def get_settings() -> Settings:
return Settings( return Settings(
# Deployment mode (ADR-021) # Deployment mode (ADR-021)
deployment_mode=os.getenv("MCP_DEPLOYMENT_MODE"), deployment_mode=_dynaconf.get("MCP_DEPLOYMENT_MODE"),
# OAuth/OIDC settings # OAuth/OIDC settings
oidc_discovery_url=os.getenv("OIDC_DISCOVERY_URL"), oidc_discovery_url=_dynaconf.get("OIDC_DISCOVERY_URL"),
oidc_client_id=os.getenv("NEXTCLOUD_OIDC_CLIENT_ID"), oidc_client_id=_dynaconf.get("NEXTCLOUD_OIDC_CLIENT_ID"),
oidc_client_secret=os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET"), oidc_client_secret=_dynaconf.get("NEXTCLOUD_OIDC_CLIENT_SECRET"),
oidc_issuer=os.getenv("OIDC_ISSUER"), oidc_issuer=_dynaconf.get("OIDC_ISSUER"),
# Nextcloud settings # Nextcloud settings
nextcloud_host=os.getenv("NEXTCLOUD_HOST"), nextcloud_host=_dynaconf.get("NEXTCLOUD_HOST"),
nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"), nextcloud_username=_dynaconf.get("NEXTCLOUD_USERNAME"),
nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"), nextcloud_password=_dynaconf.get("NEXTCLOUD_PASSWORD"),
nextcloud_app_password=os.getenv("NEXTCLOUD_APP_PASSWORD"), nextcloud_app_password=_dynaconf.get("NEXTCLOUD_APP_PASSWORD"),
# Nextcloud SSL/TLS settings # Nextcloud SSL/TLS settings
nextcloud_verify_ssl=( nextcloud_verify_ssl=_dynaconf.get("NEXTCLOUD_VERIFY_SSL"),
os.getenv("NEXTCLOUD_VERIFY_SSL", "true").lower() == "true" nextcloud_ca_bundle=_dynaconf.get("NEXTCLOUD_CA_BUNDLE"),
),
nextcloud_ca_bundle=os.getenv("NEXTCLOUD_CA_BUNDLE"),
# ADR-005: Token Audience Validation # ADR-005: Token Audience Validation
nextcloud_mcp_server_url=os.getenv("NEXTCLOUD_MCP_SERVER_URL"), nextcloud_mcp_server_url=_dynaconf.get("NEXTCLOUD_MCP_SERVER_URL"),
nextcloud_resource_uri=os.getenv("NEXTCLOUD_RESOURCE_URI"), nextcloud_resource_uri=_dynaconf.get("NEXTCLOUD_RESOURCE_URI"),
# Token verification endpoints # Token verification endpoints
jwks_uri=os.getenv("JWKS_URI"), jwks_uri=_dynaconf.get("JWKS_URI"),
introspection_uri=os.getenv("INTROSPECTION_URI"), introspection_uri=_dynaconf.get("INTROSPECTION_URI"),
userinfo_uri=os.getenv("USERINFO_URI"), userinfo_uri=_dynaconf.get("USERINFO_URI"),
# Progressive Consent settings (always enabled) # Progressive Consent settings (always enabled)
enable_offline_access=enable_background_operations, # Smart dependency resolution enable_offline_access=enable_background_operations, # Smart dependency resolution
# Multi-user BasicAuth pass-through mode # Multi-user BasicAuth pass-through mode
enable_multi_user_basic_auth=( enable_multi_user_basic_auth=_dynaconf.get("ENABLE_MULTI_USER_BASIC_AUTH"),
os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true"
),
# Login Flow v2 settings (ADR-022) # 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 and webhook storage settings (encryption key optional for webhook-only usage)
token_encryption_key=os.getenv("TOKEN_ENCRYPTION_KEY"), token_encryption_key=_dynaconf.get("TOKEN_ENCRYPTION_KEY"),
token_storage_db=os.getenv("TOKEN_STORAGE_DB", "/tmp/tokens.db"), token_storage_db=_dynaconf.get("TOKEN_STORAGE_DB"),
# Vector sync settings (ADR-007) # Vector sync settings (ADR-007)
vector_sync_enabled=enable_semantic_search, # Smart dependency resolution vector_sync_enabled=enable_semantic_search, # Smart dependency resolution
vector_sync_scan_interval=int(os.getenv("VECTOR_SYNC_SCAN_INTERVAL", "300")), vector_sync_scan_interval=_dynaconf.get("VECTOR_SYNC_SCAN_INTERVAL"),
vector_sync_processor_workers=int( vector_sync_processor_workers=_dynaconf.get("VECTOR_SYNC_PROCESSOR_WORKERS"),
os.getenv("VECTOR_SYNC_PROCESSOR_WORKERS", "3") vector_sync_queue_max_size=_dynaconf.get("VECTOR_SYNC_QUEUE_MAX_SIZE"),
), vector_sync_user_poll_interval=_dynaconf.get("VECTOR_SYNC_USER_POLL_INTERVAL"),
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")
),
# Qdrant settings # Qdrant settings
qdrant_url=os.getenv("QDRANT_URL"), qdrant_url=_dynaconf.get("QDRANT_URL"),
qdrant_location=os.getenv("QDRANT_LOCATION"), qdrant_location=_dynaconf.get("QDRANT_LOCATION"),
qdrant_api_key=os.getenv("QDRANT_API_KEY"), qdrant_api_key=_dynaconf.get("QDRANT_API_KEY"),
qdrant_collection=os.getenv("QDRANT_COLLECTION", "nextcloud_content"), qdrant_collection=_dynaconf.get("QDRANT_COLLECTION"),
# Ollama settings # Ollama settings
ollama_base_url=os.getenv("OLLAMA_BASE_URL"), ollama_base_url=_dynaconf.get("OLLAMA_BASE_URL"),
ollama_embedding_model=os.getenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text"), ollama_embedding_model=_dynaconf.get("OLLAMA_EMBEDDING_MODEL"),
ollama_verify_ssl=os.getenv("OLLAMA_VERIFY_SSL", "true").lower() == "true", ollama_verify_ssl=_dynaconf.get("OLLAMA_VERIFY_SSL"),
# OpenAI settings # OpenAI settings
openai_api_key=os.getenv("OPENAI_API_KEY"), openai_api_key=_dynaconf.get("OPENAI_API_KEY"),
openai_base_url=os.getenv("OPENAI_BASE_URL"), openai_base_url=_dynaconf.get("OPENAI_BASE_URL"),
openai_embedding_model=os.getenv( openai_embedding_model=_dynaconf.get("OPENAI_EMBEDDING_MODEL"),
"OPENAI_EMBEDDING_MODEL", "text-embedding-3-small"
),
# Document chunking settings # Document chunking settings
document_chunk_size=int(os.getenv("DOCUMENT_CHUNK_SIZE", "2048")), document_chunk_size=_dynaconf.get("DOCUMENT_CHUNK_SIZE"),
document_chunk_overlap=int(os.getenv("DOCUMENT_CHUNK_OVERLAP", "200")), document_chunk_overlap=_dynaconf.get("DOCUMENT_CHUNK_OVERLAP"),
# Observability settings # Observability settings
metrics_enabled=os.getenv("METRICS_ENABLED", "true").lower() == "true", metrics_enabled=_dynaconf.get("METRICS_ENABLED"),
metrics_port=int(os.getenv("METRICS_PORT", "9090")), metrics_port=_dynaconf.get("METRICS_PORT"),
otel_exporter_otlp_endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"), otel_exporter_otlp_endpoint=_dynaconf.get("OTEL_EXPORTER_OTLP_ENDPOINT"),
otel_exporter_verify_ssl=os.getenv("OTEL_EXPORTER_VERIFY_SSL", "false").lower() otel_exporter_verify_ssl=_dynaconf.get("OTEL_EXPORTER_VERIFY_SSL"),
== "true", otel_service_name=_dynaconf.get("OTEL_SERVICE_NAME"),
otel_service_name=os.getenv("OTEL_SERVICE_NAME", "nextcloud-mcp-server"), otel_traces_sampler=_dynaconf.get("OTEL_TRACES_SAMPLER"),
otel_traces_sampler=os.getenv("OTEL_TRACES_SAMPLER", "always_on"), otel_traces_sampler_arg=_dynaconf.get("OTEL_TRACES_SAMPLER_ARG"),
otel_traces_sampler_arg=float(os.getenv("OTEL_TRACES_SAMPLER_ARG", "1.0")), log_format=_dynaconf.get("LOG_FORMAT"),
log_format=os.getenv("LOG_FORMAT", "text"), log_level=_dynaconf.get("LOG_LEVEL"),
log_level=os.getenv("LOG_LEVEL", "INFO"), log_include_trace_context=_dynaconf.get("LOG_INCLUDE_TRACE_CONTEXT"),
log_include_trace_context=os.getenv("LOG_INCLUDE_TRACE_CONTEXT", "true").lower()
== "true",
) )
+131
View File
@@ -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 [<mode>] section (via MCP_DEPLOYMENT_MODE)
# 3. .secrets.toml [default] section
# 4. .secrets.toml [<mode>] 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]
+21
View File
@@ -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()
+102 -10
View File
@@ -6,7 +6,7 @@ from unittest.mock import patch
import pytest import pytest
from nextcloud_mcp_server.config import Settings, get_settings from nextcloud_mcp_server.config import Settings, _reload_config, get_settings
class TestQdrantConfigValidation: class TestQdrantConfigValidation:
@@ -74,6 +74,7 @@ class TestGetSettings:
@patch.dict(os.environ, {}, clear=True) @patch.dict(os.environ, {}, clear=True)
def test_get_settings_defaults_to_memory(self): def test_get_settings_defaults_to_memory(self):
"""Test get_settings() defaults to :memory: when no env vars set.""" """Test get_settings() defaults to :memory: when no env vars set."""
_reload_config()
settings = get_settings() settings = get_settings()
assert settings.qdrant_location == ":memory:" assert settings.qdrant_location == ":memory:"
assert settings.qdrant_url is None assert settings.qdrant_url is None
@@ -88,6 +89,7 @@ class TestGetSettings:
) )
def test_get_settings_network_mode(self): def test_get_settings_network_mode(self):
"""Test get_settings() with network mode env vars.""" """Test get_settings() with network mode env vars."""
_reload_config()
settings = get_settings() settings = get_settings()
assert settings.qdrant_url == "http://qdrant:6333" assert settings.qdrant_url == "http://qdrant:6333"
assert settings.qdrant_api_key == "test-key" assert settings.qdrant_api_key == "test-key"
@@ -100,6 +102,7 @@ class TestGetSettings:
) )
def test_get_settings_persistent_mode(self): def test_get_settings_persistent_mode(self):
"""Test get_settings() with persistent local mode env vars.""" """Test get_settings() with persistent local mode env vars."""
_reload_config()
settings = get_settings() settings = get_settings()
assert settings.qdrant_location == "/app/data/qdrant" assert settings.qdrant_location == "/app/data/qdrant"
assert settings.qdrant_url is None assert settings.qdrant_url is None
@@ -111,6 +114,7 @@ class TestGetSettings:
) )
def test_get_settings_explicit_memory(self): def test_get_settings_explicit_memory(self):
"""Test get_settings() with explicit :memory: env var.""" """Test get_settings() with explicit :memory: env var."""
_reload_config()
settings = get_settings() settings = get_settings()
assert settings.qdrant_location == ":memory:" assert settings.qdrant_location == ":memory:"
assert settings.qdrant_url is None assert settings.qdrant_url is None
@@ -125,6 +129,7 @@ class TestGetSettings:
) )
def test_get_settings_mutual_exclusion_error(self): def test_get_settings_mutual_exclusion_error(self):
"""Test get_settings() raises error when both URL and location set.""" """Test get_settings() raises error when both URL and location set."""
_reload_config()
with pytest.raises( with pytest.raises(
ValueError, ValueError,
match="Cannot set both QDRANT_URL and QDRANT_LOCATION", match="Cannot set both QDRANT_URL and QDRANT_LOCATION",
@@ -144,6 +149,7 @@ class TestGetSettings:
) )
def test_get_settings_vector_sync_config(self): def test_get_settings_vector_sync_config(self):
"""Test get_settings() with vector sync configuration.""" """Test get_settings() with vector sync configuration."""
_reload_config()
settings = get_settings() settings = get_settings()
assert settings.qdrant_collection == "test_collection" assert settings.qdrant_collection == "test_collection"
assert settings.vector_sync_enabled is True assert settings.vector_sync_enabled is True
@@ -192,16 +198,17 @@ class TestChunkConfigValidation:
document_chunk_overlap=300, document_chunk_overlap=300,
) )
@patch.dict(
os.environ,
{"DOCUMENT_CHUNK_OVERLAP": "-10"},
clear=True,
)
def test_negative_overlap_raises_error(self): def test_negative_overlap_raises_error(self):
"""Test that negative overlap raises ValueError.""" """Test that negative overlap raises ValidationError via dynaconf."""
with pytest.raises( from dynaconf import ValidationError
ValueError,
match="DOCUMENT_CHUNK_OVERLAP .* cannot be negative", with pytest.raises(ValidationError, match="DOCUMENT_CHUNK_OVERLAP"):
): _reload_config()
Settings(
document_chunk_size=512,
document_chunk_overlap=-10,
)
def test_small_chunk_size_warning(self, caplog): def test_small_chunk_size_warning(self, caplog):
"""Test that chunk size < 512 triggers warning.""" """Test that chunk size < 512 triggers warning."""
@@ -237,6 +244,7 @@ class TestChunkConfigValidation:
) )
def test_get_settings_chunk_config(self): def test_get_settings_chunk_config(self):
"""Test get_settings() with chunk configuration.""" """Test get_settings() with chunk configuration."""
_reload_config()
settings = get_settings() settings = get_settings()
assert settings.document_chunk_size == 1024 assert settings.document_chunk_size == 1024
assert settings.document_chunk_overlap == 102 assert settings.document_chunk_overlap == 102
@@ -251,6 +259,7 @@ class TestChunkConfigValidation:
) )
def test_get_settings_invalid_chunk_config_raises_error(self): def test_get_settings_invalid_chunk_config_raises_error(self):
"""Test get_settings() raises error for invalid chunk config.""" """Test get_settings() raises error for invalid chunk config."""
_reload_config()
with pytest.raises( with pytest.raises(
ValueError, ValueError,
match="DOCUMENT_CHUNK_OVERLAP .* must be less than DOCUMENT_CHUNK_SIZE", match="DOCUMENT_CHUNK_OVERLAP .* must be less than DOCUMENT_CHUNK_SIZE",
@@ -294,6 +303,7 @@ class TestEmbeddingModelName:
) )
def test_get_settings_openai_model(self): def test_get_settings_openai_model(self):
"""Test get_settings() loads OpenAI embedding model.""" """Test get_settings() loads OpenAI embedding model."""
_reload_config()
settings = get_settings() settings = get_settings()
assert settings.openai_api_key == "test-openai-key" assert settings.openai_api_key == "test-openai-key"
assert settings.openai_embedding_model == "openai/text-embedding-3-small" assert settings.openai_embedding_model == "openai/text-embedding-3-small"
@@ -342,3 +352,85 @@ class TestCollectionNameWithProviders:
openai_embedding_model="text-embedding-3-large", openai_embedding_model="text-embedding-3-large",
) )
assert settings.get_collection_name() == "custom-collection" 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"
+20 -1
View File
@@ -10,7 +10,7 @@ Tests cover:
import os import os
from unittest.mock import patch 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 ( from nextcloud_mcp_server.config_validators import (
AuthMode, AuthMode,
detect_auth_mode, detect_auth_mode,
@@ -274,6 +274,7 @@ class TestMultiUserBasicValidation:
): ):
from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings() settings = get_settings()
mode, errors = validate_configuration(settings) mode, errors = validate_configuration(settings)
@@ -372,6 +373,7 @@ class TestOAuthSingleAudienceValidation:
): ):
from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings() settings = get_settings()
mode, errors = validate_configuration(settings) mode, errors = validate_configuration(settings)
@@ -459,6 +461,7 @@ class TestConfigurationConsolidation:
): ):
from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings() settings = get_settings()
assert settings.vector_sync_enabled is True assert settings.vector_sync_enabled is True
@@ -474,6 +477,7 @@ class TestConfigurationConsolidation:
): ):
from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings() settings = get_settings()
assert settings.vector_sync_enabled is True assert settings.vector_sync_enabled is True
@@ -490,6 +494,7 @@ class TestConfigurationConsolidation:
): ):
from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings() settings = get_settings()
assert settings.enable_offline_access is True assert settings.enable_offline_access is True
@@ -506,6 +511,7 @@ class TestConfigurationConsolidation:
): ):
from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings() settings = get_settings()
assert settings.enable_offline_access is True assert settings.enable_offline_access is True
@@ -525,6 +531,7 @@ class TestConfigurationConsolidation:
): ):
from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings() settings = get_settings()
# Semantic search enabled # Semantic search enabled
@@ -549,6 +556,7 @@ class TestConfigurationConsolidation:
): ):
from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings() settings = get_settings()
# Semantic search enabled # Semantic search enabled
@@ -572,6 +580,7 @@ class TestConfigurationConsolidation:
): ):
from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings() settings = get_settings()
# Semantic search NOT enabled # Semantic search NOT enabled
@@ -593,6 +602,7 @@ class TestConfigurationConsolidation:
): ):
from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings() settings = get_settings()
# Should use new name value (true) # Should use new name value (true)
@@ -612,6 +622,7 @@ class TestConfigurationConsolidation:
): ):
from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings() settings = get_settings()
# Should use new name value (true) # Should use new name value (true)
@@ -637,6 +648,7 @@ class TestConfigurationConsolidation:
): ):
from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings() settings = get_settings()
mode, errors = validate_configuration(settings) mode, errors = validate_configuration(settings)
@@ -671,6 +683,7 @@ class TestExplicitModeSelection:
): ):
from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings() settings = get_settings()
mode = detect_auth_mode(settings) mode = detect_auth_mode(settings)
@@ -688,6 +701,7 @@ class TestExplicitModeSelection:
): ):
from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings() settings = get_settings()
mode = detect_auth_mode(settings) mode = detect_auth_mode(settings)
@@ -705,6 +719,7 @@ class TestExplicitModeSelection:
): ):
from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings() settings = get_settings()
mode = detect_auth_mode(settings) mode = detect_auth_mode(settings)
@@ -722,6 +737,7 @@ class TestExplicitModeSelection:
): ):
from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings() settings = get_settings()
# Should raise ValueError with clear message # Should raise ValueError with clear message
@@ -747,6 +763,7 @@ class TestExplicitModeSelection:
): ):
from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings() settings = get_settings()
mode = detect_auth_mode(settings) mode = detect_auth_mode(settings)
@@ -765,6 +782,7 @@ class TestExplicitModeSelection:
): ):
from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings() settings = get_settings()
mode = detect_auth_mode(settings) mode = detect_auth_mode(settings)
@@ -782,6 +800,7 @@ class TestExplicitModeSelection:
): ):
from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings() settings = get_settings()
mode = detect_auth_mode(settings) mode = detect_auth_mode(settings)
+10 -14
View File
@@ -4,6 +4,8 @@ import os
import pytest import pytest
from nextcloud_mcp_server.config import _reload_config, get_document_processor_config
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit
@@ -12,18 +14,16 @@ class TestDocumentProcessorConfig:
def test_config_disabled_by_default(self): def test_config_disabled_by_default(self):
"""Test that document processing is disabled by default.""" """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) os.environ.pop("ENABLE_DOCUMENT_PROCESSING", None)
_reload_config()
config = get_document_processor_config() config = get_document_processor_config()
assert config["enabled"] is False assert config["enabled"] is False
def test_config_enabled(self): def test_config_enabled(self):
"""Test enabling document processing.""" """Test enabling document processing."""
from nextcloud_mcp_server.config import get_document_processor_config
os.environ["ENABLE_DOCUMENT_PROCESSING"] = "true" os.environ["ENABLE_DOCUMENT_PROCESSING"] = "true"
try: try:
_reload_config()
config = get_document_processor_config() config = get_document_processor_config()
assert config["enabled"] is True assert config["enabled"] is True
finally: finally:
@@ -31,8 +31,6 @@ class TestDocumentProcessorConfig:
def test_unstructured_processor_config(self): def test_unstructured_processor_config(self):
"""Test Unstructured processor configuration.""" """Test Unstructured processor configuration."""
from nextcloud_mcp_server.config import get_document_processor_config
os.environ["ENABLE_UNSTRUCTURED"] = "true" os.environ["ENABLE_UNSTRUCTURED"] = "true"
os.environ["UNSTRUCTURED_API_URL"] = "http://test:8000" os.environ["UNSTRUCTURED_API_URL"] = "http://test:8000"
os.environ["UNSTRUCTURED_STRATEGY"] = "hi_res" os.environ["UNSTRUCTURED_STRATEGY"] = "hi_res"
@@ -40,6 +38,7 @@ class TestDocumentProcessorConfig:
os.environ["UNSTRUCTURED_TIMEOUT"] = "60" os.environ["UNSTRUCTURED_TIMEOUT"] = "60"
try: try:
_reload_config()
config = get_document_processor_config() config = get_document_processor_config()
assert "unstructured" in config["processors"] assert "unstructured" in config["processors"]
unst_config = config["processors"]["unstructured"] unst_config = config["processors"]["unstructured"]
@@ -56,13 +55,12 @@ class TestDocumentProcessorConfig:
def test_tesseract_processor_config(self): def test_tesseract_processor_config(self):
"""Test Tesseract processor configuration.""" """Test Tesseract processor configuration."""
from nextcloud_mcp_server.config import get_document_processor_config
os.environ["ENABLE_TESSERACT"] = "true" os.environ["ENABLE_TESSERACT"] = "true"
os.environ["TESSERACT_LANG"] = "eng+deu" os.environ["TESSERACT_LANG"] = "eng+deu"
os.environ["TESSERACT_CMD"] = "/usr/local/bin/tesseract" os.environ["TESSERACT_CMD"] = "/usr/local/bin/tesseract"
try: try:
_reload_config()
config = get_document_processor_config() config = get_document_processor_config()
assert "tesseract" in config["processors"] assert "tesseract" in config["processors"]
tess_config = config["processors"]["tesseract"] tess_config = config["processors"]["tesseract"]
@@ -75,8 +73,6 @@ class TestDocumentProcessorConfig:
def test_custom_processor_config(self): def test_custom_processor_config(self):
"""Test custom processor configuration.""" """Test custom processor configuration."""
from nextcloud_mcp_server.config import get_document_processor_config
os.environ["ENABLE_CUSTOM_PROCESSOR"] = "true" os.environ["ENABLE_CUSTOM_PROCESSOR"] = "true"
os.environ["CUSTOM_PROCESSOR_NAME"] = "my_ocr" os.environ["CUSTOM_PROCESSOR_NAME"] = "my_ocr"
os.environ["CUSTOM_PROCESSOR_URL"] = "http://localhost:9000/process" os.environ["CUSTOM_PROCESSOR_URL"] = "http://localhost:9000/process"
@@ -85,6 +81,7 @@ class TestDocumentProcessorConfig:
os.environ["CUSTOM_PROCESSOR_TYPES"] = "application/pdf,image/jpeg" os.environ["CUSTOM_PROCESSOR_TYPES"] = "application/pdf,image/jpeg"
try: try:
_reload_config()
config = get_document_processor_config() config = get_document_processor_config()
assert "custom" in config["processors"] assert "custom" in config["processors"]
custom_config = config["processors"]["custom"] custom_config = config["processors"]["custom"]
@@ -104,13 +101,12 @@ class TestDocumentProcessorConfig:
def test_multiple_processors(self): def test_multiple_processors(self):
"""Test configuration with multiple processors enabled.""" """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_DOCUMENT_PROCESSING"] = "true"
os.environ["ENABLE_UNSTRUCTURED"] = "true" os.environ["ENABLE_UNSTRUCTURED"] = "true"
os.environ["ENABLE_TESSERACT"] = "true" os.environ["ENABLE_TESSERACT"] = "true"
try: try:
_reload_config()
config = get_document_processor_config() config = get_document_processor_config()
assert config["enabled"] is True assert config["enabled"] is True
assert "unstructured" in config["processors"] assert "unstructured" in config["processors"]
@@ -122,14 +118,14 @@ class TestDocumentProcessorConfig:
def test_default_processor_selection(self): def test_default_processor_selection(self):
"""Test default processor configuration.""" """Test default processor configuration."""
from nextcloud_mcp_server.config import get_document_processor_config
os.environ.pop("DOCUMENT_PROCESSOR", None) os.environ.pop("DOCUMENT_PROCESSOR", None)
_reload_config()
config = get_document_processor_config() config = get_document_processor_config()
assert config["default_processor"] == "unstructured" assert config["default_processor"] == "unstructured"
os.environ["DOCUMENT_PROCESSOR"] = "tesseract" os.environ["DOCUMENT_PROCESSOR"] = "tesseract"
try: try:
_reload_config()
config = get_document_processor_config() config = get_document_processor_config()
assert config["default_processor"] == "tesseract" assert config["default_processor"] == "tesseract"
finally: finally:
+11 -2
View File
@@ -9,7 +9,12 @@ import certifi
import httpx import httpx
import pytest 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 from nextcloud_mcp_server.http import nextcloud_httpx_client, nextcloud_httpx_transport
@@ -50,7 +55,7 @@ class TestGetNextcloudSSLVerify:
"NEXTCLOUD_VERIFY_SSL": "true", "NEXTCLOUD_VERIFY_SSL": "true",
} }
with patch.dict(os.environ, env, clear=False): with patch.dict(os.environ, env, clear=False):
# Clear any cached settings _reload_config()
result = get_nextcloud_ssl_verify() result = get_nextcloud_ssl_verify()
assert result is True assert result is True
@@ -110,12 +115,14 @@ class TestGetSettingsSSLEnvVars:
def test_verify_ssl_env_true(self): def test_verify_ssl_env_true(self):
env = {"NEXTCLOUD_VERIFY_SSL": "true"} env = {"NEXTCLOUD_VERIFY_SSL": "true"}
with patch.dict(os.environ, env, clear=False): with patch.dict(os.environ, env, clear=False):
_reload_config()
settings = get_settings() settings = get_settings()
assert settings.nextcloud_verify_ssl is True assert settings.nextcloud_verify_ssl is True
def test_verify_ssl_env_false(self): def test_verify_ssl_env_false(self):
env = {"NEXTCLOUD_VERIFY_SSL": "false"} env = {"NEXTCLOUD_VERIFY_SSL": "false"}
with patch.dict(os.environ, env, clear=False): with patch.dict(os.environ, env, clear=False):
_reload_config()
settings = get_settings() settings = get_settings()
assert settings.nextcloud_verify_ssl is False assert settings.nextcloud_verify_ssl is False
@@ -123,6 +130,7 @@ class TestGetSettingsSSLEnvVars:
with patch.dict(os.environ, {}, clear=False): with patch.dict(os.environ, {}, clear=False):
# Remove NEXTCLOUD_VERIFY_SSL if it exists # Remove NEXTCLOUD_VERIFY_SSL if it exists
os.environ.pop("NEXTCLOUD_VERIFY_SSL", None) os.environ.pop("NEXTCLOUD_VERIFY_SSL", None)
_reload_config()
settings = get_settings() settings = get_settings()
assert settings.nextcloud_verify_ssl is True assert settings.nextcloud_verify_ssl is True
@@ -133,6 +141,7 @@ class TestGetSettingsSSLEnvVars:
) )
env = {"NEXTCLOUD_CA_BUNDLE": str(ca_file)} env = {"NEXTCLOUD_CA_BUNDLE": str(ca_file)}
with patch.dict(os.environ, env, clear=False): with patch.dict(os.environ, env, clear=False):
_reload_config()
settings = get_settings() settings = get_settings()
assert settings.nextcloud_ca_bundle == str(ca_file) assert settings.nextcloud_ca_bundle == str(ca_file)