797 lines
30 KiB
Python
797 lines
30 KiB
Python
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.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()
|
|
|
|
# Perform a quick check to ensure connection works
|
|
try:
|
|
await client.capabilities()
|
|
logger.info(
|
|
"NextcloudClient session fixture initialized and capabilities checked."
|
|
)
|
|
yield client
|
|
except Exception as e:
|
|
logger.error(f"Failed to initialize NextcloudClient session fixture: {e}")
|
|
pytest.fail(f"Failed to connect to Nextcloud or get capabilities: {e}")
|
|
finally:
|
|
await client.close()
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
|
"""
|
|
Fixture to create an MCP client session for integration tests using streamable-http.
|
|
"""
|
|
logger.info("Creating Streamable HTTP client")
|
|
streamable_context = streamablehttp_client("http://127.0.0.1:8000/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("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 session: {e}")
|
|
except Exception as e:
|
|
logger.warning(f"Error closing 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 streamable HTTP client: {e}")
|
|
except Exception as e:
|
|
logger.warning(f"Error closing streamable HTTP client: {e}")
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
async def nc_mcp_oauth_client(
|
|
interactive_oauth_token: str,
|
|
) -> 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 with OAuth authentication.
|
|
"""
|
|
logger.info("Creating Streamable HTTP client for OAuth MCP server")
|
|
|
|
# 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 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
|
|
async def temporary_note(nc_client: NextcloudClient):
|
|
"""
|
|
Fixture to create a temporary note for a test and ensure its deletion afterward.
|
|
Yields the created note dictionary.
|
|
"""
|
|
|
|
note_id = None
|
|
unique_suffix = uuid.uuid4().hex[:8]
|
|
note_title = f"Temporary Test Note {unique_suffix}"
|
|
note_content = f"Content for temporary note {unique_suffix}"
|
|
note_category = "TemporaryTesting"
|
|
created_note_data = None
|
|
|
|
logger.info(f"Creating temporary note: {note_title}")
|
|
try:
|
|
created_note_data = await nc_client.notes.create_note(
|
|
title=note_title, content=note_content, category=note_category
|
|
)
|
|
note_id = created_note_data.get("id")
|
|
if not note_id:
|
|
pytest.fail("Failed to get ID from created temporary note.")
|
|
|
|
logger.info(f"Temporary note created with ID: {note_id}")
|
|
yield created_note_data # Provide the created note data to the test
|
|
|
|
finally:
|
|
if note_id:
|
|
logger.info(f"Cleaning up temporary note ID: {note_id}")
|
|
try:
|
|
await nc_client.notes.delete_note(note_id=note_id)
|
|
logger.info(f"Successfully deleted temporary note ID: {note_id}")
|
|
except HTTPStatusError as e:
|
|
# Ignore 404 if note was already deleted by the test itself
|
|
if e.response.status_code != 404:
|
|
logger.error(f"HTTP error deleting temporary note {note_id}: {e}")
|
|
else:
|
|
logger.warning(f"Temporary note {note_id} already deleted (404).")
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error deleting temporary note {note_id}: {e}")
|
|
|
|
|
|
@pytest.fixture
|
|
async def temporary_note_with_attachment(
|
|
nc_client: NextcloudClient, temporary_note: dict
|
|
):
|
|
"""
|
|
Fixture that creates a temporary note, adds an attachment, and cleans up both.
|
|
Yields a tuple: (note_data, attachment_filename, attachment_content).
|
|
Depends on the temporary_note fixture.
|
|
"""
|
|
|
|
note_data = temporary_note
|
|
note_id = note_data["id"]
|
|
note_category = note_data.get("category") # Get category from the note data
|
|
unique_suffix = uuid.uuid4().hex[:8]
|
|
attachment_filename = f"temp_attach_{unique_suffix}.txt"
|
|
attachment_content = f"Content for {attachment_filename}".encode("utf-8")
|
|
attachment_mime = "text/plain"
|
|
|
|
logger.info(
|
|
f"Adding attachment '{attachment_filename}' to temporary note ID: {note_id} (category: '{note_category or ''}')"
|
|
)
|
|
try:
|
|
# Pass the category to add_note_attachment
|
|
upload_response = await nc_client.webdav.add_note_attachment(
|
|
note_id=note_id,
|
|
filename=attachment_filename,
|
|
content=attachment_content,
|
|
category=note_category, # Pass the fetched category
|
|
mime_type=attachment_mime,
|
|
)
|
|
assert upload_response.get("status_code") in [
|
|
201,
|
|
204,
|
|
], f"Failed to upload attachment: {upload_response}"
|
|
logger.info(f"Attachment '{attachment_filename}' added successfully.")
|
|
|
|
yield note_data, attachment_filename, attachment_content
|
|
|
|
# Cleanup for the attachment is handled by the notes_delete_note call
|
|
# in the temporary_note fixture's finally block (which deletes the .attachments dir)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to add attachment in fixture: {e}")
|
|
pytest.fail(f"Fixture setup failed during attachment upload: {e}")
|
|
|
|
# Note: The temporary_note fixture's finally block will handle note deletion,
|
|
# which should also trigger the WebDAV directory deletion attempt.
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
async def temporary_addressbook(nc_client: NextcloudClient):
|
|
"""
|
|
Fixture to create a temporary addressbook for a test and ensure its deletion afterward.
|
|
Yields the created addressbook dictionary.
|
|
"""
|
|
addressbook_name = f"test-addressbook-{uuid.uuid4().hex[:8]}"
|
|
logger.info(f"Creating temporary addressbook: {addressbook_name}")
|
|
try:
|
|
await nc_client.contacts.create_addressbook(
|
|
name=addressbook_name, display_name=f"Test Addressbook {addressbook_name}"
|
|
)
|
|
logger.info(f"Temporary addressbook created: {addressbook_name}")
|
|
yield addressbook_name
|
|
finally:
|
|
logger.info(f"Cleaning up temporary addressbook: {addressbook_name}")
|
|
try:
|
|
await nc_client.contacts.delete_addressbook(name=addressbook_name)
|
|
logger.info(
|
|
f"Successfully deleted temporary addressbook: {addressbook_name}"
|
|
)
|
|
except HTTPStatusError as e:
|
|
if e.response.status_code != 404:
|
|
logger.error(
|
|
f"HTTP error deleting temporary addressbook {addressbook_name}: {e}"
|
|
)
|
|
else:
|
|
logger.warning(
|
|
f"Temporary addressbook {addressbook_name} already deleted (404)."
|
|
)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Unexpected error deleting temporary addressbook {addressbook_name}: {e}"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
async def temporary_contact(nc_client: NextcloudClient, temporary_addressbook: str):
|
|
"""
|
|
Fixture to create a temporary contact in a temporary addressbook and ensure its deletion.
|
|
Yields the created contact's UID.
|
|
"""
|
|
contact_uid = f"test-contact-{uuid.uuid4().hex[:8]}"
|
|
addressbook_name = temporary_addressbook
|
|
contact_data = {
|
|
"fn": "John Doe",
|
|
"email": "john.doe@example.com",
|
|
"tel": "1234567890",
|
|
}
|
|
logger.info(f"Creating temporary contact in addressbook: {addressbook_name}")
|
|
try:
|
|
await nc_client.contacts.create_contact(
|
|
addressbook=addressbook_name,
|
|
uid=contact_uid,
|
|
contact_data=contact_data,
|
|
)
|
|
logger.info(f"Temporary contact created with UID: {contact_uid}")
|
|
yield contact_uid
|
|
finally:
|
|
logger.info(f"Cleaning up temporary contact: {contact_uid}")
|
|
try:
|
|
await nc_client.contacts.delete_contact(
|
|
addressbook=addressbook_name, uid=contact_uid
|
|
)
|
|
logger.info(f"Successfully deleted temporary contact: {contact_uid}")
|
|
except HTTPStatusError as e:
|
|
if e.response.status_code != 404:
|
|
logger.error(
|
|
f"HTTP error deleting temporary contact {contact_uid}: {e}"
|
|
)
|
|
else:
|
|
logger.warning(
|
|
f"Temporary contact {contact_uid} already deleted (404)."
|
|
)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Unexpected error deleting temporary contact {contact_uid}: {e}"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
async def temporary_board(nc_client: NextcloudClient):
|
|
"""
|
|
Fixture to create a temporary deck board for tests and ensure its deletion afterward.
|
|
Yields the created board data dict.
|
|
"""
|
|
board_id = None
|
|
unique_suffix = uuid.uuid4().hex[:8]
|
|
board_title = f"Temporary Test Board {unique_suffix}"
|
|
board_color = "FF0000" # Red color
|
|
created_board_data = None
|
|
|
|
logger.info(f"Creating temporary deck board: {board_title}")
|
|
try:
|
|
created_board = await nc_client.deck.create_board(board_title, board_color)
|
|
board_id = created_board.id
|
|
created_board_data = {
|
|
"id": board_id,
|
|
"title": created_board.title,
|
|
"color": created_board.color,
|
|
"archived": getattr(created_board, "archived", False),
|
|
}
|
|
|
|
logger.info(f"Temporary board created with ID: {board_id}")
|
|
yield created_board_data
|
|
|
|
finally:
|
|
if board_id:
|
|
logger.info(f"Cleaning up temporary board ID: {board_id}")
|
|
try:
|
|
await nc_client.deck.delete_board(board_id)
|
|
logger.info(f"Successfully deleted temporary board ID: {board_id}")
|
|
except HTTPStatusError as e:
|
|
# Ignore 404 if board was already deleted by the test itself
|
|
if e.response.status_code not in [404, 403]:
|
|
logger.error(f"HTTP error deleting temporary board {board_id}: {e}")
|
|
else:
|
|
logger.warning(
|
|
f"Temporary board {board_id} already deleted or access denied ({e.response.status_code})."
|
|
)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Unexpected error deleting temporary board {board_id}: {e}"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
async def temporary_board_with_stack(nc_client: NextcloudClient, temporary_board: dict):
|
|
"""
|
|
Fixture to create a temporary stack in a temporary board.
|
|
Yields a tuple: (board_data, stack_data).
|
|
Depends on the temporary_board fixture.
|
|
"""
|
|
board_data = temporary_board
|
|
board_id = board_data["id"]
|
|
unique_suffix = uuid.uuid4().hex[:8]
|
|
stack_title = f"Test Stack {unique_suffix}"
|
|
stack_order = 1
|
|
stack = None
|
|
|
|
logger.info(f"Creating temporary stack in board ID: {board_id}")
|
|
try:
|
|
stack = await nc_client.deck.create_stack(board_id, stack_title, stack_order)
|
|
stack_data = {
|
|
"id": stack.id,
|
|
"title": stack.title,
|
|
"order": stack.order,
|
|
"boardId": board_id,
|
|
}
|
|
|
|
logger.info(f"Temporary stack created with ID: {stack.id}")
|
|
yield (board_data, stack_data)
|
|
|
|
finally:
|
|
# Clean up - delete stack
|
|
if stack and hasattr(stack, "id"):
|
|
logger.info(f"Cleaning up temporary stack ID: {stack.id}")
|
|
try:
|
|
await nc_client.deck.delete_stack(board_id, stack.id)
|
|
logger.info(f"Successfully deleted temporary stack ID: {stack.id}")
|
|
except HTTPStatusError as e:
|
|
if e.response.status_code not in [404, 403]:
|
|
logger.error(f"HTTP error deleting temporary stack {stack.id}: {e}")
|
|
else:
|
|
logger.warning(
|
|
f"Temporary stack {stack.id} already deleted or access denied ({e.response.status_code})."
|
|
)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Unexpected error deleting temporary stack {stack.id}: {e}"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
async def temporary_board_with_card(
|
|
nc_client: NextcloudClient, temporary_board_with_stack: tuple
|
|
):
|
|
"""
|
|
Fixture to create a temporary card in a temporary stack within a temporary board.
|
|
Yields a tuple: (board_data, stack_data, card_data).
|
|
Depends on the temporary_board_with_stack fixture.
|
|
"""
|
|
board_data, stack_data = temporary_board_with_stack
|
|
board_id = board_data["id"]
|
|
stack_id = stack_data["id"]
|
|
unique_suffix = uuid.uuid4().hex[:8]
|
|
card_title = f"Test Card {unique_suffix}"
|
|
card_description = f"Test description for card {unique_suffix}"
|
|
card = None
|
|
|
|
logger.info(
|
|
f"Creating temporary card in stack ID: {stack_id}, board ID: {board_id}"
|
|
)
|
|
try:
|
|
card = await nc_client.deck.create_card(
|
|
board_id, stack_id, card_title, description=card_description
|
|
)
|
|
card_data = {
|
|
"id": card.id,
|
|
"title": card.title,
|
|
"description": card.description,
|
|
"stackId": stack_id,
|
|
"boardId": board_id,
|
|
}
|
|
|
|
logger.info(f"Temporary card created with ID: {card.id}")
|
|
yield (board_data, stack_data, card_data)
|
|
|
|
finally:
|
|
# Clean up - delete card
|
|
if card and hasattr(card, "id"):
|
|
logger.info(f"Cleaning up temporary card ID: {card.id}")
|
|
try:
|
|
await nc_client.deck.delete_card(board_id, stack_id, card.id)
|
|
logger.info(f"Successfully deleted temporary card ID: {card.id}")
|
|
except HTTPStatusError as e:
|
|
if e.response.status_code not in [404, 403]:
|
|
logger.error(f"HTTP error deleting temporary card {card.id}: {e}")
|
|
else:
|
|
logger.warning(
|
|
f"Temporary card {card.id} already deleted or access denied ({e.response.status_code})."
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error deleting temporary card {card.id}: {e}")
|
|
|
|
|
|
async def get_oauth_token(nextcloud_url: str, username: str, password: str) -> str:
|
|
"""
|
|
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
|
|
to directly obtain a token for automated testing.
|
|
|
|
Args:
|
|
nextcloud_url: Nextcloud base URL
|
|
username: Nextcloud username
|
|
password: Nextcloud password
|
|
|
|
Returns:
|
|
Access token string
|
|
|
|
Raises:
|
|
Exception: If token acquisition fails
|
|
"""
|
|
from nextcloud_mcp_server.auth.client_registration import load_or_register_client
|
|
|
|
logger.info(f"Getting OAuth token for testing from {nextcloud_url}")
|
|
|
|
# Perform OIDC discovery
|
|
async with httpx.AsyncClient() as http_client:
|
|
discovery_url = f"{nextcloud_url}/.well-known/openid-configuration"
|
|
logger.debug(f"Fetching OIDC discovery from: {discovery_url}")
|
|
|
|
discovery_response = await http_client.get(discovery_url)
|
|
if discovery_response.status_code != 200:
|
|
raise Exception(f"OIDC discovery failed: {discovery_response.status_code}")
|
|
|
|
oidc_config = discovery_response.json()
|
|
token_endpoint = oidc_config.get("token_endpoint")
|
|
registration_endpoint = oidc_config.get("registration_endpoint")
|
|
|
|
if not token_endpoint or not registration_endpoint:
|
|
raise Exception("OIDC discovery missing required endpoints")
|
|
|
|
logger.debug(f"Token endpoint: {token_endpoint}")
|
|
logger.debug(f"Registration endpoint: {registration_endpoint}")
|
|
|
|
# Get or register an OAuth client
|
|
client_info = await load_or_register_client(
|
|
nextcloud_url=nextcloud_url,
|
|
registration_endpoint=registration_endpoint,
|
|
storage_path=".nextcloud_oauth_test_client.json",
|
|
redirect_uris=["http://localhost:8000/oauth/callback"],
|
|
)
|
|
|
|
# Use client credentials to get a token via client_credentials grant
|
|
token_response = await http_client.post(
|
|
token_endpoint,
|
|
data={
|
|
"grant_type": "client_credentials",
|
|
"client_id": client_info.client_id,
|
|
"client_secret": client_info.client_secret,
|
|
"scope": "openid profile email",
|
|
},
|
|
)
|
|
|
|
if token_response.status_code != 200:
|
|
logger.error(f"Failed to get OAuth token: {token_response.text}")
|
|
raise Exception(f"Token request failed: {token_response.status_code}")
|
|
|
|
token_data = token_response.json()
|
|
access_token = token_data.get("access_token")
|
|
|
|
if not access_token:
|
|
raise Exception("No access_token in response")
|
|
|
|
logger.info("Successfully obtained OAuth access token for testing")
|
|
return access_token
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
async def oauth_token() -> str:
|
|
"""
|
|
Fixture to obtain an OAuth access token for integration tests.
|
|
|
|
This uses the Resource Owner Password flow to get a token without
|
|
requiring interactive browser authentication.
|
|
"""
|
|
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 token fixture requires NEXTCLOUD_HOST, USERNAME, and PASSWORD"
|
|
)
|
|
|
|
# Wait for Nextcloud to be ready
|
|
if not await wait_for_nextcloud(nextcloud_host):
|
|
pytest.fail(f"Nextcloud server at {nextcloud_host} is not ready")
|
|
|
|
try:
|
|
token = await get_oauth_token(nextcloud_host, username, password)
|
|
return token
|
|
except Exception as e:
|
|
logger.error(f"Failed to obtain OAuth token: {e}")
|
|
pytest.skip(f"Could not obtain OAuth token for testing: {e}")
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
async def nc_oauth_client(
|
|
interactive_oauth_token: str,
|
|
) -> AsyncGenerator[NextcloudClient, Any]:
|
|
"""
|
|
Fixture to create a NextcloudClient instance using OAuth authentication.
|
|
Uses the oauth_token fixture to get an access token.
|
|
"""
|
|
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 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 initialized and capabilities checked.")
|
|
yield client
|
|
except Exception as e:
|
|
logger.error(f"Failed to initialize OAuth NextcloudClient: {e}")
|
|
pytest.fail(f"Failed to connect to Nextcloud with 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.
|
|
"""
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
import threading
|
|
from urllib.parse import urlparse, parse_qs
|
|
|
|
# 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.
|
|
"""
|
|
import webbrowser
|
|
import time
|
|
|
|
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
|