"""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