Merge remote-tracking branch 'origin/master' into feature/user-api
This commit is contained in:
+2
-1
@@ -5,10 +5,11 @@ are present in calendar events and contacts during round-trip operations.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import pytest
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.models.deck import DeckStack, DeckCard, DeckLabel
|
||||
from nextcloud_mcp_server.models.deck import DeckCard, DeckLabel, DeckStack
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
pytestmark = pytest.mark.integration
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Integration tests for OAuth authentication."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.auth import BearerAuth
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
# OAuth Client Tests
|
||||
|
||||
|
||||
async def test_oauth_client_capabilities(nc_oauth_client: NextcloudClient):
|
||||
"""Test that OAuth client can fetch capabilities."""
|
||||
capabilities = await nc_oauth_client.capabilities()
|
||||
|
||||
assert capabilities is not None
|
||||
assert "ocs" in capabilities
|
||||
logger.info(
|
||||
f"OAuth client successfully fetched capabilities: {capabilities.get('ocs').get('meta')}"
|
||||
)
|
||||
|
||||
|
||||
async def test_oauth_client_notes_list(nc_oauth_client: NextcloudClient):
|
||||
"""Test that OAuth client can list notes."""
|
||||
notes = await nc_oauth_client.notes.get_all_notes()
|
||||
|
||||
assert isinstance(notes, list)
|
||||
logger.info(f"OAuth client successfully listed {len(notes)} notes")
|
||||
|
||||
|
||||
async def test_oauth_client_create_note(nc_oauth_client: NextcloudClient):
|
||||
"""Test that OAuth client can create and delete a note."""
|
||||
# Create note
|
||||
note_title = "OAuth Test Note"
|
||||
note_content = "This note was created with OAuth authentication"
|
||||
|
||||
created_note = await nc_oauth_client.notes.create_note(
|
||||
title=note_title, content=note_content
|
||||
)
|
||||
|
||||
assert created_note is not None
|
||||
assert created_note.get("title") == note_title
|
||||
note_id = created_note.get("id")
|
||||
assert note_id is not None
|
||||
|
||||
logger.info(f"OAuth client successfully created note with ID: {note_id}")
|
||||
|
||||
# Clean up - delete the note
|
||||
try:
|
||||
await nc_oauth_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"OAuth client successfully deleted note {note_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clean up test note {note_id}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# OAuth Token Validation Tests
|
||||
|
||||
|
||||
async def test_token_in_request_headers(
|
||||
nc_oauth_client: NextcloudClient, playwright_oauth_token: str
|
||||
):
|
||||
"""Verify that bearer token is being used in requests."""
|
||||
# The client should be using BearerAuth
|
||||
assert nc_oauth_client._client.auth is not None
|
||||
|
||||
# Make a request and verify it works
|
||||
capabilities = await nc_oauth_client.capabilities()
|
||||
assert capabilities is not None
|
||||
|
||||
logger.info("OAuth bearer token is correctly included in requests")
|
||||
|
||||
|
||||
async def test_invalid_token_fails():
|
||||
"""Test that an invalid token results in authentication failure."""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("NEXTCLOUD_HOST not set")
|
||||
|
||||
# Create client with invalid token using BearerAuth
|
||||
invalid_client = NextcloudClient(
|
||||
base_url=nextcloud_host,
|
||||
username="testuser",
|
||||
auth=BearerAuth("invalid_token_12345"),
|
||||
)
|
||||
|
||||
# Attempt to use a protected endpoint - should fail with 401
|
||||
# Note: capabilities endpoint is public and doesn't require auth
|
||||
with pytest.raises(HTTPStatusError) as exc_info:
|
||||
await invalid_client.notes.get_all_notes()
|
||||
|
||||
assert exc_info.value.response.status_code == 401
|
||||
|
||||
await invalid_client.close()
|
||||
logger.info("Invalid OAuth token correctly rejected")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Interactive integration tests for OAuth authentication."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
"GITHUB_ACTIONS" in os.environ,
|
||||
reason="Unable to access interactive browser in GitHub Actions",
|
||||
)
|
||||
async def test_oauth_client_with_interactive_flow(nc_oauth_client_interactive):
|
||||
"""Test that OAuth client created via interactive flow can access Nextcloud APIs."""
|
||||
# Test 1: Check capabilities
|
||||
capabilities = await nc_oauth_client_interactive.capabilities()
|
||||
assert capabilities is not None
|
||||
logger.info("OAuth client (interactive) successfully fetched capabilities")
|
||||
|
||||
# Test 2: List notes
|
||||
notes = await nc_oauth_client_interactive.notes.get_all_notes()
|
||||
assert isinstance(notes, list)
|
||||
logger.info(f"OAuth client (interactive) successfully listed {len(notes)} notes")
|
||||
|
||||
# Test 3: Create and delete a note
|
||||
test_note = await nc_oauth_client_interactive.notes.create_note(
|
||||
title="OAuth Interactive Test Note",
|
||||
content="This note was created during OAuth interactive testing",
|
||||
)
|
||||
assert test_note is not None
|
||||
assert test_note.get("id") is not None
|
||||
note_id = test_note["id"]
|
||||
logger.info(f"OAuth client (interactive) successfully created note {note_id}")
|
||||
|
||||
# Clean up
|
||||
await nc_oauth_client_interactive.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"OAuth client (interactive) successfully deleted note {note_id}")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Integration tests for Playwright-based OAuth authentication."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
async def test_playwright_oauth_token_acquisition(playwright_oauth_token: str):
|
||||
"""Test that Playwright can acquire an OAuth token automatically."""
|
||||
assert playwright_oauth_token is not None
|
||||
assert isinstance(playwright_oauth_token, str)
|
||||
assert len(playwright_oauth_token) > 0
|
||||
logger.info(
|
||||
f"Successfully acquired OAuth token via Playwright: {playwright_oauth_token[:20]}..."
|
||||
)
|
||||
|
||||
|
||||
async def test_oauth_client_with_playwright_flow(nc_oauth_client_playwright):
|
||||
"""Test that OAuth client created via Playwright flow can access Nextcloud APIs."""
|
||||
# Test 1: Check capabilities
|
||||
capabilities = await nc_oauth_client_playwright.capabilities()
|
||||
assert capabilities is not None
|
||||
logger.info("OAuth client (Playwright) successfully fetched capabilities")
|
||||
|
||||
# Test 2: List notes
|
||||
notes = await nc_oauth_client_playwright.notes.get_all_notes()
|
||||
assert isinstance(notes, list)
|
||||
logger.info(f"OAuth client (Playwright) successfully listed {len(notes)} notes")
|
||||
+726
-11
@@ -1,31 +1,85 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
from mcp import ClientSession
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def wait_for_nextcloud(
|
||||
host: str, max_attempts: int = 30, delay: float = 2.0
|
||||
) -> bool:
|
||||
"""
|
||||
Wait for Nextcloud server to be ready by checking the status endpoint.
|
||||
|
||||
Args:
|
||||
host: Nextcloud host URL
|
||||
max_attempts: Maximum number of connection attempts
|
||||
delay: Delay between attempts in seconds
|
||||
|
||||
Returns:
|
||||
True if server is ready, False otherwise
|
||||
"""
|
||||
logger.info(f"Waiting for Nextcloud server at {host} to be ready...")
|
||||
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
# Try to hit the status endpoint
|
||||
response = await client.get(f"{host}/status.php")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get("installed"):
|
||||
logger.info(
|
||||
f"Nextcloud server is ready (version: {data.get('versionstring', 'unknown')})"
|
||||
)
|
||||
return True
|
||||
except (httpx.RequestError, httpx.TimeoutException) as e:
|
||||
logger.debug(f"Attempt {attempt}/{max_attempts}: {e}")
|
||||
|
||||
if attempt < max_attempts:
|
||||
logger.info(
|
||||
f"Nextcloud not ready yet, waiting {delay}s... (attempt {attempt}/{max_attempts})"
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
logger.error(
|
||||
f"Nextcloud server at {host} did not become ready after {max_attempts} attempts"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_client() -> AsyncGenerator[NextcloudClient, Any]:
|
||||
"""
|
||||
Fixture to create a NextcloudClient instance for integration tests.
|
||||
Uses environment variables for configuration.
|
||||
Waits for Nextcloud to be ready before proceeding.
|
||||
"""
|
||||
|
||||
assert os.getenv("NEXTCLOUD_HOST"), "NEXTCLOUD_HOST env var not set"
|
||||
assert os.getenv("NEXTCLOUD_USERNAME"), "NEXTCLOUD_USERNAME env var not set"
|
||||
assert os.getenv("NEXTCLOUD_PASSWORD"), "NEXTCLOUD_PASSWORD env var not set"
|
||||
|
||||
host = os.getenv("NEXTCLOUD_HOST")
|
||||
|
||||
# Wait for Nextcloud to be ready
|
||||
if not await wait_for_nextcloud(host):
|
||||
pytest.fail(f"Nextcloud server at {host} is not ready")
|
||||
|
||||
logger.info("Creating session-scoped NextcloudClient from environment variables.")
|
||||
client = NextcloudClient.from_env()
|
||||
# Optional: Perform a quick check like getting capabilities to ensure connection works
|
||||
|
||||
# Perform a quick check to ensure connection works
|
||||
try:
|
||||
await client.capabilities()
|
||||
logger.info(
|
||||
@@ -39,18 +93,18 @@ async def nc_client() -> AsyncGenerator[NextcloudClient, Any]:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
Fixture to create an MCP client session for integration tests.
|
||||
Fixture to create an MCP client session for integration tests using streamable-http.
|
||||
"""
|
||||
logger.info("Creating SSE client")
|
||||
sse_context = sse_client(url="http://127.0.0.1:8000/sse")
|
||||
logger.info("Creating Streamable HTTP client")
|
||||
streamable_context = streamablehttp_client("http://127.0.0.1:8000/mcp")
|
||||
session_context = None
|
||||
|
||||
try:
|
||||
read, write = await sse_context.__aenter__()
|
||||
session_context = ClientSession(read, write)
|
||||
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("MCP client session initialized successfully")
|
||||
@@ -71,14 +125,131 @@ async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
logger.warning(f"Error closing session: {e}")
|
||||
|
||||
try:
|
||||
await sse_context.__aexit__(None, None, None)
|
||||
await streamable_context.__aexit__(None, None, None)
|
||||
except RuntimeError as e:
|
||||
if "cancel scope" in str(e):
|
||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||
else:
|
||||
logger.warning(f"Error closing SSE client: {e}")
|
||||
logger.warning(f"Error closing streamable HTTP client: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing SSE client: {e}")
|
||||
logger.warning(f"Error closing streamable HTTP client: {e}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_oauth_client_interactive(
|
||||
interactive_oauth_token: str,
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
Fixture to create an MCP client session for OAuth integration tests using interactive authentication.
|
||||
Connects to the OAuth-enabled MCP server on port 8001 with OAuth authentication.
|
||||
Requires manual browser login.
|
||||
|
||||
For automated testing, use nc_mcp_oauth_client fixture instead.
|
||||
|
||||
Automatically skips when running in GitHub Actions CI.
|
||||
"""
|
||||
|
||||
logger.info("Creating Streamable HTTP client for OAuth MCP server (Interactive)")
|
||||
|
||||
# Pass OAuth token as Bearer token in headers
|
||||
headers = {"Authorization": f"Bearer {interactive_oauth_token}"}
|
||||
streamable_context = streamablehttp_client(
|
||||
"http://127.0.0.1:8001/mcp", headers=headers
|
||||
)
|
||||
session_context = None
|
||||
|
||||
try:
|
||||
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 (Interactive) initialized successfully")
|
||||
|
||||
yield session
|
||||
|
||||
finally:
|
||||
# Clean up in reverse order, ignoring task scope issues
|
||||
if session_context is not None:
|
||||
try:
|
||||
await session_context.__aexit__(None, None, None)
|
||||
except RuntimeError as e:
|
||||
if "cancel scope" in str(e):
|
||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||
else:
|
||||
logger.warning(f"Error closing OAuth session (Interactive): {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing OAuth session (Interactive): {e}")
|
||||
|
||||
try:
|
||||
await streamable_context.__aexit__(None, None, None)
|
||||
except RuntimeError as e:
|
||||
if "cancel scope" in str(e):
|
||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Error closing OAuth streamable HTTP client (Interactive): {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error closing OAuth streamable HTTP client (Interactive): {e}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_oauth_client(
|
||||
playwright_oauth_token: str,
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
Fixture to create an MCP client session for OAuth integration tests using Playwright automation.
|
||||
Connects to the OAuth-enabled MCP server on port 8001 with OAuth authentication.
|
||||
|
||||
This is the default OAuth MCP fixture using headless browser automation suitable for CI/CD.
|
||||
For interactive testing with manual browser login, use nc_mcp_oauth_client_interactive instead.
|
||||
"""
|
||||
logger.info("Creating Streamable HTTP client for OAuth MCP server (Playwright)")
|
||||
|
||||
# Pass OAuth token as Bearer token in headers
|
||||
headers = {"Authorization": f"Bearer {playwright_oauth_token}"}
|
||||
streamable_context = streamablehttp_client(
|
||||
"http://127.0.0.1:8001/mcp", headers=headers
|
||||
)
|
||||
session_context = None
|
||||
|
||||
try:
|
||||
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 (Playwright) initialized successfully")
|
||||
|
||||
yield session
|
||||
|
||||
finally:
|
||||
# Clean up in reverse order, ignoring task scope issues
|
||||
if session_context is not None:
|
||||
try:
|
||||
await session_context.__aexit__(None, None, None)
|
||||
except RuntimeError as e:
|
||||
if "cancel scope" in str(e):
|
||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||
else:
|
||||
logger.warning(f"Error closing Playwright OAuth session: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing Playwright OAuth session: {e}")
|
||||
|
||||
try:
|
||||
await streamable_context.__aexit__(None, None, None)
|
||||
except RuntimeError as e:
|
||||
if "cancel scope" in str(e):
|
||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Error closing Playwright OAuth streamable HTTP client: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error closing Playwright OAuth streamable HTTP client: {e}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -396,3 +567,547 @@ async def temporary_board_with_card(
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error deleting temporary card {card.id}: {e}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_oauth_client_interactive(
|
||||
interactive_oauth_token: str,
|
||||
) -> AsyncGenerator[NextcloudClient, Any]:
|
||||
"""
|
||||
Fixture to create a NextcloudClient instance using interactive OAuth authentication.
|
||||
Uses the interactive_oauth_token fixture which requires manual browser login.
|
||||
|
||||
For automated testing, use nc_oauth_client fixture instead.
|
||||
|
||||
Automatically skips when running in GitHub Actions CI.
|
||||
"""
|
||||
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
|
||||
if not all([nextcloud_host, username]):
|
||||
pytest.skip("OAuth client fixture requires NEXTCLOUD_HOST and USERNAME")
|
||||
|
||||
logger.info(f"Creating OAuth NextcloudClient (Interactive) for user: {username}")
|
||||
client = NextcloudClient.from_token(
|
||||
base_url=nextcloud_host,
|
||||
token=interactive_oauth_token,
|
||||
username=username,
|
||||
)
|
||||
|
||||
# Verify the OAuth client works
|
||||
try:
|
||||
await client.capabilities()
|
||||
logger.info(
|
||||
"OAuth NextcloudClient (Interactive) initialized and capabilities checked."
|
||||
)
|
||||
yield client
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize OAuth NextcloudClient (Interactive): {e}")
|
||||
pytest.fail(f"Failed to connect to Nextcloud with OAuth token: {e}")
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_oauth_client(
|
||||
playwright_oauth_token: str,
|
||||
) -> AsyncGenerator[NextcloudClient, Any]:
|
||||
"""
|
||||
Fixture to create a NextcloudClient instance using automated Playwright OAuth authentication.
|
||||
This is the default OAuth fixture using headless browser automation suitable for CI/CD.
|
||||
|
||||
For interactive testing with manual browser login, use nc_oauth_client_interactive instead.
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
|
||||
if not all([nextcloud_host, username]):
|
||||
pytest.skip("OAuth client fixture requires NEXTCLOUD_HOST and USERNAME")
|
||||
|
||||
logger.info(f"Creating OAuth NextcloudClient (Playwright) for user: {username}")
|
||||
client = NextcloudClient.from_token(
|
||||
base_url=nextcloud_host,
|
||||
token=playwright_oauth_token,
|
||||
username=username,
|
||||
)
|
||||
|
||||
# Verify the OAuth client works
|
||||
try:
|
||||
await client.capabilities()
|
||||
logger.info(
|
||||
"OAuth NextcloudClient (Playwright) initialized and capabilities checked."
|
||||
)
|
||||
yield client
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize OAuth NextcloudClient (Playwright): {e}")
|
||||
pytest.fail(f"Failed to connect to Nextcloud with Playwright OAuth token: {e}")
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def oauth_callback_server():
|
||||
"""
|
||||
Fixture to create an HTTP server for OAuth callback handling.
|
||||
|
||||
Yields a tuple of (auth_state, server_url) where:
|
||||
- auth_state: A dict with {"code": None} that will be populated with the auth code
|
||||
- server_url: The callback URL for the server (e.g., "http://localhost:8081")
|
||||
|
||||
The server automatically shuts down when the fixture is torn down.
|
||||
|
||||
Automatically skips when running in GitHub Actions CI.
|
||||
"""
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
# Use a mutable container to share state across threads
|
||||
auth_state = {"code": None}
|
||||
httpd = None
|
||||
server_thread = None
|
||||
|
||||
class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, format, *args):
|
||||
# Suppress default HTTP logging
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
# Ignore subsequent requests if we already have a code
|
||||
# (this is a session-scoped fixture, so only process the first auth code)
|
||||
if auth_state["code"] is not None:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(
|
||||
b"<html><body><h1>Authentication already completed</h1></body></html>"
|
||||
)
|
||||
return
|
||||
|
||||
# Parse the callback request
|
||||
parsed_path = urlparse(self.path)
|
||||
query = parse_qs(parsed_path.query)
|
||||
code = query.get("code", [None])[0]
|
||||
|
||||
# Only process if we have a valid code
|
||||
if code:
|
||||
auth_state["code"] = code
|
||||
logger.info(f"OAuth callback received. Code: {code[:20]}...")
|
||||
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>"
|
||||
)
|
||||
else:
|
||||
# Ignore requests without a code (e.g., favicon requests)
|
||||
logger.debug(f"Ignoring request without auth code: {self.path}")
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
try:
|
||||
# Start the HTTP server
|
||||
httpd = HTTPServer(("localhost", 8081), OAuthCallbackHandler)
|
||||
server_thread = threading.Thread(target=httpd.serve_forever)
|
||||
server_thread.daemon = True
|
||||
server_thread.start()
|
||||
logger.info("OAuth callback server started on http://localhost:8081")
|
||||
|
||||
# Yield the auth state and server URL
|
||||
yield auth_state, "http://localhost:8081"
|
||||
|
||||
finally:
|
||||
# Clean up the server
|
||||
if httpd:
|
||||
logger.info("Shutting down OAuth callback server...")
|
||||
shutdown_thread = threading.Thread(target=httpd.shutdown)
|
||||
shutdown_thread.start()
|
||||
shutdown_thread.join(timeout=2) # Wait up to 2 seconds for shutdown
|
||||
httpd.server_close()
|
||||
logger.info("OAuth callback server shut down successfully")
|
||||
if server_thread:
|
||||
server_thread.join(timeout=1)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def interactive_oauth_token(oauth_callback_server) -> str:
|
||||
"""
|
||||
Fixture to obtain an OAuth access token for integration tests.
|
||||
|
||||
This uses the interactive OAuth flow to get a token.
|
||||
Depends on oauth_callback_server fixture for HTTP callback handling.
|
||||
|
||||
Automatically skips when running in GitHub Actions CI.
|
||||
"""
|
||||
|
||||
import time
|
||||
import webbrowser
|
||||
|
||||
from nextcloud_mcp_server.auth.client_registration import load_or_register_client
|
||||
|
||||
# Unpack the server fixture
|
||||
auth_state, callback_url = oauth_callback_server
|
||||
|
||||
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=[callback_url],
|
||||
force_register=True,
|
||||
)
|
||||
|
||||
# First, open Nextcloud login page to establish session
|
||||
login_url = f"{nextcloud_host}/login"
|
||||
logger.info(f"Please log in to Nextcloud at: {login_url}")
|
||||
logger.info(
|
||||
"After logging in, the OAuth authorization will proceed automatically"
|
||||
)
|
||||
|
||||
# Construct authorization URL
|
||||
auth_url = f"{authorization_endpoint}?response_type=code&client_id={client_info.client_id}&redirect_uri={callback_url}&scope=openid%20profile%20email"
|
||||
|
||||
# Open authorization URL in browser
|
||||
webbrowser.open(auth_url)
|
||||
|
||||
# Wait for auth code with timeout
|
||||
timeout = 120 # 2 minutes
|
||||
start_time = time.time()
|
||||
while not auth_state["code"]:
|
||||
if time.time() - start_time > timeout:
|
||||
raise TimeoutError("OAuth authorization timed out after 2 minutes")
|
||||
logger.info("Waiting for OAuth authorization...")
|
||||
time.sleep(1)
|
||||
|
||||
auth_code = auth_state["code"]
|
||||
logger.info("Received authorization code, exchanging for token...")
|
||||
|
||||
token_response = await http_client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": auth_code,
|
||||
"redirect_uri": callback_url,
|
||||
"client_id": client_info.client_id,
|
||||
"client_secret": client_info.client_secret,
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug(f"Token response: {token_response.text}")
|
||||
token_data = token_response.json()
|
||||
logger.debug(f"Token data: {token_data}")
|
||||
access_token = token_data.get("access_token")
|
||||
|
||||
return access_token
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def playwright_oauth_token(browser) -> str:
|
||||
"""
|
||||
Fixture to obtain an OAuth access token using Playwright headless browser automation.
|
||||
|
||||
This fully automates the OAuth flow by:
|
||||
1. Discovering OIDC endpoints
|
||||
2. Registering an OAuth client
|
||||
3. Navigating to authorization URL in headless browser
|
||||
4. Programmatically filling in login form
|
||||
5. Handling OAuth consent
|
||||
6. Extracting auth code from redirect
|
||||
7. Exchanging code for access token
|
||||
|
||||
Environment variables required:
|
||||
- NEXTCLOUD_HOST: Nextcloud instance URL
|
||||
- NEXTCLOUD_USERNAME: Username for login
|
||||
- NEXTCLOUD_PASSWORD: Password for login
|
||||
|
||||
Playwright Configuration:
|
||||
- Configure browser via pytest CLI args: --browser firefox --headed
|
||||
- Browser fixture provided by pytest-playwright-asyncio
|
||||
- See: https://playwright.dev/python/docs/test-runners
|
||||
"""
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
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(
|
||||
"Playwright OAuth requires NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, and NEXTCLOUD_PASSWORD"
|
||||
)
|
||||
|
||||
logger.info("Starting Playwright-based OAuth flow...")
|
||||
|
||||
# Use async httpx for all HTTP operations
|
||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||
# OIDC Discovery
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
logger.debug(f"Fetching OIDC discovery from: {discovery_url}")
|
||||
|
||||
discovery_response = await http_client.get(discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
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")
|
||||
|
||||
if not all([token_endpoint, registration_endpoint, authorization_endpoint]):
|
||||
raise ValueError("OIDC discovery missing required endpoints")
|
||||
|
||||
logger.debug(f"Authorization endpoint: {authorization_endpoint}")
|
||||
logger.debug(f"Token endpoint: {token_endpoint}")
|
||||
|
||||
# Register OAuth client with a callback that won't actually be used
|
||||
# (we'll extract the code from the browser URL instead)
|
||||
callback_url = "http://localhost:9999/oauth/callback"
|
||||
|
||||
# Register client asynchronously
|
||||
client_metadata = {
|
||||
"client_name": "Nextcloud MCP Server - Playwright Tests",
|
||||
"redirect_uris": [callback_url],
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"scope": "openid profile email",
|
||||
}
|
||||
|
||||
reg_response = await http_client.post(
|
||||
registration_endpoint,
|
||||
json=client_metadata,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
reg_response.raise_for_status()
|
||||
client_info_dict = reg_response.json()
|
||||
|
||||
client_id = client_info_dict["client_id"]
|
||||
client_secret = client_info_dict["client_secret"]
|
||||
|
||||
# Construct authorization URL
|
||||
auth_url = (
|
||||
f"{authorization_endpoint}?"
|
||||
f"response_type=code&"
|
||||
f"client_id={client_id}&"
|
||||
f"redirect_uri={callback_url}&"
|
||||
f"scope=openid%20profile%20email"
|
||||
)
|
||||
|
||||
logger.info("Opening browser for OAuth authorization...")
|
||||
|
||||
# Async browser automation using pytest-playwright's browser fixture
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
# Navigate to authorization URL
|
||||
logger.debug(f"Navigating to: {auth_url}")
|
||||
await page.goto(auth_url, wait_until="networkidle", timeout=30000)
|
||||
|
||||
# Check if we need to login first
|
||||
current_url = page.url
|
||||
logger.debug(f"Current URL after navigation: {current_url}")
|
||||
|
||||
# If we're on a login page, fill in credentials
|
||||
if "/login" in current_url or "/index.php/login" in current_url:
|
||||
logger.info("Login page detected, filling in credentials...")
|
||||
|
||||
# Wait for login form
|
||||
await page.wait_for_selector('input[name="user"]', timeout=10000)
|
||||
|
||||
# Fill in username and password
|
||||
await page.fill('input[name="user"]', username)
|
||||
await page.fill('input[name="password"]', password)
|
||||
|
||||
logger.debug("Credentials filled, submitting login form...")
|
||||
|
||||
# Submit the form
|
||||
await page.click('button[type="submit"]')
|
||||
|
||||
# Wait for navigation after login
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
current_url = page.url
|
||||
logger.info(f"After login, current URL: {current_url}")
|
||||
|
||||
# Now we should be on the OAuth authorization/consent page or already redirected
|
||||
# Check if there's an authorize button to click
|
||||
try:
|
||||
# Look for common authorization button patterns
|
||||
authorize_button = await page.query_selector(
|
||||
'button:has-text("Authorize"), button:has-text("Allow"), input[type="submit"][value*="uthoriz"]'
|
||||
)
|
||||
|
||||
if authorize_button:
|
||||
logger.info(
|
||||
"Authorization consent page detected, clicking authorize..."
|
||||
)
|
||||
await authorize_button.click()
|
||||
await page.wait_for_load_state("networkidle", timeout=10000)
|
||||
current_url = page.url
|
||||
logger.debug(f"After authorization, current_url: {current_url}")
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"No authorization button found or already authorized: {e}"
|
||||
)
|
||||
|
||||
# Wait for redirect to callback URL (which will fail to load, but we just need the URL)
|
||||
try:
|
||||
# The redirect might fail since localhost:9999 isn't actually running
|
||||
# But we can still extract the code from the URL
|
||||
await page.wait_for_url(f"{callback_url}*", timeout=10000)
|
||||
except Exception as e:
|
||||
# Expected - the callback URL won't load, but we should have the URL
|
||||
logger.debug(f"Callback redirect (expected to fail): {e}")
|
||||
|
||||
# Extract auth code from URL
|
||||
final_url = page.url
|
||||
logger.debug(f"Final URL: {final_url}")
|
||||
|
||||
parsed_url = urlparse(final_url)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
auth_code = query_params.get("code", [None])[0]
|
||||
|
||||
if not auth_code:
|
||||
# Take a screenshot for debugging
|
||||
screenshot_path = "/tmp/playwright_oauth_error.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(f"Screenshot saved to {screenshot_path}")
|
||||
raise ValueError(
|
||||
f"No authorization code found in redirect URL: {final_url}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Successfully extracted authorization code: {auth_code[:20]}..."
|
||||
)
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
# Exchange authorization code for access token
|
||||
logger.info("Exchanging authorization code for access token...")
|
||||
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 OAuth access token via Playwright")
|
||||
return access_token
|
||||
|
||||
|
||||
# Alternative fixtures using Playwright token (for automated/CI testing)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_oauth_client_playwright(
|
||||
playwright_oauth_token: str,
|
||||
) -> AsyncGenerator[NextcloudClient, Any]:
|
||||
"""
|
||||
Fixture to create a NextcloudClient instance using automated Playwright OAuth authentication.
|
||||
This fixture uses headless browser automation and is suitable for CI/CD pipelines.
|
||||
|
||||
For interactive testing, use nc_oauth_client fixture instead.
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
|
||||
if not all([nextcloud_host, username]):
|
||||
pytest.skip(
|
||||
"Playwright OAuth client fixture requires NEXTCLOUD_HOST and USERNAME"
|
||||
)
|
||||
|
||||
logger.info(f"Creating OAuth NextcloudClient (Playwright) for user: {username}")
|
||||
client = NextcloudClient.from_token(
|
||||
base_url=nextcloud_host,
|
||||
token=playwright_oauth_token,
|
||||
username=username,
|
||||
)
|
||||
|
||||
# Verify the OAuth client works
|
||||
try:
|
||||
await client.capabilities()
|
||||
logger.info(
|
||||
"OAuth NextcloudClient (Playwright) initialized and capabilities checked."
|
||||
)
|
||||
yield client
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Playwright OAuth NextcloudClient: {e}")
|
||||
pytest.fail(f"Failed to connect to Nextcloud with Playwright OAuth token: {e}")
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_oauth_client_playwright(
|
||||
playwright_oauth_token: str,
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
Fixture to create an MCP client session for OAuth integration tests using Playwright automation.
|
||||
Connects to the OAuth-enabled MCP server on port 8001 with OAuth authentication.
|
||||
|
||||
This fixture uses headless browser automation and is suitable for CI/CD pipelines.
|
||||
For interactive testing, use nc_mcp_oauth_client fixture instead.
|
||||
"""
|
||||
logger.info("Creating Streamable HTTP client for OAuth MCP server (Playwright)")
|
||||
|
||||
# Pass OAuth token as Bearer token in headers
|
||||
headers = {"Authorization": f"Bearer {playwright_oauth_token}"}
|
||||
streamable_context = streamablehttp_client(
|
||||
"http://127.0.0.1:8001/mcp", headers=headers
|
||||
)
|
||||
session_context = None
|
||||
|
||||
try:
|
||||
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 (Playwright) initialized successfully")
|
||||
|
||||
yield session
|
||||
|
||||
finally:
|
||||
# Clean up in reverse order, ignoring task scope issues
|
||||
if session_context is not None:
|
||||
try:
|
||||
await session_context.__aexit__(None, None, None)
|
||||
except RuntimeError as e:
|
||||
if "cancel scope" in str(e):
|
||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||
else:
|
||||
logger.warning(f"Error closing Playwright OAuth session: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing Playwright OAuth session: {e}")
|
||||
|
||||
try:
|
||||
await streamable_context.__aexit__(None, None, None)
|
||||
except RuntimeError as e:
|
||||
if "cancel scope" in str(e):
|
||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Error closing Playwright OAuth streamable HTTP client: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error closing Playwright OAuth streamable HTTP client: {e}"
|
||||
)
|
||||
|
||||
+13
-15
@@ -1,23 +1,27 @@
|
||||
"""Test error propagation in the MCP server for various error scenarios."""
|
||||
|
||||
import logging
|
||||
from mcp import ClientSession
|
||||
from mcp.shared.exceptions import McpError
|
||||
|
||||
import pytest
|
||||
from mcp import ClientSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_missing_note_resource_error(nc_mcp_client: ClientSession):
|
||||
"""Test that accessing a non-existent note resource returns proper error."""
|
||||
# Try to get a non-existent note via resource - should raise McpError with improved message
|
||||
with pytest.raises(McpError, match=r"Note 999999 not found"):
|
||||
await nc_mcp_client.read_resource("nc://Notes/999999")
|
||||
|
||||
async def test_missing_note_tool_error(nc_mcp_client: ClientSession):
|
||||
"""Test that accessing a non-existent note via tool returns proper error."""
|
||||
# Try to get a non-existent note via tool - should return error response
|
||||
response = await nc_mcp_client.call_tool("nc_notes_get_note", {"note_id": 999999})
|
||||
|
||||
# Should return error response (not raise exception) for tools
|
||||
assert response is not None
|
||||
assert response.isError is True
|
||||
assert "Note 999999 not found" in response.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_delete_missing_note_tool_error(nc_mcp_client: ClientSession):
|
||||
"""Test that deleting a non-existent note returns proper error."""
|
||||
# Try to delete a non-existent note - should return error response
|
||||
@@ -31,7 +35,6 @@ async def test_delete_missing_note_tool_error(nc_mcp_client: ClientSession):
|
||||
assert "Note 999999 not found" in response.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_search_with_empty_query(nc_mcp_client: ClientSession):
|
||||
"""Test search behavior with empty query."""
|
||||
# Search with empty query
|
||||
@@ -44,7 +47,6 @@ async def test_search_with_empty_query(nc_mcp_client: ClientSession):
|
||||
assert response.isError is False
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_tool_missing_required_parameters(nc_mcp_client: ClientSession):
|
||||
"""Test calling a tool with missing required parameters."""
|
||||
# Try to create note with missing parameters
|
||||
@@ -63,7 +65,6 @@ async def test_tool_missing_required_parameters(nc_mcp_client: ClientSession):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_update_note_with_invalid_etag(nc_mcp_client: ClientSession, nc_client):
|
||||
"""Test updating a note with invalid ETag."""
|
||||
# First create a note
|
||||
@@ -95,7 +96,6 @@ async def test_update_note_with_invalid_etag(nc_mcp_client: ClientSession, nc_cl
|
||||
await nc_client.notes.delete_note(note_id)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_calendar_missing_calendar_error(nc_mcp_client: ClientSession):
|
||||
"""Test calendar operations with non-existent calendar."""
|
||||
# Try to create event in non-existent calendar
|
||||
@@ -124,7 +124,6 @@ async def test_calendar_missing_calendar_error(nc_mcp_client: ClientSession):
|
||||
assert response.isError is True
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_webdav_read_missing_file_error(nc_mcp_client: ClientSession):
|
||||
"""Test WebDAV operations with non-existent file."""
|
||||
# Try to read a non-existent file
|
||||
@@ -148,7 +147,6 @@ async def test_webdav_read_missing_file_error(nc_mcp_client: ClientSession):
|
||||
assert response.isError is True
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_tables_missing_table_error(nc_mcp_client: ClientSession):
|
||||
"""Test Tables operations with non-existent table."""
|
||||
# Try to get schema of non-existent table
|
||||
@@ -68,7 +68,8 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
|
||||
template_uris.append(template.uriTemplate)
|
||||
|
||||
# Verify expected resource templates
|
||||
expected_templates = ["nc://Notes/{note_id}/attachments/{attachment_filename}"]
|
||||
# Note: Notes attachments are now handled via tools, not resource templates
|
||||
expected_templates = []
|
||||
|
||||
for expected_template in expected_templates:
|
||||
assert expected_template in template_uris, (
|
||||
@@ -140,9 +141,11 @@ async def test_mcp_notes_crud_workflow(
|
||||
|
||||
# 3. Read note via MCP
|
||||
logger.info(f"Reading note via MCP: {note_id}")
|
||||
read_result = await nc_mcp_client.read_resource(f"nc://Notes/{note_id}")
|
||||
assert len(read_result.contents) == 1, "Expected exactly one content item"
|
||||
read_note_data = json.loads(read_result.contents[0].text)
|
||||
read_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_get_note", {"note_id": note_id}
|
||||
)
|
||||
read_note_data = read_result.content[0].text
|
||||
read_note_data = json.loads(read_note_data)
|
||||
|
||||
assert read_note_data["title"] == test_title
|
||||
assert read_note_data["content"] == test_content
|
||||
@@ -0,0 +1,59 @@
|
||||
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_playwright):
|
||||
"""Test that MCP OAuth client via Playwright can execute tools."""
|
||||
|
||||
# Test: Execute the 'nc_notes_search_notes' tool
|
||||
result = await nc_mcp_oauth_client_playwright.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,9 +1,9 @@
|
||||
"""Unit tests for Pydantic models and serialization."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from nextcloud_mcp_server.models.base import BaseResponse
|
||||
|
||||
|
||||
Reference in New Issue
Block a user