mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-02 09:39:37 +00:00
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]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
8a60fa2d37
commit
9fbdd42988
+77
-1
@@ -20,9 +20,31 @@ from helpers import AUTH_TOKEN, wait_for_port_line, wait_for_ready
|
||||
# Project root (two levels up from tests/e2e/)
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
# Git main repo root (for worktree support — WASM build artifacts live
|
||||
# in the main repo's tools-src/*/target/ and aren't shared across worktrees)
|
||||
_MAIN_ROOT = None
|
||||
try:
|
||||
import subprocess as _sp
|
||||
_common = _sp.check_output(
|
||||
["git", "worktree", "list", "--porcelain"],
|
||||
cwd=ROOT, text=True, stderr=_sp.DEVNULL,
|
||||
)
|
||||
for line in _common.splitlines():
|
||||
if line.startswith("worktree "):
|
||||
_MAIN_ROOT = Path(line.split(" ", 1)[1])
|
||||
break # first entry is always the main worktree
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Temp directory for the libSQL database file (cleaned up automatically)
|
||||
_DB_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-")
|
||||
|
||||
# Temp directories for WASM extensions. These start empty and are populated by
|
||||
# the install pipeline during tests; fixtures do not pre-populate dev build
|
||||
# artifacts into them.
|
||||
_WASM_TOOLS_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-wasm-tools-")
|
||||
_WASM_CHANNELS_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-wasm-channels-")
|
||||
|
||||
|
||||
def _find_free_port() -> int:
|
||||
"""Bind to port 0 and return the OS-assigned port."""
|
||||
@@ -70,7 +92,53 @@ async def mock_llm_server():
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def ironclaw_server(ironclaw_binary, mock_llm_server):
|
||||
def wasm_tools_dir(_wasm_build_symlinks):
|
||||
"""Empty temp dir for WASM tools.
|
||||
|
||||
Starts empty so the server has no pre-loaded extensions at boot.
|
||||
The install API (POST /api/extensions/install) downloads and writes
|
||||
WASM files here; tests exercise the full install pipeline.
|
||||
|
||||
NOTE on capabilities file naming: Cargo builds with underscored stems
|
||||
(web_search_tool.wasm) but capabilities use hyphens (web-search-tool.
|
||||
capabilities.json). The loader expects matching stems. If you pre-load
|
||||
files, rename caps: web-search-tool → web_search_tool.
|
||||
"""
|
||||
return str(Path(_WASM_TOOLS_TMPDIR.name))
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _wasm_build_symlinks():
|
||||
"""Symlink WASM build artifacts from the main repo into the worktree.
|
||||
|
||||
In a git worktree, tools-src/*/target/ directories don't exist because
|
||||
Cargo build artifacts aren't shared. The install API's source fallback
|
||||
checks these paths. Symlinking makes the fallback work without rebuilding.
|
||||
"""
|
||||
if _MAIN_ROOT is None or _MAIN_ROOT == ROOT:
|
||||
yield
|
||||
return
|
||||
|
||||
created = []
|
||||
tools_src = ROOT / "tools-src"
|
||||
main_tools_src = _MAIN_ROOT / "tools-src"
|
||||
if tools_src.is_dir() and main_tools_src.is_dir():
|
||||
for tool_dir in tools_src.iterdir():
|
||||
if not tool_dir.is_dir():
|
||||
continue
|
||||
target = tool_dir / "target"
|
||||
main_target = main_tools_src / tool_dir.name / "target"
|
||||
if not target.exists() and main_target.is_dir():
|
||||
target.symlink_to(main_target)
|
||||
created.append(target)
|
||||
yield
|
||||
for link in created:
|
||||
if link.is_symlink():
|
||||
link.unlink()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def ironclaw_server(ironclaw_binary, mock_llm_server, wasm_tools_dir):
|
||||
"""Start the ironclaw gateway. Yields the base URL."""
|
||||
gateway_port = _find_free_port()
|
||||
env = {
|
||||
@@ -95,8 +163,16 @@ async def ironclaw_server(ironclaw_binary, mock_llm_server):
|
||||
"ROUTINES_ENABLED": "false",
|
||||
"HEARTBEAT_ENABLED": "false",
|
||||
"EMBEDDING_ENABLED": "false",
|
||||
# WASM tool/channel support
|
||||
"WASM_ENABLED": "true",
|
||||
"WASM_TOOLS_DIR": wasm_tools_dir,
|
||||
"WASM_CHANNELS_DIR": _WASM_CHANNELS_TMPDIR.name,
|
||||
# Prevent onboarding wizard from triggering
|
||||
"ONBOARD_COMPLETED": "true",
|
||||
# Force gateway OAuth callback mode (non-loopback URL) and point
|
||||
# token exchange at mock_llm.py so OAuth tests work without Google.
|
||||
"IRONCLAW_OAUTH_CALLBACK_URL": "https://oauth.test.example/oauth/callback",
|
||||
"IRONCLAW_OAUTH_EXCHANGE_URL": mock_llm_server,
|
||||
}
|
||||
# Forward LLVM coverage instrumentation env vars when present
|
||||
# (allows cargo-llvm-cov to collect profraw data from E2E runs).
|
||||
|
||||
@@ -133,3 +133,32 @@ async def wait_for_port_line(process, pattern: str, *, timeout: float = 60) -> i
|
||||
if match := re.search(pattern, decoded):
|
||||
return int(match.group(1))
|
||||
raise TimeoutError(f"Port pattern '{pattern}' not found in stdout after {timeout}s")
|
||||
|
||||
|
||||
# -- API helpers -----------------------------------------------------------
|
||||
|
||||
def auth_headers() -> dict[str, str]:
|
||||
"""Return Authorization header dict for authenticated API calls."""
|
||||
return {"Authorization": f"Bearer {AUTH_TOKEN}"}
|
||||
|
||||
|
||||
async def api_get(base_url: str, path: str, **kwargs) -> httpx.Response:
|
||||
"""Make an authenticated GET request to the ironclaw API."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
return await client.get(
|
||||
f"{base_url}{path}",
|
||||
headers=auth_headers(),
|
||||
timeout=kwargs.pop("timeout", 10),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
async def api_post(base_url: str, path: str, **kwargs) -> httpx.Response:
|
||||
"""Make an authenticated POST request to the ironclaw API."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
return await client.post(
|
||||
f"{base_url}{path}",
|
||||
headers=auth_headers(),
|
||||
timeout=kwargs.pop("timeout", 10),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
+181
-56
@@ -1,11 +1,16 @@
|
||||
"""Mock OpenAI-compatible LLM server for E2E tests."""
|
||||
"""Mock OpenAI-compatible LLM server for E2E tests.
|
||||
|
||||
Serves OpenAI-compatible endpoints for chat completions and model listing.
|
||||
Supports both streaming and non-streaming responses, plus function calling
|
||||
via TOOL_CALL_PATTERNS.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
CANNED_RESPONSES = [
|
||||
@@ -13,85 +18,207 @@ CANNED_RESPONSES = [
|
||||
(re.compile(r"2\s*\+\s*2|two plus two", re.IGNORECASE), "The answer is 4."),
|
||||
(re.compile(r"skill|install", re.IGNORECASE), "I can help you with skills management."),
|
||||
(re.compile(r"html.?test|injection.?test", re.IGNORECASE),
|
||||
'Here is some content: <script>alert("xss")</script> and <img src=x onerror="alert(1)"> and <iframe src="javascript:alert(2)"></iframe> end of content.'),
|
||||
'Here is some content: <script>alert("xss")</script> and <img src=x onerror="alert(1)">'
|
||||
' and <iframe src="javascript:alert(2)"></iframe> end of content.'),
|
||||
]
|
||||
DEFAULT_RESPONSE = "I understand your request."
|
||||
|
||||
TOOL_CALL_PATTERNS = [
|
||||
(re.compile(r"echo (.+)", re.IGNORECASE), "echo", lambda m: {"message": m.group(1)}),
|
||||
(re.compile(r"what time|current time", re.IGNORECASE), "time", lambda _: {"operation": "now"}),
|
||||
]
|
||||
|
||||
def match_response(messages: list[dict]) -> str:
|
||||
"""Find canned response for the last user message."""
|
||||
|
||||
def _last_user_content(messages: list[dict]) -> str:
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
content = msg.get("content", "")
|
||||
# Handle content that may be a list (multi-modal)
|
||||
if isinstance(content, list):
|
||||
content = " ".join(
|
||||
part.get("text", "") for part in content if part.get("type") == "text"
|
||||
p.get("text", "") for p in content if p.get("type") == "text"
|
||||
)
|
||||
for pattern, response in CANNED_RESPONSES:
|
||||
if pattern.search(content):
|
||||
return response
|
||||
return DEFAULT_RESPONSE
|
||||
return content
|
||||
return ""
|
||||
|
||||
|
||||
def match_response(messages: list[dict]) -> str:
|
||||
content = _last_user_content(messages)
|
||||
for pattern, response in CANNED_RESPONSES:
|
||||
if pattern.search(content):
|
||||
return response
|
||||
return DEFAULT_RESPONSE
|
||||
|
||||
|
||||
def match_tool_call(messages: list[dict], has_tools: bool) -> dict | None:
|
||||
if not has_tools:
|
||||
return None
|
||||
content = _last_user_content(messages)
|
||||
for pattern, tool_name, args_fn in TOOL_CALL_PATTERNS:
|
||||
m = pattern.search(content)
|
||||
if m:
|
||||
return {"tool_name": tool_name, "arguments": args_fn(m)}
|
||||
return None
|
||||
|
||||
|
||||
def _extract_tool_name(msg: dict) -> str:
|
||||
"""Extract tool name from a message, checking both 'name' field and XML content."""
|
||||
name = msg.get("name")
|
||||
if name:
|
||||
return name
|
||||
# ironclaw wraps tool output as <tool_output name="...">
|
||||
content = msg.get("content", "")
|
||||
m = re.search(r'<tool_output\s+name="([^"]+)"', content)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _find_tool_result(messages: list[dict]) -> dict | None:
|
||||
"""Find a pending tool result that appears after the last user message.
|
||||
|
||||
Only returns a tool result if it's a fresh result the agent is waiting
|
||||
for the LLM to summarize (i.e., it follows the most recent user message).
|
||||
This prevents stale tool results from earlier conversation turns from
|
||||
being re-processed.
|
||||
"""
|
||||
# Find the position of the last user message
|
||||
last_user_idx = -1
|
||||
for i in range(len(messages) - 1, -1, -1):
|
||||
if messages[i].get("role") == "user":
|
||||
last_user_idx = i
|
||||
break
|
||||
|
||||
# Only look for tool results after the last user message
|
||||
for i in range(len(messages) - 1, last_user_idx, -1):
|
||||
if messages[i].get("role") == "tool":
|
||||
return {"name": _extract_tool_name(messages[i]),
|
||||
"content": messages[i].get("content", "")}
|
||||
return None
|
||||
|
||||
|
||||
def _make_base(completion_id: str) -> dict:
|
||||
return {"id": completion_id, "object": "chat.completion.chunk",
|
||||
"created": int(time.time()), "model": "mock-model"}
|
||||
|
||||
|
||||
async def _send_sse(resp: web.StreamResponse, data: dict):
|
||||
await resp.write(f"data: {json.dumps(data)}\n\n".encode())
|
||||
|
||||
|
||||
async def chat_completions(request: web.Request) -> web.StreamResponse:
|
||||
"""Handle POST /v1/chat/completions."""
|
||||
"""Handle POST /v1/chat/completions and /chat/completions."""
|
||||
body = await request.json()
|
||||
messages = body.get("messages", [])
|
||||
stream = body.get("stream", False)
|
||||
response_text = match_response(messages)
|
||||
completion_id = f"mock-{uuid.uuid4().hex[:8]}"
|
||||
has_tools = bool(body.get("tools"))
|
||||
cid = f"mock-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Tool result in messages -> text summary
|
||||
tr = _find_tool_result(messages)
|
||||
if tr:
|
||||
text = f"The {tr['name']} tool returned: {tr['content']}"
|
||||
if not stream:
|
||||
return _text_response(cid, text)
|
||||
return await _stream_text(request, cid, text)
|
||||
|
||||
# Tool-call pattern match
|
||||
tc = match_tool_call(messages, has_tools)
|
||||
if tc:
|
||||
if not stream:
|
||||
return _tool_call_response(cid, tc)
|
||||
return await _stream_tool_call(request, cid, tc)
|
||||
|
||||
# Default text response
|
||||
text = match_response(messages)
|
||||
if not stream:
|
||||
return web.json_response({
|
||||
"id": completion_id,
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": "mock-model",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": response_text},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": len(response_text.split()), "total_tokens": 15},
|
||||
})
|
||||
return _text_response(cid, text)
|
||||
return await _stream_text(request, cid, text)
|
||||
|
||||
# Streaming response: split into word-boundary chunks
|
||||
resp = web.StreamResponse(
|
||||
status=200,
|
||||
headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache"},
|
||||
)
|
||||
await resp.prepare(request)
|
||||
|
||||
# First chunk: role
|
||||
chunk = {
|
||||
"id": completion_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": int(time.time()),
|
||||
def _text_response(cid: str, text: str) -> web.Response:
|
||||
return web.json_response({
|
||||
"id": cid, "object": "chat.completion", "created": int(time.time()),
|
||||
"model": "mock-model",
|
||||
"choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}],
|
||||
}
|
||||
await resp.write(f"data: {json.dumps(chunk)}\n\n".encode())
|
||||
"choices": [{"index": 0, "message": {"role": "assistant", "content": text},
|
||||
"finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": len(text.split()), "total_tokens": 15},
|
||||
})
|
||||
|
||||
# Content chunks: split on spaces
|
||||
words = response_text.split(" ")
|
||||
for i, word in enumerate(words):
|
||||
text = word if i == 0 else f" {word}"
|
||||
chunk["choices"][0]["delta"] = {"content": text}
|
||||
await resp.write(f"data: {json.dumps(chunk)}\n\n".encode())
|
||||
|
||||
# Final chunk: finish_reason
|
||||
def _tool_call_response(cid: str, tc: dict) -> web.Response:
|
||||
return web.json_response({
|
||||
"id": cid, "object": "chat.completion", "created": int(time.time()),
|
||||
"model": "mock-model",
|
||||
"choices": [{"index": 0, "message": {
|
||||
"role": "assistant", "content": None,
|
||||
"tool_calls": [{"id": f"call_{uuid.uuid4().hex[:8]}", "type": "function",
|
||||
"function": {"name": tc["tool_name"],
|
||||
"arguments": json.dumps(tc["arguments"])}}],
|
||||
}, "finish_reason": "tool_calls"}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
||||
})
|
||||
|
||||
|
||||
async def _stream_text(request: web.Request, cid: str, text: str) -> web.StreamResponse:
|
||||
resp = web.StreamResponse(status=200, headers={
|
||||
"Content-Type": "text/event-stream", "Cache-Control": "no-cache"})
|
||||
await resp.prepare(request)
|
||||
base = _make_base(cid)
|
||||
chunk = {**base, "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""},
|
||||
"finish_reason": None}]}
|
||||
await _send_sse(resp, chunk)
|
||||
for i, word in enumerate(text.split(" ")):
|
||||
chunk["choices"][0]["delta"] = {"content": word if i == 0 else f" {word}"}
|
||||
await _send_sse(resp, chunk)
|
||||
chunk["choices"][0]["delta"] = {}
|
||||
chunk["choices"][0]["finish_reason"] = "stop"
|
||||
await resp.write(f"data: {json.dumps(chunk)}\n\n".encode())
|
||||
await _send_sse(resp, chunk)
|
||||
await resp.write(b"data: [DONE]\n\n")
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
async def _stream_tool_call(request: web.Request, cid: str, tc: dict) -> web.StreamResponse:
|
||||
resp = web.StreamResponse(status=200, headers={
|
||||
"Content-Type": "text/event-stream", "Cache-Control": "no-cache"})
|
||||
await resp.prepare(request)
|
||||
call_id = f"call_{uuid.uuid4().hex[:8]}"
|
||||
base = _make_base(cid)
|
||||
# First chunk: role + tool call header with empty arguments
|
||||
chunk = {**base, "choices": [{"index": 0, "delta": {
|
||||
"role": "assistant",
|
||||
"tool_calls": [{"index": 0, "id": call_id, "type": "function",
|
||||
"function": {"name": tc["tool_name"], "arguments": ""}}],
|
||||
}, "finish_reason": None}]}
|
||||
await _send_sse(resp, chunk)
|
||||
# Second chunk: arguments payload
|
||||
chunk["choices"][0]["delta"] = {
|
||||
"tool_calls": [{"index": 0, "function": {"arguments": json.dumps(tc["arguments"])}}]}
|
||||
await _send_sse(resp, chunk)
|
||||
# Final chunk: finish reason
|
||||
chunk["choices"][0]["delta"] = {}
|
||||
chunk["choices"][0]["finish_reason"] = "tool_calls"
|
||||
await _send_sse(resp, chunk)
|
||||
await resp.write(b"data: [DONE]\n\n")
|
||||
return resp
|
||||
|
||||
|
||||
async def oauth_exchange(request: web.Request) -> web.Response:
|
||||
"""Mock OAuth token exchange proxy for E2E tests.
|
||||
|
||||
Accepts form params (code, redirect_uri, code_verifier) and returns
|
||||
a fake token response. Called by ironclaw's exchange_via_proxy() when
|
||||
IRONCLAW_OAUTH_EXCHANGE_URL is set.
|
||||
"""
|
||||
data = await request.post()
|
||||
code = data.get("code", "")
|
||||
return web.json_response({
|
||||
"access_token": f"mock-token-{code}",
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
|
||||
|
||||
async def models(_request: web.Request) -> web.Response:
|
||||
"""Handle GET /v1/models."""
|
||||
return web.json_response({
|
||||
"object": "list",
|
||||
"data": [{"id": "mock-model", "object": "model", "owned_by": "test"}],
|
||||
@@ -102,23 +229,21 @@ def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--port", type=int, default=0)
|
||||
args = parser.parse_args()
|
||||
|
||||
app = web.Application()
|
||||
# Register both /v1/ and non-/v1/ paths (rig-core omits the /v1/ prefix)
|
||||
app.router.add_post("/v1/chat/completions", chat_completions)
|
||||
app.router.add_post("/chat/completions", chat_completions)
|
||||
app.router.add_get("/v1/models", models)
|
||||
|
||||
# Use aiohttp's runner to get the actual bound port
|
||||
import asyncio
|
||||
app.router.add_get("/models", models)
|
||||
app.router.add_post("/oauth/exchange", oauth_exchange)
|
||||
|
||||
async def start():
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "127.0.0.1", args.port)
|
||||
await site.start()
|
||||
# Extract the actual port from the bound socket
|
||||
port = site._server.sockets[0].getsockname()[1]
|
||||
print(f"MOCK_LLM_PORT={port}", flush=True)
|
||||
# Block forever
|
||||
await asyncio.Event().wait()
|
||||
|
||||
asyncio.run(start())
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
"""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"
|
||||
@@ -458,6 +458,37 @@ async def test_install_wasm_channel_triggers_configure(page):
|
||||
assert await modal.is_visible()
|
||||
|
||||
|
||||
async def test_install_with_auth_url_opens_popup_and_shows_auth_prompt(page):
|
||||
"""Install responses with auth_url should surface the same auth prompt used elsewhere."""
|
||||
await page.evaluate("window.open = (url) => { window._lastOpenedUrl = url; }")
|
||||
await mock_ext_apis(page, registry=[_REGISTRY_WASM])
|
||||
|
||||
async def handle_install(route):
|
||||
await route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps({"success": True, "auth_url": "https://example.com/oauth"}),
|
||||
)
|
||||
|
||||
await page.route("**/api/extensions/install", handle_install)
|
||||
await go_to_extensions(page)
|
||||
|
||||
install_btn = page.locator(SEL["available_wasm_list"]).locator(SEL["ext_install_btn"]).first
|
||||
await install_btn.wait_for(state="visible", timeout=5000)
|
||||
await install_btn.click()
|
||||
|
||||
await page.wait_for_function(
|
||||
"() => window._lastOpenedUrl !== null && window._lastOpenedUrl !== undefined",
|
||||
timeout=5000,
|
||||
)
|
||||
opened = await page.evaluate("window._lastOpenedUrl")
|
||||
assert opened is not None, "window.open was not called"
|
||||
assert "example.com" in opened
|
||||
await page.locator(SEL["auth_card"] + '[data-extension-name="registry-tool"]').wait_for(
|
||||
state="visible", timeout=5000
|
||||
)
|
||||
|
||||
|
||||
# ─── Group F: Remove flow ─────────────────────────────────────────────────────
|
||||
|
||||
async def test_remove_installed_extension_confirmed(page):
|
||||
@@ -612,7 +643,7 @@ async def test_configure_modal_save_success(page):
|
||||
|
||||
|
||||
async def test_configure_modal_save_oauth(page):
|
||||
"""Save response with auth_url opens a popup via window.open."""
|
||||
"""Save response with auth_url opens a popup and shows the global auth prompt."""
|
||||
await page.evaluate("window.open = (url) => { window._lastOpenedUrl = url; }")
|
||||
|
||||
async def handle_setup(route):
|
||||
@@ -639,6 +670,9 @@ async def test_configure_modal_save_oauth(page):
|
||||
opened = await page.evaluate("window._lastOpenedUrl")
|
||||
assert opened is not None, "window.open was not called"
|
||||
assert "oauth" in opened or "example.com" in opened
|
||||
await page.locator(SEL["auth_card"] + '[data-extension-name="test-ext"]').wait_for(
|
||||
state="visible", timeout=5000
|
||||
)
|
||||
|
||||
|
||||
async def test_configure_modal_save_failure(page):
|
||||
@@ -699,7 +733,7 @@ async def test_configure_modal_enter_key_submits(page):
|
||||
# ─── Group H: Auth card (SSE-triggered) ───────────────────────────────────────
|
||||
|
||||
async def _show_auth_card(page, **kwargs):
|
||||
"""Inject an auth card via JS and wait for it to appear."""
|
||||
"""Inject the global auth prompt via JS and wait for it to appear."""
|
||||
payload = json.dumps(kwargs)
|
||||
await page.evaluate(f"showAuthCard({payload})")
|
||||
await page.locator(SEL["auth_card"]).wait_for(state="visible", timeout=5000)
|
||||
@@ -812,12 +846,43 @@ async def test_auth_card_replaces_existing_same_extension(page):
|
||||
assert "Second" in await page.locator(SEL["auth_instructions"]).text_content()
|
||||
|
||||
|
||||
async def test_auth_card_multiple_extensions_coexist(page):
|
||||
"""Auth cards for different extensions can coexist."""
|
||||
async def test_auth_card_for_different_extension_replaces_existing_prompt(page):
|
||||
"""A new auth prompt replaces the previous one to keep the UX modal and global."""
|
||||
await page.evaluate('showAuthCard({extension_name: "ext-a", instructions: "Token A"})')
|
||||
await page.evaluate('showAuthCard({extension_name: "ext-b", instructions: "Token B"})')
|
||||
await page.locator(SEL["auth_card"]).nth(1).wait_for(state="visible", timeout=3000)
|
||||
assert await page.locator(SEL["auth_card"]).count() == 2
|
||||
await page.locator(SEL["auth_card"]).wait_for(state="visible", timeout=3000)
|
||||
assert await page.locator(SEL["auth_card"]).count() == 1
|
||||
assert await page.locator(SEL["auth_card"] + '[data-extension-name="ext-a"]').count() == 0
|
||||
assert await page.locator(SEL["auth_card"] + '[data-extension-name="ext-b"]').count() == 1
|
||||
|
||||
|
||||
async def test_auth_and_configure_helpers_escape_selector_sensitive_extension_names(page):
|
||||
"""Quoted extension names should not break auth/configure modal helpers."""
|
||||
result = await page.evaluate(
|
||||
"""({ name }) => {
|
||||
showAuthCard({ extension_name: name, instructions: 'Paste token' });
|
||||
showAuthCardError(name, 'Bad token');
|
||||
const errorText = document.querySelector('.auth-error')?.textContent || '';
|
||||
removeAuthCard(name);
|
||||
const authStillPresent = Array.from(document.querySelectorAll('.auth-card'))
|
||||
.some((card) => card.getAttribute('data-extension-name') === name);
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'configure-overlay';
|
||||
overlay.setAttribute('data-extension-name', name);
|
||||
document.body.appendChild(overlay);
|
||||
closeConfigureModal(name);
|
||||
const configureStillPresent = Array.from(document.querySelectorAll('.configure-overlay'))
|
||||
.some((node) => node.getAttribute('data-extension-name') === name);
|
||||
|
||||
return { errorText, authStillPresent, configureStillPresent };
|
||||
}""",
|
||||
{"name": 'quoted "ext" name'},
|
||||
)
|
||||
|
||||
assert result["errorText"] == "Bad token"
|
||||
assert result["authStillPresent"] is False
|
||||
assert result["configureStillPresent"] is False
|
||||
|
||||
|
||||
async def test_auth_completed_sse_dismisses_card(page):
|
||||
@@ -826,13 +891,95 @@ async def test_auth_completed_sse_dismisses_card(page):
|
||||
|
||||
# Simulate the auth_completed SSE event being fired
|
||||
await page.evaluate("""
|
||||
// Call the handler the same way the SSE listener does
|
||||
removeAuthCard('myext');
|
||||
handleAuthCompleted({
|
||||
extension_name: 'myext',
|
||||
success: true,
|
||||
message: 'Authenticated!',
|
||||
});
|
||||
""")
|
||||
|
||||
assert await page.locator(SEL["auth_card"] + '[data-extension-name="myext"]').count() == 0
|
||||
|
||||
|
||||
async def test_auth_completed_for_other_extension_keeps_configure_modal_open(page):
|
||||
"""Auth completion should not close a different extension's configure modal."""
|
||||
async def handle_setup(route):
|
||||
await route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps({"secrets": [{"name": "token", "prompt": "Token", "provided": False, "optional": False, "auto_generate": False}]}),
|
||||
)
|
||||
|
||||
await page.route("**/api/extensions/test-ext/setup", handle_setup)
|
||||
await page.evaluate("showConfigureModal('test-ext')")
|
||||
await page.locator(SEL["configure_modal"]).wait_for(state="visible", timeout=5000)
|
||||
|
||||
await page.evaluate("""
|
||||
handleAuthCompleted({
|
||||
extension_name: 'other-ext',
|
||||
success: true,
|
||||
message: 'Other extension connected.',
|
||||
});
|
||||
""")
|
||||
|
||||
assert await page.locator(SEL["configure_overlay"]).is_visible(), (
|
||||
"Configure modal should remain open when another extension finishes auth"
|
||||
)
|
||||
|
||||
|
||||
async def test_auth_completed_failure_sse_shows_error_toast_and_reloads_extensions(page):
|
||||
"""Failed auth_completed handling should clear stale UI and refresh extensions."""
|
||||
reload_count = []
|
||||
|
||||
async def counting_handler(route):
|
||||
path = route.request.url.split("?")[0]
|
||||
if path.endswith("/api/extensions"):
|
||||
reload_count.append(1)
|
||||
await route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps({"extensions": []}),
|
||||
)
|
||||
else:
|
||||
await route.continue_()
|
||||
|
||||
async def handle_tools(route):
|
||||
await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}')
|
||||
|
||||
async def handle_registry(route):
|
||||
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
|
||||
|
||||
await page.route("**/api/extensions*", counting_handler)
|
||||
await page.route("**/api/extensions/tools", handle_tools)
|
||||
await page.route("**/api/extensions/registry", handle_registry)
|
||||
|
||||
await go_to_extensions(page)
|
||||
count_before = len(reload_count)
|
||||
|
||||
await _show_auth_card(page, extension_name="gmail", auth_url="https://example.com/oauth")
|
||||
assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 1
|
||||
|
||||
await page.evaluate("""
|
||||
handleAuthCompleted({
|
||||
extension_name: 'gmail',
|
||||
success: false,
|
||||
message: 'OAuth flow expired. Please try again.',
|
||||
});
|
||||
""")
|
||||
|
||||
await wait_for_toast(page, "OAuth flow expired. Please try again.")
|
||||
assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 0
|
||||
assert (
|
||||
await page.locator(
|
||||
SEL["toast_error"], has_text="OAuth flow expired. Please try again."
|
||||
).count()
|
||||
>= 1
|
||||
)
|
||||
|
||||
await page.wait_for_timeout(600)
|
||||
assert len(reload_count) > count_before, "Extensions list did not reload after auth failure"
|
||||
|
||||
|
||||
# ─── Group I: Activate flow ────────────────────────────────────────────────────
|
||||
|
||||
async def test_activate_mcp_server_success(page):
|
||||
@@ -902,8 +1049,8 @@ async def test_activate_failure_shows_error_toast(page):
|
||||
await wait_for_toast(page, "Config missing")
|
||||
|
||||
|
||||
async def test_activate_with_auth_url_opens_popup(page):
|
||||
"""Activate response with auth_url calls window.open."""
|
||||
async def test_activate_with_auth_url_opens_popup_and_shows_auth_prompt(page):
|
||||
"""Activate response with auth_url calls window.open and shows the auth prompt."""
|
||||
await page.evaluate("window.open = (url) => { window._lastOpenedUrl = url; }")
|
||||
await mock_ext_apis(page, installed=[_MCP_INACTIVE])
|
||||
|
||||
@@ -921,6 +1068,9 @@ async def test_activate_with_auth_url_opens_popup(page):
|
||||
opened = await page.evaluate("window._lastOpenedUrl")
|
||||
assert opened is not None, "window.open was not called"
|
||||
assert "example.com" in opened
|
||||
await page.locator(
|
||||
SEL["auth_card"] + '[data-extension-name="test-mcp-inactive"]'
|
||||
).wait_for(state="visible", timeout=5000)
|
||||
|
||||
|
||||
# ─── Group J: Tab reload behaviour ────────────────────────────────────────────
|
||||
@@ -947,9 +1097,9 @@ async def test_extensions_tab_reloads_on_revisit(page):
|
||||
async def handle_registry(route):
|
||||
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
|
||||
|
||||
await page.route("**/api/extensions*", counting_handler)
|
||||
await page.route("**/api/extensions/tools", handle_tools)
|
||||
await page.route("**/api/extensions/registry", handle_registry)
|
||||
await page.route("**/api/extensions*", counting_handler)
|
||||
|
||||
# First visit
|
||||
await go_to_extensions(page)
|
||||
@@ -990,19 +1140,20 @@ async def test_auth_completed_sse_triggers_extensions_reload(page):
|
||||
async def handle_registry(route):
|
||||
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
|
||||
|
||||
await page.route("**/api/extensions*", counting_handler)
|
||||
await page.route("**/api/extensions/tools", handle_tools)
|
||||
await page.route("**/api/extensions/registry", handle_registry)
|
||||
await page.route("**/api/extensions*", counting_handler)
|
||||
|
||||
await go_to_extensions(page)
|
||||
count_before = len(reload_count)
|
||||
|
||||
# Simulate auth_completed by calling loadExtensions directly (as the SSE handler does)
|
||||
# Simulate auth_completed via the shared handler.
|
||||
await page.evaluate("""
|
||||
// Simulate what the auth_completed SSE handler does when currentTab === 'extensions'
|
||||
if (typeof loadExtensions === 'function') {
|
||||
loadExtensions();
|
||||
}
|
||||
handleAuthCompleted({
|
||||
extension_name: 'reload-ext',
|
||||
success: true,
|
||||
message: 'Reloaded.',
|
||||
});
|
||||
""")
|
||||
|
||||
await page.wait_for_timeout(600)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""DM pairing flow e2e tests.
|
||||
|
||||
Tests the pairing security gate for WASM channels: listing pending requests,
|
||||
approving codes, and error handling.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from helpers import AUTH_TOKEN
|
||||
|
||||
|
||||
def _headers():
|
||||
return {"Authorization": f"Bearer {AUTH_TOKEN}"}
|
||||
|
||||
|
||||
async def test_pairing_list_returns_empty_for_unknown_channel(ironclaw_server):
|
||||
"""GET /api/pairing/{channel} returns empty list or 404 for non-existent channel."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(
|
||||
f"{ironclaw_server}/api/pairing/nonexistent-channel",
|
||||
headers=_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
# Either empty list or error is acceptable
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
assert isinstance(data, (dict, list))
|
||||
if isinstance(data, dict):
|
||||
assert "requests" in data
|
||||
assert isinstance(data["requests"], list)
|
||||
assert data["requests"] == []
|
||||
else:
|
||||
assert data == []
|
||||
else:
|
||||
# 404 or similar is fine for non-existent channel
|
||||
assert r.status_code in (404, 400)
|
||||
|
||||
|
||||
async def test_approve_invalid_code_rejected(ironclaw_server):
|
||||
"""POST /api/pairing/{channel}/approve with bad code returns error."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{ironclaw_server}/api/pairing/test-channel/approve",
|
||||
json={"code": "INVALID0"},
|
||||
headers=_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
# Should fail — no pending request with this code
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
assert data.get("success") is False or data.get("ok") is False or "error" in str(data).lower()
|
||||
else:
|
||||
assert r.status_code >= 400
|
||||
|
||||
|
||||
async def test_approve_empty_code_rejected(ironclaw_server):
|
||||
"""POST /api/pairing/{channel}/approve with empty code returns error."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{ironclaw_server}/api/pairing/test-channel/approve",
|
||||
json={"code": ""},
|
||||
headers=_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
assert data.get("success") is False or data.get("ok") is False
|
||||
else:
|
||||
assert r.status_code >= 400
|
||||
|
||||
|
||||
async def test_pairing_approve_requires_auth(ironclaw_server):
|
||||
"""POST /api/pairing/{channel}/approve without auth token is rejected."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{ironclaw_server}/api/pairing/test-channel/approve",
|
||||
json={"code": "ABCD1234"},
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code == 401 or r.status_code == 403
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Tool execution e2e tests.
|
||||
|
||||
Tests the agent loop: user message -> mock LLM returns tool_calls -> tool
|
||||
executes -> result displayed in chat. Requires the enhanced mock_llm.py
|
||||
with TOOL_CALL_PATTERNS support.
|
||||
"""
|
||||
|
||||
from helpers import SEL
|
||||
|
||||
|
||||
async def _send_and_get_response(
|
||||
page,
|
||||
message: str,
|
||||
*,
|
||||
expected_fragment: str,
|
||||
timeout: int = 30000,
|
||||
) -> str:
|
||||
"""Send a message and return the text of the newest assistant response.
|
||||
|
||||
Counts existing assistant messages before sending, then waits for a new
|
||||
one to appear and contain the expected final text fragment. This avoids
|
||||
reading partial streamed content before the assistant response is complete.
|
||||
"""
|
||||
chat_input = page.locator(SEL["chat_input"])
|
||||
await chat_input.wait_for(state="visible", timeout=5000)
|
||||
|
||||
# Count existing assistant messages before sending
|
||||
assistant_sel = SEL["message_assistant"]
|
||||
before_count = await page.locator(assistant_sel).count()
|
||||
|
||||
await chat_input.fill(message)
|
||||
await chat_input.press("Enter")
|
||||
|
||||
# Wait for the final assistant message to exist and include the expected
|
||||
# text fragment rather than returning on the first streamed chunk.
|
||||
expected = before_count + 1
|
||||
await page.wait_for_function(
|
||||
"""({ assistantSelector, expectedCount, expectedFragment }) => {
|
||||
const messages = document.querySelectorAll(assistantSelector);
|
||||
if (messages.length < expectedCount) return false;
|
||||
const text = (messages[messages.length - 1].innerText || '').trim().toLowerCase();
|
||||
return text.includes(expectedFragment.toLowerCase());
|
||||
}""",
|
||||
arg={
|
||||
"assistantSelector": assistant_sel,
|
||||
"expectedCount": expected,
|
||||
"expectedFragment": expected_fragment,
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
return await page.locator(assistant_sel).last.inner_text()
|
||||
|
||||
|
||||
async def test_builtin_echo_tool(page):
|
||||
"""Send a message that triggers the echo tool via mock LLM function calling."""
|
||||
text = await _send_and_get_response(
|
||||
page,
|
||||
"echo hello world",
|
||||
expected_fragment="hello world",
|
||||
)
|
||||
|
||||
# The mock LLM returns "The echo tool returned: <result>"
|
||||
assert "echo" in text.lower() or "hello world" in text.lower(), (
|
||||
f"Expected echo result in response, got: {text}"
|
||||
)
|
||||
|
||||
|
||||
async def test_builtin_time_tool(page):
|
||||
"""Send a message that triggers the time tool via mock LLM function calling."""
|
||||
text = await _send_and_get_response(
|
||||
page,
|
||||
"what time is it",
|
||||
expected_fragment="time",
|
||||
)
|
||||
|
||||
# The mock LLM returns "The time tool returned: <json with iso/unix>"
|
||||
assert "time" in text.lower(), (
|
||||
f"Expected time result in response, got: {text}"
|
||||
)
|
||||
|
||||
|
||||
async def test_non_tool_message_still_works(page):
|
||||
"""Messages that don't match tool patterns still get text responses."""
|
||||
text = await _send_and_get_response(
|
||||
page,
|
||||
"What is 2+2?",
|
||||
expected_fragment="4",
|
||||
timeout=15000,
|
||||
)
|
||||
|
||||
assert "4" in text, (
|
||||
f"Expected '4' in response, got: {text}"
|
||||
)
|
||||
@@ -0,0 +1,517 @@
|
||||
"""Comprehensive WASM extension lifecycle e2e tests.
|
||||
|
||||
Tests the full extension pipeline: registry → install → fields → configure →
|
||||
activate → tools → remove → reinstall. Validates response fields, not just
|
||||
status codes, to catch production bugs like missing capabilities, wrong
|
||||
activation state, and stale registry flags.
|
||||
|
||||
Lifecycle stages are expressed as scoped fixtures so each test requests the
|
||||
state it needs explicitly rather than relying on module-global flags.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from helpers import SEL, api_get, api_post
|
||||
|
||||
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 (idempotent cleanup)."""
|
||||
ext = await _get_extension(base_url, name)
|
||||
if ext:
|
||||
await api_post(base_url, f"/api/extensions/{name}/remove", timeout=30)
|
||||
|
||||
|
||||
async def _install_extension(base_url, name):
|
||||
"""Install an extension and assert success."""
|
||||
r = await api_post(
|
||||
base_url,
|
||||
"/api/extensions/install",
|
||||
json={"name": name},
|
||||
timeout=180,
|
||||
)
|
||||
assert r.status_code == 200, f"Install HTTP error: {r.status_code} {r.text[:300]}"
|
||||
data = r.json()
|
||||
assert data.get("success") is True, f"Install failed: {data.get('message', '')}"
|
||||
return data
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
async def extension_lifecycle_cleanup(ironclaw_server):
|
||||
"""Start and end the module with a clean extension set."""
|
||||
await _ensure_removed(ironclaw_server, "web-search")
|
||||
await _ensure_removed(ironclaw_server, "gmail")
|
||||
yield
|
||||
await _ensure_removed(ironclaw_server, "web-search")
|
||||
await _ensure_removed(ironclaw_server, "gmail")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def web_search_installed(ironclaw_server, extension_lifecycle_cleanup):
|
||||
"""Install web-search once for tests that require the pre-configure state."""
|
||||
data = await _install_extension(ironclaw_server, "web-search")
|
||||
return {"name": "web-search", "install": data}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def web_search_configured(ironclaw_server, web_search_installed):
|
||||
"""Configure web-search once for tests that require the active state."""
|
||||
r = await api_post(
|
||||
ironclaw_server,
|
||||
"/api/extensions/web-search/setup",
|
||||
json={"secrets": {"brave_api_key": "test-key-123"}},
|
||||
timeout=30,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data.get("success") is True, f"Configure failed: {data.get('message', '')}"
|
||||
assert data.get("activated") is True, "Should auto-activate after configure"
|
||||
return {"name": "web-search", "configure": data}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def gmail_installed(ironclaw_server, extension_lifecycle_cleanup):
|
||||
"""Install gmail once for multi-extension and OAuth setup assertions."""
|
||||
data = await _install_extension(ironclaw_server, "gmail")
|
||||
return {"name": "gmail", "install": data}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def web_search_removed(ironclaw_server, web_search_configured):
|
||||
"""Remove web-search once for post-uninstall assertions."""
|
||||
r = await api_post(
|
||||
ironclaw_server, "/api/extensions/web-search/remove", timeout=30
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data.get("success") is True, f"Remove failed: {data.get('message', '')}"
|
||||
return {"name": "web-search", "remove": data}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def web_search_reinstalled(ironclaw_server, web_search_removed):
|
||||
"""Reinstall web-search after removal to verify saved-secret recovery."""
|
||||
await _ensure_removed(ironclaw_server, "web-search")
|
||||
data = await _install_extension(ironclaw_server, "web-search")
|
||||
return {"name": "web-search", "install": data}
|
||||
|
||||
|
||||
# ── Section A: Registry Validation ──────────────────────────────────────
|
||||
|
||||
|
||||
async def test_registry_lists_extensions(ironclaw_server):
|
||||
"""Registry endpoint returns entries from the embedded catalog."""
|
||||
r = await api_get(ironclaw_server, "/api/extensions/registry")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert "entries" in data
|
||||
names = [e["name"] for e in data["entries"]]
|
||||
assert "web-search" in names
|
||||
assert "gmail" in names
|
||||
|
||||
|
||||
async def test_registry_entry_fields(ironclaw_server):
|
||||
"""Every registry entry has all required fields with correct types."""
|
||||
r = await api_get(ironclaw_server, "/api/extensions/registry")
|
||||
entries = r.json()["entries"]
|
||||
assert len(entries) > 0, "Registry should have entries"
|
||||
for entry in entries:
|
||||
assert "name" in entry and isinstance(entry["name"], str) and entry["name"]
|
||||
assert "display_name" in entry and isinstance(entry["display_name"], str)
|
||||
assert "kind" in entry and isinstance(entry["kind"], str)
|
||||
assert "description" in entry and isinstance(entry["description"], str)
|
||||
assert "installed" in entry and isinstance(entry["installed"], bool)
|
||||
assert "keywords" in entry and isinstance(entry["keywords"], list)
|
||||
|
||||
|
||||
async def test_registry_installed_flag_false_initially(ironclaw_server):
|
||||
"""Before any install, all registry entries have installed=False."""
|
||||
# Clean up in case previous test run left extensions installed
|
||||
await _ensure_removed(ironclaw_server, "web-search")
|
||||
await _ensure_removed(ironclaw_server, "gmail")
|
||||
|
||||
r = await api_get(ironclaw_server, "/api/extensions/registry")
|
||||
entries = r.json()["entries"]
|
||||
for entry in entries:
|
||||
if entry["name"] in ("web-search", "gmail"):
|
||||
assert entry["installed"] is False, (
|
||||
f"{entry['name']} should not be installed yet"
|
||||
)
|
||||
|
||||
|
||||
async def test_registry_search_filters(ironclaw_server):
|
||||
"""Search query filters registry results."""
|
||||
r = await api_get(
|
||||
ironclaw_server, "/api/extensions/registry", params={"query": "search"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
entries = r.json()["entries"]
|
||||
names = [e["name"] for e in entries]
|
||||
assert "web-search" in names
|
||||
|
||||
|
||||
async def test_registry_search_no_match(ironclaw_server):
|
||||
"""Nonsense query returns empty results."""
|
||||
r = await api_get(
|
||||
ironclaw_server,
|
||||
"/api/extensions/registry",
|
||||
params={"query": "xyznonexistent999"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()["entries"]) == 0
|
||||
|
||||
|
||||
# ── Section B: Install Lifecycle (web-search) ───────────────────────────
|
||||
|
||||
|
||||
async def test_install_web_search(web_search_installed):
|
||||
"""Install web-search from registry. Asserts success — failure here means
|
||||
the registry/download/build pipeline is broken."""
|
||||
assert "message" in web_search_installed["install"]
|
||||
|
||||
|
||||
async def test_installed_extension_fields(ironclaw_server, web_search_installed):
|
||||
"""After install, extension list shows correct fields."""
|
||||
ext = await _get_extension(ironclaw_server, "web-search")
|
||||
assert ext is not None, "web-search not in extensions list after install"
|
||||
assert ext["kind"] == "wasm_tool"
|
||||
assert ext["needs_setup"] is True, "Should need setup (has brave_api_key secret)"
|
||||
assert ext["authenticated"] is False, "Should not be authenticated before configure"
|
||||
|
||||
|
||||
async def test_installed_in_registry(ironclaw_server, web_search_installed):
|
||||
"""Registry marks installed extension with installed=True."""
|
||||
r = await api_get(ironclaw_server, "/api/extensions/registry")
|
||||
entries = r.json()["entries"]
|
||||
ws_entry = next((e for e in entries if e["name"] == "web-search"), None)
|
||||
assert ws_entry is not None
|
||||
assert ws_entry["installed"] is True, "Registry should show installed=True"
|
||||
|
||||
|
||||
async def test_setup_schema_has_secrets(ironclaw_server, web_search_installed):
|
||||
"""Setup schema returns brave_api_key with correct field info."""
|
||||
r = await api_get(ironclaw_server, "/api/extensions/web-search/setup")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert "secrets" in data
|
||||
secrets = {s["name"]: s for s in data["secrets"]}
|
||||
assert "brave_api_key" in secrets, (
|
||||
f"brave_api_key not in setup schema secrets: {list(secrets.keys())}"
|
||||
)
|
||||
key_info = secrets["brave_api_key"]
|
||||
assert key_info["provided"] is False, "Should not be provided yet"
|
||||
|
||||
|
||||
async def test_extension_not_authenticated_before_configure(
|
||||
ironclaw_server, web_search_installed
|
||||
):
|
||||
"""Installed but not configured extension is not authenticated."""
|
||||
ext = await _get_extension(ironclaw_server, "web-search")
|
||||
assert ext is not None
|
||||
# Before configuring secrets, extension shouldn't be fully authenticated
|
||||
assert ext["needs_setup"] is True, "Should still need setup before configure"
|
||||
|
||||
|
||||
async def test_activate_before_configure_rejected(ironclaw_server, web_search_installed):
|
||||
"""Activating a tool that needs setup secrets is rejected."""
|
||||
r = await api_post(
|
||||
ironclaw_server, "/api/extensions/web-search/activate", timeout=30
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data.get("success") is False, (
|
||||
f"Activate should fail before configure: {data}"
|
||||
)
|
||||
msg = data.get("message", "").lower()
|
||||
assert "requires configuration" in msg or "setup" in msg, (
|
||||
f"Error should mention configuration: {data.get('message')}"
|
||||
)
|
||||
|
||||
|
||||
# ── Section C: Configure + Activate (web-search) ────────────────────────
|
||||
|
||||
|
||||
async def test_configure_rejects_unknown_secret(ironclaw_server, web_search_installed):
|
||||
"""Submitting an unknown secret name is rejected."""
|
||||
r = await api_post(
|
||||
ironclaw_server,
|
||||
"/api/extensions/web-search/setup",
|
||||
json={"secrets": {"fake_unknown_key": "value"}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data.get("success") is False, f"Should reject unknown secret: {data}"
|
||||
assert "unknown" in data.get("message", "").lower() or "not found" in data.get(
|
||||
"message", ""
|
||||
).lower(), f"Error should mention unknown secret: {data.get('message')}"
|
||||
|
||||
|
||||
async def test_configure_with_valid_secret(web_search_configured):
|
||||
"""Configure with valid brave_api_key succeeds and auto-activates."""
|
||||
assert web_search_configured["configure"].get("activated") is True
|
||||
|
||||
|
||||
async def test_extension_active_after_configure(ironclaw_server, web_search_configured):
|
||||
"""After configure, extension shows authenticated=True and active=True."""
|
||||
ext = await _get_extension(ironclaw_server, "web-search")
|
||||
assert ext is not None
|
||||
assert ext["authenticated"] is True, "Should be authenticated after configure"
|
||||
assert ext["active"] is True, "Should be active after auto-activation"
|
||||
assert len(ext.get("tools", [])) > 0, "Should have tools registered"
|
||||
|
||||
|
||||
async def test_setup_shows_provided(ironclaw_server, web_search_configured):
|
||||
"""After configure, setup schema shows secret as provided."""
|
||||
r = await api_get(ironclaw_server, "/api/extensions/web-search/setup")
|
||||
assert r.status_code == 200
|
||||
secrets = {s["name"]: s for s in r.json()["secrets"]}
|
||||
assert "brave_api_key" in secrets
|
||||
assert secrets["brave_api_key"]["provided"] is True
|
||||
|
||||
|
||||
async def test_tools_registered_after_activate(
|
||||
ironclaw_server, web_search_configured
|
||||
):
|
||||
"""After activation, extension tools appear in the tools endpoint."""
|
||||
r = await api_get(ironclaw_server, "/api/extensions/tools")
|
||||
assert r.status_code == 200
|
||||
tool_names = [t["name"] for t in r.json()["tools"]]
|
||||
assert "web-search" in tool_names, (
|
||||
f"web-search tool not found in tools list: {tool_names}"
|
||||
)
|
||||
|
||||
|
||||
async def test_activate_already_active_idempotent(
|
||||
ironclaw_server, web_search_configured
|
||||
):
|
||||
"""Activating an already-active extension succeeds (idempotent)."""
|
||||
r = await api_post(
|
||||
ironclaw_server, "/api/extensions/web-search/activate", timeout=30
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data.get("success") is True, (
|
||||
f"Re-activation should succeed: {data.get('message', '')}"
|
||||
)
|
||||
|
||||
|
||||
async def test_configure_empty_secret_skipped(ironclaw_server, web_search_configured):
|
||||
"""Submitting an empty string for a secret skips it (doesn't overwrite)."""
|
||||
r = await api_post(
|
||||
ironclaw_server,
|
||||
"/api/extensions/web-search/setup",
|
||||
json={"secrets": {"brave_api_key": ""}},
|
||||
timeout=30,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data.get("success") is True
|
||||
|
||||
# Verify the secret is still provided (not cleared)
|
||||
r2 = await api_get(ironclaw_server, "/api/extensions/web-search/setup")
|
||||
secrets = {s["name"]: s for s in r2.json()["secrets"]}
|
||||
assert secrets["brave_api_key"]["provided"] is True, (
|
||||
"Empty value should not clear existing secret"
|
||||
)
|
||||
|
||||
|
||||
# ── Section D: Install gmail (multi-extension) ──────────────────────────
|
||||
|
||||
|
||||
async def test_install_gmail(gmail_installed):
|
||||
"""Install gmail from registry (second extension, tests isolation)."""
|
||||
assert "message" in gmail_installed["install"]
|
||||
|
||||
|
||||
async def test_gmail_fields(ironclaw_server, gmail_installed):
|
||||
"""Gmail extension has correct field values (OAuth-based auth)."""
|
||||
ext = await _get_extension(ironclaw_server, "gmail")
|
||||
assert ext is not None, "gmail not in extensions list"
|
||||
assert ext["kind"] == "wasm_tool"
|
||||
assert ext["has_auth"] is True, "Gmail should have OAuth auth"
|
||||
|
||||
|
||||
async def test_both_extensions_listed(
|
||||
ironclaw_server, web_search_configured, gmail_installed
|
||||
):
|
||||
"""Both web-search and gmail appear in extensions list (no clobbering)."""
|
||||
r = await api_get(ironclaw_server, "/api/extensions")
|
||||
names = [e["name"] for e in r.json()["extensions"]]
|
||||
assert "web-search" in names, f"web-search missing from: {names}"
|
||||
assert "gmail" in names, f"gmail missing from: {names}"
|
||||
|
||||
|
||||
async def test_gmail_setup_schema_auto_resolves(ironclaw_server, gmail_installed):
|
||||
"""Gmail setup schema returns empty secrets (builtin creds auto-resolve)."""
|
||||
r = await api_get(ironclaw_server, "/api/extensions/gmail/setup")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
secrets = data.get("secrets", [])
|
||||
# Builtin Google credentials auto-resolve client_id/client_secret via
|
||||
# is_auto_resolved_oauth_field(), so the setup schema should have no
|
||||
# user-facing secrets (or only auto-generated ones).
|
||||
user_facing = [s for s in secrets if not s.get("auto_generate", False)]
|
||||
assert len(user_facing) == 0, (
|
||||
f"Gmail should have no user-facing secrets (auto-resolved), got: "
|
||||
f"{[s['name'] for s in user_facing]}"
|
||||
)
|
||||
|
||||
|
||||
# ── Section E: Remove + Cleanup ─────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_remove_web_search(web_search_removed):
|
||||
"""Remove web-search succeeds."""
|
||||
assert web_search_removed["remove"].get("success") is True
|
||||
|
||||
|
||||
async def test_removed_not_in_extensions(ironclaw_server, web_search_removed):
|
||||
"""Removed extension no longer appears in extensions list."""
|
||||
ext = await _get_extension(ironclaw_server, "web-search")
|
||||
assert ext is None, "web-search should not be in extensions list after removal"
|
||||
|
||||
|
||||
async def test_removed_extension_not_listed(ironclaw_server, web_search_removed):
|
||||
"""Removed extension should not appear in the extension tools list."""
|
||||
r = await api_get(ironclaw_server, "/api/extensions/tools")
|
||||
assert r.status_code == 200
|
||||
tool_names = [t["name"] for t in r.json()["tools"]]
|
||||
assert "web-search" not in tool_names, (
|
||||
f"Removed web-search tool should not remain registered: {tool_names}"
|
||||
)
|
||||
|
||||
|
||||
async def test_removed_not_in_registry_installed(ironclaw_server, web_search_removed):
|
||||
"""Registry shows removed extension as installed=False."""
|
||||
r = await api_get(ironclaw_server, "/api/extensions/registry")
|
||||
ws_entry = next(
|
||||
(e for e in r.json()["entries"] if e["name"] == "web-search"), None
|
||||
)
|
||||
assert ws_entry is not None
|
||||
assert ws_entry["installed"] is False, "Registry should show installed=False"
|
||||
|
||||
|
||||
async def test_activate_after_remove_uses_replacement_bytes_not_cached_module(
|
||||
ironclaw_server, wasm_tools_dir, web_search_removed
|
||||
):
|
||||
"""After removal, activation must use the replacement bytes rather than a stale cache."""
|
||||
wasm_path = Path(wasm_tools_dir) / "web-search.wasm"
|
||||
wasm_path.write_bytes(b"not-a-valid-wasm-component")
|
||||
|
||||
r = await api_post(
|
||||
ironclaw_server, "/api/extensions/web-search/activate", timeout=30
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data.get("success") is False, (
|
||||
f"Activation should fail against replacement bytes, got: {data}"
|
||||
)
|
||||
|
||||
|
||||
async def test_reinstall_after_remove(ironclaw_server, web_search_reinstalled):
|
||||
"""Extension can be reinstalled after removal without stale activation errors."""
|
||||
ext = await _get_extension(ironclaw_server, "web-search")
|
||||
assert ext is not None, "web-search not found after reinstall"
|
||||
assert ext["active"] is True, "Reinstalled tool should auto-activate via saved secrets"
|
||||
assert ext["authenticated"] is True, "Saved secret should still authenticate on reinstall"
|
||||
# Verify no stale activation error from previous install
|
||||
assert ext.get("activation_error") is None or ext.get("activation_error") == "", (
|
||||
f"Reinstalled extension should have no stale activation error: {ext}"
|
||||
)
|
||||
|
||||
|
||||
# ── Section F: Error Paths ──────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_install_nonexistent(ironclaw_server):
|
||||
"""Installing a nonexistent extension returns an error."""
|
||||
r = await api_post(
|
||||
ironclaw_server,
|
||||
"/api/extensions/install",
|
||||
json={"name": "nonexistent-tool-xyz-999"},
|
||||
timeout=30,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
assert r.json().get("success") is False
|
||||
else:
|
||||
assert r.status_code >= 400
|
||||
|
||||
|
||||
async def test_install_empty_name(ironclaw_server):
|
||||
"""Installing with empty name returns an error."""
|
||||
r = await api_post(
|
||||
ironclaw_server,
|
||||
"/api/extensions/install",
|
||||
json={"name": ""},
|
||||
timeout=10,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
assert r.json().get("success") is False
|
||||
else:
|
||||
assert r.status_code >= 400
|
||||
|
||||
|
||||
async def test_remove_noninstalled(ironclaw_server):
|
||||
"""Removing a non-installed extension returns an error."""
|
||||
r = await api_post(
|
||||
ironclaw_server, "/api/extensions/nonexistent-xyz/remove", timeout=10
|
||||
)
|
||||
if r.status_code == 200:
|
||||
assert r.json().get("success") is False
|
||||
else:
|
||||
assert r.status_code >= 400
|
||||
|
||||
|
||||
async def test_activate_noninstalled(ironclaw_server):
|
||||
"""Activating a non-installed extension returns an error."""
|
||||
r = await api_post(
|
||||
ironclaw_server, "/api/extensions/nonexistent-xyz/activate", timeout=10
|
||||
)
|
||||
if r.status_code == 200:
|
||||
assert r.json().get("success") is False
|
||||
else:
|
||||
assert r.status_code >= 400
|
||||
|
||||
|
||||
async def test_setup_noninstalled(ironclaw_server):
|
||||
"""Setup for non-installed extension returns an error."""
|
||||
r = await api_get(ironclaw_server, "/api/extensions/nonexistent-xyz/setup")
|
||||
# May return 500 or a JSON error
|
||||
assert r.status_code >= 400 or r.json().get("success") is False
|
||||
|
||||
|
||||
async def test_configure_noninstalled(ironclaw_server):
|
||||
"""Configure for non-installed extension returns an error."""
|
||||
r = await api_post(
|
||||
ironclaw_server,
|
||||
"/api/extensions/nonexistent-xyz/setup",
|
||||
json={"secrets": {}},
|
||||
timeout=10,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
assert r.json().get("success") is False
|
||||
else:
|
||||
assert r.status_code >= 400
|
||||
|
||||
|
||||
# ── Section G: Browser UI ──────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_extensions_tab_shows_registry(page):
|
||||
"""Extensions tab loads and shows available extensions from registry."""
|
||||
tab_btn = page.locator(SEL["tab_button"].format(tab="extensions"))
|
||||
await tab_btn.click()
|
||||
panel = page.locator(SEL["tab_panel"].format(tab="extensions"))
|
||||
await panel.wait_for(state="visible", timeout=5000)
|
||||
|
||||
available_section = page.locator(SEL["available_wasm_list"])
|
||||
await available_section.wait_for(state="visible", timeout=10000)
|
||||
Reference in New Issue
Block a user