108 lines
3.9 KiB
Python
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")
|