mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* Fix hosted OAuth refresh via proxy * Address OAuth refresh review feedback * Address new OAuth refresh review comments * Address additional OAuth refresh review feedback * Harden proxy exchange redirects
228 lines
8.0 KiB
Python
228 lines
8.0 KiB
Python
"""Hosted OAuth refresh HTTP regression test.
|
|
|
|
Runs a real ironclaw binary in hosted mode, expires a stored Gmail access
|
|
token in the libSQL database, triggers a real gmail tool call through the
|
|
chat API, and verifies that refresh uses the hosted proxy endpoint.
|
|
"""
|
|
|
|
import asyncio
|
|
import sqlite3
|
|
from datetime import datetime, timezone
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
import httpx
|
|
|
|
from helpers import api_get, api_post
|
|
|
|
|
|
def _extract_state(auth_url: str) -> str:
|
|
parsed = urlparse(auth_url)
|
|
state = parse_qs(parsed.query).get("state", [None])[0]
|
|
assert state, f"auth_url should include state: {auth_url}"
|
|
return state
|
|
|
|
|
|
def _parse_timestamp(value: str | None) -> datetime | None:
|
|
if value is None:
|
|
return None
|
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
|
|
|
|
def _expire_access_token(db_path: str, user_id: str, secret_name: str) -> None:
|
|
with sqlite3.connect(db_path) as conn:
|
|
cursor = conn.execute(
|
|
"""
|
|
UPDATE secrets
|
|
SET expires_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
|
|
WHERE user_id = ?1 AND name = ?2
|
|
""",
|
|
(user_id, secret_name),
|
|
)
|
|
conn.commit()
|
|
assert cursor.rowcount == 1, f"Expected one secret row for {user_id}/{secret_name}"
|
|
|
|
|
|
def _find_secret_row(
|
|
db_path: str,
|
|
secret_name: str,
|
|
) -> tuple[str, str | None, str | None]:
|
|
with sqlite3.connect(db_path) as conn:
|
|
row = conn.execute(
|
|
"""
|
|
SELECT user_id, expires_at, updated_at
|
|
FROM secrets
|
|
WHERE name = ?1
|
|
ORDER BY updated_at DESC
|
|
LIMIT 1
|
|
""",
|
|
(secret_name,),
|
|
).fetchone()
|
|
assert row is not None, f"Missing secret row for {secret_name}"
|
|
return row[0], row[1], row[2]
|
|
|
|
|
|
async def _get_extension(base_url: str, name: str) -> dict | None:
|
|
response = await api_get(base_url, "/api/extensions", timeout=15)
|
|
response.raise_for_status()
|
|
for extension in response.json().get("extensions", []):
|
|
if extension["name"] == name:
|
|
return extension
|
|
return None
|
|
|
|
|
|
async def _reset_mock_oauth_state(mock_base_url: str) -> None:
|
|
async with httpx.AsyncClient() as client:
|
|
response = await client.post(f"{mock_base_url}/__mock/oauth/reset", timeout=10)
|
|
response.raise_for_status()
|
|
|
|
|
|
async def _get_mock_oauth_state(mock_base_url: str) -> dict:
|
|
async with httpx.AsyncClient() as client:
|
|
response = await client.get(f"{mock_base_url}/__mock/oauth/state", timeout=10)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
|
|
async def _approve_pending_request(base_url: str, thread_id: str, request_id: str) -> None:
|
|
response = await api_post(
|
|
base_url,
|
|
"/api/chat/approval",
|
|
json={"request_id": request_id, "action": "approve", "thread_id": thread_id},
|
|
timeout=15,
|
|
)
|
|
assert response.status_code == 202, (
|
|
f"Approval submission failed: {response.status_code} {response.text[:400]}"
|
|
)
|
|
|
|
|
|
async def _wait_for_gmail_tool_call(base_url: str, thread_id: str, timeout: float = 30.0) -> dict:
|
|
approved_request_ids = set()
|
|
for _ in range(int(timeout * 2)):
|
|
response = await api_get(
|
|
base_url,
|
|
f"/api/chat/history?thread_id={thread_id}",
|
|
timeout=15,
|
|
)
|
|
response.raise_for_status()
|
|
history = response.json()
|
|
|
|
pending = history.get("pending_approval")
|
|
if pending and pending["request_id"] not in approved_request_ids:
|
|
await _approve_pending_request(base_url, thread_id, pending["request_id"])
|
|
approved_request_ids.add(pending["request_id"])
|
|
|
|
for turn in history.get("turns", []):
|
|
for tool_call in turn.get("tool_calls", []):
|
|
if tool_call.get("name") == "gmail":
|
|
return history
|
|
|
|
await asyncio.sleep(0.5)
|
|
|
|
raise AssertionError(f"Timed out waiting for gmail tool call in thread {thread_id}")
|
|
|
|
|
|
async def _wait_for_refresh_request(mock_base_url: str, timeout: float = 20.0) -> dict:
|
|
for _ in range(int(timeout * 2)):
|
|
state = await _get_mock_oauth_state(mock_base_url)
|
|
if state.get("refresh_count") == 1:
|
|
return state
|
|
await asyncio.sleep(0.5)
|
|
raise AssertionError("Timed out waiting for exactly one OAuth refresh request")
|
|
|
|
|
|
async def test_hosted_gmail_oauth_refresh_uses_proxy(hosted_oauth_refresh_server):
|
|
server = hosted_oauth_refresh_server["base_url"]
|
|
db_path = hosted_oauth_refresh_server["db_path"]
|
|
mock_base_url = hosted_oauth_refresh_server["mock_llm_url"]
|
|
|
|
install_response = await api_post(
|
|
server,
|
|
"/api/extensions/install",
|
|
json={"name": "gmail"},
|
|
timeout=180,
|
|
)
|
|
assert install_response.status_code == 200, install_response.text
|
|
assert install_response.json().get("success") is True
|
|
|
|
setup_response = await api_post(
|
|
server,
|
|
"/api/extensions/gmail/setup",
|
|
json={"secrets": {}},
|
|
timeout=30,
|
|
)
|
|
assert setup_response.status_code == 200, setup_response.text
|
|
setup_data = setup_response.json()
|
|
assert setup_data.get("success") is True, setup_data
|
|
auth_url = setup_data.get("auth_url")
|
|
assert auth_url, setup_data
|
|
auth_params = parse_qs(urlparse(auth_url).query)
|
|
assert auth_params.get("client_id") == ["hosted-google-client-id"]
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
callback_response = await client.get(
|
|
f"{server}/oauth/callback",
|
|
params={"code": "mock_auth_code", "state": _extract_state(auth_url)},
|
|
timeout=30,
|
|
follow_redirects=True,
|
|
)
|
|
|
|
assert callback_response.status_code == 200, callback_response.text[:400]
|
|
callback_body = callback_response.text.lower()
|
|
assert "connected" in callback_body or "success" in callback_body
|
|
|
|
gmail = await _get_extension(server, "gmail")
|
|
assert gmail is not None, "gmail should be installed"
|
|
assert gmail["authenticated"] is True, gmail
|
|
assert "gmail" in gmail.get("tools", []), gmail
|
|
|
|
await _reset_mock_oauth_state(mock_base_url)
|
|
|
|
stored_user_id, expires_before, updated_before = _find_secret_row(
|
|
db_path, "google_oauth_token"
|
|
)
|
|
assert _parse_timestamp(expires_before) is not None
|
|
assert _parse_timestamp(updated_before) is not None
|
|
|
|
await asyncio.sleep(0.1)
|
|
_expire_access_token(db_path, stored_user_id, "google_oauth_token")
|
|
|
|
thread_response = await api_post(server, "/api/chat/thread/new", timeout=15)
|
|
assert thread_response.status_code == 200, thread_response.text
|
|
thread_id = thread_response.json()["id"]
|
|
|
|
send_response = await api_post(
|
|
server,
|
|
"/api/chat/send",
|
|
json={"content": "check gmail unread", "thread_id": thread_id},
|
|
timeout=30,
|
|
)
|
|
assert send_response.status_code == 202, send_response.text
|
|
|
|
history = await _wait_for_gmail_tool_call(server, thread_id)
|
|
assert any(
|
|
tool_call.get("name") == "gmail"
|
|
for turn in history.get("turns", [])
|
|
for tool_call in turn.get("tool_calls", [])
|
|
), history
|
|
|
|
oauth_state = await _wait_for_refresh_request(mock_base_url)
|
|
assert oauth_state["refresh_count"] == 1, oauth_state
|
|
last_refresh = oauth_state["last_refresh"]
|
|
assert last_refresh is not None, oauth_state
|
|
assert last_refresh["authorization"] == "Bearer e2e-test-token"
|
|
assert last_refresh["form"]["client_id"] == "hosted-google-client-id"
|
|
assert "client_secret" not in last_refresh["form"], last_refresh
|
|
|
|
refreshed_user_id, expires_after, updated_after = _find_secret_row(
|
|
db_path, "google_oauth_token"
|
|
)
|
|
assert refreshed_user_id == stored_user_id
|
|
expires_after_dt = _parse_timestamp(expires_after)
|
|
updated_after_dt = _parse_timestamp(updated_after)
|
|
updated_before_dt = _parse_timestamp(updated_before)
|
|
assert expires_after_dt is not None
|
|
assert updated_after_dt is not None
|
|
assert updated_before_dt is not None
|
|
assert expires_after_dt > datetime.now(timezone.utc)
|
|
assert updated_after_dt > updated_before_dt
|