"""
Unit tests for hybrid authentication mode OAuth setup.
Tests the setup_oauth_config_for_multi_user_basic() function that enables
hybrid authentication where MCP operations use BasicAuth and management
APIs use OAuth.
"""
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from nextcloud_mcp_server.app import setup_oauth_config_for_multi_user_basic
from nextcloud_mcp_server.config import Settings
pytestmark = pytest.mark.unit
@pytest.fixture
def hybrid_auth_settings():
"""Create settings for hybrid auth mode testing."""
return Settings(
nextcloud_host="https://nextcloud.example.com",
enable_offline_access=False, # Start with offline access disabled
)
@pytest.fixture
def oidc_discovery_response():
"""Mock OIDC discovery endpoint response."""
return {
"issuer": "https://nextcloud.example.com",
"authorization_endpoint": "https://nextcloud.example.com/apps/oidc/authorize",
"token_endpoint": "https://nextcloud.example.com/apps/oidc/token",
"userinfo_endpoint": "https://nextcloud.example.com/apps/oidc/userinfo",
"jwks_uri": "https://nextcloud.example.com/apps/oidc/jwks",
"introspection_endpoint": "https://nextcloud.example.com/apps/oidc/introspect",
"registration_endpoint": "https://nextcloud.example.com/apps/oidc/register",
"scopes_supported": ["openid", "profile", "email", "offline_access"],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
}
class TestSetupOAuthConfigForMultiUserBasic:
"""Test setup_oauth_config_for_multi_user_basic() function."""
async def test_successful_setup_without_offline_access(
self, hybrid_auth_settings, oidc_discovery_response, mocker
):
"""Test successful OAuth setup without offline access."""
# Mock httpx.AsyncClient
mock_response = MagicMock()
mock_response.json = MagicMock(return_value=oidc_discovery_response)
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = AsyncMock()
mocker.patch("httpx.AsyncClient", return_value=mock_client)
# Call function
(
verifier,
storage,
client_id,
client_secret,
) = await setup_oauth_config_for_multi_user_basic(
settings=hybrid_auth_settings,
client_id="test-client-id",
client_secret="test-client-secret",
)
# Verify OIDC discovery was called
mock_client.get.assert_called_once_with(
"https://nextcloud.example.com/.well-known/openid-configuration"
)
# Verify settings were updated
assert hybrid_auth_settings.oidc_client_id == "test-client-id"
assert hybrid_auth_settings.oidc_client_secret == "test-client-secret"
assert hybrid_auth_settings.oidc_issuer == "https://nextcloud.example.com"
assert (
hybrid_auth_settings.jwks_uri
== "https://nextcloud.example.com/apps/oidc/jwks"
)
assert (
hybrid_auth_settings.introspection_uri
== "https://nextcloud.example.com/apps/oidc/introspect"
)
assert (
hybrid_auth_settings.userinfo_uri
== "https://nextcloud.example.com/apps/oidc/userinfo"
)
# Verify token verifier was created
assert verifier is not None
from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
assert isinstance(verifier, UnifiedTokenVerifier)
# Verify storage is None (offline access disabled)
assert storage is None
# Verify credentials returned
assert client_id == "test-client-id"
assert client_secret == "test-client-secret"
async def test_successful_setup_with_offline_access(
self, hybrid_auth_settings, oidc_discovery_response, mocker
):
"""Test successful OAuth setup with offline access enabled."""
# Enable offline access
hybrid_auth_settings.enable_offline_access = True
# Generate a valid Fernet key for testing
from cryptography.fernet import Fernet
valid_fernet_key = Fernet.generate_key().decode()
# Mock TOKEN_ENCRYPTION_KEY environment variable
mocker.patch(
"os.getenv",
side_effect=lambda k, default=None: {
"TOKEN_ENCRYPTION_KEY": valid_fernet_key,
"NEXTCLOUD_MCP_SERVER_URL": "http://localhost:8000",
}.get(k, default),
)
# Mock httpx.AsyncClient
mock_response = MagicMock()
mock_response.json = MagicMock(return_value=oidc_discovery_response)
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = AsyncMock()
mocker.patch("httpx.AsyncClient", return_value=mock_client)
# Call function
(
verifier,
storage,
client_id,
client_secret,
) = await setup_oauth_config_for_multi_user_basic(
settings=hybrid_auth_settings,
client_id="test-client-id",
client_secret="test-client-secret",
)
# Verify storage was created
assert storage is not None
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
assert isinstance(storage, RefreshTokenStorage)
async def test_discovered_urls_used_directly(
self, hybrid_auth_settings, oidc_discovery_response, mocker
):
"""Test that discovered URLs are used directly without rewriting."""
# Mock httpx.AsyncClient
mock_response = MagicMock()
mock_response.json = MagicMock(return_value=oidc_discovery_response)
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = AsyncMock()
mocker.patch("httpx.AsyncClient", return_value=mock_client)
# Call function
(
verifier,
storage,
client_id,
client_secret,
) = await setup_oauth_config_for_multi_user_basic(
settings=hybrid_auth_settings,
client_id="test-client-id",
client_secret="test-client-secret",
)
# Verify discovered URLs are used directly (not rewritten)
assert hybrid_auth_settings.jwks_uri == oidc_discovery_response["jwks_uri"]
assert (
hybrid_auth_settings.introspection_uri
== oidc_discovery_response["introspection_endpoint"]
)
assert (
hybrid_auth_settings.userinfo_uri
== oidc_discovery_response["userinfo_endpoint"]
)
# Verify issuer is used directly for JWT validation
assert hybrid_auth_settings.oidc_issuer == oidc_discovery_response["issuer"]
async def test_oidc_discovery_failure_http_error(
self, hybrid_auth_settings, mocker
):
"""Test handling of OIDC discovery HTTP errors."""
# Create a mock response with a status error
mock_response = MagicMock()
mock_response.status_code = 404
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Not Found",
request=MagicMock(),
response=MagicMock(status_code=404),
)
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__.return_value = mock_client
# Return None to propagate exceptions (not suppress them)
mock_client.__aexit__ = AsyncMock(return_value=None)
mocker.patch("httpx.AsyncClient", return_value=mock_client)
# Should raise ValueError with helpful message (not UnboundLocalError)
with pytest.raises(ValueError, match="OIDC discovery failed"):
await setup_oauth_config_for_multi_user_basic(
settings=hybrid_auth_settings,
client_id="test-client-id",
client_secret="test-client-secret",
)
async def test_oidc_discovery_failure_connection_error(
self, hybrid_auth_settings, mocker
):
"""Test handling of OIDC discovery connection errors."""
import httpx
mock_client = AsyncMock()
mock_client.get = AsyncMock(
side_effect=httpx.ConnectError("Connection refused")
)
mock_client.__aenter__.return_value = mock_client
# Return None to propagate exceptions (not suppress them)
mock_client.__aexit__ = AsyncMock(return_value=None)
mocker.patch("httpx.AsyncClient", return_value=mock_client)
# Should raise ValueError with helpful message
with pytest.raises(ValueError, match="Cannot connect to"):
await setup_oauth_config_for_multi_user_basic(
settings=hybrid_auth_settings,
client_id="test-client-id",
client_secret="test-client-secret",
)
async def test_missing_nextcloud_host(self):
"""Test that missing NEXTCLOUD_HOST raises ValueError."""
settings = Settings() # No nextcloud_host set
with pytest.raises(ValueError, match="NEXTCLOUD_HOST is required"):
await setup_oauth_config_for_multi_user_basic(
settings=settings,
client_id="test-client-id",
client_secret="test-client-secret",
)
async def test_custom_discovery_url(
self, hybrid_auth_settings, oidc_discovery_response, mocker
):
"""Test using custom OIDC discovery URL."""
# Mock OIDC_DISCOVERY_URL environment variable
custom_discovery_url = (
"https://custom.idp.example.com/.well-known/openid-configuration"
)
mocker.patch(
"os.getenv",
side_effect=lambda k, default=None: {
"OIDC_DISCOVERY_URL": custom_discovery_url,
"NEXTCLOUD_MCP_SERVER_URL": "http://localhost:8000",
}.get(k, default),
)
# Mock httpx.AsyncClient
mock_response = MagicMock()
mock_response.json = MagicMock(return_value=oidc_discovery_response)
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = AsyncMock()
mocker.patch("httpx.AsyncClient", return_value=mock_client)
# Call function
await setup_oauth_config_for_multi_user_basic(
settings=hybrid_auth_settings,
client_id="test-client-id",
client_secret="test-client-secret",
)
# Verify custom discovery URL was used
mock_client.get.assert_called_once_with(custom_discovery_url)