"""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 import pytest from nextcloud_mcp_server.config import Settings, _reload_config 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_multi_user_basic_mode_detection(self): """Test multi-user BasicAuth mode is selected via explicit deployment_mode. ADR-022 follow-up: the ENABLE_MULTI_USER_BASIC_AUTH auto-detection branch was removed; the only way to opt in is `MCP_DEPLOYMENT_MODE=multi_user_basic`. Coverage for the explicit-mode path also lives in TestExplicitModeSelection::test_explicit_multi_user_basic_mode. """ settings = Settings( nextcloud_host="http://localhost", deployment_mode="multi_user_basic", ) mode = detect_auth_mode(settings) assert mode == AuthMode.MULTI_USER_BASIC assert settings.enable_multi_user_basic_auth is True 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_login_flow_default(self): """Test Login Flow v2 is the default multi-user mode.""" settings = Settings( nextcloud_host="http://localhost", ) mode = detect_auth_mode(settings) assert mode == AuthMode.LOGIN_FLOW 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.LOGIN_FLOW # 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.LOGIN_FLOW # 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_when_credentials_present(self): """Test multi-user mode rejects single-user credentials. When MCP_DEPLOYMENT_MODE=multi_user_basic is set explicitly but NEXTCLOUD_USERNAME/PASSWORD are also set (a misconfiguration), the explicit mode wins and validation reports the credentials as forbidden. """ settings = Settings( nextcloud_host="http://localhost", nextcloud_username="admin", nextcloud_password="password", deployment_mode="multi_user_basic", ) mode, errors = validate_configuration(settings) assert mode == AuthMode.MULTI_USER_BASIC # Should report errors for forbidden username/password assert len(errors) > 0 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", deployment_mode="multi_user_basic", ) 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", deployment_mode="multi_user_basic", 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( deployment_mode="multi_user_basic", ) 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", deployment_mode="multi_user_basic", ) mode, errors = validate_configuration(settings) # Explicit MCP_DEPLOYMENT_MODE wins over auto-detection from 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 that offline access works without OAuth credentials (will use DCR).""" settings = Settings( nextcloud_host="http://localhost", deployment_mode="multi_user_basic", 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 # No errors - DCR will be used as fallback (consistent with OAuth modes) assert len(errors) == 0 def test_offline_access_missing_encryption_key(self): """Test error when offline access enabled but encryption key missing.""" settings = Settings( nextcloud_host="http://localhost", deployment_mode="multi_user_basic", 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_auto_enables_background_ops_in_multi_user_mode(self): """Test vector sync automatically enables background operations in multi-user mode (ADR-021).""" # Before ADR-021: This would have failed validation (required explicit ENABLE_OFFLINE_ACCESS) # After ADR-021: vector_sync_enabled auto-enables background operations with patch.dict( os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", "MCP_DEPLOYMENT_MODE": "multi_user_basic", "VECTOR_SYNC_ENABLED": "true", # Using old name for backward compat test "QDRANT_LOCATION": ":memory:", "OLLAMA_BASE_URL": "http://ollama:11434", "TOKEN_ENCRYPTION_KEY": "test-key", "TOKEN_STORAGE_DB": "/tmp/test.db", "NEXTCLOUD_OIDC_CLIENT_ID": "test-client-id", "NEXTCLOUD_OIDC_CLIENT_SECRET": "test-client-secret", }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() settings = get_settings() mode, errors = validate_configuration(settings) assert mode == AuthMode.MULTI_USER_BASIC # Should have no errors - background operations auto-enabled assert len(errors) == 0 # Verify background operations were auto-enabled assert settings.enable_offline_access is True class TestLoginFlowValidation: """Test validation for Login Flow v2 mode (formerly OAUTH_SINGLE_AUDIENCE).""" def test_valid_minimal_config(self): """Test valid minimal Login Flow v2 config — enable_login_flow is now derived.""" settings = Settings( nextcloud_host="http://localhost", ) mode, errors = validate_configuration(settings) assert mode == AuthMode.LOGIN_FLOW assert len(errors) == 0 # ADR-022 follow-up: enable_login_flow is derived from the resolved mode. assert settings.enable_login_flow is True 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.LOGIN_FLOW 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.LOGIN_FLOW 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.LOGIN_FLOW assert any("token_encryption_key" in err.lower() for err in errors) def test_login_flow_mode_auto_derives_enable_login_flow_flag(self): """ADR-022 follow-up: deployment_mode=login_flow auto-derives the flag. Users no longer need to set ENABLE_LOGIN_FLOW=true (env var was removed); Settings.__post_init__ populates settings.enable_login_flow from the resolved mode at construction time, so every Settings instance carries correct flags regardless of how it was built. """ # Default-fallback case: no auth env vars → LOGIN_FLOW. settings = Settings(nextcloud_host="http://localhost") assert settings.enable_login_flow is True assert settings.enable_multi_user_basic_auth is False assert detect_auth_mode(settings) == AuthMode.LOGIN_FLOW # Single-user BasicAuth (credentials set) → neither derived flag. basic_settings = Settings( nextcloud_host="http://localhost", nextcloud_username="alice", nextcloud_password="password", ) assert basic_settings.enable_login_flow is False assert basic_settings.enable_multi_user_basic_auth is False assert detect_auth_mode(basic_settings) == AuthMode.SINGLE_USER_BASIC def test_vector_sync_auto_enables_background_ops_in_login_flow_mode(self): """Test vector sync automatically enables background operations in Login Flow v2 mode (ADR-021).""" # Before ADR-021: This would have failed validation (required explicit ENABLE_OFFLINE_ACCESS) # After ADR-021: vector_sync_enabled auto-enables background operations in multi-user modes with patch.dict( os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", "VECTOR_SYNC_ENABLED": "true", "QDRANT_LOCATION": ":memory:", "OLLAMA_BASE_URL": "http://ollama:11434", "TOKEN_ENCRYPTION_KEY": "test-key", "TOKEN_STORAGE_DB": "/tmp/test.db", # Note: No username/password = Login Flow v2 multi-user OAuth mode }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() settings = get_settings() mode, errors = validate_configuration(settings) assert mode == AuthMode.LOGIN_FLOW # Should have no errors - background operations auto-enabled assert len(errors) == 0 # Verify background operations were auto-enabled assert settings.enable_offline_access is True 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 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 class TestConfigurationConsolidation: """Test ADR-021 configuration consolidation and backward compatibility. Tests verify: - New variable names work (ENABLE_SEMANTIC_SEARCH, ENABLE_BACKGROUND_OPERATIONS) - Old variable names still work (VECTOR_SYNC_ENABLED, ENABLE_OFFLINE_ACCESS) - Deprecation warnings are logged - Auto-enablement of background operations in multi-user modes """ def test_new_semantic_search_variable_name(self): """Test ENABLE_SEMANTIC_SEARCH (new name) works correctly.""" with patch.dict( os.environ, { "ENABLE_SEMANTIC_SEARCH": "true", "QDRANT_LOCATION": ":memory:", }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() settings = get_settings() assert settings.vector_sync_enabled is True def test_old_vector_sync_variable_name_backward_compat(self): """Test VECTOR_SYNC_ENABLED (old name) still works for backward compatibility.""" with patch.dict( os.environ, { "VECTOR_SYNC_ENABLED": "true", "QDRANT_LOCATION": ":memory:", }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() settings = get_settings() assert settings.vector_sync_enabled is True def test_new_background_operations_variable_name(self): """Test ENABLE_BACKGROUND_OPERATIONS (new name) works correctly.""" with patch.dict( os.environ, { "ENABLE_BACKGROUND_OPERATIONS": "true", "TOKEN_ENCRYPTION_KEY": "test-key", "TOKEN_STORAGE_DB": "/tmp/test.db", }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() settings = get_settings() assert settings.enable_offline_access is True def test_old_offline_access_variable_name_backward_compat(self): """Test ENABLE_OFFLINE_ACCESS (old name) still works for backward compatibility.""" with patch.dict( os.environ, { "ENABLE_OFFLINE_ACCESS": "true", "TOKEN_ENCRYPTION_KEY": "test-key", "TOKEN_STORAGE_DB": "/tmp/test.db", }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() settings = get_settings() assert settings.enable_offline_access is True def test_semantic_search_auto_enables_background_ops_in_login_flow_mode(self): """Test ENABLE_SEMANTIC_SEARCH automatically enables background operations in Login Flow v2 mode.""" with patch.dict( os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", "ENABLE_SEMANTIC_SEARCH": "true", "QDRANT_LOCATION": ":memory:", "TOKEN_ENCRYPTION_KEY": "test-key", "TOKEN_STORAGE_DB": "/tmp/test.db", # Note: No NEXTCLOUD_USERNAME/PASSWORD = OAuth mode }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() settings = get_settings() # Semantic search enabled assert settings.vector_sync_enabled is True # Background operations auto-enabled (even though not explicitly set) assert settings.enable_offline_access is True def test_semantic_search_does_not_auto_enable_in_single_user_mode(self): """Test ENABLE_SEMANTIC_SEARCH does NOT auto-enable background ops in single-user mode.""" with patch.dict( os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", "NEXTCLOUD_USERNAME": "admin", "NEXTCLOUD_PASSWORD": "password", "ENABLE_SEMANTIC_SEARCH": "true", "QDRANT_LOCATION": ":memory:", # Note: Username/password set = single-user BasicAuth mode }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() settings = get_settings() # Semantic search enabled assert settings.vector_sync_enabled is True # Background operations NOT auto-enabled (not needed in single-user mode) assert settings.enable_offline_access is False def test_explicit_background_ops_still_works(self): """Test explicitly setting ENABLE_BACKGROUND_OPERATIONS works even without semantic search.""" with patch.dict( os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", "ENABLE_BACKGROUND_OPERATIONS": "true", "TOKEN_ENCRYPTION_KEY": "test-key", "TOKEN_STORAGE_DB": "/tmp/test.db", # Note: No semantic search enabled }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() settings = get_settings() # Semantic search NOT enabled assert settings.vector_sync_enabled is False # Background operations explicitly enabled assert settings.enable_offline_access is True def test_both_old_and_new_semantic_search_names_prefers_new(self): """Test setting both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED uses new name.""" with patch.dict( os.environ, { "ENABLE_SEMANTIC_SEARCH": "true", "VECTOR_SYNC_ENABLED": "false", # Old name says false "QDRANT_LOCATION": ":memory:", }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() settings = get_settings() # Should use new name value (true) assert settings.vector_sync_enabled is True def test_both_old_and_new_background_ops_names_prefers_new(self): """Test setting both ENABLE_BACKGROUND_OPERATIONS and ENABLE_OFFLINE_ACCESS uses new name.""" with patch.dict( os.environ, { "ENABLE_BACKGROUND_OPERATIONS": "true", "ENABLE_OFFLINE_ACCESS": "false", # Old name says false "TOKEN_ENCRYPTION_KEY": "test-key", "TOKEN_STORAGE_DB": "/tmp/test.db", }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() settings = get_settings() # Should use new name value (true) assert settings.enable_offline_access is True def test_validation_no_longer_requires_both_variables(self): """Test validation no longer requires explicit ENABLE_OFFLINE_ACCESS when semantic search enabled.""" with patch.dict( os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", "MCP_DEPLOYMENT_MODE": "multi_user_basic", "ENABLE_SEMANTIC_SEARCH": "true", "QDRANT_LOCATION": ":memory:", "TOKEN_ENCRYPTION_KEY": "test-key", "TOKEN_STORAGE_DB": "/tmp/test.db", # OAuth credentials required for app password retrieval (when background ops enabled) "NEXTCLOUD_OIDC_CLIENT_ID": "test-client-id", "NEXTCLOUD_OIDC_CLIENT_SECRET": "test-client-secret", # Note: ENABLE_OFFLINE_ACCESS not set - should auto-enable }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() settings = get_settings() mode, errors = validate_configuration(settings) # Should have no validation errors # (Previously would have required explicit ENABLE_OFFLINE_ACCESS) assert len(errors) == 0 assert mode == AuthMode.MULTI_USER_BASIC # Verify background operations were auto-enabled assert settings.enable_offline_access is True def test_auto_enable_info_log_emitted_at_most_once(self, caplog): """Auto-enable INFO advisory must fire once per process, not per get_settings() call. Regression: `get_settings()` is non-cached and called per-request from `get_client()`, so unguarded `logger.info` calls in `_get_background_operations_enabled()` spammed every MCP tool invocation (observed: 569 entries/hour in tenant `tenant-e2e-disc-0033`). """ import logging with patch.dict( os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", "ENABLE_SEMANTIC_SEARCH": "true", "QDRANT_LOCATION": ":memory:", "TOKEN_ENCRYPTION_KEY": "test-key", "TOKEN_STORAGE_DB": "/tmp/test.db", # No NEXTCLOUD_USERNAME/PASSWORD → multi-user mode → auto-enable triggers }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() caplog.set_level(logging.INFO, logger="nextcloud_mcp_server.config") for _ in range(5): settings = get_settings() assert settings.enable_offline_access is True auto_enable_records = [ r for r in caplog.records if r.name == "nextcloud_mcp_server.config" and "Automatically enabled background operations" in r.message ] assert len(auto_enable_records) == 1, ( f"Expected exactly one auto-enable advisory log, " f"got {len(auto_enable_records)}: " f"{[r.message for r in auto_enable_records]}" ) def test_legacy_offline_access_deprecation_warning_emitted_at_most_once( self, caplog ): """Legacy `ENABLE_OFFLINE_ACCESS` deprecation WARNING is also one-shot.""" import logging with patch.dict( os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", "ENABLE_OFFLINE_ACCESS": "true", "TOKEN_ENCRYPTION_KEY": "test-key", "TOKEN_STORAGE_DB": "/tmp/test.db", }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config") for _ in range(5): get_settings() deprecation_records = [ r for r in caplog.records if r.name == "nextcloud_mcp_server.config" and "ENABLE_OFFLINE_ACCESS is deprecated" in r.message ] assert len(deprecation_records) == 1, ( f"Expected exactly one deprecation warning, " f"got {len(deprecation_records)}: " f"{[r.message for r in deprecation_records]}" ) class TestExplicitModeSelection: """Test ADR-021 explicit mode selection via MCP_DEPLOYMENT_MODE. Tests verify: - Explicit mode selection works for all modes - Invalid mode names raise ValueError - Explicit mode takes precedence over auto-detection """ def test_explicit_single_user_basic_mode(self): """Test explicit single_user_basic mode selection.""" with patch.dict( os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", "MCP_DEPLOYMENT_MODE": "single_user_basic", "NEXTCLOUD_USERNAME": "admin", "NEXTCLOUD_PASSWORD": "password", }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() settings = get_settings() mode = detect_auth_mode(settings) assert mode == AuthMode.SINGLE_USER_BASIC def test_explicit_multi_user_basic_mode(self): """Test explicit multi_user_basic mode selection.""" with patch.dict( os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", "MCP_DEPLOYMENT_MODE": "multi_user_basic", }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() settings = get_settings() mode = detect_auth_mode(settings) assert mode == AuthMode.MULTI_USER_BASIC def test_explicit_login_flow_mode(self): """Test explicit login_flow mode selection.""" with patch.dict( os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", "MCP_DEPLOYMENT_MODE": "login_flow", }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() settings = get_settings() mode = detect_auth_mode(settings) assert mode == AuthMode.LOGIN_FLOW def test_invalid_deployment_mode_raises_error(self): """Test invalid MCP_DEPLOYMENT_MODE raises ValueError.""" with patch.dict( os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", "MCP_DEPLOYMENT_MODE": "invalid_mode", }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() settings = get_settings() # Should raise ValueError with clear message try: detect_auth_mode(settings) assert False, "Should have raised ValueError" except ValueError as e: assert "Invalid MCP_DEPLOYMENT_MODE" in str(e) assert "invalid_mode" in str(e) assert "Valid values:" in str(e) def test_oauth_single_audience_migration_hint(self): """ADR-022: rejecting `oauth_single_audience` surfaces a rename hint. Pins the special-case branch in detect_auth_mode that helps users upgrading from ADR-021 configurations spot the rename without having to grep the changelog. """ with patch.dict( os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", "MCP_DEPLOYMENT_MODE": "oauth_single_audience", }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() settings = get_settings() with pytest.raises(ValueError) as exc: detect_auth_mode(settings) msg = str(exc.value) assert "oauth_single_audience" in msg assert "login_flow" in msg assert "ADR-022" in msg def test_explicit_mode_overrides_auto_detection(self): """Test explicit mode takes precedence over auto-detection.""" with patch.dict( os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", "NEXTCLOUD_USERNAME": "admin", # Would auto-detect as single_user_basic "NEXTCLOUD_PASSWORD": "password", "MCP_DEPLOYMENT_MODE": "login_flow", # Explicit override }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() settings = get_settings() mode = detect_auth_mode(settings) # Should use explicit mode, not auto-detected mode assert mode == AuthMode.LOGIN_FLOW def test_case_insensitive_mode_names(self): """Test MCP_DEPLOYMENT_MODE is case-insensitive.""" with patch.dict( os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", "MCP_DEPLOYMENT_MODE": "LOGIN_FLOW", # Uppercase }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() settings = get_settings() mode = detect_auth_mode(settings) assert mode == AuthMode.LOGIN_FLOW def test_whitespace_in_mode_name_stripped(self): """Test whitespace in MCP_DEPLOYMENT_MODE is stripped.""" with patch.dict( os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", "MCP_DEPLOYMENT_MODE": " login_flow ", # Whitespace }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() settings = get_settings() mode = detect_auth_mode(settings) assert mode == AuthMode.LOGIN_FLOW def test_legacy_enable_multi_user_basic_auth_env_var_errors(self): """ADR-022 follow-up: ENABLE_MULTI_USER_BASIC_AUTH=true must fail loudly. The env-var alias was removed; users must migrate to `MCP_DEPLOYMENT_MODE=multi_user_basic`. Silent removal would have switched users to LOGIN_FLOW (the default) — wrong runtime mode. The check lives in Settings.__post_init__ so it fires at config load time, not only when detect_auth_mode is reached. """ with patch.dict( os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", "ENABLE_MULTI_USER_BASIC_AUTH": "true", }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() with pytest.raises(ValueError) as exc: get_settings() assert "ENABLE_MULTI_USER_BASIC_AUTH" in str(exc.value) assert "multi_user_basic" in str(exc.value) def test_legacy_enable_login_flow_env_var_errors(self): """ADR-022 follow-up: ENABLE_LOGIN_FLOW=true must fail loudly. Mirrors the ENABLE_MULTI_USER_BASIC_AUTH check — both legacy aliases now error with a one-line migration message at Settings construction. """ with patch.dict( os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", "ENABLE_LOGIN_FLOW": "true", }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() with pytest.raises(ValueError) as exc: get_settings() assert "ENABLE_LOGIN_FLOW" in str(exc.value) assert "login_flow" in str(exc.value) def test_legacy_env_var_check_ignores_falsy_strings(self): """ADR-022 follow-up: a leftover ENABLE_LOGIN_FLOW=false must NOT error. Reviewer round 2 found that the legacy check used `os.getenv(legacy)` which is truthy for the literal string 'false'. A user who had explicitly set the flag to false (meaning 'I don't want this') would get a startup ValueError after upgrading, which is wrong. The fix only fires for explicitly-truthy values. """ with patch.dict( os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", "ENABLE_LOGIN_FLOW": "false", "ENABLE_MULTI_USER_BASIC_AUTH": "0", }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() settings = get_settings() # Must not raise. # Falls through to LOGIN_FLOW (the auto-detect default) since # no credentials are set. assert detect_auth_mode(settings) == AuthMode.LOGIN_FLOW def test_derived_flags_stable_across_get_settings_calls(self): """Regression: derived flags must persist across get_settings() calls. `get_settings()` builds a fresh Settings on each invocation. Earlier commits set the derived flags as a side effect of detect_auth_mode, which meant the *next* get_settings() call started with default False — breaking per-request handlers in the integration tests for `mcp-multi-user-basic` and `mcp-login-flow`. The fix moves the derivation into Settings.__post_init__ so every instance carries correct flags. """ with patch.dict( os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", "MCP_DEPLOYMENT_MODE": "multi_user_basic", }, clear=True, ): from nextcloud_mcp_server.config import get_settings _reload_config() s1 = get_settings() s2 = get_settings() assert s1 is not s2 # fresh instance each call assert s1.enable_multi_user_basic_auth is True assert s2.enable_multi_user_basic_auth is True assert s1.enable_login_flow is False assert s2.enable_login_flow is False