mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 21:07:15 +01:00
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
web_ui tests / test (push) Waiting to run
Second module of the tool/ coverage task. No network and no browser: the callback server is replaced wholesale so consent resolves instantly, and requests.post is mocked at the same boundary test_firebase_auth.py uses. All six TokenError paths are covered, since each names a different misconfiguration and the module docstring exists because they are easy to mistake for one another -- notably the fixed-vs-free port distinction, where a Web client matching redirect URIs exactly fails with redirect_uri_mismatch before the account picker ever appears. Also asserts the server is closed on the failure path, since a leaked listener makes the next run fail to bind. Split across two files because the cap hook rejected the first attempt at 273 lines: the flow tests here, main()'s argument parsing and exit code in _cli.py, which imports the shared fixture values rather than redeclaring them. That is the gate doing its job on this session's own output. Three things the tooling caught, all fixed rather than suppressed: - S105/S106 match on the binding *name*, not the value, so any constant called SECRET/TOKEN/PASSPHRASE trips them however obviously fake it is. Renamed around the rule the way test_firebase_auth.py names _ID_1. - mypy rejected the previous commit's log_message test for asserting on the return of a `-> None` function -- a tautology. Rewritten to make a real request and assert stderr stays empty, which is the only thing that distinguishes the silenced handler from the stock one. - ruff pruned the imports that moved out during the split; verified it took only those, and that the suite is still green afterwards. crdt_sync 268 -> 286 passed, still 100%. Both modules verified at 100% branch individually; the config flip stays last. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
212 lines
7.0 KiB
Python
212 lines
7.0 KiB
Python
"""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)
|