fix: address review feedback — security, caching, CI 429 retry
- Add 429 retry with exponential backoff to register_client() (fixes CI oauth matrix failures from parallel DCR requests) - Make client_id, redirect_uri, and PKCE mandatory at token endpoint - Add null-checks for discovery_url and OAuth credentials in proxy flows - Add OIDC discovery document caching with 5-min TTL - Add per-IP rate limiting on /oauth/register DCR proxy - Discover DCR endpoint from OIDC discovery instead of hardcoding - Extract extract_user_id_from_token to auth/token_utils.py (breaks circular imports between server/ and auth/ layers) - Add TTL scope cache in scope_authorization.py (avoids DB hit per tool) - Add defense-in-depth scope validation in storage layer - Broaden elicitation exception handling with graceful fallback - Add idempotentHint to nc_auth_check_status, return "pending" status after accepted elicitation, add polling interval to description - Change ALL_SUPPORTED_SCOPES from tuple to frozenset for O(1) lookups - Replace Optional[str] with str | None throughout config.py - Use default_factory for ProxyCodeEntry/ASProxySession dataclasses - Add proxy code/session cleanup to background loop - Fix OIDC verification CI step to only run for oauth/login-flow modes - Add unit tests for access.py REST endpoints (10 tests) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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"])
|
||||
@@ -10,11 +10,20 @@ 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()
|
||||
|
||||
Reference in New Issue
Block a user