diff --git a/tests/load/INTEGRATION_GUIDE.md b/tests/load/INTEGRATION_GUIDE.md deleted file mode 100644 index 7ca2fff..0000000 --- a/tests/load/INTEGRATION_GUIDE.md +++ /dev/null @@ -1,712 +0,0 @@ -# OAuth Benchmark Integration Guide - -This document outlines the remaining code needed to complete the dynamic OAuth user creation for the load benchmark. - -## Status Overview - -### āœ… Completed (`oauth_pool.py`) -- Removed hardcoded `default_test_users()` -- Added `generate_secure_password()` utility -- Updated `OAuthUserPool` to use `NextcloudClient` for user management -- Added `create_nextcloud_user()` method -- Added `delete_nextcloud_user()` method -- Added `acquire_token_playwright()` method for OAuth automation - -### 🚧 Remaining (`oauth_benchmark.py`) -1. OAuth Callback Server class -2. OAuth client registration utilities -3. Updated main `run_oauth_benchmark()` function -4. New CLI options -5. Cleanup handlers - ---- - -## 1. OAuth Callback Server Class - -Add this class at the top of `oauth_benchmark.py` (after imports): - -```python -import threading -from http.server import BaseHTTPRequestHandler, HTTPServer -from urllib.parse import parse_qs, urlparse - - -class OAuthCallbackServer: - """ - HTTP server to capture OAuth authorization callbacks. - - Based on conftest.py:oauth_callback_server fixture. - Runs in background thread and captures auth codes via state correlation. - """ - - def __init__(self, port: int = 8081): - self.port = port - self.auth_states: dict[str, str] = {} # Map state -> auth_code - self.httpd: HTTPServer | None = None - self.server_thread: threading.Thread | None = None - - def start(self): - """Start the callback server in a background thread.""" - - class OAuthCallbackHandler(BaseHTTPRequestHandler): - def log_message(self, format, *args): - # Suppress default HTTP logging - pass - - def do_GET(handler_self): - # Parse the callback request - parsed_path = urlparse(handler_self.path) - query = parse_qs(parsed_path.query) - code = query.get("code", [None])[0] - state = query.get("state", [None])[0] - - # Only process if we have a valid code - if code: - # Store code keyed by state parameter - if state: - self.auth_states[state] = code - logger.info( - f"OAuth callback received for state={state[:16]}... Code: {code[:20]}..." - ) - else: - # Fallback for flows without state - self.auth_states["_default"] = code - logger.info(f"OAuth callback received (no state). Code: {code[:20]}...") - - handler_self.send_response(200) - handler_self.send_header("Content-type", "text/html") - handler_self.end_headers() - handler_self.wfile.write( - b"

Authentication successful!

" - b"

You can close this window.

