utils/crdt-sync/crdt_sync/tests/test_tool_oauth_callback.py
Krzysztof kuhy Rudnicki 6154dc09bf Pin pre-commit's ruff to 0.16.7 and satisfy the CPY001 it brings
The three Python packages pinned ruff twice at different versions:
requirements.txt at 0.16.7, .pre-commit-config.yaml at v0.15.2. Only the
older one ever ran, so `select = ["ALL"]` was enforced two minor versions
behind. Measured before aligning: v0.15.2 reports 0 findings, every 0.16.x
reports CPY001 on each file -- 70 crdt-sync, 23 freedays, 71 gatelock, and
no other rule.

Resolved by adding the notice rather than ignoring the rule, so the gate
keeps its teeth.

Two gatelock modules sat at exactly the 250-line cap, so a one-line header
tipped them to 251. Both are split along a real seam rather than shaved:

  _widget_fills.py    colour arithmetic over LockConfig, no tkinter import
  _surfaces_focus.py  selects among existing surfaces, never mutates them

The focus split keeps _surfaces.py's documented containment (it stays the
only module that creates, moves or destroys windows), and the static guard
in test_recovery_invariants.py globs _surfaces*.py, so the new sibling is
covered with no list to extend. widgets.py re-exports the fills helpers,
which keeps gatelock.widgets the single public surface its tests import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kMvSMtsVMB3d2uoZ56pbp
2026-09-12 18:11:24 +02:00

