Files
hermes-agent/tests/tools/test_mcp_oauth_callback_latch.py
2026-10-08 17:22:54 +02:00

108 lines
3.9 KiB
Python

"""The loopback OAuth callback latches the first terminal result (#116278).
A browser follows the ``/callback`` redirect with queryless fetches (``/favicon.ico``), and the CLI waiter
samples the result only every 500 ms. A handler that wrote every GET into the result lost the stored code
between two polls, so the user saw "Authorization Successful" while ``hermes mcp login`` timed out. These
tests drive the production entry (``_make_callback_waiter`` → ``_start_callback_server`` → handler) with a
browser stand-in that sends its requests back-to-back, well inside one poll interval.
"""
import asyncio
import io
import socket
import threading
from http.client import HTTPConnection
import pytest
pytest.importorskip("mcp.client.auth.oauth2", reason="MCP SDK 1.26.0+ required")
import tools.mcp_oauth as mo
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _get(port: int, path: str) -> int:
conn = HTTPConnection("127.0.0.1", port, timeout=5)
try:
conn.request("GET", path)
resp = conn.getresponse()
resp.read()
return resp.status
finally:
conn.close()
def _wait_listening(port: int) -> None:
for _ in range(200):
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
return
except OSError:
threading.Event().wait(0.02)
raise AssertionError("callback listener never bound")
def _drive_waiter(monkeypatch, paths: list[str]):
"""Run the real waiter on its own loop; send *paths* back-to-back once the listener is bound.
The waiter polls ``_result_taken`` every 500 ms and closes the listener as soon as the first
terminal callback lands, so on a loaded runner the stand-in's later requests raced a dead port
(``ConnectionRefusedError`` / ``ConnectionResetError``). The waiter's poll is held open until
every request has been answered; the handler's own ``_result_taken`` reads are untouched, which
is what the latch under test relies on."""
monkeypatch.setattr(mo.sys, "stdin", io.StringIO()) # paste reader sees EOF; the HTTP listener is under test
port = _free_port()
out: dict = {}
requests_sent = threading.Event()
real_taken = mo._result_taken
def run():
async def main():
with mo.force_interactive_oauth():
return await mo._make_callback_waiter(port, timeout=30)()
try:
out["result"] = asyncio.run(main())
except Exception as exc:
out["exc"] = exc
thread = threading.Thread(target=run)
def gated_taken(result):
if threading.current_thread() is thread and not requests_sent.is_set():
return False # the waiter's poll: keep the listener up until the browser stand-in is done
return real_taken(result)
monkeypatch.setattr(mo, "_result_taken", gated_taken)
thread.start()
_wait_listening(port)
try:
statuses = [_get(port, p) for p in paths]
finally:
requests_sent.set()
thread.join(timeout=15)
assert not thread.is_alive(), "waiter did not finish"
assert "exc" not in out, f"waiter raised {type(out.get('exc')).__name__}"
return statuses, out["result"]
def test_favicon_right_after_callback_does_not_clobber_the_code(monkeypatch):
statuses, result = _drive_waiter(
monkeypatch, ["/callback?code=synthetic&state=s1&iss=https://as.example", "/favicon.ico"])
assert statuses == [200, 404]
assert (result.code, result.state, result.iss) == ("synthetic", "s1", "https://as.example")
def test_first_terminal_callback_wins_over_later_ones(monkeypatch):
statuses, result = _drive_waiter(monkeypatch, [
"/favicon.ico",
"/callback?code=first&state=s1",
"/callback?code=second&state=s2",
"/callback?error=access_denied&state=s1",
])
assert statuses == [404, 200, 200, 200]
assert (result.code, result.state) == ("first", "s1")