4507359760
Implement centralized configuration validation (ADR-020) to simplify deployment mode detection and improve error messages. Changes: - Create ADR-020 documenting 5 deployment modes with required/optional config - Add config_validators.py with validate_configuration() and mode detection - Simplify app.py startup with single validation point at get_app() - Remove duplicate is_oauth_mode() function (43 lines) - Fix DeploymentMode mapping (only SELF_HOSTED and SMITHERY_STATELESS exist) - Add comprehensive unit tests (41 tests covering all modes and edge cases) - Add enable_multi_user_basic_auth to Settings and BasicAuthMiddleware Docker Compose: - Remove conflicting ENABLE_MULTI_USER_BASIC_AUTH from mcp-oauth service - Add dedicated mcp-multi-user-basic service on port 8003 Test Results: - 237/237 integration tests PASSED - All deployment modes verified: single-user BasicAuth, multi-user BasicAuth, OAuth single-audience, OAuth token exchange (Keycloak), Smithery stateless 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
579 lines
20 KiB
Python
579 lines
20 KiB
Python
"""Unit tests for configuration validation and mode detection.
|
|
|
|
Tests cover:
|
|
- Mode detection logic
|
|
- Configuration validation for each mode
|
|
- Error message generation
|
|
- Edge cases and boundary conditions
|
|
"""
|
|
|
|
import os
|
|
from unittest.mock import patch
|
|
|
|
from nextcloud_mcp_server.config import Settings
|
|
from nextcloud_mcp_server.config_validators import (
|
|
AuthMode,
|
|
detect_auth_mode,
|
|
get_mode_summary,
|
|
validate_configuration,
|
|
)
|
|
|
|
|
|
class TestModeDetection:
|
|
"""Test auth mode detection from configuration."""
|
|
|
|
def test_smithery_mode_detection(self):
|
|
"""Test Smithery mode is detected from environment variable."""
|
|
settings = Settings()
|
|
|
|
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
|
|
mode = detect_auth_mode(settings)
|
|
assert mode == AuthMode.SMITHERY_STATELESS
|
|
|
|
def test_token_exchange_mode_detection(self):
|
|
"""Test token exchange mode is detected."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
enable_token_exchange=True,
|
|
)
|
|
|
|
mode = detect_auth_mode(settings)
|
|
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
|
|
|
|
def test_multi_user_basic_mode_detection(self):
|
|
"""Test multi-user BasicAuth mode is detected."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
enable_multi_user_basic_auth=True,
|
|
)
|
|
|
|
mode = detect_auth_mode(settings)
|
|
assert mode == AuthMode.MULTI_USER_BASIC
|
|
|
|
def test_single_user_basic_mode_detection(self):
|
|
"""Test single-user BasicAuth mode is detected."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
nextcloud_username="admin",
|
|
nextcloud_password="password",
|
|
)
|
|
|
|
mode = detect_auth_mode(settings)
|
|
assert mode == AuthMode.SINGLE_USER_BASIC
|
|
|
|
def test_oauth_single_audience_default(self):
|
|
"""Test OAuth single-audience is default mode."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
)
|
|
|
|
mode = detect_auth_mode(settings)
|
|
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
|
|
|
def test_mode_priority_smithery_over_all(self):
|
|
"""Test Smithery mode has highest priority."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
nextcloud_username="admin",
|
|
nextcloud_password="password",
|
|
enable_token_exchange=True,
|
|
enable_multi_user_basic_auth=True,
|
|
)
|
|
|
|
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
|
|
mode = detect_auth_mode(settings)
|
|
assert mode == AuthMode.SMITHERY_STATELESS
|
|
|
|
def test_mode_priority_token_exchange_over_basic(self):
|
|
"""Test token exchange has priority over BasicAuth."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
nextcloud_username="admin",
|
|
nextcloud_password="password",
|
|
enable_token_exchange=True,
|
|
)
|
|
|
|
mode = detect_auth_mode(settings)
|
|
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
|
|
|
|
|
|
class TestSingleUserBasicValidation:
|
|
"""Test validation for single-user BasicAuth mode."""
|
|
|
|
def test_valid_minimal_config(self):
|
|
"""Test valid minimal single-user BasicAuth config."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
nextcloud_username="admin",
|
|
nextcloud_password="password",
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.SINGLE_USER_BASIC
|
|
assert len(errors) == 0
|
|
|
|
def test_valid_with_vector_sync(self):
|
|
"""Test valid config with vector sync enabled."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
nextcloud_username="admin",
|
|
nextcloud_password="password",
|
|
vector_sync_enabled=True,
|
|
qdrant_location=":memory:",
|
|
ollama_base_url="http://ollama:11434",
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.SINGLE_USER_BASIC
|
|
assert len(errors) == 0
|
|
|
|
def test_missing_required_host(self):
|
|
"""Test error when NEXTCLOUD_HOST is missing."""
|
|
settings = Settings(
|
|
nextcloud_username="admin",
|
|
nextcloud_password="password",
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.SINGLE_USER_BASIC
|
|
assert any("nextcloud_host" in err.lower() for err in errors)
|
|
|
|
def test_missing_required_username(self):
|
|
"""Test that partial credentials fall back to OAuth mode."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
nextcloud_password="password", # Password without username
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
# Mode detection requires BOTH username AND password for single-user BasicAuth
|
|
# If only one is present, it defaults to OAuth single-audience
|
|
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
|
# In OAuth mode, having a password set is forbidden
|
|
assert any("nextcloud_password" in err.lower() for err in errors)
|
|
|
|
def test_missing_required_password(self):
|
|
"""Test that partial credentials fall back to OAuth mode."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
nextcloud_username="admin", # Username without password
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
# Mode detection requires BOTH username AND password for single-user BasicAuth
|
|
# If only one is present, it defaults to OAuth single-audience
|
|
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
|
# In OAuth mode, having a username set is forbidden
|
|
assert any("nextcloud_username" in err.lower() for err in errors)
|
|
|
|
def test_forbidden_multi_user_basic_auth(self):
|
|
"""Test error when ENABLE_MULTI_USER_BASIC_AUTH is set."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
nextcloud_username="admin",
|
|
nextcloud_password="password",
|
|
enable_multi_user_basic_auth=True,
|
|
)
|
|
|
|
# Note: This will detect as MULTI_USER_BASIC due to priority
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.MULTI_USER_BASIC
|
|
# It will fail multi-user validation because username/password are forbidden
|
|
assert len(errors) > 0
|
|
|
|
def test_forbidden_token_exchange(self):
|
|
"""Test error when ENABLE_TOKEN_EXCHANGE is set."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
nextcloud_username="admin",
|
|
nextcloud_password="password",
|
|
enable_token_exchange=True,
|
|
)
|
|
|
|
# Note: This will detect as OAUTH_TOKEN_EXCHANGE due to priority
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
|
|
# It will fail OAuth validation
|
|
|
|
def test_vector_sync_without_embedding_provider_uses_fallback(self):
|
|
"""Test that vector sync works with Simple provider fallback (no config needed)."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
nextcloud_username="admin",
|
|
nextcloud_password="password",
|
|
vector_sync_enabled=True,
|
|
qdrant_location=":memory:",
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.SINGLE_USER_BASIC
|
|
# Should pass - Simple provider is always available as fallback
|
|
assert len(errors) == 0
|
|
|
|
|
|
class TestMultiUserBasicValidation:
|
|
"""Test validation for multi-user BasicAuth mode."""
|
|
|
|
def test_valid_minimal_config(self):
|
|
"""Test valid minimal multi-user BasicAuth config."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
enable_multi_user_basic_auth=True,
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.MULTI_USER_BASIC
|
|
assert len(errors) == 0
|
|
|
|
def test_valid_with_offline_access(self):
|
|
"""Test valid config with offline access enabled."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
enable_multi_user_basic_auth=True,
|
|
enable_offline_access=True,
|
|
oidc_client_id="test-client",
|
|
oidc_client_secret="test-secret",
|
|
token_encryption_key="test-key-" + "a" * 32,
|
|
token_storage_db="/tmp/tokens.db",
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.MULTI_USER_BASIC
|
|
assert len(errors) == 0
|
|
|
|
def test_missing_required_host(self):
|
|
"""Test error when NEXTCLOUD_HOST is missing."""
|
|
settings = Settings(
|
|
enable_multi_user_basic_auth=True,
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.MULTI_USER_BASIC
|
|
assert any("nextcloud_host" in err.lower() for err in errors)
|
|
|
|
def test_forbidden_username_password(self):
|
|
"""Test error when NEXTCLOUD_USERNAME/PASSWORD are set."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
nextcloud_username="admin",
|
|
nextcloud_password="password",
|
|
enable_multi_user_basic_auth=True,
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
# Multi-user BasicAuth has higher priority than single-user in detection
|
|
# (explicit flags come before credentials)
|
|
assert mode == AuthMode.MULTI_USER_BASIC
|
|
# Should report errors for forbidden username/password
|
|
assert any("nextcloud_username" in err.lower() for err in errors)
|
|
assert any("nextcloud_password" in err.lower() for err in errors)
|
|
|
|
def test_offline_access_missing_oauth_credentials(self):
|
|
"""Test error when offline access enabled but OAuth credentials missing."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
enable_multi_user_basic_auth=True,
|
|
enable_offline_access=True,
|
|
token_encryption_key="test-key-" + "a" * 32,
|
|
token_storage_db="/tmp/tokens.db",
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.MULTI_USER_BASIC
|
|
assert any("oidc_client_id" in err.lower() for err in errors)
|
|
|
|
def test_offline_access_missing_encryption_key(self):
|
|
"""Test error when offline access enabled but encryption key missing."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
enable_multi_user_basic_auth=True,
|
|
enable_offline_access=True,
|
|
oidc_client_id="test-client",
|
|
oidc_client_secret="test-secret",
|
|
token_storage_db="/tmp/tokens.db",
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.MULTI_USER_BASIC
|
|
assert any("token_encryption_key" in err.lower() for err in errors)
|
|
|
|
def test_vector_sync_requires_offline_access(self):
|
|
"""Test error when vector sync enabled but offline access disabled."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
enable_multi_user_basic_auth=True,
|
|
vector_sync_enabled=True,
|
|
qdrant_location=":memory:",
|
|
ollama_base_url="http://ollama:11434",
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.MULTI_USER_BASIC
|
|
assert any("enable_offline_access" in err.lower() for err in errors)
|
|
|
|
|
|
class TestOAuthSingleAudienceValidation:
|
|
"""Test validation for OAuth single-audience mode."""
|
|
|
|
def test_valid_minimal_config(self):
|
|
"""Test valid minimal OAuth single-audience config."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
|
assert len(errors) == 0
|
|
|
|
def test_valid_with_static_credentials(self):
|
|
"""Test valid config with static OAuth credentials."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
oidc_client_id="test-client",
|
|
oidc_client_secret="test-secret",
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
|
assert len(errors) == 0
|
|
|
|
def test_valid_with_offline_access(self):
|
|
"""Test valid config with offline access."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
oidc_client_id="test-client",
|
|
oidc_client_secret="test-secret",
|
|
enable_offline_access=True,
|
|
token_encryption_key="test-key-" + "a" * 32,
|
|
token_storage_db="/tmp/tokens.db",
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
|
assert len(errors) == 0
|
|
|
|
def test_forbidden_username_password(self):
|
|
"""Test that username/password trigger single-user mode instead."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
nextcloud_username="admin",
|
|
nextcloud_password="password",
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
# This should detect as SINGLE_USER_BASIC
|
|
assert mode == AuthMode.SINGLE_USER_BASIC
|
|
|
|
def test_offline_access_missing_encryption_key(self):
|
|
"""Test error when offline access enabled but encryption key missing."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
enable_offline_access=True,
|
|
token_storage_db="/tmp/tokens.db",
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
|
assert any("token_encryption_key" in err.lower() for err in errors)
|
|
|
|
def test_vector_sync_requires_offline_access(self):
|
|
"""Test error when vector sync enabled but offline access disabled."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
vector_sync_enabled=True,
|
|
qdrant_location=":memory:",
|
|
ollama_base_url="http://ollama:11434",
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
|
assert any("enable_offline_access" in err.lower() for err in errors)
|
|
|
|
|
|
class TestOAuthTokenExchangeValidation:
|
|
"""Test validation for OAuth token exchange mode."""
|
|
|
|
def test_valid_minimal_config(self):
|
|
"""Test valid minimal OAuth token exchange config."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
enable_token_exchange=True,
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
|
|
assert len(errors) == 0
|
|
|
|
def test_valid_with_credentials(self):
|
|
"""Test valid config with OAuth credentials."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
enable_token_exchange=True,
|
|
oidc_client_id="test-client",
|
|
oidc_client_secret="test-secret",
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
|
|
assert len(errors) == 0
|
|
|
|
def test_forbidden_username_password(self):
|
|
"""Test error when username/password are set."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
enable_token_exchange=True,
|
|
nextcloud_username="admin",
|
|
nextcloud_password="password",
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
|
|
assert any("nextcloud_username" in err.lower() for err in errors)
|
|
assert any("nextcloud_password" in err.lower() for err in errors)
|
|
|
|
|
|
class TestSmitheryValidation:
|
|
"""Test validation for Smithery stateless mode."""
|
|
|
|
def test_valid_empty_config(self):
|
|
"""Test valid empty config for Smithery mode."""
|
|
settings = Settings()
|
|
|
|
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.SMITHERY_STATELESS
|
|
assert len(errors) == 0
|
|
|
|
def test_forbidden_nextcloud_host(self):
|
|
"""Test error when NEXTCLOUD_HOST is set."""
|
|
settings = Settings(
|
|
nextcloud_host="http://localhost",
|
|
)
|
|
|
|
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.SMITHERY_STATELESS
|
|
assert any("nextcloud_host" in err.lower() for err in errors)
|
|
|
|
def test_forbidden_credentials(self):
|
|
"""Test error when credentials are set."""
|
|
settings = Settings(
|
|
nextcloud_username="admin",
|
|
nextcloud_password="password",
|
|
)
|
|
|
|
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.SMITHERY_STATELESS
|
|
assert any("nextcloud_username" in err.lower() for err in errors)
|
|
|
|
def test_forbidden_vector_sync(self):
|
|
"""Test error when vector sync is enabled."""
|
|
settings = Settings(
|
|
vector_sync_enabled=True,
|
|
)
|
|
|
|
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
assert mode == AuthMode.SMITHERY_STATELESS
|
|
assert any("vector_sync_enabled" in err.lower() for err in errors)
|
|
|
|
|
|
class TestModeSummary:
|
|
"""Test mode summary generation."""
|
|
|
|
def test_single_user_basic_summary(self):
|
|
"""Test summary for single-user BasicAuth mode."""
|
|
summary = get_mode_summary(AuthMode.SINGLE_USER_BASIC)
|
|
|
|
assert "single_user_basic" in summary
|
|
assert "NEXTCLOUD_HOST" in summary
|
|
assert "NEXTCLOUD_USERNAME" in summary
|
|
assert "NEXTCLOUD_PASSWORD" in summary
|
|
assert "VECTOR_SYNC_ENABLED" in summary
|
|
|
|
def test_smithery_summary(self):
|
|
"""Test summary for Smithery mode."""
|
|
summary = get_mode_summary(AuthMode.SMITHERY_STATELESS)
|
|
|
|
assert "smithery" in summary
|
|
assert "session" in summary.lower()
|
|
assert "(none" in summary # No required config
|
|
|
|
def test_oauth_token_exchange_summary(self):
|
|
"""Test summary for OAuth token exchange mode."""
|
|
summary = get_mode_summary(AuthMode.OAUTH_TOKEN_EXCHANGE)
|
|
|
|
assert "oauth_exchange" in summary
|
|
assert "ENABLE_TOKEN_EXCHANGE" in summary
|
|
assert "RFC 8693" in summary
|
|
|
|
|
|
class TestEdgeCases:
|
|
"""Test edge cases and boundary conditions."""
|
|
|
|
def test_empty_string_treated_as_missing(self):
|
|
"""Test that empty strings are treated as missing values."""
|
|
settings = Settings(
|
|
nextcloud_host="", # Empty string
|
|
nextcloud_username="admin",
|
|
nextcloud_password="password",
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
# Should fail because nextcloud_host is effectively missing
|
|
assert any("nextcloud_host" in err.lower() for err in errors)
|
|
|
|
def test_whitespace_treated_as_missing(self):
|
|
"""Test that whitespace-only strings are treated as missing."""
|
|
settings = Settings(
|
|
nextcloud_host=" ", # Whitespace only
|
|
nextcloud_username="admin",
|
|
nextcloud_password="password",
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
# Should fail because nextcloud_host is effectively missing
|
|
assert any("nextcloud_host" in err.lower() for err in errors)
|
|
|
|
def test_multiple_errors_reported(self):
|
|
"""Test that multiple errors are all reported."""
|
|
settings = Settings(
|
|
# Missing all required fields for single-user BasicAuth
|
|
)
|
|
|
|
mode, errors = validate_configuration(settings)
|
|
|
|
# Should have errors for missing host (OAuth mode is default)
|
|
assert len(errors) > 0
|