A Python port of the Invisible Internet Project (I2P)
at main 203 lines 6.9 kB view raw
1"""Tests for UPnP port mapping. 2 3TDD: tests written before implementation. 4""" 5 6import pytest 7 8from i2p_transport.upnp import UPnPManager, UPnPMapping 9 10 11class TestUPnPMapping: 12 13 def test_mapping_creation(self): 14 """UPnPMapping fields are stored correctly.""" 15 m = UPnPMapping( 16 external_port=15000, 17 internal_port=15000, 18 protocol="TCP", 19 description="I2P NTCP2", 20 ) 21 assert m.external_port == 15000 22 assert m.internal_port == 15000 23 assert m.protocol == "TCP" 24 assert m.description == "I2P NTCP2" 25 26 def test_mapping_default_description(self): 27 m = UPnPMapping(external_port=15001, internal_port=15001, protocol="UDP") 28 assert m.description == "I2P" 29 30 31class TestUPnPManager: 32 33 def test_not_available_before_discover(self): 34 """is_available is False before discovery.""" 35 mgr = UPnPManager() 36 assert mgr.is_available is False 37 38 def test_ssdp_request_format(self): 39 """SSDP M-SEARCH request has correct format.""" 40 mgr = UPnPManager() 41 req = mgr.build_ssdp_request() 42 text = req.decode("utf-8") 43 assert text.startswith("M-SEARCH * HTTP/1.1\r\n") 44 assert "HOST: 239.255.255.250:1900\r\n" in text 45 assert "MAN: \"ssdp:discover\"\r\n" in text 46 assert "MX:" in text 47 assert "ST:" in text 48 assert text.endswith("\r\n\r\n") 49 50 def test_parse_ssdp_response(self): 51 """Extracts LOCATION URL from SSDP response.""" 52 mgr = UPnPManager() 53 response = ( 54 b"HTTP/1.1 200 OK\r\n" 55 b"CACHE-CONTROL: max-age=1800\r\n" 56 b"LOCATION: http://192.168.1.1:5000/rootDesc.xml\r\n" 57 b"ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n" 58 b"\r\n" 59 ) 60 location = mgr.parse_ssdp_response(response) 61 assert location == "http://192.168.1.1:5000/rootDesc.xml" 62 63 def test_parse_ssdp_response_no_location(self): 64 """Returns None if no LOCATION header.""" 65 mgr = UPnPManager() 66 response = ( 67 b"HTTP/1.1 200 OK\r\n" 68 b"CACHE-CONTROL: max-age=1800\r\n" 69 b"\r\n" 70 ) 71 assert mgr.parse_ssdp_response(response) is None 72 73 def test_soap_request_format(self): 74 """SOAP XML for AddPortMapping is well-formed.""" 75 mgr = UPnPManager() 76 mgr._service_type = "urn:schemas-upnp-org:service:WANIPConnection:1" 77 xml_str = mgr.build_soap_request( 78 action="AddPortMapping", 79 args={ 80 "NewRemoteHost": "", 81 "NewExternalPort": "15000", 82 "NewProtocol": "TCP", 83 "NewInternalPort": "15000", 84 "NewInternalClient": "192.168.1.100", 85 "NewEnabled": "1", 86 "NewPortMappingDescription": "I2P", 87 "NewLeaseDuration": "0", 88 }, 89 ) 90 assert "AddPortMapping" in xml_str 91 assert "NewExternalPort" in xml_str 92 assert "15000" in xml_str 93 assert "urn:schemas-upnp-org:service:WANIPConnection:1" in xml_str 94 # Basic XML well-formedness check 95 import xml.etree.ElementTree as ET 96 ET.fromstring(xml_str) # Should not raise 97 98 def test_parse_ssdp_case_insensitive(self): 99 """LOCATION header parsing is case-insensitive.""" 100 mgr = UPnPManager() 101 response = ( 102 b"HTTP/1.1 200 OK\r\n" 103 b"location: http://10.0.0.1:1234/desc.xml\r\n" 104 b"\r\n" 105 ) 106 location = mgr.parse_ssdp_response(response) 107 assert location == "http://10.0.0.1:1234/desc.xml" 108 109 def test_parse_ssdp_empty(self): 110 mgr = UPnPManager() 111 assert mgr.parse_ssdp_response(b"") is None 112 113 def test_parse_ssdp_binary_garbage(self): 114 mgr = UPnPManager() 115 assert mgr.parse_ssdp_response(b"\xff\xfe\xfd") is None 116 117 118class TestUPnPManagerState: 119 def test_initial_state(self): 120 mgr = UPnPManager() 121 assert mgr._control_url is None 122 assert mgr._service_type is None 123 assert mgr._mappings == [] 124 assert mgr._external_ip is None 125 126 def test_available_after_control_url(self): 127 mgr = UPnPManager() 128 mgr._control_url = "http://192.168.1.1/control" 129 assert mgr.is_available is True 130 131 132class TestUPnPManagerWithoutDiscovery: 133 """Operations that require discovery should fail gracefully.""" 134 135 @pytest.mark.asyncio 136 async def test_add_mapping_returns_false(self): 137 mgr = UPnPManager() 138 m = UPnPMapping(9700, 9700, "TCP") 139 assert await mgr.add_mapping(m) is False 140 141 @pytest.mark.asyncio 142 async def test_remove_mapping_returns_false(self): 143 mgr = UPnPManager() 144 m = UPnPMapping(9700, 9700, "TCP") 145 assert await mgr.remove_mapping(m) is False 146 147 @pytest.mark.asyncio 148 async def test_get_external_ip_returns_none(self): 149 mgr = UPnPManager() 150 assert await mgr.get_external_ip() is None 151 152 @pytest.mark.asyncio 153 async def test_remove_all_empty(self): 154 mgr = UPnPManager() 155 await mgr.remove_all_mappings() # no crash 156 157 @pytest.mark.asyncio 158 async def test_soap_call_returns_none(self): 159 mgr = UPnPManager() 160 assert await mgr._soap_call("<xml/>", "TestAction") is None 161 162 163class TestGetLocalIP: 164 def test_returns_string(self): 165 ip = UPnPManager._get_local_ip() 166 assert isinstance(ip, str) 167 # Real IP or fallback 168 assert "." in ip 169 170 171class TestSOAPRequestVariants: 172 def test_delete_port_mapping(self): 173 mgr = UPnPManager() 174 mgr._service_type = "urn:schemas-upnp-org:service:WANIPConnection:1" 175 xml = mgr.build_soap_request("DeletePortMapping", { 176 "NewRemoteHost": "", 177 "NewExternalPort": "9700", 178 "NewProtocol": "TCP", 179 }) 180 assert "DeletePortMapping" in xml 181 assert "<NewExternalPort>9700</NewExternalPort>" in xml 182 183 def test_get_external_ip_request(self): 184 mgr = UPnPManager() 185 mgr._service_type = "urn:schemas-upnp-org:service:WANIPConnection:1" 186 xml = mgr.build_soap_request("GetExternalIPAddress", {}) 187 assert "GetExternalIPAddress" in xml 188 assert "s:Envelope" in xml 189 190 def test_uses_default_service_type(self): 191 mgr = UPnPManager() 192 mgr._service_type = None 193 xml = mgr.build_soap_request("TestAction", {}) 194 # Should use first service type as default 195 from i2p_transport.upnp import _SERVICE_TYPES 196 assert _SERVICE_TYPES[0] in xml 197 198 def test_soap_xml_is_valid(self): 199 import xml.etree.ElementTree as ET 200 mgr = UPnPManager() 201 mgr._service_type = "urn:schemas-upnp-org:service:WANPPPConnection:1" 202 xml = mgr.build_soap_request("GetExternalIPAddress", {}) 203 ET.fromstring(xml) # must not raise