feat: add Docker Compose profiles and Login Flow v2 service
Add selective service startup via Docker Compose profiles so each MCP deployment mode runs independently. Also add the new mcp-login-flow service (port 8004) for Login Flow v2 authentication (ADR-022). Profile assignments: - single-user: mcp (port 8000) - multi-user-basic: mcp-multi-user-basic (port 8003) - oauth: mcp-oauth (port 8001) - keycloak: keycloak + mcp-keycloak (port 8002) - login-flow: mcp-login-flow (port 8004) Infrastructure services (db, redis, app, recipes) always start. Integration tests cover the full Login Flow v2 provisioning flow: OAuth → browser login → app password → Nextcloud API access for notes, calendar, contacts, files, deck, and cookbook operations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
5796e2ba54
commit
8b5c2395b5
@@ -0,0 +1,198 @@
|
||||
"""Unit tests for Login Flow v2 MCP auth tools.
|
||||
|
||||
Tests the auth tools logic with mocked storage and Login Flow client.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.models.auth import ALL_SUPPORTED_SCOPES
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def encryption_key():
|
||||
"""Generate a test encryption key."""
|
||||
return Fernet.generate_key().decode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temp_storage(encryption_key):
|
||||
"""Create temporary storage with encryption for testing."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test_auth_tools.db"
|
||||
storage = RefreshTokenStorage(
|
||||
db_path=str(db_path), encryption_key=encryption_key
|
||||
)
|
||||
await storage.initialize()
|
||||
yield storage
|
||||
|
||||
|
||||
async def test_store_app_password_with_scopes(temp_storage):
|
||||
"""Test storing app password with scopes."""
|
||||
await temp_storage.store_app_password_with_scopes(
|
||||
user_id="alice",
|
||||
app_password="aaaaa-bbbbb-ccccc-ddddd-eeeee",
|
||||
scopes=["notes:read", "notes:write"],
|
||||
username="alice_nc",
|
||||
)
|
||||
|
||||
data = await temp_storage.get_app_password_with_scopes("alice")
|
||||
assert data is not None
|
||||
assert data["app_password"] == "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
assert data["scopes"] == ["notes:read", "notes:write"]
|
||||
assert data["username"] == "alice_nc"
|
||||
assert data["created_at"] is not None
|
||||
assert data["updated_at"] is not None
|
||||
|
||||
|
||||
async def test_store_app_password_null_scopes(temp_storage):
|
||||
"""Test storing app password with NULL scopes (all allowed)."""
|
||||
await temp_storage.store_app_password_with_scopes(
|
||||
user_id="bob",
|
||||
app_password="fffff-ggggg-hhhhh-iiiii-jjjjj",
|
||||
scopes=None,
|
||||
)
|
||||
|
||||
data = await temp_storage.get_app_password_with_scopes("bob")
|
||||
assert data is not None
|
||||
assert data["scopes"] is None # NULL = all scopes allowed
|
||||
assert data["username"] is None
|
||||
|
||||
|
||||
async def test_store_app_password_with_scopes_replaces(temp_storage):
|
||||
"""Test that storing replaces existing record."""
|
||||
await temp_storage.store_app_password_with_scopes(
|
||||
user_id="alice",
|
||||
app_password="aaaaa-bbbbb-ccccc-ddddd-eeeee",
|
||||
scopes=["notes:read"],
|
||||
)
|
||||
await temp_storage.store_app_password_with_scopes(
|
||||
user_id="alice",
|
||||
app_password="xxxxx-yyyyy-zzzzz-aaaaa-bbbbb",
|
||||
scopes=["notes:read", "calendar:read"],
|
||||
username="alice_nc",
|
||||
)
|
||||
|
||||
data = await temp_storage.get_app_password_with_scopes("alice")
|
||||
assert data["app_password"] == "xxxxx-yyyyy-zzzzz-aaaaa-bbbbb"
|
||||
assert data["scopes"] == ["notes:read", "calendar:read"]
|
||||
|
||||
|
||||
async def test_get_app_password_with_scopes_nonexistent(temp_storage):
|
||||
"""Test getting scoped password for non-existent user."""
|
||||
data = await temp_storage.get_app_password_with_scopes("nonexistent")
|
||||
assert data is None
|
||||
|
||||
|
||||
# ── Login Flow Session Tests ──
|
||||
|
||||
|
||||
async def test_store_and_get_login_flow_session(temp_storage):
|
||||
"""Test storing and retrieving a login flow session."""
|
||||
await temp_storage.store_login_flow_session(
|
||||
user_id="alice",
|
||||
poll_token="secret-poll-token",
|
||||
poll_endpoint="https://cloud.example.com/login/v2/poll",
|
||||
requested_scopes=["notes:read", "notes:write"],
|
||||
)
|
||||
|
||||
session = await temp_storage.get_login_flow_session("alice")
|
||||
assert session is not None
|
||||
assert session["poll_token"] == "secret-poll-token"
|
||||
assert session["poll_endpoint"] == "https://cloud.example.com/login/v2/poll"
|
||||
assert session["requested_scopes"] == ["notes:read", "notes:write"]
|
||||
assert session["created_at"] is not None
|
||||
assert session["expires_at"] is not None
|
||||
|
||||
|
||||
async def test_get_login_flow_session_nonexistent(temp_storage):
|
||||
"""Test getting session for user with no pending flow."""
|
||||
session = await temp_storage.get_login_flow_session("nonexistent")
|
||||
assert session is None
|
||||
|
||||
|
||||
async def test_get_login_flow_session_expired(temp_storage):
|
||||
"""Test that expired sessions are not returned."""
|
||||
await temp_storage.store_login_flow_session(
|
||||
user_id="alice",
|
||||
poll_token="expired-token",
|
||||
poll_endpoint="https://cloud.example.com/login/v2/poll",
|
||||
expires_at=1, # Expired long ago
|
||||
)
|
||||
|
||||
session = await temp_storage.get_login_flow_session("alice")
|
||||
assert session is None
|
||||
|
||||
|
||||
async def test_delete_login_flow_session(temp_storage):
|
||||
"""Test deleting a login flow session."""
|
||||
await temp_storage.store_login_flow_session(
|
||||
user_id="alice",
|
||||
poll_token="token",
|
||||
poll_endpoint="https://cloud.example.com/poll",
|
||||
)
|
||||
|
||||
deleted = await temp_storage.delete_login_flow_session("alice")
|
||||
assert deleted is True
|
||||
|
||||
# Verify it's gone
|
||||
session = await temp_storage.get_login_flow_session("alice")
|
||||
assert session is None
|
||||
|
||||
|
||||
async def test_delete_login_flow_session_nonexistent(temp_storage):
|
||||
"""Test deleting a non-existent session returns False."""
|
||||
deleted = await temp_storage.delete_login_flow_session("nonexistent")
|
||||
assert deleted is False
|
||||
|
||||
|
||||
async def test_delete_expired_login_flow_sessions(temp_storage):
|
||||
"""Test cleanup of expired sessions."""
|
||||
# Store 2 expired and 1 valid session
|
||||
await temp_storage.store_login_flow_session(
|
||||
user_id="expired1",
|
||||
poll_token="t1",
|
||||
poll_endpoint="https://cloud.example.com/poll",
|
||||
expires_at=1,
|
||||
)
|
||||
await temp_storage.store_login_flow_session(
|
||||
user_id="expired2",
|
||||
poll_token="t2",
|
||||
poll_endpoint="https://cloud.example.com/poll",
|
||||
expires_at=2,
|
||||
)
|
||||
await temp_storage.store_login_flow_session(
|
||||
user_id="valid",
|
||||
poll_token="t3",
|
||||
poll_endpoint="https://cloud.example.com/poll",
|
||||
# Default expiry = 20 minutes from now
|
||||
)
|
||||
|
||||
count = await temp_storage.delete_expired_login_flow_sessions()
|
||||
assert count == 2
|
||||
|
||||
# Valid session should still exist
|
||||
session = await temp_storage.get_login_flow_session("valid")
|
||||
assert session is not None
|
||||
|
||||
|
||||
# ── Response Model Tests ──
|
||||
|
||||
|
||||
def test_all_supported_scopes():
|
||||
"""Test that ALL_SUPPORTED_SCOPES contains expected scopes."""
|
||||
assert "notes:read" in ALL_SUPPORTED_SCOPES
|
||||
assert "notes:write" in ALL_SUPPORTED_SCOPES
|
||||
assert "calendar:read" in ALL_SUPPORTED_SCOPES
|
||||
assert "files:read" in ALL_SUPPORTED_SCOPES
|
||||
assert "deck:read" in ALL_SUPPORTED_SCOPES
|
||||
# Scopes should be in pairs (read/write)
|
||||
read_scopes = [s for s in ALL_SUPPORTED_SCOPES if s.endswith(":read")]
|
||||
write_scopes = [s for s in ALL_SUPPORTED_SCOPES if s.endswith(":write")]
|
||||
assert len(read_scopes) == len(write_scopes)
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Unit tests for Login Flow v2 HTTP client.
|
||||
|
||||
Tests the LoginFlowV2Client with mocked HTTP responses for:
|
||||
- Flow initiation (POST /index.php/login/v2)
|
||||
- Flow polling (completed, pending, expired)
|
||||
- Error handling
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.auth.login_flow import (
|
||||
LoginFlowInitResponse,
|
||||
LoginFlowPollResult,
|
||||
LoginFlowV2Client,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flow_client():
|
||||
"""Create a LoginFlowV2Client for testing."""
|
||||
return LoginFlowV2Client(
|
||||
nextcloud_host="https://cloud.example.com",
|
||||
verify_ssl=False,
|
||||
)
|
||||
|
||||
|
||||
def _mock_response(status_code: int, json_data: dict) -> MagicMock:
|
||||
"""Create a mock httpx response."""
|
||||
response = MagicMock()
|
||||
response.status_code = status_code
|
||||
response.json.return_value = json_data
|
||||
response.raise_for_status = MagicMock()
|
||||
if status_code >= 400:
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
response.raise_for_status.side_effect = HTTPStatusError(
|
||||
"error", request=MagicMock(), response=response
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
async def test_initiate_success(flow_client):
|
||||
"""Test successful Login Flow v2 initiation."""
|
||||
mock_response = _mock_response(
|
||||
200,
|
||||
{
|
||||
"login": "https://cloud.example.com/login/v2/grant?token=abc123",
|
||||
"poll": {
|
||||
"endpoint": "https://cloud.example.com/login/v2/poll",
|
||||
"token": "secret-poll-token",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.return_value = mock_response
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.login_flow.nextcloud_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = await flow_client.initiate()
|
||||
|
||||
assert isinstance(result, LoginFlowInitResponse)
|
||||
assert result.login_url == "https://cloud.example.com/login/v2/grant?token=abc123"
|
||||
assert result.poll_endpoint == "https://cloud.example.com/login/v2/poll"
|
||||
assert result.poll_token == "secret-poll-token"
|
||||
|
||||
|
||||
async def test_poll_completed(flow_client):
|
||||
"""Test polling when user has completed login."""
|
||||
mock_response = _mock_response(
|
||||
200,
|
||||
{
|
||||
"server": "https://cloud.example.com",
|
||||
"loginName": "alice",
|
||||
"appPassword": "aaaaa-bbbbb-ccccc-ddddd-eeeee",
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.return_value = mock_response
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.login_flow.nextcloud_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = await flow_client.poll(
|
||||
poll_endpoint="https://cloud.example.com/login/v2/poll",
|
||||
poll_token="secret-poll-token",
|
||||
)
|
||||
|
||||
assert isinstance(result, LoginFlowPollResult)
|
||||
assert result.status == "completed"
|
||||
assert result.server == "https://cloud.example.com"
|
||||
assert result.login_name == "alice"
|
||||
assert result.app_password == "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
|
||||
|
||||
async def test_poll_pending(flow_client):
|
||||
"""Test polling when login is still pending."""
|
||||
mock_response = _mock_response(404, {})
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.return_value = mock_response
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.login_flow.nextcloud_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = await flow_client.poll(
|
||||
poll_endpoint="https://cloud.example.com/login/v2/poll",
|
||||
poll_token="secret-poll-token",
|
||||
)
|
||||
|
||||
assert result.status == "pending"
|
||||
assert result.server is None
|
||||
assert result.app_password is None
|
||||
|
||||
|
||||
async def test_poll_expired(flow_client):
|
||||
"""Test polling when flow has expired."""
|
||||
mock_response = _mock_response(403, {})
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.return_value = mock_response
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.login_flow.nextcloud_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = await flow_client.poll(
|
||||
poll_endpoint="https://cloud.example.com/login/v2/poll",
|
||||
poll_token="expired-token",
|
||||
)
|
||||
|
||||
assert result.status == "expired"
|
||||
assert result.app_password is None
|
||||
|
||||
|
||||
async def test_initiate_with_custom_user_agent(flow_client):
|
||||
"""Test that custom user agent is passed in the request."""
|
||||
mock_response = _mock_response(
|
||||
200,
|
||||
{
|
||||
"login": "https://cloud.example.com/login/v2/grant?token=abc",
|
||||
"poll": {
|
||||
"endpoint": "https://cloud.example.com/login/v2/poll",
|
||||
"token": "tok",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.return_value = mock_response
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.login_flow.nextcloud_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
await flow_client.initiate(user_agent="my-custom-agent")
|
||||
|
||||
# Verify the user agent was passed
|
||||
call_kwargs = mock_client.post.call_args
|
||||
assert call_kwargs.kwargs["headers"]["User-Agent"] == "my-custom-agent"
|
||||
|
||||
|
||||
async def test_login_flow_init_response_model():
|
||||
"""Test LoginFlowInitResponse Pydantic model validation."""
|
||||
resp = LoginFlowInitResponse(
|
||||
login_url="https://cloud.example.com/login",
|
||||
poll_endpoint="https://cloud.example.com/poll",
|
||||
poll_token="token123",
|
||||
)
|
||||
assert resp.login_url == "https://cloud.example.com/login"
|
||||
assert resp.poll_endpoint == "https://cloud.example.com/poll"
|
||||
assert resp.poll_token == "token123"
|
||||
|
||||
|
||||
async def test_login_flow_poll_result_model():
|
||||
"""Test LoginFlowPollResult Pydantic model validation."""
|
||||
# Completed result
|
||||
completed = LoginFlowPollResult(
|
||||
status="completed",
|
||||
server="https://cloud.example.com",
|
||||
login_name="bob",
|
||||
app_password="xxxxx-yyyyy-zzzzz-aaaaa-bbbbb",
|
||||
)
|
||||
assert completed.status == "completed"
|
||||
assert completed.login_name == "bob"
|
||||
|
||||
# Pending result
|
||||
pending = LoginFlowPollResult(status="pending")
|
||||
assert pending.status == "pending"
|
||||
assert pending.server is None
|
||||
assert pending.app_password is None
|
||||
@@ -0,0 +1,110 @@
|
||||
"""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.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.auth.scope_authorization import (
|
||||
_get_stored_scopes,
|
||||
_is_login_flow_mode,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_is_login_flow_mode_disabled():
|
||||
"""Test that login flow mode is off by default."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
assert _is_login_flow_mode() is False
|
||||
|
||||
|
||||
def test_is_login_flow_mode_enabled():
|
||||
"""Test that login flow mode is enabled when env var is set."""
|
||||
with patch.dict(os.environ, {"ENABLE_LOGIN_FLOW": "true"}):
|
||||
assert _is_login_flow_mode() is True
|
||||
|
||||
|
||||
def test_is_login_flow_mode_case_insensitive():
|
||||
"""Test case insensitivity of the env var."""
|
||||
with patch.dict(os.environ, {"ENABLE_LOGIN_FLOW": "True"}):
|
||||
assert _is_login_flow_mode() is True
|
||||
with patch.dict(os.environ, {"ENABLE_LOGIN_FLOW": "TRUE"}):
|
||||
assert _is_login_flow_mode() is True
|
||||
with patch.dict(os.environ, {"ENABLE_LOGIN_FLOW": "false"}):
|
||||
assert _is_login_flow_mode() is False
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
mock_storage.initialize = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.storage.RefreshTokenStorage.from_env",
|
||||
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,
|
||||
}
|
||||
mock_storage.initialize = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.storage.RefreshTokenStorage.from_env",
|
||||
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
|
||||
mock_storage.initialize = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.storage.RefreshTokenStorage.from_env",
|
||||
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 return None (fail-closed)."""
|
||||
mock_storage = AsyncMock()
|
||||
mock_storage.initialize.side_effect = RuntimeError("DB error")
|
||||
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.storage.RefreshTokenStorage.from_env",
|
||||
return_value=mock_storage,
|
||||
):
|
||||
result = await _get_stored_scopes("alice")
|
||||
|
||||
assert result is None
|
||||
Reference in New Issue
Block a user