Consolidate three independent RefreshTokenStorage lazy singletons into a single lock-protected get_shared_storage() function, eliminating race conditions on concurrent first-access. Remove blanket try/except in _get_stored_scopes so storage errors propagate as proper MCP errors instead of silently triggering "please provision" messages. Handle declined/cancelled elicitation results in Login Flow tools by cleaning up sessions and returning clear status. Add update_app_password_scopes() to avoid unnecessary decrypt/re-encrypt when only scopes change. Add unprovisioned-user early exit and no-op detection to nc_auth_update_scopes. Remove four dead config fields and misleading NEXTCLOUD_PASSWORD deprecation warning. Add periodic login flow session cleanup task. Generate separate Fernet keys per service. Add board cleanup in deck integration test. Gate CI unit tests on linting and skip Astrolabe build for single-user profile. Fix test markers from oauth to multi_user_basic for astrolabe integration tests. Update login_flow.py docstrings to document outbound HTTP calls. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
85 lines
2.4 KiB
Python
85 lines
2.4 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,
|
|
)
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
|
|
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")
|