test: Fix oauth interactive browser tests
This commit is contained in:
+66
-15
@@ -135,6 +135,49 @@ async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
|||||||
logger.warning(f"Error closing streamable HTTP client: {e}")
|
logger.warning(f"Error closing streamable HTTP client: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def nc_mcp_oauth_client() -> AsyncGenerator[ClientSession, Any]:
|
||||||
|
"""
|
||||||
|
Fixture to create an MCP client session for OAuth integration tests using streamable-http.
|
||||||
|
Connects to the OAuth-enabled MCP server on port 8001.
|
||||||
|
"""
|
||||||
|
logger.info("Creating Streamable HTTP client for OAuth MCP server")
|
||||||
|
streamable_context = streamablehttp_client("http://127.0.0.1:8001/mcp")
|
||||||
|
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 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: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error closing 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 OAuth streamable HTTP client: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error closing OAuth streamable HTTP client: {e}")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def temporary_note(nc_client: NextcloudClient):
|
async def temporary_note(nc_client: NextcloudClient):
|
||||||
"""
|
"""
|
||||||
@@ -613,29 +656,37 @@ async def interactive_oauth_token() -> str:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
if self.path.startswith("/shutdown"):
|
# 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_response(200)
|
||||||
self.send_header("Content-type", "text/html")
|
self.send_header("Content-type", "text/html")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(
|
self.wfile.write(
|
||||||
b"<html><body><h1>Server shutting down...</h1></body></html>"
|
b"<html><body><h1>Authentication already completed</h1></body></html>"
|
||||||
)
|
)
|
||||||
threading.Thread(target=httpd.shutdown).start()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Parse the callback request
|
||||||
parsed_path = urlparse(self.path)
|
parsed_path = urlparse(self.path)
|
||||||
query = parse_qs(parsed_path.query)
|
query = parse_qs(parsed_path.query)
|
||||||
code = query.get("code", [None])[0]
|
code = query.get("code", [None])[0]
|
||||||
auth_state["code"] = code
|
|
||||||
logger.info(
|
# Only process if we have a valid code
|
||||||
f"OAuth callback received. Code: {code[:20] if code else 'None'}..."
|
if code:
|
||||||
)
|
auth_state["code"] = code
|
||||||
self.send_response(200)
|
logger.info(f"OAuth callback received. Code: {code[:20]}...")
|
||||||
self.send_header("Content-type", "text/html")
|
self.send_response(200)
|
||||||
self.end_headers()
|
self.send_header("Content-type", "text/html")
|
||||||
self.wfile.write(
|
self.end_headers()
|
||||||
b"<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>"
|
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()
|
||||||
|
|
||||||
httpd = HTTPServer(("localhost", 8081), OAuthCallbackHandler)
|
httpd = HTTPServer(("localhost", 8081), OAuthCallbackHandler)
|
||||||
server_thread = threading.Thread(target=httpd.serve_forever)
|
server_thread = threading.Thread(target=httpd.serve_forever)
|
||||||
@@ -704,9 +755,9 @@ async def interactive_oauth_token() -> str:
|
|||||||
access_token = token_data.get("access_token")
|
access_token = token_data.get("access_token")
|
||||||
|
|
||||||
# Shut down the server
|
# Shut down the server
|
||||||
|
# Call shutdown directly instead of via HTTP to avoid race conditions
|
||||||
await http_client.get("http://localhost:8081/shutdown")
|
|
||||||
if httpd:
|
if httpd:
|
||||||
|
httpd.shutdown()
|
||||||
httpd.server_close()
|
httpd.server_close()
|
||||||
if server_thread:
|
if server_thread:
|
||||||
server_thread.join(timeout=1)
|
server_thread.join(timeout=1)
|
||||||
|
|||||||
+108
-106
@@ -1,9 +1,12 @@
|
|||||||
"""Integration tests for OAuth authentication."""
|
"""Integration tests for OAuth authentication."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from httpx import HTTPStatusError
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth import BearerAuth
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -11,121 +14,120 @@ logger = logging.getLogger(__name__)
|
|||||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||||
|
|
||||||
|
|
||||||
class TestOAuthClient:
|
# OAuth Client Tests
|
||||||
"""Test OAuth-authenticated NextcloudClient."""
|
|
||||||
|
|
||||||
async def test_oauth_client_capabilities(self, 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(self, 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(self, 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
|
|
||||||
|
|
||||||
|
|
||||||
class TestOAuthTokenValidation:
|
async def test_oauth_client_capabilities(nc_oauth_client: NextcloudClient):
|
||||||
"""Test OAuth token validation and bearer auth."""
|
"""Test that OAuth client can fetch capabilities."""
|
||||||
|
capabilities = await nc_oauth_client.capabilities()
|
||||||
|
|
||||||
async def test_token_in_request_headers(
|
assert capabilities is not None
|
||||||
self, nc_oauth_client: NextcloudClient, oauth_token: str
|
assert "ocs" in capabilities
|
||||||
):
|
logger.info(
|
||||||
"""Verify that bearer token is being used in requests."""
|
f"OAuth client successfully fetched capabilities: {capabilities.get('ocs').get('meta')}"
|
||||||
# The client should be using BearerAuth
|
)
|
||||||
assert nc_oauth_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(self):
|
|
||||||
"""Test that an invalid token results in authentication failure."""
|
|
||||||
import os
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth import BearerAuth
|
|
||||||
|
|
||||||
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 the client should fail with 401
|
|
||||||
from httpx import HTTPStatusError
|
|
||||||
|
|
||||||
with pytest.raises(HTTPStatusError) as exc_info:
|
|
||||||
await invalid_client.capabilities()
|
|
||||||
|
|
||||||
assert exc_info.value.response.status_code == 401
|
|
||||||
|
|
||||||
await invalid_client.close()
|
|
||||||
logger.info("Invalid OAuth token correctly rejected")
|
|
||||||
|
|
||||||
|
|
||||||
class TestOAuthMCPIntegration:
|
async def test_oauth_client_notes_list(nc_oauth_client: NextcloudClient):
|
||||||
"""Test OAuth integration with MCP server."""
|
"""Test that OAuth client can list notes."""
|
||||||
|
notes = await nc_oauth_client.notes.get_all_notes()
|
||||||
|
|
||||||
async def test_mcp_oauth_server_connection(self, nc_mcp_oauth_client):
|
assert isinstance(notes, list)
|
||||||
"""Test connection to OAuth-enabled MCP server."""
|
logger.info(f"OAuth client successfully listed {len(notes)} notes")
|
||||||
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(self, nc_mcp_oauth_client):
|
async def test_oauth_client_create_note(nc_oauth_client: NextcloudClient):
|
||||||
"""Test executing a tool on the OAuth-enabled MCP server."""
|
"""Test that OAuth client can create and delete a note."""
|
||||||
import json
|
# Create note
|
||||||
|
note_title = "OAuth Test Note"
|
||||||
|
note_content = "This note was created with OAuth authentication"
|
||||||
|
|
||||||
# Example: Execute the 'nc_tables_list_tables' tool
|
created_note = await nc_oauth_client.notes.create_note(
|
||||||
result = await nc_mcp_oauth_client.call_tool("nc_tables_list_tables")
|
title=note_title, content=note_content
|
||||||
|
)
|
||||||
|
|
||||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
assert created_note is not None
|
||||||
assert result.content is not None
|
assert created_note.get("title") == note_title
|
||||||
notes_list = json.loads(result.content[0].text)
|
note_id = created_note.get("id")
|
||||||
|
assert note_id is not None
|
||||||
|
|
||||||
assert isinstance(notes_list, list)
|
logger.info(f"OAuth client successfully created note with ID: {note_id}")
|
||||||
|
|
||||||
logger.info(
|
# Clean up - delete the note
|
||||||
f"Successfully executed 'nc_tables_list_tables' tool on OAuth MCP server and got {len(notes_list)} notes."
|
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, interactive_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")
|
||||||
|
|
||||||
|
|
||||||
|
# OAuth MCP Integration 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."""
|
||||||
|
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."
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user