Add `proxy` config option to server (IRC), teams, telegram, and mumble sections. IRC defaults to false (preserving current direct-connect behavior); all others default to true. The `derp.http` module now accepts `proxy=True/False` on urlopen, create_connection, open_connection, and build_opener -- when false, uses stdlib directly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
374 lines
12 KiB
Python
374 lines
12 KiB
Python
"""Tests for the HTTP/TCP module with optional SOCKS5 proxy."""
|
|
|
|
import socket
|
|
import ssl
|
|
import urllib.error
|
|
import urllib.request
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from socks import SOCKS5
|
|
|
|
import derp.http
|
|
from derp.http import (
|
|
_PROXY_ADDR,
|
|
_PROXY_PORT,
|
|
_get_opener,
|
|
_get_pool,
|
|
_ProxyHandler,
|
|
build_opener,
|
|
create_connection,
|
|
urlopen,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_caches():
|
|
"""Clear cached opener and pool between tests."""
|
|
derp.http._default_opener = None
|
|
derp.http._pool = None
|
|
yield
|
|
derp.http._default_opener = None
|
|
derp.http._pool = None
|
|
|
|
|
|
# -- Connection pool ---------------------------------------------------------
|
|
|
|
class TestConnectionPool:
|
|
def test_pool_lazy_init(self):
|
|
assert derp.http._pool is None
|
|
pool = _get_pool()
|
|
assert pool is not None
|
|
assert derp.http._pool is pool
|
|
|
|
def test_pool_cached(self):
|
|
a = _get_pool()
|
|
b = _get_pool()
|
|
assert a is b
|
|
|
|
def test_pool_is_socks_manager(self):
|
|
from urllib3.contrib.socks import SOCKSProxyManager
|
|
pool = _get_pool()
|
|
assert isinstance(pool, SOCKSProxyManager)
|
|
|
|
|
|
# -- Legacy opener -----------------------------------------------------------
|
|
|
|
class TestProxyHandler:
|
|
def test_uses_socks5(self):
|
|
handler = _ProxyHandler()
|
|
assert handler.args[0] == SOCKS5
|
|
|
|
def test_proxy_address(self):
|
|
handler = _ProxyHandler()
|
|
assert handler.args[1] == _PROXY_ADDR
|
|
assert handler.args[2] == _PROXY_PORT
|
|
|
|
def test_rdns_enabled(self):
|
|
handler = _ProxyHandler()
|
|
assert handler.args[3] is True
|
|
|
|
def test_default_ssl_context(self):
|
|
handler = _ProxyHandler()
|
|
assert isinstance(handler._ssl_context, ssl.SSLContext)
|
|
|
|
def test_custom_ssl_context(self):
|
|
ctx = ssl.create_default_context()
|
|
ctx.check_hostname = False
|
|
handler = _ProxyHandler(context=ctx)
|
|
assert handler._ssl_context is ctx
|
|
|
|
def test_is_https_handler(self):
|
|
handler = _ProxyHandler()
|
|
assert isinstance(handler, urllib.request.HTTPSHandler)
|
|
|
|
|
|
class TestBuildOpener:
|
|
def test_includes_proxy_handler(self):
|
|
opener = build_opener()
|
|
proxy = [h for h in opener.handlers if isinstance(h, _ProxyHandler)]
|
|
assert len(proxy) == 1
|
|
|
|
def test_passes_extra_handlers(self):
|
|
class Custom(urllib.request.HTTPRedirectHandler):
|
|
pass
|
|
|
|
opener = build_opener(Custom)
|
|
custom = [h for h in opener.handlers if isinstance(h, Custom)]
|
|
assert len(custom) == 1
|
|
|
|
def test_passes_ssl_context(self):
|
|
ctx = ssl.create_default_context()
|
|
ctx.check_hostname = False
|
|
opener = build_opener(context=ctx)
|
|
proxy = [h for h in opener.handlers if isinstance(h, _ProxyHandler)][0]
|
|
assert proxy._ssl_context is ctx
|
|
|
|
|
|
class TestOpenerCache:
|
|
def test_default_opener_cached(self):
|
|
a = _get_opener()
|
|
b = _get_opener()
|
|
assert a is b
|
|
|
|
def test_custom_context_not_cached(self):
|
|
ctx = ssl.create_default_context()
|
|
a = _get_opener(context=ctx)
|
|
b = _get_opener(context=ctx)
|
|
assert a is not b
|
|
|
|
def test_build_opener_no_args_returns_cached(self):
|
|
a = build_opener()
|
|
b = build_opener()
|
|
assert a is b
|
|
|
|
def test_build_opener_with_handlers_returns_fresh(self):
|
|
class Custom(urllib.request.HTTPRedirectHandler):
|
|
pass
|
|
|
|
a = build_opener()
|
|
b = build_opener(Custom)
|
|
assert a is not b
|
|
|
|
|
|
# -- urlopen (pooled path) --------------------------------------------------
|
|
|
|
class TestUrlopen:
|
|
@patch.object(derp.http, "_get_pool")
|
|
def test_extracts_request_fields(self, mock_pool_fn):
|
|
pool = MagicMock()
|
|
resp = MagicMock()
|
|
resp.status = 200
|
|
pool.request.return_value = resp
|
|
mock_pool_fn.return_value = pool
|
|
|
|
req = urllib.request.Request(
|
|
"https://example.com/test",
|
|
headers={"X-Custom": "val"},
|
|
method="POST",
|
|
)
|
|
req.data = b"body"
|
|
urlopen(req, timeout=10)
|
|
|
|
pool.request.assert_called_once()
|
|
call_kw = pool.request.call_args
|
|
assert call_kw[0][0] == "POST"
|
|
assert call_kw[0][1] == "https://example.com/test"
|
|
assert call_kw[1]["body"] == b"body"
|
|
|
|
@patch.object(derp.http, "_get_pool")
|
|
def test_string_url(self, mock_pool_fn):
|
|
pool = MagicMock()
|
|
resp = MagicMock()
|
|
resp.status = 200
|
|
pool.request.return_value = resp
|
|
mock_pool_fn.return_value = pool
|
|
|
|
urlopen("https://example.com/")
|
|
call_args = pool.request.call_args
|
|
assert call_args[0] == ("GET", "https://example.com/")
|
|
|
|
@patch.object(derp.http, "_get_pool")
|
|
def test_raises_http_error_on_4xx(self, mock_pool_fn):
|
|
pool = MagicMock()
|
|
resp = MagicMock()
|
|
resp.status = 404
|
|
resp.reason = "Not Found"
|
|
resp.headers = {}
|
|
resp.read.return_value = b""
|
|
pool.request.return_value = resp
|
|
mock_pool_fn.return_value = pool
|
|
|
|
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
|
urlopen("https://example.com/missing")
|
|
assert exc_info.value.code == 404
|
|
|
|
@patch.object(derp.http, "_get_pool")
|
|
def test_raises_http_error_on_5xx(self, mock_pool_fn):
|
|
pool = MagicMock()
|
|
resp = MagicMock()
|
|
resp.status = 500
|
|
resp.reason = "Internal Server Error"
|
|
resp.headers = {}
|
|
resp.read.return_value = b""
|
|
pool.request.return_value = resp
|
|
mock_pool_fn.return_value = pool
|
|
|
|
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
|
urlopen("https://example.com/error")
|
|
assert exc_info.value.code == 500
|
|
|
|
@patch.object(derp.http, "_get_pool")
|
|
def test_returns_response_on_2xx(self, mock_pool_fn):
|
|
pool = MagicMock()
|
|
resp = MagicMock()
|
|
resp.status = 200
|
|
pool.request.return_value = resp
|
|
mock_pool_fn.return_value = pool
|
|
|
|
result = urlopen("https://example.com/")
|
|
assert result is resp
|
|
|
|
@patch.object(derp.http, "_get_pool")
|
|
def test_context_falls_back_to_opener(self, mock_pool_fn):
|
|
"""Custom SSL context should use legacy opener, not pool."""
|
|
ctx = ssl.create_default_context()
|
|
ctx.check_hostname = False
|
|
ctx.verify_mode = ssl.CERT_NONE
|
|
|
|
with patch.object(derp.http, "_get_opener") as mock_opener_fn:
|
|
opener = MagicMock()
|
|
resp = MagicMock()
|
|
opener.open.return_value = resp
|
|
mock_opener_fn.return_value = opener
|
|
|
|
result = urlopen("https://example.com/", context=ctx)
|
|
|
|
mock_pool_fn.assert_not_called()
|
|
mock_opener_fn.assert_called_once_with(ctx)
|
|
assert result is resp
|
|
|
|
|
|
# -- create_connection -------------------------------------------------------
|
|
|
|
class TestCreateConnection:
|
|
@patch("derp.http.socks.socksocket")
|
|
def test_sets_socks5_proxy(self, mock_cls):
|
|
sock = MagicMock()
|
|
mock_cls.return_value = sock
|
|
create_connection(("example.com", 443), timeout=5)
|
|
sock.set_proxy.assert_called_once_with(
|
|
SOCKS5, _PROXY_ADDR, _PROXY_PORT, rdns=True,
|
|
)
|
|
|
|
@patch("derp.http.socks.socksocket")
|
|
def test_connects_to_target(self, mock_cls):
|
|
sock = MagicMock()
|
|
mock_cls.return_value = sock
|
|
create_connection(("example.com", 443))
|
|
sock.connect.assert_called_once_with(("example.com", 443))
|
|
|
|
@patch("derp.http.socks.socksocket")
|
|
def test_sets_timeout(self, mock_cls):
|
|
sock = MagicMock()
|
|
mock_cls.return_value = sock
|
|
create_connection(("example.com", 80), timeout=7)
|
|
sock.settimeout.assert_called_once_with(7)
|
|
|
|
@patch("derp.http.socks.socksocket")
|
|
def test_no_timeout_when_none(self, mock_cls):
|
|
sock = MagicMock()
|
|
mock_cls.return_value = sock
|
|
create_connection(("example.com", 80))
|
|
sock.settimeout.assert_not_called()
|
|
|
|
@patch("derp.http.socks.socksocket")
|
|
def test_returns_socket(self, mock_cls):
|
|
sock = MagicMock()
|
|
mock_cls.return_value = sock
|
|
result = create_connection(("example.com", 443))
|
|
assert result is sock
|
|
|
|
|
|
# -- proxy=False paths -------------------------------------------------------
|
|
|
|
class TestUrlopenDirect:
|
|
"""Tests for urlopen(proxy=False) -- stdlib direct path."""
|
|
|
|
@patch("derp.http.urllib.request.urlopen")
|
|
def test_uses_stdlib_urlopen(self, mock_urlopen):
|
|
resp = MagicMock()
|
|
mock_urlopen.return_value = resp
|
|
result = urlopen("https://example.com/", proxy=False)
|
|
mock_urlopen.assert_called_once()
|
|
assert result is resp
|
|
|
|
@patch("derp.http.urllib.request.urlopen")
|
|
def test_passes_timeout(self, mock_urlopen):
|
|
resp = MagicMock()
|
|
mock_urlopen.return_value = resp
|
|
urlopen("https://example.com/", timeout=15, proxy=False)
|
|
_, kwargs = mock_urlopen.call_args
|
|
assert kwargs["timeout"] == 15
|
|
|
|
@patch("derp.http.urllib.request.urlopen")
|
|
def test_passes_context(self, mock_urlopen):
|
|
resp = MagicMock()
|
|
mock_urlopen.return_value = resp
|
|
ctx = ssl.create_default_context()
|
|
urlopen("https://example.com/", context=ctx, proxy=False)
|
|
_, kwargs = mock_urlopen.call_args
|
|
assert kwargs["context"] is ctx
|
|
|
|
@patch.object(derp.http, "_get_pool")
|
|
@patch("derp.http.urllib.request.urlopen")
|
|
def test_skips_socks_pool(self, mock_urlopen, mock_pool_fn):
|
|
resp = MagicMock()
|
|
mock_urlopen.return_value = resp
|
|
urlopen("https://example.com/", proxy=False)
|
|
mock_pool_fn.assert_not_called()
|
|
|
|
|
|
class TestBuildOpenerDirect:
|
|
"""Tests for build_opener(proxy=False) -- no SOCKS5 handler."""
|
|
|
|
def test_no_proxy_handler(self):
|
|
opener = build_opener(proxy=False)
|
|
proxy_handlers = [h for h in opener.handlers
|
|
if isinstance(h, _ProxyHandler)]
|
|
assert len(proxy_handlers) == 0
|
|
|
|
def test_with_extra_handler(self):
|
|
class Custom(urllib.request.HTTPRedirectHandler):
|
|
pass
|
|
|
|
opener = build_opener(Custom, proxy=False)
|
|
custom = [h for h in opener.handlers if isinstance(h, Custom)]
|
|
assert len(custom) == 1
|
|
proxy_handlers = [h for h in opener.handlers
|
|
if isinstance(h, _ProxyHandler)]
|
|
assert len(proxy_handlers) == 0
|
|
|
|
|
|
class TestCreateConnectionDirect:
|
|
"""Tests for create_connection(proxy=False) -- stdlib socket."""
|
|
|
|
@patch("derp.http.socket.socket")
|
|
def test_uses_stdlib_socket(self, mock_sock_cls):
|
|
sock = MagicMock()
|
|
mock_sock_cls.return_value = sock
|
|
result = create_connection(("example.com", 443), proxy=False)
|
|
mock_sock_cls.assert_called_once_with(
|
|
socket.AF_INET, socket.SOCK_STREAM,
|
|
)
|
|
assert result is sock
|
|
|
|
@patch("derp.http.socket.socket")
|
|
def test_connects_to_target(self, mock_sock_cls):
|
|
sock = MagicMock()
|
|
mock_sock_cls.return_value = sock
|
|
create_connection(("example.com", 443), proxy=False)
|
|
sock.connect.assert_called_once_with(("example.com", 443))
|
|
|
|
@patch("derp.http.socket.socket")
|
|
def test_sets_timeout(self, mock_sock_cls):
|
|
sock = MagicMock()
|
|
mock_sock_cls.return_value = sock
|
|
create_connection(("example.com", 443), timeout=10, proxy=False)
|
|
sock.settimeout.assert_called_once_with(10)
|
|
|
|
@patch("derp.http.socket.socket")
|
|
def test_no_socks_proxy_set(self, mock_sock_cls):
|
|
sock = MagicMock()
|
|
mock_sock_cls.return_value = sock
|
|
create_connection(("example.com", 443), proxy=False)
|
|
sock.set_proxy.assert_not_called()
|
|
|
|
@patch("derp.http.socks.socksocket")
|
|
@patch("derp.http.socket.socket")
|
|
def test_no_socksocket_created(self, mock_sock_cls, mock_socks_cls):
|
|
sock = MagicMock()
|
|
mock_sock_cls.return_value = sock
|
|
create_connection(("example.com", 443), proxy=False)
|
|
mock_socks_cls.assert_not_called()
|