"""Tests for CONNECT-only proxy tunnel.""" import asyncio from unittest.mock import AsyncMock, MagicMock import pytest from i2p_apps.i2ptunnel.connect_client import ConnectClientTask from i2p_apps.i2ptunnel.http_proxy import parse_http_request, extract_i2p_destination class TestParseHTTPRequest: """Test the HTTP request parser used by connect_client.""" def test_connect_with_port(self): req = parse_http_request("CONNECT example.i2p:443 HTTP/1.1") assert req.is_connect is True assert req.host == "example.i2p" assert req.port == 443 def test_connect_without_port(self): req = parse_http_request("CONNECT example.i2p HTTP/1.1") assert req.is_connect is True assert req.host == "example.i2p" assert req.port == 443 # default def test_get_absolute_url(self): req = parse_http_request("GET http://example.i2p/path HTTP/1.1") assert req.is_connect is False assert req.host == "example.i2p" assert req.port == 80 assert req.path == "/path" def test_get_absolute_with_port(self): req = parse_http_request("GET http://example.i2p:8080/test HTTP/1.1") assert req.host == "example.i2p" assert req.port == 8080 assert req.path == "/test" def test_malformed_request(self): with pytest.raises(ValueError, match="Malformed"): parse_http_request("INVALID") def test_method_case_insensitive(self): req = parse_http_request("connect example.i2p:443 HTTP/1.1") assert req.is_connect is True class TestExtractI2PDestination: def test_i2p_host(self): assert extract_i2p_destination("example.i2p") == "example.i2p" def test_b32_host(self): assert extract_i2p_destination("abcd.b32.i2p") == "abcd.b32.i2p" def test_non_i2p(self): assert extract_i2p_destination("example.com") is None def test_empty(self): assert extract_i2p_destination("") is None class TestConnectClientProperties: def test_is_connect_only(self): config = MagicMock() config.proxy_list = [] session = MagicMock() task = ConnectClientTask(config, session) assert task._is_connect_only is True def test_strip_all_headers(self): headers = {"Host": "example.i2p", "User-Agent": "curl/7"} assert ConnectClientTask._strip_all_headers(headers) == {} class TestConsumeHeaders: @pytest.mark.asyncio async def test_reads_until_empty_line(self): config = MagicMock() config.proxy_list = [] session = MagicMock() task = ConnectClientTask(config, session) reader = AsyncMock() reader.readline = AsyncMock(side_effect=[ b"Host: example.i2p\r\n", b"User-Agent: test\r\n", b"\r\n", ]) headers = await task._consume_headers(reader) assert "Host" in headers assert "User-Agent" in headers @pytest.mark.asyncio async def test_empty_headers(self): config = MagicMock() config.proxy_list = [] session = MagicMock() task = ConnectClientTask(config, session) reader = AsyncMock() reader.readline = AsyncMock(side_effect=[b"\r\n"]) headers = await task._consume_headers(reader) assert headers == {} class TestResolve: @pytest.mark.asyncio async def test_b32_passthrough(self): config = MagicMock() config.proxy_list = [] session = MagicMock() task = ConnectClientTask(config, session) result = await task._resolve("abcdef.b32.i2p") assert result == "abcdef.b32.i2p" @pytest.mark.asyncio async def test_i2p_lookup(self): config = MagicMock() config.proxy_list = [] session = AsyncMock() session.lookup = AsyncMock(return_value="resolved-dest") task = ConnectClientTask(config, session) result = await task._resolve("example.i2p") assert result == "resolved-dest" session.lookup.assert_called_once_with("example.i2p") @pytest.mark.asyncio async def test_non_i2p_passthrough(self): config = MagicMock() config.proxy_list = [] session = MagicMock() task = ConnectClientTask(config, session) result = await task._resolve("example.com") assert result == "example.com" def _make_task(): config = MagicMock() config.proxy_list = [] session = AsyncMock() return ConnectClientTask(config, session) def _mock_writer(): writer = MagicMock() writer.write = MagicMock() writer.drain = AsyncMock() writer.close = MagicMock() return writer class TestHandleClient: @pytest.mark.asyncio async def test_empty_read(self): task = _make_task() reader = AsyncMock() reader.readline = AsyncMock(return_value=b"") writer = _mock_writer() await task.handle_client(reader, writer) @pytest.mark.asyncio async def test_bad_request_line(self): task = _make_task() reader = AsyncMock() reader.readline = AsyncMock(side_effect=[ b"INVALID\r\n", ]) writer = _mock_writer() await task.handle_client(reader, writer) writer.write.assert_called_with(b"HTTP/1.1 400 Bad Request\r\n\r\n") @pytest.mark.asyncio async def test_non_connect_method_rejected(self): task = _make_task() reader = AsyncMock() reader.readline = AsyncMock(side_effect=[ b"GET http://example.i2p/ HTTP/1.1\r\n", b"Host: example.i2p\r\n", b"\r\n", ]) writer = _mock_writer() await task.handle_client(reader, writer) writer.write.assert_called_with(b"HTTP/1.1 405 Method Not Allowed\r\n\r\n") @pytest.mark.asyncio async def test_connect_non_i2p_no_outproxy(self): task = _make_task() reader = AsyncMock() reader.readline = AsyncMock(side_effect=[ b"CONNECT example.com:443 HTTP/1.1\r\n", b"\r\n", ]) writer = _mock_writer() await task.handle_client(reader, writer) writer.write.assert_called_with(b"HTTP/1.1 503 No Outproxy\r\n\r\n") @pytest.mark.asyncio async def test_connect_i2p_resolve_fail(self): task = _make_task() task._session.lookup = AsyncMock(return_value=None) reader = AsyncMock() reader.readline = AsyncMock(side_effect=[ b"CONNECT unknown.i2p:443 HTTP/1.1\r\n", b"\r\n", ]) writer = _mock_writer() await task.handle_client(reader, writer) writer.write.assert_called_with(b"HTTP/1.1 503 Destination Not Found\r\n\r\n") @pytest.mark.asyncio async def test_connect_i2p_connect_fail(self): task = _make_task() task._session.connect = AsyncMock(side_effect=ConnectionError("fail")) reader = AsyncMock() reader.readline = AsyncMock(side_effect=[ b"CONNECT abcd.b32.i2p:443 HTTP/1.1\r\n", b"\r\n", ]) writer = _mock_writer() await task.handle_client(reader, writer) writer.write.assert_called_with(b"HTTP/1.1 504 Connection Timeout\r\n\r\n")