Files
optimclaw/tests/e2e/scenarios/test_extension_oauth.py
T
9fbdd42988 fix(extensions): fix lifecycle bugs + comprehensive E2E tests (#1070)
* feat(extensions): unify auth and configure into single entrypoint

Refactors the extension lifecycle to eliminate the divergence between
chat and gateway paths that caused Telegram setup via chat to fail
(missing webhook secret auto-generation, no token validation).

Key changes:
- Rename save_setup_secrets() → configure(): single entrypoint for
  providing secrets to any extension (WasmChannel, WasmTool, MCP).
  Validates, stores, auto-generates, and activates.
- Add configure_token(): convenience wrapper for single-token callers
  (chat auth card, WebSocket, agent auth mode).
- Refactor auth() to pure status check: remove token parameter,
  delete token-storing branches from auth_mcp/auth_wasm_tool,
  rename auth_wasm_channel → auth_wasm_channel_status.
- Add ConfigureResult/MissingSecret types for structured responses.
- Replace hardcoded Telegram token validation with generic
  validation_endpoint from capabilities.json.
- Update all callers (9 files) to use the new interface.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: use ValidationFailed error variant instead of string matching

Replace brittle msg.contains("Invalid token") checks with a proper
ExtensionError::ValidationFailed variant. configure() now returns
this variant for token validation failures, and callers match on it
directly instead of parsing error message strings.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address review — SSRF protection, error typing, missing-secret selection, WS auth

1. SSRF: call validate_fetch_url() before validation_endpoint HTTP request
2. Transport errors map to ExtensionError::Other (not ValidationFailed)
3. configure_token() picks first *missing* secret, not first non-optional
4. WebSocket error path re-emits AuthRequired on ValidationFailed

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* test: add regression tests for extension lifecycle refactoring

- test_configure_token_picks_first_missing_secret: verifies multi-secret
  channels can be configured one secret at a time (commit ce106f4)
- test_auth_is_read_only_for_wasm_channel: verifies auth() has no side
  effects and doesn't store secrets (commit 47f8eb6)
- test_validation_failed_is_distinct_error_variant: verifies the typed
  error variant can be pattern-matched (commit a318161)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address review comments — activation dispatch, dead code, caps consolidation

- Fix configure() fallthrough bug: dispatch activation by ExtensionKind
  instead of unconditionally calling activate_wasm_channel() for all
  non-WasmTool types (MCP servers and channel relays now use their
  correct activation methods)
- Remove dead MissingSecret struct and missing_secrets field (never
  populated, flagged by reviewer)
- Consolidate capabilities file parsing in configure(): parse once
  and reuse for allowed names, validation_endpoint, and auto-generation
- Fix auth() doc comment: note MCP OAuth side effects
- Fix stale save_setup_secrets reference in server.rs comment
- Add regression test for activation dispatch bug

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(extensions): fix 5 extension lifecycle bugs found during E2E testing

Bug fixes in src/extensions/manager.rs:
- Add auth guard to activate_wasm_tool() blocking activation when secrets
  are missing (NeedsSetup), matching activate_wasm_channel() behavior
- Evict WasmToolRuntime module cache on remove() so reinstall uses fresh binary
- Clear activation_errors on remove() for both WasmTool and WasmChannel
- Clean up in-progress OAuth flows on remove() (abort TCP listener, purge
  pending flow entries)

Bug fix in src/channels/web/server.rs:
- Broadcast AuthCompleted SSE event on expired OAuth callback so web UI
  doesn't stay stuck showing "auth required"

E2E test coverage:
- test_wasm_lifecycle.py: 35 tests covering install/configure/activate/
  remove/reinstall lifecycle with regression tests for bugs 1 and 3
- test_extension_oauth.py: 9 tests covering OAuth round-trip flow
- test_tool_execution.py: 5 tests for tool invocation via chat
- test_pairing.py: 4 tests for pairing request lifecycle
- Enhanced conftest.py, helpers.py, mock_llm.py for OAuth mock support

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(web): unify extension auth UX and add lifecycle regressions

* test: fix pending oauth flow fixtures after rebase

* test(e2e): fix playwright route ordering for extensions reloads

* test: address e2e review follow-ups

* test: address remaining PR review comments

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-12 16:36:08 -07:00

265 lines
9.0 KiB
Python

"""Extension OAuth round-trip e2e tests.
Tests the full internal OAuth callback pipeline: install gmail → configure
(get auth_url) → simulate OAuth callback → verify token stored. Uses gateway
callback mode + mock token exchange (no real Google login).
The conftest sets IRONCLAW_OAUTH_CALLBACK_URL (non-loopback, forces gateway
mode) and IRONCLAW_OAUTH_EXCHANGE_URL (points to mock_llm.py's /oauth/exchange).
"""
from urllib.parse import parse_qs, urlparse
import httpx
import pytest
from helpers import api_get, api_post
# Module-level state
_gmail_installed = False
_auth_url = None
_csrf_state = None
def _extract_state(auth_url: str) -> str:
"""Extract the CSRF state parameter from an OAuth authorization URL."""
parsed = urlparse(auth_url)
qs = parse_qs(parsed.query)
assert "state" in qs, f"auth_url should contain state param: {auth_url}"
state = qs["state"][0]
assert len(state) > 0
return state
async def _get_extension(base_url, name):
"""Get a specific extension from the extensions list, or None."""
r = await api_get(base_url, "/api/extensions")
for ext in r.json().get("extensions", []):
if ext["name"] == name:
return ext
return None
async def _ensure_removed(base_url, name):
"""Remove extension if already installed."""
ext = await _get_extension(base_url, name)
if ext:
await api_post(base_url, f"/api/extensions/{name}/remove", timeout=30)
# ── Section A: Install + OAuth Initiation ────────────────────────────────
async def test_oauth_install_gmail(ironclaw_server):
"""Install gmail from registry for OAuth testing."""
global _gmail_installed
await _ensure_removed(ironclaw_server, "gmail")
r = await api_post(
ironclaw_server,
"/api/extensions/install",
json={"name": "gmail"},
timeout=180,
)
assert r.status_code == 200
data = r.json()
assert data.get("success") is True, f"Install failed: {data.get('message', '')}"
_gmail_installed = True
async def test_oauth_configure_returns_auth_url(ironclaw_server):
"""Configure with empty secrets returns an OAuth auth_url."""
global _auth_url, _csrf_state
if not _gmail_installed:
pytest.skip("gmail not installed")
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"Configure failed: {data.get('message', '')}"
_auth_url = data.get("auth_url")
assert _auth_url is not None, f"Expected auth_url in response: {data}"
assert "accounts.google.com" in _auth_url, (
f"auth_url should point to Google: {_auth_url}"
)
_csrf_state = _extract_state(_auth_url)
async def test_oauth_activate_returns_auth_url(ironclaw_server):
"""Activate on un-authenticated gmail returns auth_url."""
if not _gmail_installed:
pytest.skip("gmail not installed")
r = await api_post(
ironclaw_server, "/api/extensions/gmail/activate", timeout=30
)
assert r.status_code == 200
data = r.json()
# Activation may fail with auth_url or succeed with auth_url
auth_url = data.get("auth_url")
assert auth_url is not None, f"Expected auth_url in activate response: {data}"
# ── Section B: Internal OAuth Round-Trip ─────────────────────────────────
async def test_oauth_callback_exchanges_token(ironclaw_server):
"""Simulate OAuth callback with mock code — verifies token exchange."""
global _csrf_state
if not _csrf_state:
pytest.skip("No CSRF state from configure step")
# Re-configure to get a fresh pending flow (previous configure may have
# been consumed by the activate test above)
r = await api_post(
ironclaw_server,
"/api/extensions/gmail/setup",
json={"secrets": {}},
timeout=30,
)
data = r.json()
auth_url = data.get("auth_url")
if auth_url:
_csrf_state = _extract_state(auth_url)
# Hit the OAuth callback endpoint directly (public route, no auth header).
# The callback handler looks up the pending flow by state, calls
# exchange_via_proxy() which hits mock_llm.py's /oauth/exchange, and
# stores the returned fake token.
async with httpx.AsyncClient() as client:
r = await client.get(
f"{ironclaw_server}/oauth/callback",
params={"code": "mock_auth_code", "state": _csrf_state},
timeout=30,
follow_redirects=True,
)
assert r.status_code == 200, f"Callback returned {r.status_code}: {r.text[:300]}"
body = r.text.lower()
# The landing page says "<name> Connected" on success, "failed" on error
assert "connected" in body or "success" in body, (
f"Callback HTML should indicate success: {r.text[:500]}"
)
async def test_oauth_callback_replay_rejected(ironclaw_server):
"""Replaying the same callback is rejected (flow consumed on first use)."""
if not _csrf_state:
pytest.skip("No CSRF state")
async with httpx.AsyncClient() as client:
r = await client.get(
f"{ironclaw_server}/oauth/callback",
params={"code": "mock_auth_code", "state": _csrf_state},
timeout=10,
follow_redirects=True,
)
# Should fail — the flow was already consumed
body = r.text.lower()
assert "error" in body or "fail" in body or "expired" in body or r.status_code >= 400, (
f"Replay should be rejected, got status={r.status_code}: {r.text[:500]}"
)
async def test_oauth_callback_invalid_state(ironclaw_server):
"""Callback with bogus state is rejected."""
async with httpx.AsyncClient() as client:
r = await client.get(
f"{ironclaw_server}/oauth/callback",
params={"code": "x", "state": "totally-bogus-state-value"},
timeout=10,
follow_redirects=True,
)
body = r.text.lower()
assert "error" in body or "fail" in body or "expired" in body or r.status_code >= 400, (
f"Invalid state should be rejected, got status={r.status_code}: {r.text[:500]}"
)
async def test_oauth_extension_authenticated(ironclaw_server):
"""After OAuth callback, gmail shows authenticated=True."""
if not _gmail_installed:
pytest.skip("gmail not installed")
ext = await _get_extension(ironclaw_server, "gmail")
assert ext is not None, "gmail not in extensions list"
assert ext["authenticated"] is True, (
f"gmail should be authenticated after OAuth callback: {ext}"
)
async def test_oauth_tools_registered(ironclaw_server):
"""After OAuth authentication, gmail tools appear in tools endpoint."""
if not _gmail_installed:
pytest.skip("gmail not installed")
ext = await _get_extension(ironclaw_server, "gmail")
assert ext is not None
# Check the extension's tools array
tools = ext.get("tools", [])
assert len(tools) > 0, (
f"gmail should have tools registered after auth: {ext}"
)
async def test_remove_during_pending_oauth_invalidates_callback(ironclaw_server):
"""Removing an extension while OAuth is pending invalidates the callback state."""
if not _gmail_installed:
pytest.skip("gmail not installed")
r = await api_post(
ironclaw_server,
"/api/extensions/gmail/setup",
json={"secrets": {}},
timeout=30,
)
assert r.status_code == 200
data = r.json()
auth_url = data.get("auth_url")
assert auth_url is not None, f"Expected auth_url in response: {data}"
callback_state = _extract_state(auth_url)
remove_r = await api_post(
ironclaw_server, "/api/extensions/gmail/remove", timeout=30
)
assert remove_r.status_code == 200
assert remove_r.json().get("success") is True, (
f"Removing gmail during pending OAuth should succeed: {remove_r.text[:300]}"
)
async with httpx.AsyncClient() as client:
callback_r = await client.get(
f"{ironclaw_server}/oauth/callback",
params={"code": "mock_auth_code", "state": callback_state},
timeout=30,
follow_redirects=True,
)
assert callback_r.status_code == 200
body = callback_r.text.lower()
assert "error" in body or "fail" in body or "expired" in body, (
f"Callback after removal should fail: {callback_r.text[:500]}"
)
ext = await _get_extension(ironclaw_server, "gmail")
assert ext is None, "gmail should remain removed after invalidated callback"
# ── Section C: Cleanup ──────────────────────────────────────────────────
async def test_cleanup_gmail(ironclaw_server):
"""Remove gmail (cleanup for other test files)."""
await _ensure_removed(ironclaw_server, "gmail")
ext = await _get_extension(ironclaw_server, "gmail")
assert ext is None, "gmail should be removed"