A Python port of the Invisible Internet Project (I2P)
at main 296 lines 10 kB view raw
1"""Tests for I2P tunnel infrastructure — HTTP proxy, SOCKS proxy, server tunnels.""" 2 3import struct 4import pytest 5 6from i2p_apps.i2ptunnel.http_proxy import ( 7 HTTPRequest, 8 HTTPProxy, 9 parse_http_request, 10 extract_i2p_destination, 11) 12from i2p_apps.i2ptunnel.socks_proxy import ( 13 SOCKS_VERSION, 14 AUTH_NONE, 15 CMD_CONNECT, 16 ATYP_DOMAIN, 17 REPLY_SUCCESS, 18 REPLY_GENERAL_FAILURE, 19 REPLY_HOST_UNREACHABLE, 20 SOCKSGreeting, 21 SOCKSRequest, 22 SOCKSProxy, 23 parse_socks_greeting, 24 build_socks_greeting_reply, 25 parse_socks_request, 26 build_socks_reply, 27) 28from i2p_apps.i2ptunnel.server_tunnel import ServerTunnel 29from i2p_apps.i2ptunnel.tunnel_manager import I2PTunnelManager 30 31 32# --------------------------------------------------------------------------- 33# HTTPProxy tests 34# --------------------------------------------------------------------------- 35 36class TestParseHTTPRequest: 37 """Test HTTP request first-line parsing.""" 38 39 def test_connect_request(self): 40 req = parse_http_request("CONNECT example.i2p:443 HTTP/1.1") 41 assert req.method == "CONNECT" 42 assert req.host == "example.i2p" 43 assert req.port == 443 44 assert req.is_connect is True 45 assert req.raw_first_line == "CONNECT example.i2p:443 HTTP/1.1" 46 47 def test_regular_get_request(self): 48 req = parse_http_request("GET http://forum.i2p/index.html HTTP/1.1") 49 assert req.method == "GET" 50 assert req.host == "forum.i2p" 51 assert req.port == 80 52 assert req.path == "/index.html" 53 assert req.is_connect is False 54 55 def test_regular_get_with_port(self): 56 req = parse_http_request("GET http://forum.i2p:8080/api HTTP/1.1") 57 assert req.host == "forum.i2p" 58 assert req.port == 8080 59 assert req.path == "/api" 60 61 def test_connect_without_port(self): 62 req = parse_http_request("CONNECT example.i2p HTTP/1.1") 63 assert req.host == "example.i2p" 64 assert req.port == 443 # default for CONNECT 65 66 def test_post_request(self): 67 req = parse_http_request("POST http://mail.i2p/send HTTP/1.1") 68 assert req.method == "POST" 69 assert req.host == "mail.i2p" 70 assert req.path == "/send" 71 assert req.is_connect is False 72 73 74class TestExtractI2PDestination: 75 """Test .i2p destination extraction.""" 76 77 def test_plain_i2p_host(self): 78 assert extract_i2p_destination("forum.i2p") == "forum.i2p" 79 80 def test_subdomain_i2p(self): 81 assert extract_i2p_destination("www.forum.i2p") == "www.forum.i2p" 82 83 def test_non_i2p_host(self): 84 assert extract_i2p_destination("example.com") is None 85 86 def test_empty_host(self): 87 assert extract_i2p_destination("") is None 88 89 def test_i2p_in_middle(self): 90 # "i2p.example.com" is NOT an i2p destination 91 assert extract_i2p_destination("i2p.example.com") is None 92 93 94class TestHTTPProxy: 95 """Test HTTPProxy instance behavior.""" 96 97 def test_default_listen_address(self): 98 proxy = HTTPProxy() 99 assert proxy.listen_address == ("127.0.0.1", 4444) 100 101 def test_custom_listen_address(self): 102 proxy = HTTPProxy(listen_host="0.0.0.0", listen_port=8888) 103 assert proxy.listen_address == ("0.0.0.0", 8888) 104 105 def test_not_running_initially(self): 106 proxy = HTTPProxy() 107 assert proxy.is_running is False 108 109 def test_build_502_response(self): 110 proxy = HTTPProxy() 111 resp = proxy.build_error_response(502, "Bad Gateway") 112 assert b"502" in resp 113 assert b"Bad Gateway" in resp 114 assert resp.startswith(b"HTTP/1.1 502") 115 116 117# --------------------------------------------------------------------------- 118# SOCKSProxy tests 119# --------------------------------------------------------------------------- 120 121class TestParseSOCKSGreeting: 122 """Test SOCKS5 greeting parsing.""" 123 124 def test_no_auth_greeting(self): 125 # version=5, nmethods=1, methods=[0 (no auth)] 126 data = bytes([0x05, 0x01, 0x00]) 127 greeting = parse_socks_greeting(data) 128 assert greeting.version == 5 129 assert greeting.methods == [AUTH_NONE] 130 131 def test_multi_method_greeting(self): 132 # version=5, nmethods=2, methods=[0, 2] 133 data = bytes([0x05, 0x02, 0x00, 0x02]) 134 greeting = parse_socks_greeting(data) 135 assert greeting.version == 5 136 assert greeting.methods == [0, 2] 137 138 def test_invalid_version_raises(self): 139 data = bytes([0x04, 0x01, 0x00]) # SOCKS4 140 with pytest.raises(ValueError, match="[Vv]ersion"): 141 parse_socks_greeting(data) 142 143 144class TestBuildSOCKSGreetingReply: 145 """Test SOCKS5 greeting reply construction.""" 146 147 def test_no_auth_reply(self): 148 reply = build_socks_greeting_reply(AUTH_NONE) 149 assert reply == bytes([0x05, 0x00]) 150 151 def test_custom_method_reply(self): 152 reply = build_socks_greeting_reply(0x02) 153 assert reply == bytes([0x05, 0x02]) 154 155 156class TestParseSOCKSRequest: 157 """Test SOCKS5 CONNECT request parsing.""" 158 159 def test_domain_connect(self): 160 # VER=5, CMD=1 (connect), RSV=0, ATYP=3 (domain) 161 domain = b"forum.i2p" 162 data = bytes([0x05, 0x01, 0x00, 0x03, len(domain)]) + domain + struct.pack("!H", 80) 163 req = parse_socks_request(data) 164 assert req.version == 5 165 assert req.command == CMD_CONNECT 166 assert req.address_type == ATYP_DOMAIN 167 assert req.dest_addr == "forum.i2p" 168 assert req.dest_port == 80 169 170 def test_domain_with_high_port(self): 171 domain = b"mail.i2p" 172 data = bytes([0x05, 0x01, 0x00, 0x03, len(domain)]) + domain + struct.pack("!H", 8443) 173 req = parse_socks_request(data) 174 assert req.dest_addr == "mail.i2p" 175 assert req.dest_port == 8443 176 177 def test_invalid_version_raises(self): 178 data = bytes([0x04, 0x01, 0x00, 0x03, 0x05]) + b"a.i2p" + struct.pack("!H", 80) 179 with pytest.raises(ValueError, match="[Vv]ersion"): 180 parse_socks_request(data) 181 182 183class TestBuildSOCKSReply: 184 """Test SOCKS5 reply construction.""" 185 186 def test_success_reply(self): 187 reply = build_socks_reply(REPLY_SUCCESS) 188 assert reply[0] == 0x05 # version 189 assert reply[1] == REPLY_SUCCESS 190 assert reply[2] == 0x00 # reserved 191 assert len(reply) == 10 # VER + REP + RSV + ATYP(1) + ADDR(4) + PORT(2) 192 193 def test_failure_reply(self): 194 reply = build_socks_reply(REPLY_GENERAL_FAILURE) 195 assert reply[1] == REPLY_GENERAL_FAILURE 196 197 def test_host_unreachable_reply(self): 198 reply = build_socks_reply(REPLY_HOST_UNREACHABLE) 199 assert reply[1] == REPLY_HOST_UNREACHABLE 200 201 def test_custom_bind(self): 202 reply = build_socks_reply(REPLY_SUCCESS, bind_addr="127.0.0.1", bind_port=1080) 203 port_bytes = reply[-2:] 204 port = struct.unpack("!H", port_bytes)[0] 205 assert port == 1080 206 207 208class TestSOCKSProxy: 209 """Test SOCKSProxy instance.""" 210 211 def test_default_listen_address(self): 212 proxy = SOCKSProxy() 213 assert proxy.listen_address == ("127.0.0.1", 4445) 214 215 def test_not_running_initially(self): 216 proxy = SOCKSProxy() 217 assert proxy.is_running is False 218 219 220# --------------------------------------------------------------------------- 221# ServerTunnel tests 222# --------------------------------------------------------------------------- 223 224class TestServerTunnel: 225 """Test server tunnel dataclass.""" 226 227 def test_create_with_local_address(self): 228 tunnel = ServerTunnel(name="web", local_host="127.0.0.1", local_port=8080) 229 assert tunnel.name == "web" 230 assert tunnel.local_host == "127.0.0.1" 231 assert tunnel.local_port == 8080 232 assert tunnel.i2p_port == 0 233 assert tunnel.running is False 234 235 def test_create_with_custom_i2p_port(self): 236 tunnel = ServerTunnel(name="irc", local_host="127.0.0.1", local_port=6667, i2p_port=6668) 237 assert tunnel.i2p_port == 6668 238 239 def test_forward_data_mock(self): 240 """ServerTunnel should track forwarded byte counts.""" 241 tunnel = ServerTunnel(name="web", local_host="127.0.0.1", local_port=8080) 242 assert tunnel.bytes_forwarded == 0 243 tunnel.record_forwarded(1024) 244 assert tunnel.bytes_forwarded == 1024 245 tunnel.record_forwarded(512) 246 assert tunnel.bytes_forwarded == 1536 247 248 249# --------------------------------------------------------------------------- 250# I2PTunnelManager tests 251# --------------------------------------------------------------------------- 252 253class TestI2PTunnelManager: 254 """Test tunnel manager add/remove/list.""" 255 256 def test_add_and_list(self): 257 mgr = I2PTunnelManager() 258 proxy = HTTPProxy() 259 mgr.add_tunnel("http", proxy) 260 assert mgr.list_tunnels() == ["http"] 261 assert mgr.tunnel_count == 1 262 263 def test_remove_tunnel(self): 264 mgr = I2PTunnelManager() 265 proxy = HTTPProxy() 266 mgr.add_tunnel("http", proxy) 267 assert mgr.remove_tunnel("http") is True 268 assert mgr.tunnel_count == 0 269 270 def test_remove_nonexistent(self): 271 mgr = I2PTunnelManager() 272 assert mgr.remove_tunnel("nope") is False 273 274 def test_get_tunnel_by_name(self): 275 mgr = I2PTunnelManager() 276 proxy = SOCKSProxy() 277 mgr.add_tunnel("socks", proxy) 278 assert mgr.get_tunnel("socks") is proxy 279 280 def test_get_tunnel_missing(self): 281 mgr = I2PTunnelManager() 282 assert mgr.get_tunnel("missing") is None 283 284 def test_add_duplicate_raises(self): 285 mgr = I2PTunnelManager() 286 mgr.add_tunnel("http", HTTPProxy()) 287 with pytest.raises(ValueError, match="already exists"): 288 mgr.add_tunnel("http", HTTPProxy()) 289 290 def test_mixed_tunnel_types(self): 291 mgr = I2PTunnelManager() 292 mgr.add_tunnel("http", HTTPProxy()) 293 mgr.add_tunnel("socks", SOCKSProxy()) 294 mgr.add_tunnel("server", ServerTunnel(name="web", local_host="127.0.0.1", local_port=80)) 295 assert mgr.tunnel_count == 3 296 assert sorted(mgr.list_tunnels()) == ["http", "server", "socks"]