"""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 resp.data = b"ok" resp.reason = "OK" resp.headers = {} pool.request.return_value = resp mock_pool_fn.return_value = pool result = urlopen("https://example.com/") assert result.status == 200 assert result.read() == b"ok" @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()