213 lines
7.1 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki
"""Tests for the one-shot OAuth redirect catcher in ``tool/_oauth_callback``.
No network access: everything binds 127.0.0.1 on a port the module itself
picks. The handler is driven over real HTTP rather than with a faked request
object, because the branches worth protecting here are the ones that only
appear once a socket is involved -- the favicon fetch that must *not* end the
flow, and the serve loop's exit conditions, which run on a worker thread.
"""
from __future__ import annotations
import http.client
import http.server
import time
from typing import TYPE_CHECKING
import pytest
from tool import _oauth_callback as oc
if TYPE_CHECKING:
from collections.abc import Iterator
import threading
_STATE = "state-the-callback-must-echo"
def _get(port: int, path: str) -> tuple[int, bytes]:
"""Issue one GET against the loopback server and read the whole reply."""
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
try:
conn.request("GET", path)
response = conn.getresponse()
return response.status, response.read()
finally:
conn.close()
def _retire(srv: http.server.HTTPServer, thread: threading.Thread) -> None:
"""Stop the serve thread, then close the socket -- in that order.
``server_close()`` while the thread is still inside ``handle_request()``
pulls the socket out from under it and raises on the worker. Setting a
result is what the loop's own exit condition tests, so it retires the
thread the same way a real callback does.
"""
oc._CallbackHandler.result = oc._CallbackHandler.result or {"code": "teardown"}
thread.join(timeout=5)
srv.server_close()
@pytest.fixture
def server() -> Iterator[tuple[int, threading.Thread]]:
"""Start the callback server on a free port and always tear it down."""
port = oc._free_port()
srv, thread = oc._start_callback_server(port, _STATE)
try:
yield port, thread
finally:
_retire(srv, thread)
def test_free_port_returns_a_bindable_loopback_port() -> None:
"""The port is real and free: binding it again must succeed."""
port = oc._free_port()
assert 1024 < port <= 65535
# The socket is closed by the time _free_port returns, so a second server
# can take the port. If it could not, the callback server would race the
# probe socket and fail to bind.
srv, thread = oc._start_callback_server(port, _STATE)
_retire(srv, thread)
def test_callback_with_matching_state_is_recorded_and_acked(
server: tuple[int, threading.Thread],
) -> None:
"""The happy path: code + matching state -> 200, and the loop exits."""
port, thread = server
status, body = _get(port, f"/?code=the-code&state={_STATE}")
assert status == 200
assert b"you can close this tab" in body
assert oc._CallbackHandler.result == {"code": "the-code", "state": _STATE}
# The serve loop's `while not result` condition must now be false, so the
# thread retires on its own rather than sitting on the socket.
thread.join(timeout=5)
assert not thread.is_alive()
def test_mismatched_state_is_recorded_but_reported_as_failure(
server: tuple[int, threading.Thread],
) -> None:
"""A code echoing the wrong state is a failure, not a success."""
port, thread = server
status, body = _get(port, "/?code=the-code&state=not-the-state")
assert status == 400
assert b"Authorization failed" in body
# Still recorded: the caller needs to see what came back to explain it.
assert oc._CallbackHandler.result["state"] == "not-the-state"
thread.join(timeout=5)
def test_error_callback_without_a_code_is_reported_as_failure(
server: tuple[int, threading.Thread],
) -> None:
"""Google reports a declined consent as ``error``, with no ``code``."""
port, thread = server
status, body = _get(port, "/?error=access_denied")
assert status == 400
assert b"Authorization failed" in body
assert oc._CallbackHandler.result == {"error": "access_denied"}
thread.join(timeout=5)
def test_favicon_fetch_is_answered_but_does_not_end_the_flow(
server: tuple[int, threading.Thread],
) -> None:
"""The regression this module's docstring is about.
Browsers fetch /favicon.ico against the redirect origin. Treating that as
the callback retires the server before Google ever redirects, which the
user sees as a consent timeout.
"""
port, thread = server
status, body = _get(port, "/favicon.ico")
assert status == 204
assert body == b""
assert oc._CallbackHandler.result == {}
# Still serving: the real callback can still arrive and be recorded.
assert thread.is_alive()
status, _ = _get(port, f"/?code=late-code&state={_STATE}")
assert status == 200
assert oc._CallbackHandler.result["code"] == "late-code"
thread.join(timeout=5)
def test_serve_loop_gives_up_once_the_consent_deadline_passes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The other loop exit: no callback ever arrives.
The deadline is 300s of wall clock, so it is moved into the past instead
of waited out. `server.timeout` bounds handle_request(), which is what
lets the loop re-test the deadline at all.
"""
monkeypatch.setattr(oc, "_CONSENT_TIMEOUT_SECONDS", -1)
port = oc._free_port()
srv, thread = oc._start_callback_server(port, _STATE)
try:
thread.join(timeout=5)
assert not thread.is_alive()
assert oc._CallbackHandler.result == {}
finally:
_retire(srv, thread)
def test_log_message_writes_nothing_to_stderr(
server: tuple[int, threading.Thread],
capfd: pytest.CaptureFixture[str],
) -> None:
"""The default handler logs every request to stderr; this one must not.
Asserted through a real request rather than by calling the override
directly: it returns None either way, so only the absence of output
distinguishes a silenced handler from the stock one.
"""
port, _ = server
_get(port, f"/?code=the-code&state={_STATE}")
assert capfd.readouterr().err == ""
def test_starting_the_server_resets_state_from_a_previous_run() -> None:
"""Class attributes outlive one flow, so a stale result must not leak."""
oc._CallbackHandler.result = {"code": "stale-from-an-earlier-flow"}
oc._CallbackHandler.expected_state = "stale-state"
port = oc._free_port()
srv, thread = oc._start_callback_server(port, _STATE)
try:
assert oc._CallbackHandler.result == {}
assert oc._CallbackHandler.expected_state == _STATE
finally:
_retire(srv, thread)
def test_time_is_not_slept_away_by_the_fixture() -> None:
"""Guard against the deadline being computed once at import time.
If ``deadline`` were module-level rather than per-call, a server started
later in the session would inherit an already-expired deadline and never
serve. Starting one now and checking it is alive catches that.
"""
port = oc._free_port()
srv, thread = oc._start_callback_server(port, _STATE)
try:
time.sleep(0.05)
assert thread.is_alive()
finally:
_retire(srv, thread)