"""Integration test for multi-user Astrolabe background sync enablement. Cross-system interface test: Tests the MCP server's integration with the Astrolabe Nextcloud app, which is installed from the Nextcloud app store via app-hooks/post-installation/20-install-astrolabe-app.sh. Astrolabe source lives in a separate repository (https://github.com/cbcoutinho/astrolabe). This test verifies that multiple users can independently: 1. Log in to Nextcloud 2. Click the one-click "Enable background indexing" opt-in in Astrolabe settings 3. Have a dedicated app password minted from their session and handed to the MCP server (core/getapppassword — no Security-settings step, no copy-paste) 4. Verify the app password is stored in the database Tests the one-click background-indexing provisioning flow: user login → Astrolabe settings → Enable background indexing → session app password minted + forwarded to MCP → background sync active → DB verification. """ import logging import re import subprocess import tempfile import anyio import pytest from playwright.async_api import Locator, Page logger = logging.getLogger(__name__) pytestmark = [pytest.mark.integration, pytest.mark.multi_user_basic] # Accessible-name fallbacks for the background-sync buttons. The stable element # ids (#mcp-enable-background-button / #mcp-revoke-background-button) are the # primary, refactor-proof hook restored in the Astrolabe frontend; resolving by # button text as well means a future id rename degrades to a slower-but-working # lookup instead of a hard 5s Playwright timeout. _ENABLE_NAME = re.compile("Enable background indexing", re.IGNORECASE) _REVOKE_NAME = re.compile("Disable background indexing", re.IGNORECASE) async def _resolve_settings_button( page: Page, button_id: str, name: re.Pattern, *, timeout: int = 5000 ) -> Locator: """Resolve a background-sync settings button, preferring its stable id. Waits up to ``timeout`` for ``button_id`` to become visible; if it never appears, falls back to the button's accessible name. Returning the id locator unchanged when present keeps the match unambiguous (the id button also matches by text, so a combined ``.or_()`` would strict-mode-violate). """ by_id = page.locator(button_id) try: await by_id.wait_for(timeout=timeout, state="visible") return by_id except Exception: logger.warning( "%s not found; falling back to accessible-name lookup", button_id ) by_name = page.get_by_role("button", name=name) await by_name.wait_for(timeout=timeout, state="visible") return by_name async def _background_sync_enabled(page: Page) -> bool: """True when the settings page is in the enabled state (revoke control shown). Checks the stable id first, then the accessible name as a fallback. """ if await page.locator("#mcp-revoke-background-button").count() > 0: return True return await page.get_by_role("button", name=_REVOKE_NAME).count() > 0 async def login_to_nextcloud(page: Page, username: str, password: str): """Helper function to login to Nextcloud via Playwright. Args: page: Playwright page instance username: Nextcloud username password: Nextcloud password """ nextcloud_url = "http://localhost:8080" logger.info("Logging in to Nextcloud as %s...", username) await page.goto(f"{nextcloud_url}/login", wait_until="networkidle") # Fill in login form await page.wait_for_selector('input[name="user"]', timeout=10000) await page.fill('input[name="user"]', username) await page.fill('input[name="password"]', password) # Submit form - use force=True to bypass stability check (CSS transitions) submit_button = page.locator('button[type="submit"]') try: await submit_button.click(force=True, timeout=10000) except Exception: # Fallback: JavaScript click logger.info("Using JavaScript click for login button...") await page.evaluate( """ const btn = document.querySelector('button[type="submit"]'); if (btn) btn.click(); """ ) await page.wait_for_load_state("networkidle", timeout=30000) # Verify logged in (should redirect away from login page) current_url = page.url assert "/login" not in current_url, ( f"Login failed for {username}, still on login page" ) logger.info("✓ Successfully logged in as %s", username) async def navigate_to_astrolabe_settings(page: Page): """Navigate to Astrolabe personal settings page. Args: page: Playwright page instance (must be authenticated) """ nextcloud_url = "http://localhost:8080" settings_url = f"{nextcloud_url}/settings/user/astrolabe" logger.info("Navigating to Astrolabe settings: %s", settings_url) await page.goto(settings_url, wait_until="networkidle", timeout=30000) # Verify we're on the settings page current_url = page.url assert "/settings/user/astrolabe" in current_url, ( f"Failed to navigate to Astrolabe settings, current URL: {current_url}" ) logger.info("✓ Successfully loaded Astrolabe settings page") async def enable_background_sync(page: Page, username: str) -> bool: """Provision background indexing via the one-click opt-in button. The refactored settings page mints a dedicated app password from the current Nextcloud session (core/getapppassword) and hands it to the MCP server — there is no app-password generation in Security settings and no copy-paste. Idempotent: if already enabled (the revoke form is shown instead of the enable button), returns True without acting. Args: page: Playwright page instance (must be logged in) username: Username (for logging) Returns: True once background indexing is enabled. """ logger.info("Enabling background indexing for %s...", username) await page.goto( "http://localhost:8080/settings/user/astrolabe", wait_until="networkidle" ) await anyio.sleep(1) if await _background_sync_enabled(page): logger.info("✓ Background indexing already enabled for %s", username) return True enable_button = await _resolve_settings_button( page, "#mcp-enable-background-button", _ENABLE_NAME ) await enable_button.click() logger.info("Clicked 'Enable background indexing' for %s", username) # On success the page JS reloads to the enabled state (revoke form shown). try: await _resolve_settings_button( page, "#mcp-revoke-background-button", _REVOKE_NAME, timeout=15000 ) logger.info("✓ Background indexing enabled for %s", username) return True except Exception: screenshot_path = ( f"{tempfile.gettempdir()}/astrolabe_enable_failed_{username}.png" ) await page.screenshot(path=screenshot_path) raise ValueError( f"Background indexing did not enable for {username}. " f"Screenshot: {screenshot_path}" ) async def complete_astrolabe_authorization( page: Page, username: str, password: str ) -> dict: """Provision background indexing for a user (one-click app-password opt-in). The auth refactor dropped the per-user OAuth step entirely — search now uses a session-minted JWT, so the only remaining "authorization" is the one-click background-indexing opt-in (a dedicated app password minted from the session and handed to the MCP server). Args: page: Playwright page instance (must be logged in) username: Nextcloud username password: Nextcloud password (for reference, not used directly) Returns: Dict with {"step1": True (no-op, kept for caller compat), "step2": bool, "app_password": None} """ logger.info("Provisioning Astrolabe background indexing for %s...", username) # step1 is retained as always-True for backward compat with callers — there # is no longer an OAuth authorize step to perform. result = {"step1": True, "step2": False, "app_password": None} result["step2"] = await enable_background_sync(page, username) return result async def verify_app_password_created(username: str) -> bool: """Verify that background sync app password was stored for the user. This checks the Nextcloud database for background sync credentials stored by Astrolabe in the oc_preferences table. Args: username: Nextcloud username Returns: True if background sync app password exists """ logger.info("Verifying background sync app password for %s...", username) # Query the database to check for background sync credentials # Astrolabe stores app passwords in oc_preferences, not oc_authtoken query = f""" SELECT userid, configkey, configvalue FROM oc_preferences WHERE userid = '{username}' AND appid = 'astrolabe' AND configkey IN ('background_sync_password', 'background_sync_type', 'background_sync_provisioned_at') ORDER BY configkey; """ try: result = subprocess.run( [ "docker", "compose", "exec", "-T", "db", "mariadb", "-u", "root", "-ppassword", "nextcloud", "-e", query, ], capture_output=True, text=True, timeout=10, ) output = result.stdout logger.debug("Background sync credentials query result:\\n%s", output) # Check if background sync credentials exist # We should see 3 rows: background_sync_password, background_sync_type, background_sync_provisioned_at lines = output.strip().split("\n") if len(lines) >= 3: # Header + at least 2 data rows (password + type) # Verify background_sync_type is "app_password" if "app_password" in output: logger.info("✓ Background sync app password stored for %s", username) return True else: logger.warning( "Background sync credentials found but type is not app_password for %s", username, ) return False else: logger.warning("No background sync credentials found for %s", username) return False except Exception as e: logger.error( "Error checking background sync credentials for %s: %s", username, e ) return False def clear_stale_test_state(clear_preferences: bool = False) -> None: """Clear stale app passwords, bruteforce entries, and optionally Astrolabe preferences.""" commands: list[tuple[list[str], str]] = [ ( [ "docker", "compose", "exec", "-T", "mcp-multi-user-basic", "sqlite3", "/app/data/tokens.db", "DELETE FROM app_passwords;", ], "app passwords", ), ( [ "docker", "compose", "exec", "-T", "db", "mariadb", "-u", "root", "-ppassword", "nextcloud", "-e", "DELETE FROM oc_bruteforce_attempts;", ], "bruteforce entries", ), ] if clear_preferences: commands.append( ( [ "docker", "compose", "exec", "-T", "db", "mariadb", "-u", "root", "-ppassword", "nextcloud", "-e", "DELETE FROM oc_preferences WHERE appid = 'astrolabe';", ], "Astrolabe preferences", ), ) for cmd, label in commands: result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) if result.returncode != 0: logger.warning( "Failed to clear %s (rc=%s): %s", label, result.returncode, result.stderr, ) else: logger.debug("Cleared %s", label) @pytest.mark.integration @pytest.mark.multi_user_basic async def test_multi_user_astrolabe_background_sync_enablement( browser, nc_client, test_users_setup, configure_astrolabe_for_mcp_server, ): """Test that multiple users can independently enable background sync via app passwords. This test verifies the complete app password provisioning flow: 1. Users log in to Nextcloud 2. Users generate app passwords in Security settings 3. Users navigate to Astrolabe personal settings 4. Users enter their app passwords in the Astrolabe form 5. Background sync becomes active with "Active" badge 6. App passwords are stored in the database (oc_authtoken table) 7. The process works correctly for multiple users Requirements: - Astrolabe app installed in Nextcloud and configured for mcp-multi-user-basic - MCP server running in multi-user BasicAuth mode (mcp-multi-user-basic service) - Test users (alice, bob) created with valid credentials This tests ADR-002 Tier 2 authentication: User-specific app passwords for background operations in multi-user BasicAuth deployments. """ # Clear stale state from previous test runs logger.info("Clearing stale app passwords and bruteforce entries...") clear_stale_test_state() # Configure Astrolabe to point to the mcp-multi-user-basic server logger.info("Configuring Astrolabe for mcp-multi-user-basic server...") await configure_astrolabe_for_mcp_server( mcp_server_internal_url="http://mcp-multi-user-basic:8000", mcp_server_public_url="http://localhost:8003", ) # Test users to check test_users = ["alice", "bob"] # Verify test users were created by the fixture logger.info("Verifying test users exist in Nextcloud...") for username in test_users: try: # Use nc_client to check if user exists user_details = await nc_client.users.get_user_details(username) logger.info( "✓ Confirmed %s exists (display name: %s)", username, user_details.displayname, ) except Exception as e: raise AssertionError( f"Test user {username} does not exist! " f"test_users_setup fixture may have failed. Error: {e}" ) results = {} for username in test_users: logger.info("\\n%s", "=" * 60) logger.info("Testing background sync enablement for: %s", username) logger.info("%s", "=" * 60) user_config = test_users_setup[username] password = user_config["password"] # Create new browser context for this user context = await browser.new_context(ignore_https_errors=True) page = await context.new_page() try: # Step 1: Login to Nextcloud await login_to_nextcloud(page, username, password) # Step 2: One-click "Enable background indexing" (mints a dedicated # app password from the session and hands it to the MCP server). sync_enabled = await enable_background_sync(page, username) # Step 3: Verify app password was stored in database app_password_stored = await verify_app_password_created(username) # Give it time to complete await anyio.sleep(1) results[username] = { "settings_accessed": True, "sync_enabled": sync_enabled, "app_password_stored": app_password_stored, "background_sync_active": sync_enabled and app_password_stored, } logger.info("\\n%s results:", username) logger.info(" Settings accessed: ✓") logger.info(" Sync enabled: %s", "✓" if sync_enabled else "✗") logger.info( " App password stored: %s", "✓" if app_password_stored else "✗" ) logger.info( " Background sync active: %s", "✓" if (sync_enabled and app_password_stored) else "✗", ) except Exception as e: logger.error("Error during %s test: %s", username, e) results[username] = { "settings_accessed": False, "app_password_generated": False, "sync_enabled": False, "app_password_stored": False, "background_sync_active": False, "error": str(e), } finally: await context.close() # Verify all users succeeded logger.info("\\n%s", "=" * 60) logger.info("Test Summary") logger.info("%s", "=" * 60) for username, result in results.items(): logger.info("\\n%s:", username) for key, value in result.items(): if key != "error": status = "✓" if value else "✗" logger.info(" %s: %s", key, status) elif value: logger.info(" error: %s", value) # Assert all users successfully enabled background sync for username in test_users: result = results[username] assert result["settings_accessed"], ( f"{username} could not access Astrolabe settings" ) assert result["sync_enabled"], ( f"{username} background sync enablement did not complete successfully" ) assert result["app_password_stored"], ( f"{username} app password was not stored in database" ) assert result["background_sync_active"], ( f"{username} background sync is not active" ) logger.info( "\\n✓ All %s users successfully enabled background sync via app passwords!", len(test_users), ) async def revoke_background_sync_access(page: Page, username: str) -> bool: """Revoke background sync access by clicking the "Disable background indexing" button. Args: page: Playwright page instance (must be authenticated) username: Username (for logging) Returns: True if revocation was successful """ logger.info("Revoking background sync access for %s...", username) nextcloud_url = "http://localhost:8080" # Set up network request and console listeners network_requests = [] network_responses = [] console_messages = [] def log_request(req): network_requests.append(f"{req.method} {req.url}") def log_response(resp): response_info = f"{resp.status} {resp.url}" network_responses.append(response_info) logger.info("Response: %s", response_info) def log_console(msg): console_messages.append(f"[{msg.type}] {msg.text}") page.on("request", log_request) page.on("response", log_response) page.on("console", log_console) # Navigate to Astrolabe settings await page.goto( f"{nextcloud_url}/settings/user/astrolabe", wait_until="networkidle" ) # Wait for page to load await anyio.sleep(1) # The revoke control (#mcp-revoke-background-button, inside the # #mcp-revoke-background-form container) is only rendered while background # indexing is enabled. if not await _background_sync_enabled(page): logger.warning( "Background indexing not enabled for %s, nothing to revoke", username ) return False try: revoke_button = await _resolve_settings_button( page, "#mcp-revoke-background-button", _REVOKE_NAME ) logger.info("Found 'Disable background indexing' button") except Exception: screenshot_path = ( f"{tempfile.gettempdir()}/astrolabe_no_revoke_button_{username}.png" ) await page.screenshot(path=screenshot_path) raise ValueError( f"Could not find revoke button for {username}. Screenshot: {screenshot_path}" ) # Set up dialog handler for confirmation dialog page.once("dialog", lambda dialog: dialog.accept()) # Click the "Disable background indexing" button await revoke_button.click() logger.info("Clicked the revoke button") # Wait for the request to complete and page to reload await page.wait_for_load_state("networkidle", timeout=15000) await anyio.sleep(2) # Log network requests after clicking logger.info("Network requests after Revoke for %s:", username) for req in network_requests[-10:]: logger.info(" %s", req) # Log network responses logger.info("Network responses after Revoke for %s:", username) for resp in network_responses[-10:]: logger.info(" %s", resp) # Check specifically for the revoke POST response revoke_responses = [r for r in network_responses if "credentials/revoke" in r] if revoke_responses: logger.info("Revoke endpoint response: %s", revoke_responses[-1]) if "200" not in revoke_responses[-1]: logger.error("Revoke POST did not return 200 OK: %s", revoke_responses[-1]) return False else: logger.warning("No response found for credentials/revoke endpoint!") # Take screenshot for debugging screenshot_path = ( f"{tempfile.gettempdir()}/astrolabe_revoke_no_response_{username}.png" ) await page.screenshot(path=screenshot_path) return False # Log any console messages if console_messages: logger.info("Console messages for %s:", username) for msg in console_messages: logger.info(" %s", msg) # Check for error notifications (toast messages) try: error_toast = page.locator(".toastify.toast-error, .toast-error") if await error_toast.count() > 0: error_text = await error_toast.first.text_content() logger.error("Error notification for %s: %s", username, error_text) return False except Exception: pass # After revoke + reload the settings page returns to the un-provisioned # state: the revoke button is gone and the enable button is shown again. try: await anyio.sleep(2) if await _background_sync_enabled(page): logger.error("Revoke button still visible for %s after revoke!", username) screenshot_path = ( f"{tempfile.gettempdir()}/astrolabe_revoke_still_enabled_{username}.png" ) await page.screenshot(path=screenshot_path) return False except Exception: pass logger.info("✓ Background sync access revoked for %s", username) return True async def verify_app_password_deleted(username: str) -> bool: """Verify that background sync app password was deleted for the user. Args: username: Nextcloud username Returns: True if background sync credentials no longer exist """ logger.info("Verifying background sync credentials deleted for %s...", username) query = f""" SELECT userid, configkey, configvalue FROM oc_preferences WHERE userid = '{username}' AND appid = 'astrolabe' AND configkey IN ('background_sync_password', 'background_sync_type', 'background_sync_provisioned_at') ORDER BY configkey; """ try: result = subprocess.run( [ "docker", "compose", "exec", "-T", "db", "mariadb", "-u", "root", "-ppassword", "nextcloud", "-e", query, ], capture_output=True, text=True, timeout=10, ) output = result.stdout logger.debug("Background sync credentials query result:\\n%s", output) # After deletion, we should NOT see background_sync_password if "background_sync_password" not in output: logger.info("✓ Background sync credentials deleted for %s", username) return True else: logger.warning("Background sync credentials still exist for %s", username) return False except Exception as e: logger.error( "Error checking background sync credentials for %s: %s", username, e ) return False @pytest.mark.integration @pytest.mark.multi_user_basic async def test_revoke_background_sync_access( browser, nc_client, test_users_setup, configure_astrolabe_for_mcp_server, ): """Test that users can revoke background sync access via the "Disable background indexing" button. This test verifies: 1. User enables background sync via app password 2. User clicks "Disable background indexing" button 3. Confirmation dialog is handled 4. POST request is sent to /api/v1/background-sync/credentials/revoke 5. "Active" badge disappears from settings page 6. Background sync credentials are deleted from database This tests the fix for the issue where POST requests to the revoke endpoint were returning errors due to HTTP method mismatch (was DELETE, now POST). """ # Clear stale state from previous test runs logger.info( "Clearing stale app passwords, bruteforce entries, and Astrolabe preferences..." ) clear_stale_test_state(clear_preferences=True) # Configure Astrolabe to point to the mcp-multi-user-basic server logger.info("Configuring Astrolabe for mcp-multi-user-basic server...") await configure_astrolabe_for_mcp_server( mcp_server_internal_url="http://mcp-multi-user-basic:8000", mcp_server_public_url="http://localhost:8003", ) # Test with a single user for this specific test username = "alice" user_config = test_users_setup[username] password = user_config["password"] # Create new browser context context = await browser.new_context(ignore_https_errors=True) page = await context.new_page() try: # Step 1: Login to Nextcloud await login_to_nextcloud(page, username, password) # Provision background indexing (app-password opt-in; no OAuth step). auth_result = await complete_astrolabe_authorization(page, username, password) assert auth_result["step2"], f"App password provisioning failed for {username}" # Step 3: Verify background sync is enabled assert await verify_app_password_created(username), ( f"Background sync not enabled for {username}" ) # Step 4: Revoke background sync access revoke_success = await revoke_background_sync_access(page, username) assert revoke_success, f"Failed to revoke background sync access for {username}" # Step 5: Verify credentials are deleted from database credentials_deleted = await verify_app_password_deleted(username) assert credentials_deleted, ( f"Background sync credentials not deleted for {username}" ) logger.info( "\\n✓ Successfully revoked background sync access for %s!", username ) finally: await context.close()