Many identity providers (AWS Cognito, Okta, Azure AD) reject or mishandle colons in OAuth scope names. This migrates all custom scopes from `resource:action` to `resource.action` format (e.g., `notes:read` → `notes.read`), which is universally accepted and aligns with industry conventions (Microsoft, Google). Includes Alembic migration 004 for stored scope strings and ADR-024 documenting the rationale and RFC references. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
94 lines
2.6 KiB
Python
94 lines
2.6 KiB
Python
"""Unit tests for @require_scopes with stored app passwords (Login Flow v2).
|
|
|
|
Tests the third enforcement mode in scope_authorization.py that checks
|
|
application-level scopes stored alongside app passwords.
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from nextcloud_mcp_server.auth.scope_authorization import (
|
|
_get_stored_scopes,
|
|
_scope_cache,
|
|
)
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_scope_cache():
|
|
"""Clear scope cache before each test."""
|
|
_scope_cache.clear()
|
|
yield
|
|
_scope_cache.clear()
|
|
|
|
|
|
async def test_get_stored_scopes_with_scopes():
|
|
"""Test getting specific scopes from storage."""
|
|
mock_storage = AsyncMock()
|
|
mock_storage.get_app_password_with_scopes.return_value = {
|
|
"app_password": "xxxxx",
|
|
"scopes": ["notes.read", "calendar.read"],
|
|
"username": "alice",
|
|
"created_at": 1000,
|
|
"updated_at": 1000,
|
|
}
|
|
|
|
with patch(
|
|
"nextcloud_mcp_server.auth.scope_authorization.get_shared_storage",
|
|
return_value=mock_storage,
|
|
):
|
|
result = await _get_stored_scopes("alice")
|
|
|
|
assert result == ["notes.read", "calendar.read"]
|
|
|
|
|
|
async def test_get_stored_scopes_null_scopes():
|
|
"""Test that NULL scopes returns 'all'."""
|
|
mock_storage = AsyncMock()
|
|
mock_storage.get_app_password_with_scopes.return_value = {
|
|
"app_password": "xxxxx",
|
|
"scopes": None,
|
|
"username": "bob",
|
|
"created_at": 1000,
|
|
"updated_at": 1000,
|
|
}
|
|
|
|
with patch(
|
|
"nextcloud_mcp_server.auth.scope_authorization.get_shared_storage",
|
|
return_value=mock_storage,
|
|
):
|
|
result = await _get_stored_scopes("bob")
|
|
|
|
assert result == "all"
|
|
|
|
|
|
async def test_get_stored_scopes_no_password():
|
|
"""Test that missing app password returns None."""
|
|
mock_storage = AsyncMock()
|
|
mock_storage.get_app_password_with_scopes.return_value = None
|
|
|
|
with patch(
|
|
"nextcloud_mcp_server.auth.scope_authorization.get_shared_storage",
|
|
return_value=mock_storage,
|
|
):
|
|
result = await _get_stored_scopes("nobody")
|
|
|
|
assert result is None
|
|
|
|
|
|
async def test_get_stored_scopes_storage_error():
|
|
"""Test that storage errors propagate to the caller."""
|
|
mock_storage = AsyncMock()
|
|
mock_storage.get_app_password_with_scopes.side_effect = RuntimeError("DB error")
|
|
|
|
with (
|
|
patch(
|
|
"nextcloud_mcp_server.auth.scope_authorization.get_shared_storage",
|
|
return_value=mock_storage,
|
|
),
|
|
pytest.raises(RuntimeError, match="DB error"),
|
|
):
|
|
await _get_stored_scopes("alice")
|