Files
ironclaw/tests/e2e
Illia Polosukhin 09d3ebb3cd fix(auth): tighten Telegram pairing UX and OAuth-failure recovery (#3317, #3319, #3320) (#3381)
* fix(auth): tighten Telegram pairing UX and OAuth-failure recovery (#3317, #3319, #3320)

Three Bug Bash P1 issues from the same user journey: setup → use → fail.
The unifying root cause was per-channel auth tested in isolation; cross-channel
flows (Telegram → Gmail OAuth → resume) had no coverage and three small leaks
combined into a stuck conversation.

#3317 — Telegram pairing reply now names every IronClaw surface explicitly
(web settings, agent chat, terminal). The agent submission parser learns
`approve <channel> <code>`, dispatched through a new bridge handler that
mirrors `POST /api/pairing/{channel}/approve`.

#3319 — OAuth callback failures now log a category + correlation ID so a
user-reported "I saw 400" maps to one log line. Adds the
`OauthCallbackFailure` enum and `oauth_failure_correlation_id` helper.

#3320 — Two cleanup gaps fixed: (a) `/clear` now drains
`pending_oauth_flows` for the user (otherwise stale flows linger 5min and
mask new auth attempts); (b) OAuth provider-error and exchange-failure paths
now auto-cancel the engine pending auth gate via `clear_engine_pending_auth`,
so the conversation isn't blocked waiting for a resume that will never arrive.

Tests: 5 new submission-parser tests, 2 new bridge-handler tests, and one
new OAuth callback test verifying the pending-flow drain on provider error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(auth): cross-channel pairing claim coverage + canary lane (#3317)

Adds the structural coverage that was missing when #3317 shipped:

- E2E (`tests/e2e/scenarios/test_telegram_pairing_chat_claim.py`):
  three scenarios that drive the full Telegram pairing flow through
  the gateway. Asserts the bot reply names every IronClaw surface
  (web Settings, agent chat, terminal CLI), drives `approve telegram
  CODE` through `/api/chat/send` and verifies the paired user
  exchanges messages without re-prompting, and confirms invalid
  codes get a clear rejection instead of an LLM-improvised reply.

- Rust integration (`tests/telegram_pairing_chat_claim_integration.rs`):
  drives `Submission::PairingClaim` through a real `Agent` →
  `bridge::handle_pairing_claim` → `PairingStore::approve` chain
  using `TestRig` with engine v2 enabled. Covers the happy path
  (mints a code, claims it via chat, asserts `Pairing approved`)
  and the invalid-code rejection. The unit tests in `bridge/router`
  cover only the no-extension-manager and invalid-channel branches —
  this test exercises the wiring between submission parser, agent
  loop dispatch, and bridge handler that #3317 specifically broke.

- Canary (`scripts/live_canary/auth_registry.py`): adds the two
  user-visible scenarios to `AUTH_CHANNEL_TESTS` so the auth-channels
  lane (scheduled every 6h) catches the regression class in CI
  before any real user encounters it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: align pairing-claim and oauth-correlation comments with code (#3381)

Three Copilot review comments on PR #3381 flagged docstring/code drift in
already-merged PR #3317/#3319/#3320 changes. No behavior change — only
the doc strings move:

- `Submission::PairingClaim.code` and the inline `approve <channel> <code>`
  parser comment claimed the user's casing was preserved, but the parser
  builds the code from `lower` and the regression tests already lock in
  the lowercased shape (`code == "abc12345"`). Update both comments to
  describe the actual normalize-then-store contract.

- `oauth_failure_correlation_id` claimed the correlation appeared in the
  user-facing error subtitle, but the failure path renders
  `landing_html(label, false)` whose subtitle is fixed and never receives
  the correlation. Mark the helper as logs-only and note that plumbing
  the ID through the HTML is a follow-up.

[skip-regression-check] doc-only, behavior already covered by existing
pairing-claim parser tests in submission.rs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(telegram): bump channel registry to 0.2.11

The pairing-reply wording was updated in channels-src/telegram/, which
the version-check CI requires be matched by a registry version bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(telegram): fix import path in pairing chat claim e2e test

The scenarios/ folder is a Python package (has __init__.py), so a
flat `from test_telegram_e2e import …` fails with
ModuleNotFoundError during pytest collection. Switch to a relative
import that matches the package layout, and drop the unused
OWNER_USER_ID symbol while we're here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): address Copilot review on /clear OAuth drain + correlation doc

Two follow-ups on PR #3381's Copilot pass:

1. `Agent::process_clear` (engine v1 path) now drains in-flight OAuth
   flows for the clearing user, mirroring the engine-v2 cleanup added in
   `bridge::router::clear_engine_conversation`. Without this, `/clear`
   was a clean slate on v2 but v1 left ghost flows in
   `extension_manager.pending_oauth_flows()` until the 5-minute
   `OAUTH_FLOW_EXPIRY` ticked over — same regression class #3320 fixed
   on v2.

2. `oauth_failure_correlation_id`'s docstring previously said "redacted
   state fingerprint", but callers seed it with the raw `state` query
   value (or `flow.extension_name` for post-resolution failures).
   Updated the doc to describe the actual behaviour: an arbitrary seed
   that is hashed before any hex output, with a pointer to
   `redact_oauth_state_for_logs` for the log-safe fingerprint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e): make Telegram pairing chat-claim suite actually run

The scenario landed in PR #3317 was orphaned — never wired into any CI
lane and could not pass even when run by hand. Three structural
issues, all fixed here:

1. `install_telegram` now overlays the locally-built WASM (and matching
   capabilities file) on top of the registry-downloaded artifact when
   present. The pairing-reply wording lives inside the WASM binary, so
   without this overlay the test was asserting source-tree text against
   the previous release's bytes. The overlay is best-effort: when the
   local WASM is absent (CI groups that don't build the channel), the
   test that depends on it skips with a clear message and the canary
   lane in `scripts/live_canary/auth_registry.py` still covers the
   wording end-to-end against the deployed binary.

2. `Submission::PairingClaim` is handled out-of-band by the bridge
   layer; the response is delivered via `WebChannel::respond` →
   `AppEvent::Response` over SSE only — no `Turn` is persisted, so
   polling `/api/chat/history` could never see it. Refactored
   `test_chat_surface_approves_pairing_code` and
   `test_chat_surface_rejects_invalid_pairing_code` onto a
   `_send_and_collect_response` helper that opens the SSE stream first
   (so the broadcast doesn't fan out to zero subscribers) and matches
   on the `response` event for the test thread.

3. Wired the file into `e2e.yml`'s `extensions` group so the suite
   actually runs on every PR.

Verified locally: all three scenarios pass, plus the existing 22
Telegram e2e tests still green with the install-overlay change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bridge): bound and sanitize invalid-channel echo in pairing claim

Address Copilot review on PR #3381: `handle_pairing_claim`'s
invalid-channel branch was rendering the raw `channel` token (and the
underlying `IdentityError`, which itself echoes the offending input)
back to the user. Both routes are unbounded and could carry control
characters or markup since `channel` comes from chat input — a
hostile prompt could blow up the SSE / Telegram / TUI reply or smuggle
backticks/escape sequences through.

Cap the echo at 32 ASCII-alphanumeric (or `-`/`_`) characters and
replace the verbatim error with a fixed category description, so the
reply size and shape are bounded by what we render explicitly. Add a
regression test that drives a 200-char hostile blob (control chars +
backticks) through the handler and asserts the rendered reply stays
clean and short.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bridge): include hyphens in invalid-channel error copy

Address Copilot review on PR #3381: the invalid-channel reply
listed "lowercase letters, digits, or underscores" as the valid
character set, but `ExtensionName::new` (and `web::features::pairing::
parse_channel`) intentionally accept hyphens too — they're folded to
underscores during canonicalization. A user typing `slack-relay` would
otherwise get an "invalid name" reply listing rules that contradict
the actual validator.

Updated the message to include hyphens with `telegram` and
`slack-relay` as concrete examples, and tightened the regression test
to assert against the user-controlled preview region between the
delimiter backticks rather than a global backtick count (which was
fragile to copy that includes example slugs in backticks).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): address PR #3381 review on credential-scoped gate cleanup and Telegram surface promise

Three reviewer findings, one commit:

- OAuth provider-error and exchange-failure paths used
  `clear_engine_pending_auth(user, None)`, which discards every
  Authentication gate for the user. A failed Gmail callback could
  silently wipe an unrelated Slack/MCP gate waiting on a different
  thread. New `clear_engine_pending_auth_for_credential(user, credential)`
  helper in bridge::router scopes cleanup to the failed flow.
  Provider-error path tracks `removed_secret_name` alongside
  `removed_user_id` so the scoped variant is callable.
- Expired-flow branch in the OAuth callback handler had two bugs: it
  never cleared the engine pending auth gate (so the conversation sat
  blocked forever, same #3320 class the provider-error fix addresses),
  and the broader `clear_auth_mode` it called would re-discard via
  the unscoped helper anyway. Now calls the credential-scoped helper
  and the legacy-v1-only `clear_session_auth_mode_for_thread`.
- Telegram pairing reply advertised `approve telegram CODE` as
  usable "in any IronClaw chat (TUI / web / Telegram)", but an
  unpaired Telegram DM is intercepted by the allowlist gate before
  the agent parser sees the command — the user would just get
  another pairing reply. Reply now lists only the surfaces that
  actually work (web / TUI / CLI) and a comment explains why.

Regression coverage:

- `clear_engine_pending_auth_for_credential_only_clears_matching_credential`
  locks in helper scoping (Gmail/Slack two-gate scenario).
- `oauth_callback_expired_flow_clears_credential_scoped_engine_gate`
  drives the full callback through axum oneshot with engine state
  seeded; asserts the matching gate clears and the unrelated gate
  survives.
- E2E `test_telegram_dm_approve_command_is_intercepted_by_allowlist_gate`
  exercises the Telegram webhook path (not /api/chat/send) to lock in
  the channel-layer interception, wired into the auth canary lane.
- Existing E2E pairing-reply test gains an assertion that
  "TUI / web / Telegram" is *not* in the reply.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(oauth): mirror failure-cleanup contract on provider-error and reconcile stale comment

Copilot review on PR #3381 caught two real issues in the credential-scoped cleanup
landed in d45cf1bd8:

- Provider-error branch (`?error=access_denied`) returned the error page
  without broadcasting `OnboardingState::Failed` or clearing the legacy v1
  session `pending_auth`. The exchange-failure and expiry branches do both.
  Net effect: the auth card stayed spinning and the next user message was
  intercepted as a token. Now mirrors the other failure paths — keep the
  full `flow`, emit Failed SSE, clear v1 session, clear credential-scoped
  engine gate, then return the error page.
- Post-exchange comment said "failed callbacks should leave the gate
  visible for retry" — that was the pre-#3320 contract. Rewrote it to
  describe the new shape: each failure mode clears its own gate at the
  failure site; this section only handles legacy-v1 session cleanup that
  runs regardless of outcome. Also explains why we use
  `clear_session_auth_mode_for_thread` here instead of `clear_auth_mode`
  (the latter would re-clear the engine gate on the *success* path and
  break the `ExternalCallback` resume).

Regression: `test_oauth_callback_provider_error_broadcasts_onboarding_failed`
in the oauth tests module — drives an `?error=access_denied` callback with
a flow whose `sse_manager` is attached, asserts the receiver gets
`OnboardingState::Failed` with the provider's `error_description` as the
message body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:25:31 -07:00
..

IronClaw E2E Tests

Browser-level end-to-end tests for the IronClaw web gateway using Python + Playwright.

Prerequisites

  • Python 3.11+
  • Rust toolchain (for building ironclaw)
  • Chromium (installed via Playwright)

Setup

cd tests/e2e
pip install -e .
playwright install chromium

Build ironclaw

The tests need the ironclaw binary built with libsql support:

cargo build --no-default-features --features libsql

Run tests

# From repo root
pytest tests/e2e/ -v

# Run a single scenario
pytest tests/e2e/scenarios/test_chat.py -v

# With visible browser (not headless)
HEADED=1 pytest tests/e2e/scenarios/test_connection.py -v

Architecture

Tests start two subprocesses:

  1. Mock LLM (mock_llm.py) -- fake OpenAI-compat server with canned responses
  2. IronClaw -- the real binary with gateway enabled, pointing to the mock LLM

Then Playwright drives a headless Chromium browser against the gateway, making DOM assertions.

Scenarios

File What it tests
test_connection.py Auth, tab navigation, connection status
test_chat.py Send message, SSE streaming, response rendering
test_skills.py ClawHub search, skill install/remove
test_tool_approval.py Tool approval overlay (approve, deny, always, params toggle)
test_sse_reconnect.py SSE reconnection handling, keepalive comments, restart recovery, stale reconnect IDs, and connection-limit coverage
test_html_injection.py HTML injection security
test_extensions.py Extensions tab: install, remove, configure, OAuth, auth card, activate

Adding new scenarios

  1. Create tests/e2e/scenarios/test_<name>.py
  2. Use the page fixture for a fresh browser page
  3. Use selectors from helpers.py (update SEL dict if new elements are needed)
  4. Keep tests deterministic -- use the mock LLM, not real providers

Live Persona Failure Notes

For the live 20+ turn persona workflows and recurring tool-misuse patterns seen there, see LIVE_TOOL_FAILURES.md.

Mocking API responses with page.route()

For tabs that depend on external data (extensions, jobs, memory, routines), use Playwright's page.route() to intercept the browser's HTTP requests to the ironclaw gateway and return deterministic fixture JSON. This avoids needing real installed binaries, live external services, or complex database setup.

Basic pattern

import json

async def test_something(page):
    # 1. Set up route intercepts BEFORE navigation triggers the fetch
    # Always use async def handlers — route.fulfill() is a coroutine and must be awaited.
    async def handle_tools(route):
        await route.fulfill(
            status=200,
            content_type="application/json",
            body=json.dumps({"tools": [{"name": "echo", "description": "Echo"}]}),
        )

    await page.route("**/api/extensions/tools", handle_tools)

    # 2. Navigate / interact to trigger the fetch
    await page.locator('.tab-bar button[data-tab="extensions"]').click()

    # 3. Assert on the rendered DOM
    rows = page.locator("#tools-tbody tr")
    assert await rows.count() == 1

Matching only the exact path

**/api/extensions matches http://host/api/extensions but NOT sub-paths like http://host/api/extensions/install. For the bare list endpoint, add a check inside the handler:

async def handle_ext_list(route):
    path = route.request.url.split("?")[0]
    if path.endswith("/api/extensions"):
        await route.fulfill(json={"extensions": []})
    else:
        await route.continue_()   # Let sub-paths through to the real server

await page.route("**/api/extensions*", handle_ext_list)

Mocking method-specific behaviour (GET vs POST)

async def handle_setup(route):
    if route.request.method == "GET":
        await route.fulfill(json={"secrets": [...]})
    else:  # POST
        await route.fulfill(json={"success": True})

await page.route("**/api/extensions/my-ext/setup", handle_setup)

Counting calls (for reload tests)

calls = []

async def counting_handler(route):
    calls.append(1)
    await route.fulfill(json={"extensions": []})

await page.route("**/api/extensions", counting_handler)
# ... interact ...
assert len(calls) == 2   # called twice (initial + after some action)

Applying the pattern to other tabs

Tab Key API endpoints to mock
Jobs /api/jobs, /api/jobs/{id}, /api/jobs/{id}/events
Memory /api/memory/search, /api/memory/tree, /api/memory/read
Routines /api/routines, /api/routines/{id}/runs

Injecting state directly via page.evaluate()

For purely client-side UI (components rendered entirely in JS without API calls), call the JavaScript function directly to skip the network layer entirely:

# Show an approval card without needing a real tool execution
await page.evaluate("""
    showApproval({
        request_id: 'test-001',
        thread_id: currentThreadId,
        tool_name: 'shell',
        description: 'Run something',
    })
""")

This is the pattern used in most of test_tool_approval.py and parts of test_extensions.py (auth card, configure modal). The waiting-approval regression in test_tool_approval.py uses a real tool call instead so it can exercise backend approval state.