" - ) - else: - # Ignore requests without a code - logger.debug(f"Ignoring request without auth code: {handler_self.path}") - handler_self.send_response(404) - handler_self.end_headers() - - # Start the HTTP server - self.httpd = HTTPServer(("localhost", self.port), OAuthCallbackHandler) - self.server_thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) - self.server_thread.start() - logger.info(f"OAuth callback server started on http://localhost:{self.port}") - - def stop(self): - """Shutdown the callback server.""" - if self.httpd: - logger.info("Shutting down OAuth callback server...") - shutdown_thread = threading.Thread(target=self.httpd.shutdown) - shutdown_thread.start() - shutdown_thread.join(timeout=2) - self.httpd.server_close() - logger.info("OAuth callback server shut down successfully") - if self.server_thread: - self.server_thread.join(timeout=1) - - @property - def url(self) -> str: - """Get the callback URL.""" - return f"http://localhost:{self.port}" -``` - ---- - -## 2. OAuth Client Registration Utilities - -Add these utility functions in `oauth_benchmark.py`: - -```python -async def discover_oidc_endpoints(nextcloud_host: str) -> dict[str, str]: - """ - Discover OIDC endpoints via OpenID Connect Discovery. - - Args: - nextcloud_host: Nextcloud base URL - - Returns: - Dict with token_endpoint, authorization_endpoint, registration_endpoint - """ - async with httpx.AsyncClient(timeout=30.0, verify=False) as http_client: - discovery_url = f"{nextcloud_host}/.well-known/openid-configuration" - logger.info(f"Discovering OIDC endpoints from {discovery_url}") - - response = await http_client.get(discovery_url) - response.raise_for_status() - oidc_config = 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.info("Successfully discovered OIDC endpoints") - return { - "token_endpoint": token_endpoint, - "registration_endpoint": registration_endpoint, - "authorization_endpoint": authorization_endpoint, - } - - -async def setup_oauth_client( - oidc_endpoints: dict[str, str], - callback_url: str, - storage_path: str = ".nextcloud_oauth_benchmark_client.json", -) -> tuple[str, str]: - """ - Register or load OAuth client credentials. - - Args: - oidc_endpoints: Dict from discover_oidc_endpoints() - callback_url: OAuth callback URL - storage_path: Path to store client credentials - - Returns: - Tuple of (client_id, client_secret) - """ - from nextcloud_mcp_server.auth.client_registration import load_or_register_client - - logger.info("Setting up OAuth client for benchmark...") - - # Get Nextcloud host from environment - nextcloud_host = os.getenv("NEXTCLOUD_HOST") - if not nextcloud_host: - raise ValueError("NEXTCLOUD_HOST environment variable required") - - client_info = await load_or_register_client( - nextcloud_url=nextcloud_host, - registration_endpoint=oidc_endpoints["registration_endpoint"], - storage_path=storage_path, - client_name="Nextcloud MCP OAuth Benchmark", - redirect_uris=[callback_url], - ) - - logger.info(f"OAuth client ready: {client_info.client_id[:16]}...") - return client_info.client_id, client_info.client_secret -``` - ---- - -## 3. User Creation Helper Function - -Add this helper function: - -```python -async def create_and_authenticate_user( - user_pool: OAuthUserPool, - browser: Any, - username: str, - password: str, - auth_states: dict[str, str], - delay: float = 0, -) -> UserSessionWrapper: - """ - Create a Nextcloud user and acquire OAuth token. - - Args: - user_pool: OAuthUserPool instance - browser: Playwright browser - username: Username to create - password: Password for user - auth_states: Shared auth_states dict from callback server - delay: Delay before starting (for staggering) - - Returns: - UserSessionWrapper for the authenticated user - """ - if delay > 0: - await asyncio.sleep(delay) - - logger.info(f"Creating and authenticating user: {username}") - - # 1. Create Nextcloud user - user_config = await user_pool.create_nextcloud_user( - username=username, - password=password, - display_name=f"Benchmark User {username}", - ) - - # 2. Acquire OAuth token via Playwright - import secrets - state = secrets.token_urlsafe(32) - - try: - token = await user_pool.acquire_token_playwright( - browser=browser, - username=username, - password=password, - state=state, - auth_states=auth_states, - ) - - # 3. Add to user pool - await user_pool.add_user(username, password, token) - - # 4. Create MCP session - # Note: This requires implementing MCP session creation with OAuth token - # For now, we'll create a placeholder session - # In production, you'd use: - # session = await user_pool.create_user_session(username, mcp_url) - # wrapper = UserSessionWrapper(username, session, user_pool) - - logger.info(f"Successfully created and authenticated: {username}") - - # Return placeholder for now - # In production implementation, return actual UserSessionWrapper - return None # TODO: Implement MCP session creation - - except Exception as e: - logger.error(f"Failed to authenticate {username}: {e}") - # Cleanup: delete user if authentication failed - try: - await user_pool.delete_nextcloud_user(username) - except Exception as cleanup_error: - logger.warning(f"Failed to cleanup user {username}: {cleanup_error}") - raise -``` - ---- - -## 4. Updated Main Benchmark Function - -Replace the existing `run_oauth_benchmark()` function with: - -```python -async def run_oauth_benchmark( - num_users: int, - duration: float, - mcp_url: str, - warmup: float = 5.0, - user_prefix: str = "bench", - cleanup: bool = True, - browser_type: str = "chromium", - headed: bool = False, -) -> OAuthBenchmarkMetrics: - """ - Run the OAuth multi-user benchmark with dynamic user creation. - - Args: - num_users: Number of concurrent users to create - duration: Test duration in seconds - mcp_url: MCP server URL - warmup: Warmup period in seconds - user_prefix: Prefix for generated usernames - cleanup: Whether to delete users after benchmark - browser_type: Browser to use (chromium, firefox, webkit) - headed: Show browser window (for debugging) - - Returns: - OAuthBenchmarkMetrics with results - """ - metrics = OAuthBenchmarkMetrics() - stop_event = asyncio.Event() - callback_server = None - browser = None - admin_client = None - user_pool = None - created_usernames = [] - - # Setup signal handlers for graceful shutdown - def signal_handler(sig, frame): - logger.warning("Received interrupt signal, stopping benchmark...") - stop_event.set() - - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - - try: - print(f"\nStarting OAuth benchmark with {num_users} users for {duration}s...") - print(f"Target: {mcp_url}") - print(f"Warmup period: {warmup}s") - print(f"User prefix: {user_prefix}") - print(f"Cleanup after: {cleanup}\n") - - # Get Nextcloud host from environment - nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080") - - # 1. Start OAuth callback server - print("Starting OAuth callback server...") - callback_server = OAuthCallbackServer(port=8081) - callback_server.start() - - # 2. Discover OIDC endpoints - print("Discovering OIDC endpoints...") - oidc_endpoints = await discover_oidc_endpoints(nextcloud_host) - - # 3. Setup OAuth client - print("Registering OAuth client...") - client_id, client_secret = await setup_oauth_client( - oidc_endpoints, callback_server.url - ) - - # 4. Create admin NextcloudClient for user management - print("Initializing admin client...") - from nextcloud_mcp_server.client import NextcloudClient - admin_client = NextcloudClient.from_env() - - # 5. Create user pool - user_pool = OAuthUserPool( - admin_client=admin_client, - client_id=client_id, - client_secret=client_secret, - callback_url=callback_server.url, - token_endpoint=oidc_endpoints["token_endpoint"], - authorization_endpoint=oidc_endpoints["authorization_endpoint"], - ) - - # Initialize HTTP client for token exchange - async with user_pool: - # 6. Launch Playwright browser - print(f"Launching {browser_type} browser (headed={headed})...") - from playwright.async_api import async_playwright - - async with async_playwright() as p: - browser = await p[browser_type].launch(headless=not headed) - - # 7. Create users dynamically - print(f"\nCreating {num_users} users dynamically...") - user_tasks = [] - - for i in range(num_users): - username = f"{user_prefix}_user{i+1:03d}" - password = generate_secure_password() - created_usernames.append(username) - - # Stagger user creation (2 seconds apart) - delay = i * 2.0 - - user_tasks.append( - create_and_authenticate_user( - user_pool, - browser, - username, - password, - callback_server.auth_states, - delay, - ) - ) - - # Create users in parallel (with staggering) - print(f"Authenticating {num_users} users via Playwright...") - user_wrappers = await asyncio.gather(*user_tasks, return_exceptions=True) - - # Filter out failures - successful_users = [ - w for w in user_wrappers - if w is not None and not isinstance(w, Exception) - ] - - print(f"\nSuccessfully authenticated {len(successful_users)}/{num_users} users") - - if not successful_users: - print("ERROR: No users successfully authenticated. Cannot run benchmark.") - return metrics - - # 8. TODO: Run actual benchmark workload - # (This part needs MCP session creation to be implemented) - print("\nāš ļø Benchmark workload execution not yet implemented") - print("This requires implementing MCP session creation with OAuth tokens") - print(f"\nSimulating {duration}s benchmark duration...") - - # Warmup - if warmup > 0: - print(f"Warmup: {warmup}s...") - await asyncio.sleep(warmup) - - # Start metrics - metrics.start() - - # Simulate duration - await asyncio.sleep(min(duration, 5)) # Cap at 5s for demo - - # Stop metrics - metrics.stop() - - # 9. Close browser - await browser.close() - browser = None - - except KeyboardInterrupt: - print("\n\nBenchmark interrupted by user") - stop_event.set() - - except Exception as e: - logger.error(f"Benchmark failed: {e}", exc_info=True) - print(f"\nERROR: {e}") - - finally: - # Cleanup - print("\n" + "=" * 80) - print("CLEANUP") - print("=" * 80) - - if cleanup and created_usernames and user_pool: - print(f"\nDeleting {len(created_usernames)} benchmark users...") - for username in created_usernames: - try: - await user_pool.delete_nextcloud_user(username) - print(f" āœ“ Deleted: {username}") - except Exception as e: - print(f" āœ— Failed to delete {username}: {e}") - elif created_usernames: - print(f"\nSkipping cleanup (--no-cleanup). Created users:") - for username in created_usernames: - print(f" - {username}") - - # Close admin client - if admin_client: - await admin_client.close() - - # Stop callback server - if callback_server: - callback_server.stop() - - # Close browser if still open - if browser: - try: - await browser.close() - except Exception: - pass - - print("=" * 80 + "\n") - - return metrics -``` - ---- - -## 5. Updated CLI Options - -Update the `@click.command()` decorator and `main()` function: - -```python -@click.command() -@click.option( - "--users", - "-u", - type=int, - default=2, - show_default=True, - help="Number of concurrent users to create dynamically", -) -@click.option( - "--duration", - "-d", - type=float, - default=30.0, - show_default=True, - help="Test duration in seconds", -) -@click.option( - "--warmup", - "-w", - type=float, - default=5.0, - show_default=True, - help="Warmup duration before collecting metrics (seconds)", -) -@click.option( - "--url", - default="http://127.0.0.1:8001/mcp", - show_default=True, - help="MCP OAuth server URL", -) -@click.option( - "--output", - "-o", - type=click.Path(), - help="Output file for JSON results (optional)", -) -@click.option( - "--workload", - type=click.Choice(["mixed", "sharing", "collaboration", "baseline"]), - default="mixed", - show_default=True, - help="Workload type to execute", -) -@click.option( - "--user-prefix", - default="bench", - show_default=True, - help="Prefix for generated usernames (e.g., bench_user001)", -) -@click.option( - "--cleanup/--no-cleanup", - default=True, - show_default=True, - help="Delete users after benchmark", -) -@click.option( - "--browser", - type=click.Choice(["chromium", "firefox", "webkit"]), - default="chromium", - show_default=True, - help="Browser for Playwright automation", -) -@click.option( - "--headed", - is_flag=True, - help="Show browser window (for debugging)", -) -@click.option( - "--verbose", - "-v", - is_flag=True, - help="Enable verbose logging", -) -def main( - users: int, - duration: float, - warmup: float, - url: str, - output: str | None, - workload: str, - user_prefix: str, - cleanup: bool, - browser: str, - headed: bool, - verbose: bool, -): - """ - OAuth Multi-User Load Testing for Nextcloud MCP Server. - - Dynamically creates N users, acquires OAuth tokens via Playwright, - and runs realistic multi-user collaboration workflows. - - Examples: - - # 4 users, 60-second test - uv run python -m tests.load.oauth_benchmark --users 4 --duration 60 - - # 10 users, custom prefix, keep users after - uv run python -m tests.load.oauth_benchmark -u 10 --user-prefix loadtest --no-cleanup - - # Debug mode with visible browser - uv run python -m tests.load.oauth_benchmark -u 2 -d 30 --browser firefox --headed - """ - if verbose: - logging.getLogger().setLevel(logging.DEBUG) - logging.getLogger("tests.load").setLevel(logging.DEBUG) - - async def run(): - # Check required environment variables - required_vars = ["NEXTCLOUD_HOST", "NEXTCLOUD_USERNAME", "NEXTCLOUD_PASSWORD"] - missing = [var for var in required_vars if not os.getenv(var)] - if missing: - print(f"ERROR: Missing required environment variables: {', '.join(missing)}") - sys.exit(1) - - # Run benchmark - metrics = await run_oauth_benchmark( - num_users=users, - duration=duration, - mcp_url=url, - warmup=warmup, - user_prefix=user_prefix, - cleanup=cleanup, - browser_type=browser, - headed=headed, - ) - - # Print report - metrics.print_report() - - # Export to JSON if requested - if output: - with open(output, "w") as f: - json.dump(metrics.to_dict(), f, indent=2) - print(f"Results exported to: {output}") - - try: - asyncio.run(run()) - except KeyboardInterrupt: - print("\nBenchmark interrupted by user") - sys.exit(130) - except Exception as e: - print(f"ERROR: {e}", file=sys.stderr) - if verbose: - raise - sys.exit(1) -``` - ---- - -## 6. Required Imports - -Add these imports at the top of `oauth_benchmark.py`: - -```python -import threading -from http.server import BaseHTTPRequestHandler, HTTPServer -from urllib.parse import parse_qs, urlparse - -import httpx - -from tests.load.oauth_pool import ( - OAuthUserPool, - UserSessionWrapper, - generate_secure_password, -) -``` - ---- - -## Testing Checklist - -Once implemented, test with: - -```bash -# 1. Test with 2 users in headed mode (watch OAuth flow) -uv run python -m tests.load.oauth_benchmark -u 2 -d 10 --headed --no-cleanup - -# 2. Verify users were created in Nextcloud admin UI: -# - bench_user001 -# - bench_user002 - -# 3. Test cleanup -uv run python -m tests.load.oauth_benchmark -u 2 -d 10 --cleanup - -# 4. Verify users were deleted - -# 5. Test with custom prefix -uv run python -m tests.load.oauth_benchmark -u 3 --user-prefix test --cleanup - -# 6. Test error handling (interrupt with Ctrl+C) -uv run python -m tests.load.oauth_benchmark -u 5 -d 60 -# Press Ctrl+C after a few seconds -# Verify cleanup still happens -``` - ---- - -## Known Limitations / TODOs - -1. **MCP Session Creation**: The `create_and_authenticate_user()` function returns `None` because MCP session creation with OAuth tokens is not yet implemented. This needs: - - Integration with `mcp.client.streamable_http` - - Passing OAuth token to MCP server - - Creating `UserSessionWrapper` with authenticated session - -2. **Workload Execution**: The benchmark doesn't run actual workloads yet - it just simulates the duration. Once MCP sessions are created, uncomment the workload execution code. - -3. **Parallel Optimization**: User creation is staggered by 2 seconds. This could be optimized based on server capacity. - -4. **Error Recovery**: If a user fails to authenticate, it's removed from the pool but the benchmark continues. Consider adding a minimum user threshold. - ---- - -## Summary - -The integration is ~80% complete: -- āœ… User pool management -- āœ… Dynamic user creation/deletion -- āœ… Playwright OAuth automation -- āœ… Callback server -- āœ… OAuth client registration -- āœ… CLI options -- āœ… Cleanup handlers -- āš ļø MCP session creation (placeholder) -- āš ļø Workload execution (depends on sessions) - -The framework is **production-ready** for user management and OAuth token acquisition. The final piece is connecting OAuth tokens to MCP sessions, which requires understanding how the MCP client handles OAuth authentication.