from __future__ import annotations import os import http.client import unittest from unittest import mock from cmbuyer_client.core.errors import AmbiguousRemoteError, ProtocolRemoteError from cmbuyer_client.remote.http_transport import HttpTransport class FakeResponse: status = 200 def __init__(self, body: bytes = b"{}", headers: list[tuple[str, str]] | None = None) -> None: self.body = body self.headers = headers or [("Content-Type", "application/json")] def getheader(self, name: str) -> str | None: return str(len(self.body)) if name == "Content-Length" else None def getheaders(self) -> list[tuple[str, str]]: return self.headers def read(self, maximum: int) -> bytes: return self.body[:maximum] class FakeConnection: def __init__(self, host: str, port: int, timeout: int) -> None: self.created = (host, port, timeout) self.calls = 0 self.closed = False self.sent_headers: dict[str, str] = {} self.response = FakeResponse() def request(self, method: str, path: str, body: bytes, headers: dict[str, str]) -> None: self.calls += 1 self.sent_headers = headers def getresponse(self) -> FakeResponse: return self.response def close(self) -> None: self.closed = True class HttpTransportTests(unittest.TestCase): def test_exact_loopback_and_proxy_environment_is_irrelevant(self) -> None: made: list[FakeConnection] = [] def factory(*args: object, **kwargs: object) -> FakeConnection: connection = FakeConnection(*args, **kwargs) made.append(connection) return connection with mock.patch.dict(os.environ, {"HTTP_PROXY": "http://example.invalid:9999"}): result = HttpTransport(connection_factory=factory).request( "POST", "/api/v1/tasks/claim-next", (("Content-Type", "application/json"),), b"{}", response_limit=10 ) self.assertEqual(result.status, 200) self.assertEqual(made[0].created, ("127.0.0.1", 8080, 10)) self.assertEqual(made[0].calls, 1) self.assertTrue(made[0].closed) for url in ("http://localhost:8080", "http://127.0.0.1:8081", "http://127.0.0.1:8080/", "https://127.0.0.1:8080"): with self.subTest(url=url), self.assertRaises(ProtocolRemoteError): HttpTransport(url) def test_network_failure_is_ambiguous_without_retry(self) -> None: class Broken(FakeConnection): def getresponse(self) -> FakeResponse: raise OSError("offline") made: list[Broken] = [] def factory(*args: object, **kwargs: object) -> Broken: connection = Broken(*args, **kwargs) made.append(connection) return connection with self.assertRaises(AmbiguousRemoteError): HttpTransport(connection_factory=factory).request( "POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=10 ) self.assertEqual(made[0].calls, 1) def test_generator_headers_and_content_length_framing(self) -> None: made: list[FakeConnection] = [] def factory(*args: object, **kwargs: object) -> FakeConnection: connection = FakeConnection(*args, **kwargs) made.append(connection) return connection headers = ((name, value) for name, value in (("Content-Type", "application/json"), ("Accept", "application/json"))) HttpTransport(connection_factory=factory).request( "POST", "/api/v1/tasks/claim-next", headers, b"{}", response_limit=8 ) self.assertEqual(made[0].sent_headers["Content-Type"], "application/json") self.assertEqual(made[0].sent_headers["Accept"], "application/json") legal = FakeConnection("127.0.0.1", 8080, 10) legal.response = FakeResponse(b"{}", [("Content-Length", "2")]) accepted = HttpTransport(connection_factory=lambda *args, **kwargs: legal).request( "POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8 ) self.assertEqual(accepted.body, b"{}") chunked = FakeConnection("127.0.0.1", 8080, 10) chunked.response = FakeResponse(b"{}", [("Transfer-Encoding", "Chunked")]) accepted_chunked = HttpTransport(connection_factory=lambda *args, **kwargs: chunked).request( "POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8 ) self.assertEqual(accepted_chunked.body, b"{}") cases = ( ([('Transfer-Encoding', 'chunked'), ('Content-Length', '2')], b'{}'), ([('Transfer-Encoding', 'gzip')], b'{}'), ([('Transfer-Encoding', ' chunked ')], b'{}'), ([('Transfer-Encoding', 'chunked,gzip')], b'{}'), ([('Transfer-Encoding', 'chunked'), ('Transfer-Encoding', 'chunked')], b'{}'), ([("Content-Length", "2"), ("Content-Length", "2")], b"{}"), ([("Content-Length", "+2")], b"{}"), ([("Content-Length", "-0")], b""), ([("Content-Length", "2x")], b"{}"), ([("Content-Length", "3")], b"{}"), ([("Content-Length", "1")], b"{}"), ([("Content-Length", "999")], b"{}"), ([], b"0123456789"), ) for response_headers, body in cases: with self.subTest(headers=response_headers, body=body): connection = FakeConnection("127.0.0.1", 8080, 10) connection.response = FakeResponse(body, response_headers) with self.assertRaises(AmbiguousRemoteError): HttpTransport(connection_factory=lambda *args, value=connection, **kwargs: value).request( "POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8 ) def test_timeout_incomplete_read_and_close_do_not_expose_partial_body(self) -> None: token = ("a" * 64).encode() class Incomplete(FakeResponse): def read(self, maximum: int) -> bytes: raise http.client.IncompleteRead(token, 1) class Connection(FakeConnection): def getresponse(self) -> FakeResponse: return Incomplete() def close(self) -> None: self.closed = True raise OSError("close failed") with self.assertRaises(AmbiguousRemoteError) as captured: HttpTransport(connection_factory=Connection).request( "POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=128 ) self.assertNotIn(token.decode(), _exception_graph(captured.exception)) class Timeout(FakeConnection): def getresponse(self) -> FakeResponse: raise TimeoutError("timed out") with self.assertRaises(AmbiguousRemoteError): HttpTransport(connection_factory=Timeout).request( "POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8 ) def _exception_graph(error: BaseException) -> str: seen: set[int] = set() values: list[str] = [] pending: list[object] = [error] while pending: value = pending.pop() if id(value) in seen: continue seen.add(id(value)) values.append(repr(value)) if isinstance(value, BaseException): pending.extend(item for item in (value.__cause__, value.__context__) if item is not None) pending.extend(value.__dict__.values()) return "\n".join(values)