"""Tests for UPnP port mapping. TDD: tests written before implementation. """ import pytest from i2p_transport.upnp import UPnPManager, UPnPMapping class TestUPnPMapping: def test_mapping_creation(self): """UPnPMapping fields are stored correctly.""" m = UPnPMapping( external_port=15000, internal_port=15000, protocol="TCP", description="I2P NTCP2", ) assert m.external_port == 15000 assert m.internal_port == 15000 assert m.protocol == "TCP" assert m.description == "I2P NTCP2" def test_mapping_default_description(self): m = UPnPMapping(external_port=15001, internal_port=15001, protocol="UDP") assert m.description == "I2P" class TestUPnPManager: def test_not_available_before_discover(self): """is_available is False before discovery.""" mgr = UPnPManager() assert mgr.is_available is False def test_ssdp_request_format(self): """SSDP M-SEARCH request has correct format.""" mgr = UPnPManager() req = mgr.build_ssdp_request() text = req.decode("utf-8") assert text.startswith("M-SEARCH * HTTP/1.1\r\n") assert "HOST: 239.255.255.250:1900\r\n" in text assert "MAN: \"ssdp:discover\"\r\n" in text assert "MX:" in text assert "ST:" in text assert text.endswith("\r\n\r\n") def test_parse_ssdp_response(self): """Extracts LOCATION URL from SSDP response.""" mgr = UPnPManager() response = ( b"HTTP/1.1 200 OK\r\n" b"CACHE-CONTROL: max-age=1800\r\n" b"LOCATION: http://192.168.1.1:5000/rootDesc.xml\r\n" b"ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n" b"\r\n" ) location = mgr.parse_ssdp_response(response) assert location == "http://192.168.1.1:5000/rootDesc.xml" def test_parse_ssdp_response_no_location(self): """Returns None if no LOCATION header.""" mgr = UPnPManager() response = ( b"HTTP/1.1 200 OK\r\n" b"CACHE-CONTROL: max-age=1800\r\n" b"\r\n" ) assert mgr.parse_ssdp_response(response) is None def test_soap_request_format(self): """SOAP XML for AddPortMapping is well-formed.""" mgr = UPnPManager() mgr._service_type = "urn:schemas-upnp-org:service:WANIPConnection:1" xml_str = mgr.build_soap_request( action="AddPortMapping", args={ "NewRemoteHost": "", "NewExternalPort": "15000", "NewProtocol": "TCP", "NewInternalPort": "15000", "NewInternalClient": "192.168.1.100", "NewEnabled": "1", "NewPortMappingDescription": "I2P", "NewLeaseDuration": "0", }, ) assert "AddPortMapping" in xml_str assert "NewExternalPort" in xml_str assert "15000" in xml_str assert "urn:schemas-upnp-org:service:WANIPConnection:1" in xml_str # Basic XML well-formedness check import xml.etree.ElementTree as ET ET.fromstring(xml_str) # Should not raise def test_parse_ssdp_case_insensitive(self): """LOCATION header parsing is case-insensitive.""" mgr = UPnPManager() response = ( b"HTTP/1.1 200 OK\r\n" b"location: http://10.0.0.1:1234/desc.xml\r\n" b"\r\n" ) location = mgr.parse_ssdp_response(response) assert location == "http://10.0.0.1:1234/desc.xml" def test_parse_ssdp_empty(self): mgr = UPnPManager() assert mgr.parse_ssdp_response(b"") is None def test_parse_ssdp_binary_garbage(self): mgr = UPnPManager() assert mgr.parse_ssdp_response(b"\xff\xfe\xfd") is None class TestUPnPManagerState: def test_initial_state(self): mgr = UPnPManager() assert mgr._control_url is None assert mgr._service_type is None assert mgr._mappings == [] assert mgr._external_ip is None def test_available_after_control_url(self): mgr = UPnPManager() mgr._control_url = "http://192.168.1.1/control" assert mgr.is_available is True class TestUPnPManagerWithoutDiscovery: """Operations that require discovery should fail gracefully.""" @pytest.mark.asyncio async def test_add_mapping_returns_false(self): mgr = UPnPManager() m = UPnPMapping(9700, 9700, "TCP") assert await mgr.add_mapping(m) is False @pytest.mark.asyncio async def test_remove_mapping_returns_false(self): mgr = UPnPManager() m = UPnPMapping(9700, 9700, "TCP") assert await mgr.remove_mapping(m) is False @pytest.mark.asyncio async def test_get_external_ip_returns_none(self): mgr = UPnPManager() assert await mgr.get_external_ip() is None @pytest.mark.asyncio async def test_remove_all_empty(self): mgr = UPnPManager() await mgr.remove_all_mappings() # no crash @pytest.mark.asyncio async def test_soap_call_returns_none(self): mgr = UPnPManager() assert await mgr._soap_call("", "TestAction") is None class TestGetLocalIP: def test_returns_string(self): ip = UPnPManager._get_local_ip() assert isinstance(ip, str) # Real IP or fallback assert "." in ip class TestSOAPRequestVariants: def test_delete_port_mapping(self): mgr = UPnPManager() mgr._service_type = "urn:schemas-upnp-org:service:WANIPConnection:1" xml = mgr.build_soap_request("DeletePortMapping", { "NewRemoteHost": "", "NewExternalPort": "9700", "NewProtocol": "TCP", }) assert "DeletePortMapping" in xml assert "9700" in xml def test_get_external_ip_request(self): mgr = UPnPManager() mgr._service_type = "urn:schemas-upnp-org:service:WANIPConnection:1" xml = mgr.build_soap_request("GetExternalIPAddress", {}) assert "GetExternalIPAddress" in xml assert "s:Envelope" in xml def test_uses_default_service_type(self): mgr = UPnPManager() mgr._service_type = None xml = mgr.build_soap_request("TestAction", {}) # Should use first service type as default from i2p_transport.upnp import _SERVICE_TYPES assert _SERVICE_TYPES[0] in xml def test_soap_xml_is_valid(self): import xml.etree.ElementTree as ET mgr = UPnPManager() mgr._service_type = "urn:schemas-upnp-org:service:WANPPPConnection:1" xml = mgr.build_soap_request("GetExternalIPAddress", {}) ET.fromstring(xml) # must not raise