diff --git a/app-hooks/post-installation/20-install-astrolabe-app.sh b/app-hooks/post-installation/20-install-astrolabe-app.sh index ba70720..b472447 100755 --- a/app-hooks/post-installation/20-install-astrolabe-app.sh +++ b/app-hooks/post-installation/20-install-astrolabe-app.sh @@ -2,7 +2,7 @@ set -euox pipefail -echo "Installing and configuring Astrolabe app for testing..." +echo "Installing Astrolabe app for testing..." # Check if development astrolabe app is mounted at /opt/apps/astrolabe if [ -d /opt/apps/astrolabe ]; then @@ -30,55 +30,7 @@ else php /var/www/html/occ app:enable astrolabe fi -# Configure MCP server URLs in Nextcloud system config -# - mcp_server_url: Internal URL for PHP app to call MCP server APIs (Docker internal network) -# - mcp_server_public_url: Public URL for OAuth token audience (what browsers/MCP clients see) -php /var/www/html/occ config:system:set mcp_server_url --value='http://mcp-oauth:8001' -php /var/www/html/occ config:system:set mcp_server_public_url --value='http://localhost:8001' - -# Create OAuth client for Astrolabe app -# The resource_url MUST match what the MCP server expects as token audience -# This allows tokens from this client to be validated by MCP server's UnifiedTokenVerifier -MCP_CLIENT_ID="nextcloudMcpServerUIPublicClient" -MCP_RESOURCE_URL="http://localhost:8001" -MCP_REDIRECT_URI="http://localhost:8080/apps/astrolabe/oauth/callback" - -echo "Configuring OAuth client for Astrolabe..." - -# Check if client already exists -if php /var/www/html/occ oidc:list 2>/dev/null | grep -q "$MCP_CLIENT_ID"; then - echo "OAuth client $MCP_CLIENT_ID already exists, removing to recreate with correct settings..." - php /var/www/html/occ oidc:remove "$MCP_CLIENT_ID" || true -fi - -# Create OAuth client with correct resource_url for MCP server audience -echo "Creating OAuth confidential client with resource_url=$MCP_RESOURCE_URL" -CLIENT_OUTPUT=$(php /var/www/html/occ oidc:create \ - "Astrolabe" \ - "$MCP_REDIRECT_URI" \ - --client_id="$MCP_CLIENT_ID" \ - --type=confidential \ - --flow=code \ - --token_type=jwt \ - --resource_url="$MCP_RESOURCE_URL" \ - --allowed_scopes="openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write") - -echo "$CLIENT_OUTPUT" - -# Extract client_secret from JSON output -CLIENT_SECRET=$(echo "$CLIENT_OUTPUT" | php -r 'echo json_decode(file_get_contents("php://stdin"), true)["client_secret"] ?? "";') - -if [ -n "$CLIENT_SECRET" ]; then - echo "Configuring Astrolabe client secret in system config..." - php /var/www/html/occ config:system:set astrolabe_client_secret --value="$CLIENT_SECRET" - echo "✓ Client secret configured: ${CLIENT_SECRET:0:8}..." -else - echo "⚠ Warning: Could not extract client_secret from OIDC client creation" -fi - -# Configure OAuth client ID in system config -echo "Configuring Astrolabe client ID in system config..." -php /var/www/html/occ config:system:set astrolabe_client_id --value="$MCP_CLIENT_ID" -echo "✓ Client ID configured: $MCP_CLIENT_ID" - -echo "Astrolabe app installed and configured successfully" +echo "✓ Astrolabe app installed successfully" +echo "" +echo "Note: MCP server configuration is managed dynamically during tests" +echo " to support testing multiple MCP server deployments." diff --git a/tests/conftest.py b/tests/conftest.py index e429f9a..29daa91 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -114,6 +114,7 @@ async def create_mcp_client_session( client_name: str = "MCP", elicitation_callback: Any = None, sampling_callback: Any = None, + headers: dict[str, str] | None = None, ) -> AsyncGenerator[ClientSession, Any]: """ Factory function to create an MCP client session with proper lifecycle management. @@ -135,6 +136,8 @@ async def create_mcp_client_session( Should match signature: async def callback(context: RequestContext, params: ElicitRequestParams) -> ElicitResult | ErrorData sampling_callback: Optional callback for handling sampling (LLM generation) requests. Should match signature: async def callback(context: RequestContext, params: CreateMessageRequestParams) -> CreateMessageResult | ErrorData + headers: Optional custom headers (e.g., for BasicAuth). If both headers and token are provided, + custom headers take precedence. Yields: Initialized MCP ClientSession @@ -147,8 +150,9 @@ async def create_mcp_client_session( """ logger.info(f"Creating Streamable HTTP client for {client_name}") - # Prepare headers with OAuth token if provided - headers = {"Authorization": f"Bearer {token}"} if token else None + # Prepare headers - custom headers take precedence over token-based auth + if headers is None: + headers = {"Authorization": f"Bearer {token}"} if token else None # Use native async with - Python ensures LIFO cleanup # Cleanup order will be: ClientSession.__aexit__ -> streamablehttp_client.__aexit__ @@ -240,6 +244,32 @@ async def nc_mcp_oauth_client( yield session +@pytest.fixture(scope="session") +async def nc_mcp_basic_auth_client( + anyio_backend, +) -> AsyncGenerator[ClientSession, Any]: + """ + Fixture to create an MCP client session with BasicAuth credentials. + Connects to the multi-user BasicAuth MCP server on port 8003 with ENABLE_MULTI_USER_BASIC_AUTH=true. + + Uses BasicAuth credentials for multi-user pass-through mode (ADR-020). + Credentials are passed in Authorization header and forwarded to Nextcloud APIs. + + Uses anyio pytest plugin for proper async fixture handling. + """ + import base64 + + credentials = base64.b64encode(b"admin:admin").decode("utf-8") + auth_header = f"Basic {credentials}" + + async for session in create_mcp_client_session( + url="http://localhost:8003/mcp", + headers={"Authorization": auth_header}, + client_name="BasicAuth MCP (Multi-User)", + ): + yield session + + @pytest.fixture(scope="session") async def nc_mcp_oauth_jwt_client( anyio_backend, @@ -3187,3 +3217,199 @@ async def nc_mcp_keycloak_client_no_custom_scopes( client_name="Keycloak No Custom Scopes MCP", ): yield session + + +# ======================================================================== +# Astrolabe Dynamic Configuration Fixtures +# ======================================================================== + + +@pytest.fixture(scope="session") +async def configure_astrolabe_for_mcp_server(nc_client): + """Configure Astrolabe app to connect to a specific MCP server. + + This fixture dynamically configures the Astrolabe app's MCP server settings + and OAuth client, allowing tests to verify integration with different MCP + server deployments (mcp-oauth, mcp-keycloak, mcp-multi-user-basic, etc.). + + Usage: + async def test_my_integration(configure_astrolabe_for_mcp_server): + await configure_astrolabe_for_mcp_server( + mcp_server_internal_url="http://mcp-oauth:8001", + mcp_server_public_url="http://localhost:8001" + ) + # ... test Astrolabe integration ... + + Args: + nc_client: NextcloudClient fixture for occ command execution + + Returns: + Async function that accepts: + - mcp_server_internal_url: Internal Docker URL for PHP app to call MCP APIs + - mcp_server_public_url: Public URL for OAuth token audience validation + - client_id: Optional OAuth client ID (default: "nextcloudMcpServerUIPublicClient") + """ + import json + import subprocess + + async def _configure( + mcp_server_internal_url: str, + mcp_server_public_url: str, + client_id: str = "nextcloudMcpServerUIPublicClient", + ) -> dict[str, str]: + """Configure Astrolabe for the specified MCP server. + + Returns: + Dict with client_id and client_secret + """ + logger.info( + f"Configuring Astrolabe for MCP server: {mcp_server_internal_url} (public: {mcp_server_public_url})" + ) + + # Configure MCP server URLs in Nextcloud system config + subprocess.run( + [ + "docker", + "compose", + "exec", + "-T", + "app", + "php", + "/var/www/html/occ", + "config:system:set", + "mcp_server_url", + "--value", + mcp_server_internal_url, + ], + check=True, + capture_output=True, + ) + + subprocess.run( + [ + "docker", + "compose", + "exec", + "-T", + "app", + "php", + "/var/www/html/occ", + "config:system:set", + "mcp_server_public_url", + "--value", + mcp_server_public_url, + ], + check=True, + capture_output=True, + ) + + logger.info("✓ MCP server URLs configured") + + # Remove existing OAuth client if it exists + try: + subprocess.run( + [ + "docker", + "compose", + "exec", + "-T", + "app", + "php", + "/var/www/html/occ", + "oidc:remove", + client_id, + ], + check=False, # Don't fail if client doesn't exist + capture_output=True, + ) + logger.info(f"Removed existing OAuth client: {client_id}") + except Exception: + pass + + # Create OAuth client for Astrolabe + redirect_uri = "http://localhost:8080/apps/astrolabe/oauth/callback" + + result = subprocess.run( + [ + "docker", + "compose", + "exec", + "-T", + "app", + "php", + "/var/www/html/occ", + "oidc:create", + "Astrolabe", + redirect_uri, + "--client_id", + client_id, + "--type", + "confidential", + "--flow", + "code", + "--token_type", + "jwt", + "--resource_url", + mcp_server_public_url, + "--allowed_scopes", + "openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write", + ], + check=True, + capture_output=True, + text=True, + ) + + # Parse client_secret from JSON output + client_output = json.loads(result.stdout.strip()) + client_secret = client_output.get("client_secret") + + if not client_secret: + raise ValueError( + "Failed to extract client_secret from OAuth client creation" + ) + + logger.info(f"✓ OAuth client created: {client_id}") + + # Store client credentials in Nextcloud system config + subprocess.run( + [ + "docker", + "compose", + "exec", + "-T", + "app", + "php", + "/var/www/html/occ", + "config:system:set", + "astrolabe_client_id", + "--value", + client_id, + ], + check=True, + capture_output=True, + ) + + subprocess.run( + [ + "docker", + "compose", + "exec", + "-T", + "app", + "php", + "/var/www/html/occ", + "config:system:set", + "astrolabe_client_secret", + "--value", + client_secret, + ], + check=True, + capture_output=True, + ) + + logger.info("✓ Client credentials stored in system config") + logger.info(f"Astrolabe configured for MCP server: {mcp_server_public_url}") + + return {"client_id": client_id, "client_secret": client_secret} + + return _configure diff --git a/tests/server/oauth/test_astrolabe_multi_server_integration.py b/tests/server/oauth/test_astrolabe_multi_server_integration.py new file mode 100644 index 0000000..da8a207 --- /dev/null +++ b/tests/server/oauth/test_astrolabe_multi_server_integration.py @@ -0,0 +1,104 @@ +"""Test Astrolabe integration with multiple MCP server deployments. + +This test suite verifies that the Astrolabe app can be dynamically configured +to connect to different MCP server deployments (mcp-oauth, mcp-keycloak, etc.). + +The configuration is managed dynamically during tests using the +configure_astrolabe_for_mcp_server fixture, which allows testing multiple +deployment scenarios without requiring static post-installation configuration. +""" + +import logging + +import pytest + +logger = logging.getLogger(__name__) + +pytestmark = [pytest.mark.integration, pytest.mark.oauth] + + +class TestAstrolabeMultiServerIntegration: + """Test suite for Astrolabe integration with multiple MCP servers.""" + + @pytest.mark.parametrize( + "mcp_server_config", + [ + { + "name": "mcp-oauth", + "internal_url": "http://mcp-oauth:8001", + "public_url": "http://localhost:8001", + }, + { + "name": "mcp-keycloak", + "internal_url": "http://mcp-keycloak:8002", + "public_url": "http://localhost:8002", + }, + # Add more MCP server configurations as needed: + # { + # "name": "mcp-multi-user-basic", + # "internal_url": "http://mcp-multi-user-basic:8000", + # "public_url": "http://localhost:8003", + # }, + ], + ) + async def test_astrolabe_configuration_for_different_servers( + self, configure_astrolabe_for_mcp_server, mcp_server_config + ): + """Test that Astrolabe can be configured for different MCP servers. + + This test verifies that: + 1. The configure_astrolabe_for_mcp_server fixture successfully configures + the Astrolabe app for different MCP server endpoints + 2. OAuth client credentials are properly generated and stored + 3. The configuration can be dynamically changed between tests + """ + logger.info(f"Configuring Astrolabe for {mcp_server_config['name']}...") + + # Configure Astrolabe for the specific MCP server + credentials = await configure_astrolabe_for_mcp_server( + mcp_server_internal_url=mcp_server_config["internal_url"], + mcp_server_public_url=mcp_server_config["public_url"], + ) + + # Verify credentials were returned + assert "client_id" in credentials + assert "client_secret" in credentials + assert credentials["client_id"] == "nextcloudMcpServerUIPublicClient" + assert len(credentials["client_secret"]) > 0 + + logger.info( + f"✓ Astrolabe successfully configured for {mcp_server_config['name']}" + ) + logger.info(f" Internal URL: {mcp_server_config['internal_url']}") + logger.info(f" Public URL: {mcp_server_config['public_url']}") + logger.info(f" Client ID: {credentials['client_id']}") + logger.info(f" Client Secret: {credentials['client_secret'][:8]}...") + + async def test_astrolabe_reconfiguration(self, configure_astrolabe_for_mcp_server): + """Test that Astrolabe can be reconfigured multiple times in the same session. + + This verifies that the OAuth client can be recreated with different + settings without conflicts. + """ + # First configuration: mcp-oauth + logger.info("First configuration: mcp-oauth") + credentials1 = await configure_astrolabe_for_mcp_server( + mcp_server_internal_url="http://mcp-oauth:8001", + mcp_server_public_url="http://localhost:8001", + ) + + assert credentials1["client_id"] == "nextcloudMcpServerUIPublicClient" + + # Second configuration: mcp-keycloak (reconfiguration) + logger.info("Second configuration: mcp-keycloak (reconfiguration)") + credentials2 = await configure_astrolabe_for_mcp_server( + mcp_server_internal_url="http://mcp-keycloak:8002", + mcp_server_public_url="http://localhost:8002", + ) + + assert credentials2["client_id"] == "nextcloudMcpServerUIPublicClient" + + # Client secrets should be different (new client created) + assert credentials1["client_secret"] != credentials2["client_secret"] + + logger.info("✓ Astrolabe successfully reconfigured without conflicts") diff --git a/tests/server/oauth/test_nc_php_app_debug.py b/tests/server/oauth/test_nc_php_app_debug.py index ad94078..766564b 100644 --- a/tests/server/oauth/test_nc_php_app_debug.py +++ b/tests/server/oauth/test_nc_php_app_debug.py @@ -10,8 +10,14 @@ logger = logging.getLogger(__name__) pytestmark = [pytest.mark.integration, pytest.mark.oauth] -async def test_capture_settings_page(browser): +async def test_capture_settings_page(browser, configure_astrolabe_for_mcp_server): """Capture what's actually rendered on the personal settings page.""" + # Configure Astrolabe for mcp-oauth server + await configure_astrolabe_for_mcp_server( + mcp_server_internal_url="http://mcp-oauth:8001", + mcp_server_public_url="http://localhost:8001", + ) + nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080") username = os.getenv("NEXTCLOUD_USERNAME", "admin") password = os.getenv("NEXTCLOUD_PASSWORD", "admin") diff --git a/tests/server/oauth/test_nc_php_app_oauth.py b/tests/server/oauth/test_nc_php_app_oauth.py index 4f953f4..ef2a2cf 100644 --- a/tests/server/oauth/test_nc_php_app_oauth.py +++ b/tests/server/oauth/test_nc_php_app_oauth.py @@ -44,14 +44,32 @@ async def nc_admin_http_client(nextcloud_credentials): @pytest.fixture(scope="module") -async def authorized_nc_session(browser, nextcloud_credentials): +async def configure_astrolabe_for_tests(configure_astrolabe_for_mcp_server): + """Configure Astrolabe to connect to mcp-oauth server before running tests. + + This module-scoped fixture ensures Astrolabe is properly configured + for the mcp-oauth server (http://localhost:8001) before any tests run. + """ + logger.info("Configuring Astrolabe for mcp-oauth server...") + await configure_astrolabe_for_mcp_server( + mcp_server_internal_url="http://mcp-oauth:8001", + mcp_server_public_url="http://localhost:8001", + ) + logger.info("✓ Astrolabe configured for mcp-oauth server") + + +@pytest.fixture(scope="module") +async def authorized_nc_session( + browser, nextcloud_credentials, configure_astrolabe_for_tests +): """Module-scoped fixture that logs in and authorizes the NC PHP app once. This fixture: - 1. Creates a browser context - 2. Logs in to Nextcloud - 3. Authorizes the MCP Server UI app (if not already authorized) - 4. Returns the page for use in all tests + 1. Configures Astrolabe for mcp-oauth server (via configure_astrolabe_for_tests) + 2. Creates a browser context + 3. Logs in to Nextcloud + 4. Authorizes the MCP Server UI app (if not already authorized) + 5. Returns the page for use in all tests The authorization is done once and reused for all tests in this module. """