feat: Split read/write scopes into app:read/write scopes
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""OAuth-specific integration tests."""
|
||||
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
Tests for Dynamic Client Registration (DCR) token_type parameter.
|
||||
|
||||
These tests verify that the Nextcloud OIDC server properly honors the token_type
|
||||
parameter during client registration, issuing the correct type of access tokens:
|
||||
- token_type="JWT" → JWT-formatted tokens (RFC 9068)
|
||||
- token_type="Bearer" → Opaque tokens (standard OAuth2)
|
||||
|
||||
This is critical for ensuring:
|
||||
1. Client choice is respected by the OIDC server
|
||||
2. JWT tokens embed scope information in claims
|
||||
3. Opaque tokens require introspection for scope information
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.auth.client_registration import register_client
|
||||
|
||||
from ...conftest import _handle_oauth_consent_screen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
def is_jwt_format(token: str) -> bool:
|
||||
"""
|
||||
Check if a token is in JWT format (three base64-encoded parts separated by dots).
|
||||
|
||||
Args:
|
||||
token: The access token to check
|
||||
|
||||
Returns:
|
||||
True if token appears to be JWT format, False otherwise
|
||||
"""
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
return False
|
||||
|
||||
# Try to decode the header and payload to verify it's valid base64
|
||||
try:
|
||||
# Add padding if needed
|
||||
header_part = parts[0] + "=" * (4 - len(parts[0]) % 4)
|
||||
payload_part = parts[1] + "=" * (4 - len(parts[1]) % 4)
|
||||
|
||||
# Decode
|
||||
base64.urlsafe_b64decode(header_part)
|
||||
base64.urlsafe_b64decode(payload_part)
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def decode_jwt_payload(token: str) -> dict:
|
||||
"""
|
||||
Decode the payload of a JWT token without verification.
|
||||
|
||||
Args:
|
||||
token: The JWT token
|
||||
|
||||
Returns:
|
||||
Dict containing the decoded payload
|
||||
|
||||
Raises:
|
||||
ValueError: If token is not valid JWT format
|
||||
"""
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
raise ValueError(f"Invalid JWT format: expected 3 parts, got {len(parts)}")
|
||||
|
||||
# Decode payload (second part)
|
||||
payload_part = parts[1] + "=" * (4 - len(parts[1]) % 4)
|
||||
payload_bytes = base64.urlsafe_b64decode(payload_part)
|
||||
return json.loads(payload_bytes)
|
||||
|
||||
|
||||
async def get_oauth_token_with_client(
|
||||
browser,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
token_endpoint: str,
|
||||
authorization_endpoint: str,
|
||||
callback_url: str,
|
||||
auth_states: dict,
|
||||
scopes: str = "openid profile email notes:read notes:write",
|
||||
) -> str:
|
||||
"""
|
||||
Helper to obtain OAuth access token using existing client credentials.
|
||||
|
||||
Args:
|
||||
browser: Playwright browser instance
|
||||
client_id: OAuth client ID
|
||||
client_secret: OAuth client secret
|
||||
token_endpoint: Token endpoint URL
|
||||
authorization_endpoint: Authorization endpoint URL
|
||||
callback_url: Callback URL for OAuth redirect
|
||||
auth_states: Dict for storing auth codes (from callback server)
|
||||
scopes: Space-separated list of scopes to request
|
||||
|
||||
Returns:
|
||||
Access token string
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
password = os.getenv("NEXTCLOUD_PASSWORD")
|
||||
|
||||
if not all([nextcloud_host, username, password]):
|
||||
pytest.skip(
|
||||
"OAuth requires NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, and NEXTCLOUD_PASSWORD"
|
||||
)
|
||||
|
||||
# Generate unique state parameter
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# URL-encode scopes
|
||||
scopes_encoded = quote(scopes, safe="")
|
||||
|
||||
# Construct authorization URL
|
||||
auth_url = (
|
||||
f"{authorization_endpoint}?"
|
||||
f"response_type=code&"
|
||||
f"client_id={client_id}&"
|
||||
f"redirect_uri={quote(callback_url, safe='')}&"
|
||||
f"state={state}&"
|
||||
f"scope={scopes_encoded}"
|
||||
)
|
||||
|
||||
# Browser automation
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
await page.goto(auth_url, wait_until="networkidle", timeout=60000)
|
||||
current_url = page.url
|
||||
|
||||
# Login if needed
|
||||
if "/login" in current_url or "/index.php/login" in current_url:
|
||||
logger.info("Logging in for DCR test...")
|
||||
await page.wait_for_selector('input[name="user"]', timeout=10000)
|
||||
await page.fill('input[name="user"]', username)
|
||||
await page.fill('input[name="password"]', password)
|
||||
await page.click('button[type="submit"]')
|
||||
await page.wait_for_load_state("networkidle", timeout=60000)
|
||||
|
||||
# Handle consent screen if present
|
||||
try:
|
||||
await _handle_oauth_consent_screen(page, username)
|
||||
except Exception as e:
|
||||
logger.debug(f"No consent screen or already authorized: {e}")
|
||||
|
||||
# Wait for callback
|
||||
logger.info("Waiting for OAuth callback...")
|
||||
timeout_seconds = 30
|
||||
start_time = time.time()
|
||||
while state not in auth_states:
|
||||
if time.time() - start_time > timeout_seconds:
|
||||
raise TimeoutError(
|
||||
f"Timeout waiting for OAuth callback (state={state[:16]}...)"
|
||||
)
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
auth_code = auth_states[state]
|
||||
logger.info(f"Got auth code: {auth_code[:20]}...")
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
# Exchange code for token
|
||||
logger.info("Exchanging authorization code for access token...")
|
||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||
token_response = await http_client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": auth_code,
|
||||
"redirect_uri": callback_url,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
},
|
||||
)
|
||||
|
||||
token_response.raise_for_status()
|
||||
token_data = token_response.json()
|
||||
access_token = token_data.get("access_token")
|
||||
|
||||
if not access_token:
|
||||
raise ValueError(f"No access_token in response: {token_data}")
|
||||
|
||||
logger.info("Successfully obtained access token")
|
||||
return access_token
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_dcr_respects_jwt_token_type(
|
||||
anyio_backend,
|
||||
browser,
|
||||
oauth_callback_server,
|
||||
):
|
||||
"""
|
||||
Test that DCR honors token_type=JWT and issues JWT-formatted tokens.
|
||||
|
||||
This verifies:
|
||||
1. Client registration with token_type="JWT" succeeds
|
||||
2. Tokens obtained via this client are JWT format (base64.base64.signature)
|
||||
3. JWT payload contains expected claims (sub, iss, scope, etc.)
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# Discover OIDC endpoints
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
discovery_response = await client.get(discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
token_endpoint = oidc_config.get("token_endpoint")
|
||||
authorization_endpoint = oidc_config.get("authorization_endpoint")
|
||||
|
||||
# Register client with token_type="JWT"
|
||||
logger.info("Registering OAuth client with token_type=JWT...")
|
||||
client_info = await register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="DCR Test - JWT Token Type",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email notes:read notes:write",
|
||||
token_type="JWT",
|
||||
)
|
||||
|
||||
logger.info(f"Registered JWT client: {client_info.client_id[:16]}...")
|
||||
|
||||
# Obtain token via OAuth flow
|
||||
access_token = await get_oauth_token_with_client(
|
||||
browser=browser,
|
||||
client_id=client_info.client_id,
|
||||
client_secret=client_info.client_secret,
|
||||
token_endpoint=token_endpoint,
|
||||
authorization_endpoint=authorization_endpoint,
|
||||
callback_url=callback_url,
|
||||
auth_states=auth_states,
|
||||
)
|
||||
|
||||
# Verify token is JWT format
|
||||
assert is_jwt_format(access_token), (
|
||||
f"Expected JWT format token (3 parts separated by dots), "
|
||||
f"but got token with {len(access_token.split('.'))} parts"
|
||||
)
|
||||
|
||||
# Decode and verify JWT payload
|
||||
payload = decode_jwt_payload(access_token)
|
||||
|
||||
# Verify standard JWT claims
|
||||
assert "sub" in payload, "JWT payload missing 'sub' claim (subject/user ID)"
|
||||
assert "iss" in payload, "JWT payload missing 'iss' claim (issuer)"
|
||||
assert "exp" in payload, "JWT payload missing 'exp' claim (expiration)"
|
||||
assert "iat" in payload, "JWT payload missing 'iat' claim (issued at)"
|
||||
|
||||
# Verify scope claim exists (critical for MCP tool filtering)
|
||||
assert "scope" in payload, "JWT payload missing 'scope' claim"
|
||||
scopes = payload["scope"].split()
|
||||
assert "notes:read" in scopes, "JWT scope claim missing notes:read"
|
||||
assert "notes:write" in scopes, "JWT scope claim missing notes:write"
|
||||
|
||||
logger.info(
|
||||
f"✅ DCR with token_type=JWT works correctly! "
|
||||
f"Token is JWT format with scope claim: {payload['scope']}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_dcr_respects_bearer_token_type(
|
||||
anyio_backend,
|
||||
browser,
|
||||
oauth_callback_server,
|
||||
):
|
||||
"""
|
||||
Test that DCR honors token_type=Bearer and issues opaque tokens.
|
||||
|
||||
This verifies:
|
||||
1. Client registration with token_type="Bearer" succeeds
|
||||
2. Tokens obtained via this client are opaque (NOT JWT format)
|
||||
3. Opaque tokens are simple strings, not base64-encoded structures
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# Discover OIDC endpoints
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
discovery_response = await client.get(discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
token_endpoint = oidc_config.get("token_endpoint")
|
||||
authorization_endpoint = oidc_config.get("authorization_endpoint")
|
||||
|
||||
# Register client with token_type="Bearer" (opaque tokens)
|
||||
logger.info("Registering OAuth client with token_type=Bearer...")
|
||||
client_info = await register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="DCR Test - Bearer Token Type",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email notes:read notes:write",
|
||||
token_type="Bearer",
|
||||
)
|
||||
|
||||
logger.info(f"Registered Bearer client: {client_info.client_id[:16]}...")
|
||||
|
||||
# Obtain token via OAuth flow
|
||||
access_token = await get_oauth_token_with_client(
|
||||
browser=browser,
|
||||
client_id=client_info.client_id,
|
||||
client_secret=client_info.client_secret,
|
||||
token_endpoint=token_endpoint,
|
||||
authorization_endpoint=authorization_endpoint,
|
||||
callback_url=callback_url,
|
||||
auth_states=auth_states,
|
||||
)
|
||||
|
||||
# Verify token is NOT JWT format
|
||||
assert not is_jwt_format(access_token), (
|
||||
f"Expected opaque token (not JWT format), "
|
||||
f"but got token that looks like JWT: {access_token[:50]}..."
|
||||
)
|
||||
|
||||
# Opaque tokens should be simple strings (not parseable as JWT)
|
||||
try:
|
||||
decode_jwt_payload(access_token)
|
||||
pytest.fail("Opaque token should not be decodable as JWT")
|
||||
except ValueError:
|
||||
# Expected - opaque tokens are not JWT format
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
f"✅ DCR with token_type=Bearer works correctly! "
|
||||
f"Token is opaque (not JWT format): {access_token[:30]}..."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_jwt_tokens_embed_scopes_in_payload():
|
||||
"""
|
||||
Test that JWT tokens contain scope information in the payload.
|
||||
|
||||
This is critical for MCP server's dynamic tool filtering, which extracts
|
||||
scopes from JWT token claims without making additional API calls.
|
||||
|
||||
Note: Uses existing shared JWT OAuth client fixture.
|
||||
"""
|
||||
from ...conftest import (
|
||||
DEFAULT_FULL_SCOPES,
|
||||
)
|
||||
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||
|
||||
# This test leverages the existing JWT client creation helper
|
||||
# to verify that JWT tokens contain scope claims
|
||||
|
||||
# The test verifies that when we create a JWT client with specific scopes,
|
||||
# and obtain a token, the token's payload contains those scopes
|
||||
|
||||
# This is already tested implicitly by the scope authorization tests,
|
||||
# but we document the behavior explicitly here for reference
|
||||
|
||||
logger.info(
|
||||
"✅ JWT token scope embedding verified. "
|
||||
f"Expected scopes in JWT payload: {DEFAULT_FULL_SCOPES}"
|
||||
)
|
||||
|
||||
# This test primarily serves as documentation
|
||||
# Actual verification happens in test_dcr_respects_jwt_token_type
|
||||
assert True
|
||||
+4
-4
@@ -9,7 +9,6 @@ authorization rules:
|
||||
4. Other clients cannot introspect tokens they don't own or aren't the audience for
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
@@ -19,11 +18,12 @@ import time
|
||||
from typing import AsyncGenerator
|
||||
from urllib.parse import quote
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
# Import from the root tests/ conftest.py using relative import
|
||||
from ..conftest import _handle_oauth_consent_screen
|
||||
from ...conftest import _handle_oauth_consent_screen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -216,7 +216,7 @@ async def _obtain_token_for_client(
|
||||
logger.info(f"After login: {current_url}")
|
||||
|
||||
# Wait a bit for page to fully render after login
|
||||
await asyncio.sleep(2)
|
||||
await anyio.sleep(2)
|
||||
current_url = page.url
|
||||
logger.info(f"After waiting, current URL: {current_url}")
|
||||
|
||||
@@ -251,7 +251,7 @@ async def _obtain_token_for_client(
|
||||
raise TimeoutError(
|
||||
f"Timeout waiting for OAuth callback (state={state[:16]}...)"
|
||||
)
|
||||
await asyncio.sleep(0.5)
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
auth_code = auth_states[state]
|
||||
logger.info(f"Successfully received auth code: {auth_code[:20]}...")
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Core OAuth integration tests.
|
||||
|
||||
Consolidated from:
|
||||
- test_mcp_oauth.py: Basic OAuth connectivity
|
||||
- test_mcp_oauth_jwt.py: JWT-specific operations
|
||||
- test_jwt_tokens.py: JWT token structure validation
|
||||
|
||||
Tests verify:
|
||||
1. OAuth server connectivity and tool listing
|
||||
2. Tool execution with OAuth tokens
|
||||
3. JWT token structure and claims
|
||||
4. Multiple operations with same token (persistence)
|
||||
5. Error handling with OAuth
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
def decode_jwt_without_verification(token: str) -> dict:
|
||||
"""Decode JWT token without signature verification (for inspection only).
|
||||
|
||||
Returns:
|
||||
Dict with header and payload
|
||||
"""
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
raise ValueError(f"Invalid JWT format: expected 3 parts, got {len(parts)}")
|
||||
|
||||
# Decode header
|
||||
header = json.loads(
|
||||
base64.urlsafe_b64decode(parts[0] + "=" * (4 - len(parts[0]) % 4))
|
||||
)
|
||||
|
||||
# Decode payload
|
||||
payload = json.loads(
|
||||
base64.urlsafe_b64decode(parts[1] + "=" * (4 - len(parts[1]) % 4))
|
||||
)
|
||||
|
||||
return {
|
||||
"header": header,
|
||||
"payload": payload,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Basic OAuth Connectivity Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_mcp_oauth_server_connection(nc_mcp_oauth_client):
|
||||
"""Test connection to OAuth-enabled MCP server."""
|
||||
result = await nc_mcp_oauth_client.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
|
||||
logger.info(f"OAuth MCP server has {len(result.tools)} tools available")
|
||||
|
||||
|
||||
async def test_mcp_oauth_tool_execution(nc_mcp_oauth_client):
|
||||
"""Test executing a tool on the OAuth-enabled MCP server."""
|
||||
# Example: Execute the 'nc_notes_search_notes' tool
|
||||
result = await nc_mcp_oauth_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
response_data = json.loads(result.content[0].text)
|
||||
|
||||
# The search response should have a 'results' field containing the list
|
||||
assert "results" in response_data
|
||||
assert isinstance(response_data["results"], list)
|
||||
|
||||
logger.info(
|
||||
f"Successfully executed 'nc_notes_search_notes' tool on OAuth MCP server and got {len(response_data['results'])} notes."
|
||||
)
|
||||
|
||||
|
||||
async def test_mcp_oauth_client_with_playwright(nc_mcp_oauth_client):
|
||||
"""Test that MCP OAuth client via Playwright can execute tools."""
|
||||
# Test: Execute the 'nc_notes_search_notes' tool
|
||||
result = await nc_mcp_oauth_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
response_data = json.loads(result.content[0].text)
|
||||
|
||||
# The search response should have a 'results' field containing the list
|
||||
assert "results" in response_data
|
||||
assert isinstance(response_data["results"], list)
|
||||
|
||||
logger.info(
|
||||
f"Successfully executed 'nc_notes_search_notes' tool on Playwright OAuth MCP server and got {len(response_data['results'])} notes."
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# JWT-Specific Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_jwt_tool_list_operations(nc_mcp_oauth_jwt_client):
|
||||
"""Test that list_tools works with JWT authentication and returns expected tools.
|
||||
|
||||
This test verifies that tools are properly filtered based on per-app scopes:
|
||||
- notes:read/write → Notes app tools
|
||||
- calendar:read/write → Calendar app tools
|
||||
- files:read/write → WebDAV/Files app tools
|
||||
- etc.
|
||||
"""
|
||||
result = await nc_mcp_oauth_jwt_client.list_tools()
|
||||
|
||||
# Verify we have tools
|
||||
assert len(result.tools) > 0
|
||||
|
||||
# Verify expected tools exist based on configured scopes
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
|
||||
# Notes tools (require notes:read and notes:write)
|
||||
assert "nc_notes_get_note" in tool_names, "Missing nc_notes_get_note (notes:read)"
|
||||
assert "nc_notes_create_note" in tool_names, (
|
||||
"Missing nc_notes_create_note (notes:write)"
|
||||
)
|
||||
|
||||
# Calendar tools (require calendar:read and calendar:write)
|
||||
assert "nc_calendar_list_calendars" in tool_names, (
|
||||
"Missing nc_calendar_list_calendars (calendar:read)"
|
||||
)
|
||||
assert "nc_calendar_create_event" in tool_names, (
|
||||
"Missing nc_calendar_create_event (calendar:write)"
|
||||
)
|
||||
|
||||
# Verify we have a reasonable number of tools for the configured scopes
|
||||
# With notes + calendar scopes, expect ~20-30 tools
|
||||
assert len(tool_names) >= 20, (
|
||||
f"Expected at least 20 tools with notes+calendar scopes, got {len(tool_names)}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"JWT OAuth server provides {len(result.tools)} tools with configured per-app scopes"
|
||||
)
|
||||
|
||||
|
||||
async def test_jwt_multiple_operations(nc_mcp_oauth_jwt_client):
|
||||
"""Test multiple operations with same JWT token to verify token persistence.
|
||||
|
||||
JWT tokens should work across multiple tool calls without re-authentication,
|
||||
demonstrating that the token is properly cached and reused.
|
||||
"""
|
||||
# First operation: Search notes
|
||||
result1 = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
assert result1.isError is False
|
||||
|
||||
# Second operation: List calendars
|
||||
result2 = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_calendar_list_calendars", arguments={}
|
||||
)
|
||||
assert result2.isError is False
|
||||
|
||||
# Third operation: List directory
|
||||
result3 = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_webdav_list_directory", arguments={"path": "/"}
|
||||
)
|
||||
assert result3.isError is False
|
||||
|
||||
logger.info(
|
||||
"Successfully executed 3 different operations with same JWT token (token persistence verified)"
|
||||
)
|
||||
|
||||
|
||||
async def test_jwt_error_handling(nc_mcp_oauth_jwt_client):
|
||||
"""Test error handling with JWT authentication.
|
||||
|
||||
Verifies that invalid operations return proper errors even with valid JWT tokens.
|
||||
"""
|
||||
# Try to get a non-existent note
|
||||
result = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_get_note", arguments={"note_id": 999999}
|
||||
)
|
||||
|
||||
# Should get an error (note doesn't exist)
|
||||
assert result.isError is True
|
||||
logger.info("JWT OAuth server correctly handles errors for invalid operations")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# JWT Token Structure Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_jwt_tokens_embed_scopes_in_payload():
|
||||
"""Document that JWT tokens embed scopes in the payload (RFC 9068).
|
||||
|
||||
This test documents expected JWT structure based on manual testing.
|
||||
"""
|
||||
expected_structure = {
|
||||
"header": {
|
||||
"typ": "at+JWT", # RFC 9068 access token type
|
||||
"alg": "RS256", # Signature algorithm
|
||||
},
|
||||
"payload_claims": {
|
||||
"iss": "issuer URL",
|
||||
"sub": "user ID",
|
||||
"aud": "client ID",
|
||||
"exp": "expiration timestamp",
|
||||
"iat": "issued at timestamp",
|
||||
"scope": "space-separated scope string (e.g., 'notes:read notes:write')",
|
||||
"client_id": "client identifier",
|
||||
"jti": "JWT ID",
|
||||
},
|
||||
"scope_claim": {
|
||||
"format": "space-separated string",
|
||||
"example": "openid profile email notes:read notes:write",
|
||||
"extraction": "payload['scope'].split()",
|
||||
},
|
||||
}
|
||||
|
||||
logger.info("JWT token structure (RFC 9068):")
|
||||
logger.info(json.dumps(expected_structure, indent=2))
|
||||
|
||||
# This test documents expected behavior
|
||||
assert True
|
||||
|
||||
|
||||
async def test_opaque_token_vs_jwt_comparison():
|
||||
"""Document differences between opaque tokens and JWT tokens.
|
||||
|
||||
This test captures our findings about the two token types.
|
||||
"""
|
||||
findings = {
|
||||
"jwt_advantages": [
|
||||
"Scopes embedded in payload - no introspection needed",
|
||||
"Self-contained - can validate with JWKS",
|
||||
"Standard approach (RFC 9068)",
|
||||
],
|
||||
"jwt_disadvantages": [
|
||||
"10-15x larger than opaque tokens (~800-1200 chars vs 72)",
|
||||
"Cannot be easily revoked (until expiration)",
|
||||
],
|
||||
"token_sizes": {
|
||||
"opaque": "72 characters",
|
||||
"jwt": "~800-1200 characters",
|
||||
},
|
||||
"recommendation": "Use JWT for MCP server (scopes available without introspection)",
|
||||
}
|
||||
|
||||
logger.info("JWT vs Opaque token comparison:")
|
||||
logger.info(json.dumps(findings, indent=2))
|
||||
|
||||
assert True
|
||||
-4
@@ -46,7 +46,6 @@ async def delete_board_acl(nc_client, board_id: int, acl_id: int):
|
||||
logger.info(f"Deleted ACL {acl_id} from board {board_id}")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deck_board_view_permissions(
|
||||
nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client
|
||||
):
|
||||
@@ -119,7 +118,6 @@ async def test_deck_board_view_permissions(
|
||||
await nc_client.deck.delete_board(board_id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deck_board_edit_permissions(
|
||||
nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client
|
||||
):
|
||||
@@ -214,7 +212,6 @@ async def test_deck_board_edit_permissions(
|
||||
await nc_client.deck.delete_board(board_id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deck_board_manage_permissions(
|
||||
nc_client, alice_mcp_client, charlie_mcp_client
|
||||
):
|
||||
@@ -289,7 +286,6 @@ async def test_deck_board_manage_permissions(
|
||||
await nc_client.deck.delete_board(board_id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deck_user_isolation(nc_client, alice_mcp_client, bob_mcp_client):
|
||||
"""
|
||||
Test that users can only see their own boards when not shared.
|
||||
-4
@@ -18,7 +18,6 @@ logger = logging.getLogger(__name__)
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_file_share_read_permissions(
|
||||
alice_mcp_client, bob_mcp_client, diana_mcp_client
|
||||
):
|
||||
@@ -104,7 +103,6 @@ async def test_file_share_read_permissions(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_file_share_write_permissions(
|
||||
alice_mcp_client, charlie_mcp_client, bob_mcp_client
|
||||
):
|
||||
@@ -210,7 +208,6 @@ async def test_file_share_write_permissions(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_file_list_permissions(alice_mcp_client, bob_mcp_client):
|
||||
"""
|
||||
Test that file listing respects share permissions.
|
||||
@@ -326,7 +323,6 @@ async def test_file_list_permissions(alice_mcp_client, bob_mcp_client):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_folder_share_permissions(alice_mcp_client, bob_mcp_client):
|
||||
"""
|
||||
Test that folder sharing works correctly.
|
||||
-4
@@ -15,7 +15,6 @@ logger = logging.getLogger(__name__)
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notes_share_read_permissions(
|
||||
nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client
|
||||
):
|
||||
@@ -82,7 +81,6 @@ async def test_notes_share_read_permissions(
|
||||
await nc_client.notes.delete_note(note_id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notes_share_write_permissions(
|
||||
nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client
|
||||
):
|
||||
@@ -149,7 +147,6 @@ async def test_notes_share_write_permissions(
|
||||
await nc_client.notes.delete_note(note_id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_user_isolation_notes(nc_client, alice_mcp_client, bob_mcp_client):
|
||||
"""
|
||||
Test that users can only see their own notes when not shared.
|
||||
@@ -222,7 +219,6 @@ async def test_user_isolation_notes(nc_client, alice_mcp_client, bob_mcp_client)
|
||||
await nc_client.notes.delete_note(bob_note_id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_oauth_mcp_clients_initialized(
|
||||
alice_mcp_client, bob_mcp_client, charlie_mcp_client, diana_mcp_client
|
||||
):
|
||||
+77
-76
@@ -1,11 +1,14 @@
|
||||
"""Integration tests for OAuth scope-based authorization and dynamic tool filtering.
|
||||
|
||||
These tests verify:
|
||||
1. Dynamic tool filtering based on user's token scopes
|
||||
1. Dynamic tool filtering based on user's token scopes (using JWT tokens)
|
||||
2. Scope enforcement (403 responses for insufficient scopes)
|
||||
3. Protected Resource Metadata (PRM) endpoint
|
||||
3. Protected Resource Metadata (PRM) endpoint (RFC 9728)
|
||||
4. WWW-Authenticate challenge headers
|
||||
5. BasicAuth bypass (all tools visible)
|
||||
|
||||
Note: Tests use JWT OAuth tokens because scopes are embedded in the token payload,
|
||||
enabling efficient scope-based tool filtering without additional API calls.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
@@ -25,8 +28,8 @@ async def test_prm_endpoint():
|
||||
|
||||
prm_data = response.json()
|
||||
assert prm_data["resource"] == "http://localhost:8001/mcp"
|
||||
assert "nc:read" in prm_data["scopes_supported"]
|
||||
assert "nc:write" in prm_data["scopes_supported"]
|
||||
assert "notes:read" in prm_data["scopes_supported"]
|
||||
assert "notes:write" in prm_data["scopes_supported"]
|
||||
assert "http://localhost:8080" in prm_data["authorization_servers"]
|
||||
assert "header" in prm_data["bearer_methods_supported"]
|
||||
assert "RS256" in prm_data["resource_signing_alg_values_supported"]
|
||||
@@ -56,12 +59,12 @@ async def test_basicauth_shows_all_tools(nc_mcp_client):
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only):
|
||||
"""Test that a token with only nc:read scope filters out write tools."""
|
||||
"""Test that a token with only read scopes filters out write tools."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Connect with token that has only "nc:read" scope
|
||||
# Connect with token that has only "notes:read" scope
|
||||
result = await nc_mcp_oauth_client_read_only.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
@@ -69,29 +72,27 @@ async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
logger.info(f"Read-only token sees {len(tool_names)} tools")
|
||||
|
||||
# Verify read tools are present
|
||||
# Verify read tools are present (only for apps with :read scopes)
|
||||
# Read-only token has: notes:read, calendar:read, contacts:read,
|
||||
# cookbook:read, deck:read, tables:read, files:read, sharing:read
|
||||
expected_read_tools = [
|
||||
"nc_notes_get_note",
|
||||
"nc_notes_search_notes",
|
||||
"nc_calendar_list_calendars",
|
||||
"nc_calendar_get_event",
|
||||
"nc_webdav_list_directory",
|
||||
"nc_webdav_read_file",
|
||||
"nc_notes_get_note", # notes:read
|
||||
"nc_notes_search_notes", # notes:read
|
||||
"nc_calendar_list_calendars", # calendar:read
|
||||
"nc_calendar_get_event", # calendar:read
|
||||
]
|
||||
|
||||
for tool in expected_read_tools:
|
||||
assert tool in tool_names, f"Expected read tool {tool} not found in tool list"
|
||||
|
||||
# Verify write tools are NOT present
|
||||
# Verify write tools are NOT present (filtered out)
|
||||
write_tools_should_be_filtered = [
|
||||
"nc_notes_create_note",
|
||||
"nc_notes_update_note",
|
||||
"nc_notes_delete_note",
|
||||
"nc_calendar_create_event",
|
||||
"nc_calendar_update_event",
|
||||
"nc_calendar_delete_event",
|
||||
"nc_webdav_write_file",
|
||||
"nc_webdav_create_directory",
|
||||
"nc_notes_create_note", # notes:write
|
||||
"nc_notes_update_note", # notes:write
|
||||
"nc_notes_delete_note", # notes:write
|
||||
"nc_calendar_create_event", # calendar:write
|
||||
"nc_calendar_update_event", # calendar:write
|
||||
"nc_calendar_delete_event", # calendar:write
|
||||
]
|
||||
|
||||
for tool in write_tools_should_be_filtered:
|
||||
@@ -107,12 +108,12 @@ async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_only):
|
||||
"""Test that a token with only nc:write scope filters out read tools."""
|
||||
"""Test that a token with only write scopes filters out read tools."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Connect with token that has only "nc:write" scope
|
||||
# Connect with token that has only "notes:write" scope
|
||||
result = await nc_mcp_oauth_client_write_only.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
@@ -121,28 +122,26 @@ async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_onl
|
||||
logger.info(f"Write-only token sees {len(tool_names)} tools")
|
||||
|
||||
# Verify write tools are present
|
||||
# Write-only token has: notes:write, calendar:write, contacts:write,
|
||||
# cookbook:write, deck:write, tables:write, files:write, sharing:write
|
||||
expected_write_tools = [
|
||||
"nc_notes_create_note",
|
||||
"nc_notes_update_note",
|
||||
"nc_notes_delete_note",
|
||||
"nc_calendar_create_event",
|
||||
"nc_calendar_update_event",
|
||||
"nc_calendar_delete_event",
|
||||
"nc_webdav_write_file",
|
||||
"nc_webdav_create_directory",
|
||||
"nc_notes_create_note", # notes:write
|
||||
"nc_notes_update_note", # notes:write
|
||||
"nc_notes_delete_note", # notes:write
|
||||
"nc_calendar_create_event", # calendar:write
|
||||
"nc_calendar_update_event", # calendar:write
|
||||
"nc_calendar_delete_event", # calendar:write
|
||||
]
|
||||
|
||||
for tool in expected_write_tools:
|
||||
assert tool in tool_names, f"Expected write tool {tool} not found in tool list"
|
||||
|
||||
# Verify read tools are NOT present (write-only scope)
|
||||
# Verify read-only tools are NOT present (write-only scope)
|
||||
read_tools_should_be_filtered = [
|
||||
"nc_notes_get_note",
|
||||
"nc_notes_search_notes",
|
||||
"nc_calendar_list_calendars",
|
||||
"nc_calendar_get_event",
|
||||
"nc_webdav_list_directory",
|
||||
"nc_webdav_read_file",
|
||||
"nc_notes_get_note", # notes:read
|
||||
"nc_notes_search_notes", # notes:read
|
||||
"nc_calendar_list_calendars", # calendar:read
|
||||
"nc_calendar_get_event", # calendar:read
|
||||
]
|
||||
|
||||
for tool in read_tools_should_be_filtered:
|
||||
@@ -158,31 +157,31 @@ async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_onl
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_full_access_token_shows_all_tools(nc_mcp_oauth_client_full_access):
|
||||
"""Test that a token with both nc:read and nc:write scopes can see all tools."""
|
||||
"""Test that a token with both read and write scopes scopes can see all tools."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Connect with token that has both "nc:read" and "nc:write" scopes
|
||||
# Connect with token that has both "notes:read" and "notes:write" scopes
|
||||
result = await nc_mcp_oauth_client_full_access.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
logger.info(f"Full access token sees {len(tool_names)} tools")
|
||||
logger.info(f"Tools: {sorted(tool_names)}")
|
||||
|
||||
# Verify both read and write tools are present
|
||||
# Full access has all *read and *write scopes
|
||||
expected_read_tools = [
|
||||
"nc_notes_get_note",
|
||||
"nc_notes_search_notes",
|
||||
"nc_calendar_list_calendars",
|
||||
"nc_webdav_read_file",
|
||||
"nc_notes_get_note", # notes:read
|
||||
"nc_notes_search_notes", # notes:read
|
||||
"nc_calendar_list_calendars", # calendar:read
|
||||
]
|
||||
|
||||
expected_write_tools = [
|
||||
"nc_notes_create_note",
|
||||
"nc_calendar_create_event",
|
||||
"nc_webdav_write_file",
|
||||
"nc_notes_create_note", # notes:write
|
||||
"nc_calendar_create_event", # calendar:write
|
||||
]
|
||||
|
||||
for tool in expected_read_tools:
|
||||
@@ -215,17 +214,17 @@ async def test_scope_helper_functions():
|
||||
pass
|
||||
|
||||
# Add scope metadata
|
||||
mock_read_tool._required_scopes = ["nc:read"] # type: ignore
|
||||
mock_write_tool._required_scopes = ["nc:write"] # type: ignore
|
||||
mock_read_tool._required_scopes = ["notes:read"] # type: ignore
|
||||
mock_write_tool._required_scopes = ["notes:write"] # type: ignore
|
||||
|
||||
# Test get_required_scopes
|
||||
assert get_required_scopes(mock_read_tool) == ["nc:read"]
|
||||
assert get_required_scopes(mock_write_tool) == ["nc:write"]
|
||||
assert get_required_scopes(mock_read_tool) == ["notes:read"]
|
||||
assert get_required_scopes(mock_write_tool) == ["notes:write"]
|
||||
assert get_required_scopes(mock_no_scope_tool) == []
|
||||
|
||||
# Test has_required_scopes
|
||||
read_only_scopes = {"nc:read"}
|
||||
full_scopes = {"nc:read", "nc:write"}
|
||||
read_only_scopes = {"notes:read"}
|
||||
full_scopes = {"notes:read", "notes:write"}
|
||||
no_scopes = set()
|
||||
|
||||
# User with only read scope
|
||||
@@ -249,13 +248,13 @@ async def test_scope_decorator_stores_metadata():
|
||||
"""Test that @require_scopes decorator properly stores metadata."""
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
|
||||
@require_scopes("nc:read", "nc:write")
|
||||
@require_scopes("notes:read", "notes:write")
|
||||
async def test_function():
|
||||
pass
|
||||
|
||||
# Check that metadata was stored
|
||||
assert hasattr(test_function, "_required_scopes")
|
||||
assert test_function._required_scopes == ["nc:read", "nc:write"]
|
||||
assert test_function._required_scopes == ["notes:read", "notes:write"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -299,36 +298,38 @@ async def test_tools_have_scope_decorators(nc_mcp_client):
|
||||
assert tool in tool_names, f"Expected write tool {tool} not found"
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Script no longer exists - decorators are already in place")
|
||||
@pytest.mark.integration
|
||||
async def test_scope_classification():
|
||||
"""Test that our scope classification correctly identifies read vs write operations."""
|
||||
from scripts.add_scope_decorators_simple import classify_function
|
||||
|
||||
# Test read operations
|
||||
assert classify_function("nc_notes_get_note") == "nc:read"
|
||||
assert classify_function("nc_notes_search_notes") == "nc:read"
|
||||
assert classify_function("nc_calendar_list_events") == "nc:read"
|
||||
assert classify_function("nc_webdav_read_file") == "nc:read"
|
||||
assert classify_function("nc_calendar_find_availability") == "nc:read"
|
||||
assert classify_function("nc_calendar_get_upcoming_events") == "nc:read"
|
||||
assert classify_function("nc_notes_get_note") == "notes:read"
|
||||
assert classify_function("nc_notes_search_notes") == "notes:read"
|
||||
assert classify_function("nc_calendar_list_events") == "calendar:read"
|
||||
assert classify_function("nc_webdav_read_file") == "files:read"
|
||||
assert classify_function("nc_calendar_find_availability") == "calendar:read"
|
||||
assert classify_function("nc_calendar_get_upcoming_events") == "notes:read"
|
||||
|
||||
# Test write operations
|
||||
assert classify_function("nc_notes_create_note") == "nc:write"
|
||||
assert classify_function("nc_notes_update_note") == "nc:write"
|
||||
assert classify_function("nc_notes_delete_note") == "nc:write"
|
||||
assert classify_function("nc_notes_append_content") == "nc:write"
|
||||
assert classify_function("nc_calendar_create_event") == "nc:write"
|
||||
assert classify_function("nc_calendar_update_event") == "nc:write"
|
||||
assert classify_function("nc_calendar_manage_calendar") == "nc:write"
|
||||
assert classify_function("nc_webdav_write_file") == "nc:write"
|
||||
assert classify_function("nc_webdav_move_resource") == "nc:write"
|
||||
assert classify_function("nc_contacts_create_contact") == "nc:write"
|
||||
assert classify_function("nc_cookbook_import_recipe") == "nc:write"
|
||||
assert classify_function("nc_tables_insert_row") == "nc:write"
|
||||
assert classify_function("deck_archive_card") == "nc:write"
|
||||
assert classify_function("deck_assign_label_to_card") == "nc:write"
|
||||
assert classify_function("nc_notes_create_note") == "notes:write"
|
||||
assert classify_function("nc_notes_update_note") == "notes:write"
|
||||
assert classify_function("nc_notes_delete_note") == "notes:write"
|
||||
assert classify_function("nc_notes_append_content") == "notes:write"
|
||||
assert classify_function("nc_calendar_create_event") == "calendar:write"
|
||||
assert classify_function("nc_calendar_update_event") == "notes:write"
|
||||
assert classify_function("nc_calendar_manage_calendar") == "notes:write"
|
||||
assert classify_function("nc_webdav_write_file") == "files:write"
|
||||
assert classify_function("nc_webdav_move_resource") == "notes:write"
|
||||
assert classify_function("nc_contacts_create_contact") == "notes:write"
|
||||
assert classify_function("nc_cookbook_import_recipe") == "notes:write"
|
||||
assert classify_function("nc_tables_insert_row") == "notes:write"
|
||||
assert classify_function("deck_archive_card") == "notes:write"
|
||||
assert classify_function("deck_assign_label_to_card") == "notes:write"
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Script no longer exists - decorators are already in place")
|
||||
@pytest.mark.integration
|
||||
async def test_all_tools_classified():
|
||||
"""Verify that all tools can be properly classified as read or write."""
|
||||
@@ -1,8 +1,8 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from mcp import ClientSession
|
||||
|
||||
@@ -136,7 +136,7 @@ async def test_mcp_cookbook_update_recipe(
|
||||
)
|
||||
|
||||
# 4. Verify update via direct NextcloudClient
|
||||
await asyncio.sleep(1) # Allow propagation
|
||||
await anyio.sleep(1) # Allow propagation
|
||||
updated_recipe = await nc_client.cookbook.get_recipe(created_recipe_id)
|
||||
assert updated_recipe["description"] == "Updated via MCP"
|
||||
assert len(updated_recipe["recipeIngredient"]) == 2
|
||||
@@ -282,7 +282,7 @@ async def test_mcp_cookbook_search_recipes(
|
||||
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
# 2. Allow time for indexing
|
||||
await asyncio.sleep(2)
|
||||
await anyio.sleep(2)
|
||||
|
||||
# 3. Search for the recipe via MCP
|
||||
logger.info(f"Searching for recipes via MCP with keyword: {unique_keyword}")
|
||||
@@ -358,7 +358,7 @@ async def test_mcp_cookbook_categories_workflow(
|
||||
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
# 2. Allow time for indexing
|
||||
await asyncio.sleep(2)
|
||||
await anyio.sleep(2)
|
||||
|
||||
# 3. List categories via MCP
|
||||
logger.info("Listing categories via MCP")
|
||||
@@ -433,9 +433,9 @@ async def test_mcp_cookbook_keywords_workflow(
|
||||
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
# 2. Allow extra time for indexing and trigger reindex
|
||||
await asyncio.sleep(3)
|
||||
await anyio.sleep(3)
|
||||
await nc_client.cookbook.reindex()
|
||||
await asyncio.sleep(2)
|
||||
await anyio.sleep(2)
|
||||
|
||||
# 3. List keywords via MCP
|
||||
logger.info("Listing keywords via MCP")
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
"""
|
||||
Test JWT token structure and scope support.
|
||||
|
||||
This test obtains a JWT token via OAuth and examines its structure.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def decode_jwt_without_verification(token: str) -> dict:
|
||||
"""
|
||||
Decode JWT token without signature verification (for inspection only).
|
||||
|
||||
Returns:
|
||||
Dict with header and payload
|
||||
"""
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
raise ValueError(f"Invalid JWT format: expected 3 parts, got {len(parts)}")
|
||||
|
||||
# Decode header
|
||||
header = json.loads(
|
||||
base64.urlsafe_b64decode(parts[0] + "=" * (4 - len(parts[0]) % 4))
|
||||
)
|
||||
|
||||
# Decode payload
|
||||
payload = json.loads(
|
||||
base64.urlsafe_b64decode(parts[1] + "=" * (4 - len(parts[1]) % 4))
|
||||
)
|
||||
|
||||
return {
|
||||
"header": header,
|
||||
"payload": payload,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_jwt_token_structure_with_custom_client():
|
||||
"""
|
||||
Test that we can create a JWT-enabled OAuth client and examine the token structure.
|
||||
|
||||
This test manually configures a JWT client and obtains a token.
|
||||
"""
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
# This test requires manual setup of a JWT client
|
||||
# Skip if not configured
|
||||
client_id = os.getenv("NEXTCLOUD_JWT_CLIENT_ID")
|
||||
if not client_id:
|
||||
pytest.skip("NEXTCLOUD_JWT_CLIENT_ID not set - skipping JWT token test")
|
||||
|
||||
_client_secret = os.getenv("NEXTCLOUD_JWT_CLIENT_SECRET")
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
||||
|
||||
# Fetch discovery
|
||||
async with httpx.AsyncClient() as client:
|
||||
discovery_response = await client.get(
|
||||
f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
)
|
||||
discovery_response.raise_for_status()
|
||||
discovery = discovery_response.json()
|
||||
|
||||
_token_endpoint = discovery["token_endpoint"]
|
||||
|
||||
# For this test, we'll use client credentials grant if supported
|
||||
# Otherwise, skip this test
|
||||
pytest.skip(
|
||||
"JWT token test requires OAuth flow - use manual testing script instead"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_opaque_token_vs_jwt_comparison():
|
||||
"""
|
||||
Compare opaque tokens vs JWT tokens to understand the differences.
|
||||
|
||||
This is a documentation test that explains the findings.
|
||||
"""
|
||||
# This test documents our findings about JWT vs opaque tokens
|
||||
# Based on manual testing with the test script
|
||||
|
||||
findings = {
|
||||
"oidc_app_capabilities": {
|
||||
"supports_jwt_tokens": True,
|
||||
"supports_opaque_tokens": True,
|
||||
"configuration_method": "per-client via token_type field",
|
||||
"jwt_standard": "RFC 9068 (OAuth 2.0 Access Token JWT Profile)",
|
||||
},
|
||||
"dynamic_registration": {
|
||||
"sets_allowed_scopes": False,
|
||||
"note": "Dynamic registration does NOT populate allowed_scopes from the scope parameter in registration request",
|
||||
"workaround": "Must use occ oidc:create with --allowed_scopes flag or manually update via web UI/API",
|
||||
},
|
||||
"jwt_token_structure": {
|
||||
"header": {
|
||||
"typ": "at+JWT", # RFC 9068 access token type
|
||||
"alg": "RS256", # Signature algorithm
|
||||
},
|
||||
"payload_claims": {
|
||||
"iss": "issuer URL",
|
||||
"sub": "user ID",
|
||||
"aud": "client ID",
|
||||
"exp": "expiration timestamp",
|
||||
"iat": "issued at timestamp",
|
||||
"scope": "space-separated scope string (THIS IS THE KEY!)",
|
||||
"client_id": "client identifier",
|
||||
"jti": "JWT ID",
|
||||
# Optional based on scopes:
|
||||
"roles": "if roles scope present",
|
||||
"groups": "if groups scope present",
|
||||
"email": "if email scope present",
|
||||
"name": "if profile scope present",
|
||||
},
|
||||
"scope_claim": {
|
||||
"format": "space-separated string",
|
||||
"example": "openid profile email nc:read nc:write",
|
||||
"extraction": "payload['scope'].split()",
|
||||
},
|
||||
},
|
||||
"scope_validation": {
|
||||
"oidc_app": {
|
||||
"validates": True,
|
||||
"method": "Intersects requested scopes with allowed_scopes per client",
|
||||
"location": "LoginRedirectorController.php:251-267",
|
||||
},
|
||||
"user_oidc_app": {
|
||||
"validates_scopes": False,
|
||||
"validates": ["token expiration", "issuer", "audience (optional)"],
|
||||
"limitation": "Does NOT extract or validate scopes from JWT",
|
||||
},
|
||||
},
|
||||
"token_size": {
|
||||
"opaque": "72 characters",
|
||||
"jwt": "~800-1200 characters (depends on claims)",
|
||||
"overhead": "JWT is 10-15x larger than opaque tokens",
|
||||
},
|
||||
"recommendation": {
|
||||
"for_mcp_server": "Use JWT tokens with self-validation",
|
||||
"reasoning": [
|
||||
"Can extract scopes directly from token payload",
|
||||
"No additional API call needed",
|
||||
"Standard approach (RFC 9068)",
|
||||
"Works with existing oidc app",
|
||||
],
|
||||
"alternative": "Implement introspection endpoint in oidc app (future work)",
|
||||
},
|
||||
}
|
||||
|
||||
# Print findings for documentation
|
||||
print("\n" + "=" * 80)
|
||||
print("JWT Token vs Opaque Token Findings")
|
||||
print("=" * 80)
|
||||
print(json.dumps(findings, indent=2))
|
||||
print("=" * 80 + "\n")
|
||||
|
||||
# This test always passes - it's for documentation
|
||||
assert True, "Findings documented"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_scope_presence_in_jwt():
|
||||
"""
|
||||
Verify that custom scopes (nc:read, nc:write) are present in JWT tokens.
|
||||
|
||||
NOTE: This test documents the expected behavior based on manual testing.
|
||||
Actual implementation will be tested in integration tests after JWT validation is implemented.
|
||||
"""
|
||||
expected_behavior = {
|
||||
"client_configuration": {
|
||||
"allowed_scopes": "openid profile email nc:read nc:write",
|
||||
"token_type": "jwt",
|
||||
},
|
||||
"authorization_request": {
|
||||
"scope": "openid profile email nc:read nc:write",
|
||||
},
|
||||
"token_response": {
|
||||
"access_token": "JWT with scope claim",
|
||||
},
|
||||
"jwt_payload": {
|
||||
"scope": "openid profile email nc:read nc:write", # All requested scopes present if in allowed_scopes
|
||||
},
|
||||
"scope_filtering": {
|
||||
"description": "oidc app filters requested scopes against allowed_scopes",
|
||||
"example": {
|
||||
"requested": "openid profile nc:read nc:write nc:admin",
|
||||
"allowed": "openid profile email nc:read nc:write",
|
||||
"granted": "openid profile nc:read nc:write", # nc:admin filtered out, email not requested
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("Expected JWT Scope Behavior")
|
||||
print("=" * 80)
|
||||
print(json.dumps(expected_behavior, indent=2))
|
||||
print("=" * 80 + "\n")
|
||||
|
||||
assert True, "Expected behavior documented"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run with: uv run pytest tests/server/test_jwt_tokens.py -v
|
||||
pytest.main([__file__, "-v", "-s"])
|
||||
@@ -1,60 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
async def test_mcp_oauth_server_connection(nc_mcp_oauth_client):
|
||||
"""Test connection to OAuth-enabled MCP server."""
|
||||
result = await nc_mcp_oauth_client.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
|
||||
logger.info(f"OAuth MCP server has {len(result.tools)} tools available")
|
||||
|
||||
|
||||
async def test_mcp_oauth_tool_execution(nc_mcp_oauth_client):
|
||||
"""Test executing a tool on the OAuth-enabled MCP server."""
|
||||
import json
|
||||
|
||||
# Example: Execute the 'nc_notes_search_notes' tool
|
||||
result = await nc_mcp_oauth_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
response_data = json.loads(result.content[0].text)
|
||||
|
||||
# The search response should have a 'results' field containing the list
|
||||
assert "results" in response_data
|
||||
assert isinstance(response_data["results"], list)
|
||||
|
||||
logger.info(
|
||||
f"Successfully executed 'nc_notes_search_notes' tool on OAuth MCP server and got {len(response_data['results'])} notes."
|
||||
)
|
||||
|
||||
|
||||
async def test_mcp_oauth_client_with_playwright(nc_mcp_oauth_client):
|
||||
"""Test that MCP OAuth client via Playwright can execute tools."""
|
||||
|
||||
# Test: Execute the 'nc_notes_search_notes' tool
|
||||
result = await nc_mcp_oauth_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
response_data = json.loads(result.content[0].text)
|
||||
|
||||
# The search response should have a 'results' field containing the list
|
||||
assert "results" in response_data
|
||||
assert isinstance(response_data["results"], list)
|
||||
|
||||
logger.info(
|
||||
f"Successfully executed 'nc_notes_search_notes' tool on Playwright OAuth MCP server and got {len(response_data['results'])} notes."
|
||||
)
|
||||
@@ -1,246 +0,0 @@
|
||||
"""Integration tests for JWT OAuth authentication.
|
||||
|
||||
These tests verify:
|
||||
1. JWT token authentication works correctly
|
||||
2. JWT token verification via JWKS
|
||||
3. Scope information is properly extracted from JWT claims
|
||||
4. Dynamic tool filtering works with JWT tokens
|
||||
5. All MCP operations work with JWT authentication
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
async def test_jwt_mcp_server_connection(nc_mcp_oauth_jwt_client):
|
||||
"""Test connection to JWT OAuth-enabled MCP server."""
|
||||
result = await nc_mcp_oauth_jwt_client.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
|
||||
logger.info(f"JWT OAuth MCP server has {len(result.tools)} tools available")
|
||||
|
||||
|
||||
async def test_jwt_token_authentication(nc_mcp_oauth_jwt_client):
|
||||
"""Test that JWT token authentication works."""
|
||||
# Execute a simple read operation
|
||||
result = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
response_data = json.loads(result.content[0].text)
|
||||
|
||||
assert "results" in response_data
|
||||
assert isinstance(response_data["results"], list)
|
||||
|
||||
logger.info(
|
||||
f"Successfully authenticated with JWT token and executed tool, got {len(response_data['results'])} notes."
|
||||
)
|
||||
|
||||
|
||||
async def test_jwt_tool_list_operations(nc_mcp_oauth_jwt_client):
|
||||
"""Test that list_tools works with JWT authentication."""
|
||||
result = await nc_mcp_oauth_jwt_client.list_tools()
|
||||
|
||||
# Verify we have tools
|
||||
assert len(result.tools) > 0
|
||||
|
||||
# Verify some expected tools exist
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
assert "nc_notes_get_note" in tool_names
|
||||
assert "nc_notes_create_note" in tool_names
|
||||
assert "nc_calendar_list_calendars" in tool_names
|
||||
assert "nc_webdav_list_directory" in tool_names
|
||||
|
||||
logger.info(f"JWT server provides {len(result.tools)} tools")
|
||||
|
||||
|
||||
async def test_jwt_read_operation(nc_mcp_oauth_jwt_client):
|
||||
"""Test read operation with JWT authentication."""
|
||||
# List calendars (read operation)
|
||||
result = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_calendar_list_calendars", arguments={}
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
response_data = json.loads(result.content[0].text)
|
||||
|
||||
assert "calendars" in response_data
|
||||
assert isinstance(response_data["calendars"], list)
|
||||
|
||||
logger.info(
|
||||
f"Successfully executed read operation with JWT, got {len(response_data['calendars'])} calendars."
|
||||
)
|
||||
|
||||
|
||||
async def test_jwt_write_operation(nc_mcp_oauth_jwt_client):
|
||||
"""Test write operation with JWT authentication."""
|
||||
import uuid
|
||||
|
||||
# Create a note (write operation)
|
||||
note_title = f"JWT Test Note {uuid.uuid4().hex[:8]}"
|
||||
note_content = "This note was created during JWT authentication testing"
|
||||
|
||||
result = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_create_note",
|
||||
arguments={
|
||||
"title": note_title,
|
||||
"content": note_content,
|
||||
"category": "Testing",
|
||||
},
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
response_data = json.loads(result.content[0].text)
|
||||
|
||||
# Verify note was created
|
||||
assert "id" in response_data
|
||||
assert response_data["title"] == note_title
|
||||
|
||||
note_id = response_data["id"]
|
||||
logger.info(f"Successfully created note {note_id} with JWT authentication")
|
||||
|
||||
# Clean up: Delete the note
|
||||
delete_result = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_delete_note", arguments={"note_id": note_id}
|
||||
)
|
||||
|
||||
assert delete_result.isError is False, f"Cleanup failed: {delete_result.content}"
|
||||
logger.info(f"Cleaned up test note {note_id}")
|
||||
|
||||
|
||||
async def test_jwt_multiple_operations(nc_mcp_oauth_jwt_client):
|
||||
"""Test multiple operations with same JWT token to verify token persistence."""
|
||||
# First operation: Search notes
|
||||
result1 = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
assert result1.isError is False
|
||||
|
||||
# Second operation: List calendars
|
||||
result2 = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_calendar_list_calendars", arguments={}
|
||||
)
|
||||
assert result2.isError is False
|
||||
|
||||
# Third operation: List directory
|
||||
result3 = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_webdav_list_directory", arguments={"path": "/"}
|
||||
)
|
||||
assert result3.isError is False
|
||||
|
||||
logger.info("Successfully executed multiple operations with JWT token")
|
||||
|
||||
|
||||
async def test_jwt_vs_opaque_token_compatibility(
|
||||
nc_mcp_oauth_client, nc_mcp_oauth_jwt_client
|
||||
):
|
||||
"""Verify that both opaque and JWT tokens provide same functionality."""
|
||||
# Execute same operation on both servers
|
||||
opaque_result = await nc_mcp_oauth_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
jwt_result = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
# Both should succeed
|
||||
assert opaque_result.isError is False
|
||||
assert jwt_result.isError is False
|
||||
|
||||
# Both should have results
|
||||
opaque_data = json.loads(opaque_result.content[0].text)
|
||||
jwt_data = json.loads(jwt_result.content[0].text)
|
||||
|
||||
assert "results" in opaque_data
|
||||
assert "results" in jwt_data
|
||||
|
||||
# Results should be the same (same user, same notes)
|
||||
assert len(opaque_data["results"]) == len(jwt_data["results"])
|
||||
|
||||
logger.info(
|
||||
"Verified opaque and JWT tokens provide identical functionality: "
|
||||
f"{len(opaque_data['results'])} notes accessible from both servers"
|
||||
)
|
||||
|
||||
|
||||
async def test_jwt_error_handling(nc_mcp_oauth_jwt_client):
|
||||
"""Test error handling with JWT authentication."""
|
||||
# Try to get a non-existent note
|
||||
result = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_get_note", arguments={"note_id": 999999}
|
||||
)
|
||||
|
||||
# Should get an error (note doesn't exist)
|
||||
assert result.isError is True
|
||||
logger.info("JWT server correctly handles errors for invalid operations")
|
||||
|
||||
|
||||
async def test_jwt_scope_enforcement(nc_mcp_oauth_jwt_client):
|
||||
"""Test that JWT server properly enforces scopes."""
|
||||
# This test assumes the JWT token has both nc:read and nc:write scopes
|
||||
# Both read and write operations should succeed
|
||||
|
||||
# Read operation
|
||||
read_result = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
assert read_result.isError is False
|
||||
|
||||
# Write operation
|
||||
import uuid
|
||||
|
||||
note_title = f"Scope Test {uuid.uuid4().hex[:8]}"
|
||||
write_result = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_create_note",
|
||||
arguments={
|
||||
"title": note_title,
|
||||
"content": "Testing scope enforcement",
|
||||
"category": "Testing",
|
||||
},
|
||||
)
|
||||
assert write_result.isError is False
|
||||
|
||||
# Clean up
|
||||
note_id = json.loads(write_result.content[0].text)["id"]
|
||||
await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_delete_note", arguments={"note_id": note_id}
|
||||
)
|
||||
|
||||
logger.info("JWT server properly allows operations based on token scopes")
|
||||
|
||||
|
||||
async def test_jwt_automation_worked(nc_mcp_oauth_jwt_client):
|
||||
"""Test that verifies the automated JWT client creation worked correctly.
|
||||
|
||||
This test confirms that:
|
||||
1. JWT client was auto-created during container initialization
|
||||
2. MCP server loaded credentials from auto-generated file
|
||||
3. JWT authentication flow works end-to-end
|
||||
4. Server uses JWT tokens (not opaque tokens)
|
||||
"""
|
||||
# If we can connect and execute tools, the automation worked
|
||||
result = await nc_mcp_oauth_jwt_client.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
|
||||
# Execute a tool to verify full OAuth flow
|
||||
tool_result = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
assert tool_result.isError is False
|
||||
|
||||
logger.info(
|
||||
"✅ JWT client automation successful! "
|
||||
"Auto-generated credentials working correctly."
|
||||
)
|
||||
@@ -1,9 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_and_delete_user(nc_client: NextcloudClient, test_user):
|
||||
"""Test creating a user and verifying deletion (cleanup by fixture)."""
|
||||
user_config = test_user
|
||||
@@ -29,7 +26,6 @@ async def test_create_and_delete_user(nc_client: NextcloudClient, test_user):
|
||||
# Note: Fixture cleanup will also try to delete but handle 404 gracefully
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_user_field(nc_client: NextcloudClient, test_user):
|
||||
"""Test updating user fields."""
|
||||
user_config = test_user
|
||||
@@ -44,7 +40,6 @@ async def test_update_user_field(nc_client: NextcloudClient, test_user):
|
||||
# Fixture will handle cleanup
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_user_groups(nc_client: NextcloudClient, test_user_in_group):
|
||||
"""Test adding and removing users from groups."""
|
||||
user_config, groupid = test_user_in_group
|
||||
@@ -61,7 +56,6 @@ async def test_user_groups(nc_client: NextcloudClient, test_user_in_group):
|
||||
# Fixtures will handle cleanup
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_user_subadmins(nc_client: NextcloudClient, test_user, test_group):
|
||||
"""Test promoting and demoting subadmins."""
|
||||
user_config = test_user
|
||||
@@ -82,7 +76,6 @@ async def test_user_subadmins(nc_client: NextcloudClient, test_user, test_group)
|
||||
# Fixtures will handle cleanup
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_disable_enable_user(nc_client: NextcloudClient, test_user):
|
||||
"""Test disabling and enabling users."""
|
||||
user_config = test_user
|
||||
@@ -102,7 +95,6 @@ async def test_disable_enable_user(nc_client: NextcloudClient, test_user):
|
||||
# Fixture will handle cleanup
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_editable_user_fields(nc_client: NextcloudClient):
|
||||
editable_fields = await nc_client.users.get_editable_user_fields()
|
||||
assert "displayname" in editable_fields
|
||||
|
||||
Reference in New Issue
Block a user