mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 22:07:15 +01:00
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
213 lines
7.1 KiB
Python
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)
|