Merge pull request #589 from cbcoutinho/feat/docker-compose-profiles-login-flow
feat: Docker Compose profiles and Login Flow v2 integration tests
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
"""Unit tests for access.py REST API endpoints.
|
||||
|
||||
Tests the REST API endpoints for user access and scope management:
|
||||
- GET /api/v1/users/{user_id}/access - Get user's provisioned access and scopes
|
||||
- PATCH /api/v1/users/{user_id}/scopes - Update user's application-level scopes
|
||||
- GET /api/v1/scopes - List all supported scopes
|
||||
"""
|
||||
|
||||
import base64
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from nextcloud_mcp_server.api.access import (
|
||||
get_user_access,
|
||||
list_supported_scopes,
|
||||
update_user_scopes,
|
||||
)
|
||||
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 instance with encryption for testing."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test_access.db"
|
||||
storage = RefreshTokenStorage(
|
||||
db_path=str(db_path), encryption_key=encryption_key
|
||||
)
|
||||
await storage.initialize()
|
||||
yield storage
|
||||
|
||||
|
||||
def create_basic_auth_header(username: str, password: str) -> str:
|
||||
"""Create BasicAuth header value."""
|
||||
credentials = f"{username}:{password}"
|
||||
encoded = base64.b64encode(credentials.encode()).decode()
|
||||
return f"Basic {encoded}"
|
||||
|
||||
|
||||
def create_test_app(storage):
|
||||
"""Create a test Starlette app with the access endpoints."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/access",
|
||||
get_user_access,
|
||||
methods=["GET"],
|
||||
),
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/scopes",
|
||||
update_user_scopes,
|
||||
methods=["PATCH"],
|
||||
),
|
||||
Route(
|
||||
"/api/v1/scopes",
|
||||
list_supported_scopes,
|
||||
methods=["GET"],
|
||||
),
|
||||
],
|
||||
)
|
||||
app.state.storage = storage
|
||||
return app
|
||||
|
||||
|
||||
class TestGetUserAccess:
|
||||
"""Tests for GET /api/v1/users/{user_id}/access."""
|
||||
|
||||
async def test_not_provisioned(self, temp_storage):
|
||||
"""Returns provisioned=False when no app password stored."""
|
||||
app = create_test_app(temp_storage)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get(
|
||||
"/api/v1/users/alice/access",
|
||||
headers={"Authorization": create_basic_auth_header("alice", "pw")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
assert data["provisioned"] is False
|
||||
assert data["scopes"] is None
|
||||
|
||||
async def test_provisioned_with_scopes(self, temp_storage):
|
||||
"""Returns provisioned=True with scopes when app password exists."""
|
||||
await temp_storage.store_app_password_with_scopes(
|
||||
user_id="alice",
|
||||
app_password="test-app-pw",
|
||||
scopes=["notes:read", "calendar:write"],
|
||||
username="alice_nc",
|
||||
)
|
||||
|
||||
app = create_test_app(temp_storage)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get(
|
||||
"/api/v1/users/alice/access",
|
||||
headers={"Authorization": create_basic_auth_header("alice", "pw")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
assert data["provisioned"] is True
|
||||
assert set(data["scopes"]) == {"notes:read", "calendar:write"}
|
||||
assert data["username"] == "alice_nc"
|
||||
|
||||
async def test_missing_auth_header(self, temp_storage):
|
||||
"""Returns 401 when no Authorization header."""
|
||||
app = create_test_app(temp_storage)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get("/api/v1/users/alice/access")
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_user_id_mismatch(self, temp_storage):
|
||||
"""Returns 403 when path user_id doesn't match auth credentials."""
|
||||
app = create_test_app(temp_storage)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get(
|
||||
"/api/v1/users/alice/access",
|
||||
headers={"Authorization": create_basic_auth_header("bob", "pw")},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestUpdateUserScopes:
|
||||
"""Tests for PATCH /api/v1/users/{user_id}/scopes."""
|
||||
|
||||
async def test_update_valid_scopes(self, temp_storage):
|
||||
"""Successfully updates scopes for a provisioned user."""
|
||||
await temp_storage.store_app_password_with_scopes(
|
||||
user_id="alice",
|
||||
app_password="test-app-pw",
|
||||
scopes=["notes:read"],
|
||||
username="alice_nc",
|
||||
)
|
||||
|
||||
app = create_test_app(temp_storage)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.patch(
|
||||
"/api/v1/users/alice/scopes",
|
||||
headers={"Authorization": create_basic_auth_header("alice", "pw")},
|
||||
json={"scopes": ["notes:read", "notes:write", "calendar:read"]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
assert set(data["scopes"]) == {"notes:read", "notes:write", "calendar:read"}
|
||||
|
||||
async def test_invalid_scopes(self, temp_storage):
|
||||
"""Returns 400 for invalid scope names."""
|
||||
await temp_storage.store_app_password_with_scopes(
|
||||
user_id="alice",
|
||||
app_password="test-app-pw",
|
||||
scopes=["notes:read"],
|
||||
)
|
||||
|
||||
app = create_test_app(temp_storage)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.patch(
|
||||
"/api/v1/users/alice/scopes",
|
||||
headers={"Authorization": create_basic_auth_header("alice", "pw")},
|
||||
json={"scopes": ["notes:read", "invalid:scope"]},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
data = resp.json()
|
||||
assert data["success"] is False
|
||||
assert "invalid:scope" in data["error"]
|
||||
|
||||
async def test_user_not_provisioned(self, temp_storage):
|
||||
"""Returns 404 when user has no app password."""
|
||||
app = create_test_app(temp_storage)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.patch(
|
||||
"/api/v1/users/alice/scopes",
|
||||
headers={"Authorization": create_basic_auth_header("alice", "pw")},
|
||||
json={"scopes": ["notes:read"]},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
data = resp.json()
|
||||
assert data["success"] is False
|
||||
|
||||
async def test_missing_scopes_field(self, temp_storage):
|
||||
"""Returns 400 when scopes field is missing from body."""
|
||||
app = create_test_app(temp_storage)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.patch(
|
||||
"/api/v1/users/alice/scopes",
|
||||
headers={"Authorization": create_basic_auth_header("alice", "pw")},
|
||||
json={"something_else": True},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_invalid_json_body(self, temp_storage):
|
||||
"""Returns 400 for invalid JSON body."""
|
||||
app = create_test_app(temp_storage)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.patch(
|
||||
"/api/v1/users/alice/scopes",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header("alice", "pw"),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
content=b"not json",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestListSupportedScopes:
|
||||
"""Tests for GET /api/v1/scopes."""
|
||||
|
||||
async def test_returns_all_scopes(self, temp_storage):
|
||||
"""Returns all supported scopes sorted."""
|
||||
app = create_test_app(temp_storage)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get("/api/v1/scopes")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
assert set(data["scopes"]) == ALL_SUPPORTED_SCOPES
|
||||
# Verify it's sorted
|
||||
assert data["scopes"] == sorted(data["scopes"])
|
||||
@@ -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)
|
||||
@@ -32,7 +32,7 @@ def mock_metrics():
|
||||
def mock_tracer():
|
||||
"""Mock OpenTelemetry tracer."""
|
||||
with patch(
|
||||
"nextcloud_mcp_server.observability.tracing.trace_operation"
|
||||
"nextcloud_mcp_server.observability.metrics.trace_operation"
|
||||
) as mock_trace:
|
||||
# Configure mock to act as a context manager that allows exceptions to propagate
|
||||
mock_trace.return_value.__enter__ = MagicMock(return_value=None)
|
||||
|
||||
@@ -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
|
||||
@@ -184,7 +184,7 @@ async def test_provision_app_password_success(temp_storage, mocker):
|
||||
"""Test successful app password provisioning."""
|
||||
# Mock settings (imported locally in the function)
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
"nextcloud_mcp_server.api.passwords.get_settings",
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
@@ -203,7 +203,7 @@ async def test_provision_app_password_success(temp_storage, mocker):
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
"nextcloud_mcp_server.api.passwords.nextcloud_httpx_client",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
@@ -233,7 +233,7 @@ async def test_provision_app_password_success(temp_storage, mocker):
|
||||
async def test_provision_app_password_nextcloud_validation_fails(mocker):
|
||||
"""Test that failed Nextcloud validation returns 401."""
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
"nextcloud_mcp_server.api.passwords.get_settings",
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
@@ -251,7 +251,7 @@ async def test_provision_app_password_nextcloud_validation_fails(mocker):
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
"nextcloud_mcp_server.api.passwords.nextcloud_httpx_client",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
@@ -356,7 +356,7 @@ async def test_delete_app_password_success(temp_storage, mocker):
|
||||
|
||||
# Mock settings (imported locally in the function)
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
"nextcloud_mcp_server.api.passwords.get_settings",
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
@@ -374,7 +374,7 @@ async def test_delete_app_password_success(temp_storage, mocker):
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
"nextcloud_mcp_server.api.passwords.nextcloud_httpx_client",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
@@ -404,7 +404,7 @@ async def test_delete_app_password_not_found(temp_storage, mocker):
|
||||
"""Test deleting non-existent app password."""
|
||||
# Mock settings (imported locally in the function)
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
"nextcloud_mcp_server.api.passwords.get_settings",
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
@@ -422,7 +422,7 @@ async def test_delete_app_password_not_found(temp_storage, mocker):
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
"nextcloud_mcp_server.api.passwords.nextcloud_httpx_client",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
@@ -447,7 +447,7 @@ async def test_delete_app_password_not_found(temp_storage, mocker):
|
||||
async def test_delete_app_password_invalid_credentials(mocker):
|
||||
"""Test that invalid credentials returns 401 for deletion."""
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
"nextcloud_mcp_server.api.passwords.get_settings",
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
@@ -465,7 +465,7 @@ async def test_delete_app_password_invalid_credentials(mocker):
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
"nextcloud_mcp_server.api.passwords.nextcloud_httpx_client",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
@@ -521,7 +521,7 @@ async def test_delete_app_password_username_mismatch():
|
||||
async def test_provision_app_password_rate_limiting(mocker):
|
||||
"""Test that rate limiting blocks excessive provisioning attempts."""
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
"nextcloud_mcp_server.api.passwords.get_settings",
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
@@ -539,7 +539,7 @@ async def test_provision_app_password_rate_limiting(mocker):
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
"nextcloud_mcp_server.api.passwords.nextcloud_httpx_client",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
@@ -584,7 +584,7 @@ async def test_provision_app_password_rate_limiting(mocker):
|
||||
async def test_rate_limiting_is_per_user(mocker):
|
||||
"""Test that rate limiting is applied per user, not globally."""
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
"nextcloud_mcp_server.api.passwords.get_settings",
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
@@ -602,7 +602,7 @@ async def test_rate_limiting_is_per_user(mocker):
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
"nextcloud_mcp_server.api.passwords.nextcloud_httpx_client",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
|
||||
@@ -70,10 +70,11 @@ class TestStatusEndpointOidcConfig:
|
||||
# get_settings and detect_auth_mode are imported inside the function
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
"nextcloud_mcp_server.api.management.get_settings",
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||
return_value=AuthMode.MULTI_USER_BASIC,
|
||||
),
|
||||
):
|
||||
@@ -107,10 +108,11 @@ class TestStatusEndpointOidcConfig:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
"nextcloud_mcp_server.api.management.get_settings",
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||
return_value=AuthMode.MULTI_USER_BASIC,
|
||||
),
|
||||
):
|
||||
@@ -135,10 +137,11 @@ class TestStatusEndpointOidcConfig:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
"nextcloud_mcp_server.api.management.get_settings",
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||
return_value=AuthMode.MULTI_USER_BASIC,
|
||||
),
|
||||
):
|
||||
@@ -167,10 +170,11 @@ class TestStatusEndpointOidcConfig:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
"nextcloud_mcp_server.api.management.get_settings",
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||
return_value=AuthMode.OAUTH_SINGLE_AUDIENCE,
|
||||
),
|
||||
):
|
||||
@@ -202,10 +206,11 @@ class TestStatusEndpointOidcConfig:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
"nextcloud_mcp_server.api.management.get_settings",
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||
),
|
||||
):
|
||||
@@ -235,10 +240,11 @@ class TestStatusEndpointOidcConfig:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
"nextcloud_mcp_server.api.management.get_settings",
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||
return_value=AuthMode.MULTI_USER_BASIC,
|
||||
),
|
||||
):
|
||||
@@ -267,10 +273,11 @@ class TestStatusEndpointOidcConfig:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
"nextcloud_mcp_server.api.management.get_settings",
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||
return_value=AuthMode.MULTI_USER_BASIC,
|
||||
),
|
||||
):
|
||||
@@ -295,10 +302,11 @@ class TestStatusEndpointBasicResponse:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
"nextcloud_mcp_server.api.management.get_settings",
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||
),
|
||||
):
|
||||
@@ -320,10 +328,11 @@ class TestStatusEndpointBasicResponse:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
"nextcloud_mcp_server.api.management.get_settings",
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||
),
|
||||
):
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user