test: Setup interactive browser test
This commit is contained in:
@@ -4,3 +4,4 @@ __pycache__/
|
|||||||
*.env
|
*.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
.nextcloud_oauth_test_client.json
|
||||||
|
|||||||
@@ -274,18 +274,20 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
# Determine authentication mode
|
# Determine authentication mode
|
||||||
oauth_enabled = is_oauth_mode()
|
oauth_enabled = is_oauth_mode()
|
||||||
|
|
||||||
# WARNING: This is a synchronous function but OAuth setup requires async
|
|
||||||
# For now, OAuth configuration will be handled differently
|
|
||||||
# We'll need to restructure this or use a factory pattern
|
|
||||||
|
|
||||||
if oauth_enabled:
|
if oauth_enabled:
|
||||||
logger.info("Configuring MCP server for OAuth mode")
|
logger.info("Configuring MCP server for OAuth mode")
|
||||||
logger.warning(
|
# Asynchronously get the OAuth configuration
|
||||||
"OAuth mode requires async initialization - use factory pattern or separate setup"
|
import asyncio
|
||||||
|
|
||||||
|
nextcloud_host, token_verifier, auth_settings = asyncio.run(
|
||||||
|
setup_oauth_config()
|
||||||
|
)
|
||||||
|
mcp = FastMCP(
|
||||||
|
"Nextcloud MCP",
|
||||||
|
lifespan=app_lifespan_oauth,
|
||||||
|
token_verifier=token_verifier,
|
||||||
|
auth=auth_settings,
|
||||||
)
|
)
|
||||||
# For now, fall back to a simplified OAuth setup
|
|
||||||
# TODO: This needs to be restructured to support async initialization
|
|
||||||
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_oauth)
|
|
||||||
else:
|
else:
|
||||||
logger.info("Configuring MCP server for BasicAuth mode")
|
logger.info("Configuring MCP server for BasicAuth mode")
|
||||||
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic)
|
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic)
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ async def load_or_register_client(
|
|||||||
storage_path: str | Path,
|
storage_path: str | Path,
|
||||||
client_name: str = "Nextcloud MCP Server",
|
client_name: str = "Nextcloud MCP Server",
|
||||||
redirect_uris: list[str] | None = None,
|
redirect_uris: list[str] | None = None,
|
||||||
force_register: bool = False,
|
force_register: bool = True,
|
||||||
) -> ClientInfo:
|
) -> ClientInfo:
|
||||||
"""
|
"""
|
||||||
Load client from storage or register a new one if not found/expired.
|
Load client from storage or register a new one if not found/expired.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
|
|||||||
ValueError: If username cannot be extracted from token
|
ValueError: If username cannot be extracted from token
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
logger.info(f"Inspecting session object: {dir(ctx.request_context.session)}")
|
||||||
# Get AccessToken from MCP session (set by TokenVerifier)
|
# Get AccessToken from MCP session (set by TokenVerifier)
|
||||||
access_token: AccessToken = ctx.request_context.session.access_token
|
access_token: AccessToken = ctx.request_context.session.access_token
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class NextcloudClient:
|
|||||||
|
|
||||||
async def capabilities(self):
|
async def capabilities(self):
|
||||||
response = await self._client.get(
|
response = await self._client.get(
|
||||||
"/ocs/v2.php/cloud/capabilities",
|
"/ocs/v2.php/apps/notifications/api/v2/notifications",
|
||||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|||||||
+2
-1
@@ -25,7 +25,8 @@ log_cli = 1
|
|||||||
log_cli_level = "INFO"
|
log_cli_level = "INFO"
|
||||||
log_level = "INFO"
|
log_level = "INFO"
|
||||||
markers = [
|
markers = [
|
||||||
"integration: marks tests as slow (deselect with '-m \"not slow\"')"
|
"integration: marks tests as slow (deselect with '-m \"not slow\"')",
|
||||||
|
"interactive: marks tests as interactive (deselect with '-m \"not interactive\"')"
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.commitizen]
|
[tool.commitizen]
|
||||||
|
|||||||
+96
-39
@@ -454,7 +454,7 @@ async def temporary_board_with_card(
|
|||||||
|
|
||||||
async def get_oauth_token(nextcloud_url: str, username: str, password: str) -> str:
|
async def get_oauth_token(nextcloud_url: str, username: str, password: str) -> str:
|
||||||
"""
|
"""
|
||||||
Get an OAuth access token from Nextcloud OIDC using Resource Owner Password flow.
|
Get an OAuth access token from Nextcloud OIDC using Client Credentials flow.
|
||||||
|
|
||||||
This is a helper function for testing only - it bypasses the normal OAuth flow
|
This is a helper function for testing only - it bypasses the normal OAuth flow
|
||||||
to directly obtain a token for automated testing.
|
to directly obtain a token for automated testing.
|
||||||
@@ -501,16 +501,13 @@ async def get_oauth_token(nextcloud_url: str, username: str, password: str) -> s
|
|||||||
redirect_uris=["http://localhost:8000/oauth/callback"],
|
redirect_uris=["http://localhost:8000/oauth/callback"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Use client credentials to get a token via password grant
|
# Use client credentials to get a token via client_credentials grant
|
||||||
# Note: This requires the OIDC app to support Resource Owner Password flow
|
|
||||||
token_response = await http_client.post(
|
token_response = await http_client.post(
|
||||||
token_endpoint,
|
token_endpoint,
|
||||||
data={
|
data={
|
||||||
"grant_type": "password",
|
"grant_type": "client_credentials",
|
||||||
"client_id": client_info.client_id,
|
"client_id": client_info.client_id,
|
||||||
"client_secret": client_info.client_secret,
|
"client_secret": client_info.client_secret,
|
||||||
"username": username,
|
|
||||||
"password": password,
|
|
||||||
"scope": "openid profile email",
|
"scope": "openid profile email",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -590,43 +587,103 @@ async def nc_oauth_client(oauth_token: str) -> AsyncGenerator[NextcloudClient, A
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def nc_mcp_oauth_client() -> AsyncGenerator[ClientSession, Any]:
|
async def nc_mcp_oauth_client_interactive() -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""
|
"""
|
||||||
Fixture to create an MCP client session for OAuth integration tests.
|
Fixture to create an MCP client session for interactive OAuth integration tests.
|
||||||
Connects to the OAuth-enabled MCP server on port 8001.
|
Performs an interactive OAuth flow to obtain an access token.
|
||||||
"""
|
"""
|
||||||
logger.info("Creating Streamable HTTP client for OAuth MCP server")
|
import webbrowser
|
||||||
streamable_context = streamablehttp_client("http://127.0.0.1:8001/mcp")
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
session_context = None
|
import threading
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
try:
|
import time
|
||||||
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
|
||||||
session_context = ClientSession(read_stream, write_stream)
|
|
||||||
session = await session_context.__aenter__()
|
|
||||||
await session.initialize()
|
|
||||||
logger.info("OAuth MCP client session initialized successfully")
|
|
||||||
|
|
||||||
yield session
|
auth_code = None
|
||||||
|
|
||||||
finally:
|
class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
||||||
# Clean up in reverse order, ignoring task scope issues
|
def do_GET(self):
|
||||||
if session_context is not None:
|
nonlocal auth_code
|
||||||
try:
|
if self.path.startswith("/shutdown"):
|
||||||
await session_context.__aexit__(None, None, None)
|
self.send_response(200)
|
||||||
except RuntimeError as e:
|
self.send_header("Content-type", "text/html")
|
||||||
if "cancel scope" in str(e):
|
self.end_headers()
|
||||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
self.wfile.write(
|
||||||
else:
|
b"<html><body><h1>Server shutting down...</h1></body></html>"
|
||||||
logger.warning(f"Error closing OAuth session: {e}")
|
)
|
||||||
except Exception as e:
|
threading.Thread(target=httpd.shutdown).start()
|
||||||
logger.warning(f"Error closing OAuth session: {e}")
|
return
|
||||||
|
|
||||||
|
parsed_path = urlparse(self.path)
|
||||||
|
query = parse_qs(parsed_path.query)
|
||||||
|
auth_code = query.get("code", [None])[0]
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-type", "text/html")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(
|
||||||
|
b"<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
httpd = HTTPServer(("localhost", 8081), OAuthCallbackHandler)
|
||||||
|
server_thread = threading.Thread(target=httpd.serve_forever)
|
||||||
|
server_thread.daemon = True
|
||||||
|
server_thread.start()
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.client_registration import load_or_register_client
|
||||||
|
|
||||||
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||||
|
async with httpx.AsyncClient() as http_client:
|
||||||
|
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||||
|
discovery_response = await http_client.get(discovery_url)
|
||||||
|
oidc_config = discovery_response.json()
|
||||||
|
token_endpoint = oidc_config.get("token_endpoint")
|
||||||
|
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||||
|
authorization_endpoint = oidc_config.get("authorization_endpoint")
|
||||||
|
|
||||||
|
client_info = await load_or_register_client(
|
||||||
|
nextcloud_url=nextcloud_host,
|
||||||
|
registration_endpoint=registration_endpoint,
|
||||||
|
storage_path=".nextcloud_oauth_test_client.json",
|
||||||
|
redirect_uris=["http://localhost:8081"],
|
||||||
|
force_register=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_url = f"{authorization_endpoint}?response_type=code&client_id={client_info.client_id}&redirect_uri=http://localhost:8081&scope=openid%20profile%20email"
|
||||||
|
webbrowser.open(auth_url)
|
||||||
|
|
||||||
|
while not auth_code:
|
||||||
|
logger.info("Sleeping until auth_code available")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
token_response = await http_client.post(
|
||||||
|
token_endpoint,
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": auth_code,
|
||||||
|
"redirect_uri": "http://localhost:8081",
|
||||||
|
"client_id": client_info.client_id,
|
||||||
|
"client_secret": client_info.client_secret,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Token response: {token_response.text}")
|
||||||
|
|
||||||
|
# Shut down the server
|
||||||
|
token_data = token_response.json()
|
||||||
|
logger.info(f"Token data: {token_data}")
|
||||||
|
access_token = token_data.get("access_token")
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {access_token}"}
|
||||||
|
logger.info(f"Headers: {headers}")
|
||||||
|
async with streamablehttp_client("http://127.0.0.1:8001/mcp", headers=headers) as (
|
||||||
|
read_stream,
|
||||||
|
write_stream,
|
||||||
|
_,
|
||||||
|
):
|
||||||
|
async with ClientSession(read_stream, write_stream) as session:
|
||||||
|
await session.initialize()
|
||||||
try:
|
try:
|
||||||
await streamable_context.__aexit__(None, None, None)
|
yield session
|
||||||
except RuntimeError as e:
|
finally:
|
||||||
if "cancel scope" in str(e):
|
# Shut down the server
|
||||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
await http_client.get("http://localhost:8081/shutdown")
|
||||||
else:
|
|
||||||
logger.warning(f"Error closing OAuth streamable HTTP client: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Error closing OAuth streamable HTTP client: {e}")
|
|
||||||
|
|||||||
@@ -105,22 +105,27 @@ class TestOAuthTokenValidation:
|
|||||||
class TestOAuthMCPIntegration:
|
class TestOAuthMCPIntegration:
|
||||||
"""Test OAuth integration with MCP server."""
|
"""Test OAuth integration with MCP server."""
|
||||||
|
|
||||||
@pytest.mark.skip(
|
|
||||||
reason="OAuth MCP server integration requires full OAuth flow implementation"
|
|
||||||
)
|
|
||||||
async def test_mcp_oauth_server_connection(self, nc_mcp_oauth_client):
|
async def test_mcp_oauth_server_connection(self, nc_mcp_oauth_client):
|
||||||
"""Test connection to OAuth-enabled MCP server."""
|
"""Test connection to OAuth-enabled MCP server."""
|
||||||
# This test is currently skipped because the OAuth MCP server
|
|
||||||
# requires the full OAuth authorization flow to be implemented
|
|
||||||
# in the MCP SDK and app.py
|
|
||||||
|
|
||||||
# Once implemented, this test should:
|
|
||||||
# 1. Connect to the OAuth MCP server
|
|
||||||
# 2. Verify tools are available
|
|
||||||
# 3. Call a tool and verify it works with OAuth auth
|
|
||||||
|
|
||||||
result = await nc_mcp_oauth_client.list_tools()
|
result = await nc_mcp_oauth_client.list_tools()
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result.tools) > 0
|
assert len(result.tools) > 0
|
||||||
|
|
||||||
logger.info(f"OAuth MCP server has {len(result.tools)} tools available")
|
logger.info(f"OAuth MCP server has {len(result.tools)} tools available")
|
||||||
|
|
||||||
|
async def test_mcp_oauth_tool_execution(self, nc_mcp_oauth_client):
|
||||||
|
"""Test executing a tool on the OAuth-enabled MCP server."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Example: Execute the 'nc_tables_list_tables' tool
|
||||||
|
result = await nc_mcp_oauth_client.call_tool("nc_tables_list_tables")
|
||||||
|
|
||||||
|
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||||
|
assert result.content is not None
|
||||||
|
notes_list = json.loads(result.content[0].text)
|
||||||
|
|
||||||
|
assert isinstance(notes_list, list)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Successfully executed 'nc_tables_list_tables' tool on OAuth MCP server and got {len(notes_list)} notes."
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Interactive integration tests for OAuth authentication."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.integration, pytest.mark.interactive]
|
||||||
|
|
||||||
|
|
||||||
|
class TestOAuthInteractive:
|
||||||
|
"""Test interactive OAuth authentication."""
|
||||||
|
|
||||||
|
async def test_mcp_oauth_tool_execution_interactive(
|
||||||
|
self, nc_mcp_oauth_client_interactive
|
||||||
|
):
|
||||||
|
"""Test executing a tool on the OAuth-enabled MCP server with an interactive token."""
|
||||||
|
# Example: Execute the 'nc_notes_list' tool
|
||||||
|
result = await nc_mcp_oauth_client_interactive.call_tool("nc_tables_list")
|
||||||
|
|
||||||
|
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||||
|
assert result.content is not None
|
||||||
|
import json
|
||||||
|
|
||||||
|
notes_list = json.loads(result.content[0].text)
|
||||||
|
|
||||||
|
assert isinstance(notes_list, list)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Successfully executed 'nc_notes_list' tool on OAuth MCP server and got {len(notes_list)} notes."
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user