diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 5b20345e3..bc705df72 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -54,7 +54,7 @@ jobs: - group: features files: "tests/e2e/scenarios/test_skills.py tests/e2e/scenarios/test_tool_approval.py tests/e2e/scenarios/test_webhook.py" - group: extensions - files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_telegram_token_validation.py tests/e2e/scenarios/test_telegram_hot_activation.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_pairing.py tests/e2e/scenarios/test_mcp_auth_flow.py tests/e2e/scenarios/test_oauth_credential_fallback.py tests/e2e/scenarios/test_routine_oauth_credential_injection.py" + files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_oauth_url_parameters.py tests/e2e/scenarios/test_telegram_token_validation.py tests/e2e/scenarios/test_telegram_hot_activation.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_pairing.py tests/e2e/scenarios/test_mcp_auth_flow.py tests/e2e/scenarios/test_oauth_credential_fallback.py tests/e2e/scenarios/test_routine_oauth_credential_injection.py" - group: routines files: "tests/e2e/scenarios/test_owner_scope.py tests/e2e/scenarios/test_routine_event_batch.py" steps: diff --git a/tests/e2e/scenarios/test_oauth_url_parameters.py b/tests/e2e/scenarios/test_oauth_url_parameters.py new file mode 100644 index 000000000..0dae3e535 --- /dev/null +++ b/tests/e2e/scenarios/test_oauth_url_parameters.py @@ -0,0 +1,249 @@ +"""OAuth URL parameter validation e2e tests. + +Tests for bug #992: Google OAuth URL broken when initiated from Telegram. +Specifically verifies that OAuth query parameters are correctly formatted: +- "client_id" (with underscore) NOT "clientid" (without underscore) +- All standard OAuth parameters are present and correctly encoded +- URLs are consistent across channels (web, Telegram, etc.) + +The test verifies: +1. OAuth URL is generated with correct parameters +2. URL works with the OAuth provider (Google) +3. Extra parameters (access_type, prompt) are preserved +""" + +from urllib.parse import parse_qs, urlparse +import pytest + +from helpers import api_post, api_get + + +async def _extract_oauth_params(auth_url: str) -> dict: + """Extract and validate OAuth query parameters from auth_url. + + Returns dict with parsed parameters: + { + 'client_id': '...', + 'redirect_uri': '...', + 'response_type': 'code', + 'scope': '...', + 'state': '...', + 'access_type': '...', + 'prompt': '...', + ... + } + """ + parsed = urlparse(auth_url) + qs = parse_qs(parsed.query) + + # Convert lists to single values for easier testing + params = {k: v[0] if len(v) > 0 else v for k, v in qs.items()} + return params + + +async def _get_extension(ironclaw_server, name): + """Get a specific extension from the extensions list, or None.""" + r = await api_get(ironclaw_server, "/api/extensions") + for ext in r.json().get("extensions", []): + if ext["name"] == name: + return ext + return None + + +@pytest.fixture +async def installed_gmail(ironclaw_server): + """Installs the 'gmail' extension before a test and removes it after. + + This fixture handles the setup and teardown of the Gmail extension, + ensuring a clean state for each test. + """ + # Ensure Gmail is not installed before test + ext = await _get_extension(ironclaw_server, "gmail") + if ext: + r = await api_post(ironclaw_server, "/api/extensions/gmail/remove", timeout=30) + assert r.status_code == 200 + + # Install Gmail + r = await api_post( + ironclaw_server, + "/api/extensions/install", + json={"name": "gmail"}, + timeout=180, + ) + assert r.status_code == 200, f"Gmail install failed: {r.text}" + assert r.json().get("success") is True, f"Install failed: {r.json().get('message', '')}" + + yield + + # Teardown: remove gmail + r = await api_post(ironclaw_server, "/api/extensions/gmail/remove", timeout=30) + assert r.status_code == 200, f"Gmail removal failed: {r.text}" + + +@pytest.fixture +async def auth_url(ironclaw_server, installed_gmail): + """Generate and return an OAuth auth URL. + + Requires Gmail to be installed (depends on installed_gmail fixture). + """ + r = await api_post( + ironclaw_server, + "/api/extensions/gmail/setup", + json={"secrets": {}}, + timeout=30, + ) + assert r.status_code == 200 + data = r.json() + assert data.get("success") is True, f"Setup failed: {data.get('message', '')}" + + url = data.get("auth_url") + assert url is not None, f"Expected auth_url in response: {data}" + assert "accounts.google.com" in url, f"auth_url should point to Google: {url}" + + return url + + +@pytest.fixture +async def oauth_params(auth_url): + """Extract and return OAuth parameters from auth_url. + + Depends on auth_url fixture. + """ + return await _extract_oauth_params(auth_url) + + +# ─ OAuth URL parameter validation tests ──────────────────────────────── + +async def test_oauth_url_has_client_id_not_clientid(oauth_params, auth_url): + """Verify OAuth URL has 'client_id' (with underscore), NOT 'clientid'. + + Bug #992: Ensure the parameter name is correct across all channels. + """ + params = oauth_params + + # The bug: "clientid" appears instead of "client_id" + # Verify the CORRECT parameter name exists + assert "client_id" in params, ( + f"OAuth URL missing 'client_id' parameter. " + f"URL: {auth_url}\nParams: {params}" + ) + assert params["client_id"], "client_id should have a value" + + # Verify the INCORRECT parameter name does NOT exist + assert "clientid" not in params, ( + f"OAuth URL should NOT have 'clientid' (without underscore). " + f"Bug #992: URL: {auth_url}\nParams: {params}" + ) + + +async def test_oauth_url_has_required_parameters(oauth_params): + """Verify all required OAuth 2.0 parameters are present.""" + params = oauth_params + + # Required OAuth 2.0 parameters + required = ["client_id", "response_type", "redirect_uri", "scope", "state"] + for param in required: + assert param in params, ( + f"Missing required OAuth parameter: {param}. " + f"Params: {params}" + ) + assert params[param], f"Parameter '{param}' should have a non-empty value" + + # Validate specific values + assert params["response_type"] == "code", "Should use authorization_code flow" + assert "oauth" in params["redirect_uri"], "Redirect URI should be an OAuth callback" + + +async def test_oauth_url_has_extra_params(oauth_params): + """Verify extra_params from capabilities.json are included.""" + params = oauth_params + + # Google-specific extra_params from gmail-tool.capabilities.json + assert "access_type" in params, ( + "Should include 'access_type' from extra_params" + ) + assert params["access_type"] == "offline", ( + "access_type should be 'offline' for Gmail" + ) + + assert "prompt" in params, ( + "Should include 'prompt' from extra_params" + ) + assert params["prompt"] == "consent", ( + "prompt should be 'consent' for Gmail" + ) + + +async def test_oauth_url_is_valid_google_oauth(auth_url): + """Verify the URL is a valid Google OAuth 2.0 authorization URL.""" + # Verify scheme and host + parsed = urlparse(auth_url) + assert parsed.scheme == "https", "OAuth URL must use HTTPS" + assert "accounts.google.com" in parsed.netloc, "Must be Google's OAuth endpoint" + assert parsed.path == "/o/oauth2/v2/auth", "Must use Google OAuth 2.0 endpoint" + + +async def test_oauth_url_state_is_unique(ironclaw_server, installed_gmail, oauth_params, auth_url): + """Verify CSRF state is present and unique per request.""" + # Get a new OAuth URL + r = await api_post( + ironclaw_server, + "/api/extensions/gmail/setup", + json={"secrets": {}}, + timeout=30, + ) + assert r.status_code == 200 + new_auth_url = r.json().get("auth_url") + assert new_auth_url is not None + + # Extract state from both URLs + original_params = oauth_params + new_params = await _extract_oauth_params(new_auth_url) + + original_state = original_params.get("state") + new_state = new_params.get("state") + + assert original_state is not None, "Should have state parameter" + assert new_state is not None, "New request should have state parameter" + assert original_state != new_state, ( + "CSRF state should be unique per request (for security)" + ) + + +async def test_oauth_url_escaping(auth_url): + """Verify URL query parameters are properly escaped.""" + # Verify special characters in values are URL-encoded + # For example, scopes contain spaces which should be %20 + assert "%20" in auth_url or "+" in auth_url or "%2B" in auth_url or " " not in auth_url, ( + "OAuth URL should properly encode special characters in parameters" + ) + + +# ─ Telegram-specific tests (when Telegram channel is available) ────────── + +class TestOAuthURLViaTelegram: + """Test OAuth URL generation specifically via Telegram channel. + + These tests would verify that the same OAuth URL works correctly when + transmitted through the Telegram WASM channel (as opposed to web gateway). + + Currently marked as xfail pending Telegram channel setup in E2E tests. + """ + + @pytest.mark.skip(reason="Telegram channel E2E setup not yet implemented") + async def test_telegram_oauth_url_has_correct_parameters(self): + """Verify OAuth URL sent via Telegram has correct parameter names.""" + # This test would: + # 1. Send a message via Telegram that triggers OAuth + # 2. Capture the status update sent to Telegram + # 3. Extract the auth_url from the message + # 4. Verify it has "client_id" not "clientid" + pass + + @pytest.mark.skip(reason="Telegram channel E2E setup not yet implemented") + async def test_telegram_oauth_url_can_be_regenerated(self): + """Verify OAuth URL can be regenerated when requested via Telegram.""" + # This test would verify that the bug #992 symptom + # "URL cannot be regenerated when asked" is fixed. + # If the URL is cached incorrectly, regeneration would fail. + pass