"""Tests for STUN NAT probe — TDD: tests before implementation.""" import os import socket import struct from unittest.mock import MagicMock, patch import pytest from i2p_apps.setup.stun_probe import ( ATTR_MAPPED_ADDRESS, ATTR_XOR_MAPPED_ADDRESS, STUN_BINDING_REQUEST, STUN_BINDING_RESPONSE, STUN_MAGIC_COOKIE, NatProbeResult, StunResult, build_binding_request, parse_stun_response, stun_request, detect_nat_type, ) class TestBuildBindingRequest: def test_returns_20_bytes(self): packet, txn_id = build_binding_request() assert len(packet) == 20 def test_message_type_is_binding_request(self): packet, _ = build_binding_request() msg_type = struct.unpack("!H", packet[0:2])[0] assert msg_type == STUN_BINDING_REQUEST def test_message_length_is_zero(self): packet, _ = build_binding_request() msg_len = struct.unpack("!H", packet[2:4])[0] assert msg_len == 0 def test_magic_cookie(self): packet, _ = build_binding_request() cookie = struct.unpack("!I", packet[4:8])[0] assert cookie == STUN_MAGIC_COOKIE def test_transaction_id_is_12_bytes(self): _, txn_id = build_binding_request() assert len(txn_id) == 12 def test_transaction_id_is_random(self): _, txn1 = build_binding_request() _, txn2 = build_binding_request() assert txn1 != txn2 def _build_stun_response(ext_ip: str, ext_port: int, txn_id: bytes, use_xor: bool = True) -> bytes: """Build a mock STUN Binding Response with XOR-MAPPED-ADDRESS.""" ip_bytes = socket.inet_aton(ext_ip) ip_int = struct.unpack("!I", ip_bytes)[0] if use_xor: x_port = ext_port ^ (STUN_MAGIC_COOKIE >> 16) x_addr = ip_int ^ STUN_MAGIC_COOKIE attr_type = ATTR_XOR_MAPPED_ADDRESS else: x_port = ext_port x_addr = ip_int attr_type = ATTR_MAPPED_ADDRESS # Attribute: type(2) + length(2) + reserved(1) + family(1) + port(2) + addr(4) = 12 bytes attr = struct.pack("!HH", attr_type, 8) attr += struct.pack("!BBH", 0x00, 0x01, x_port) attr += struct.pack("!I", x_addr) # Header: type(2) + length(2) + cookie(4) + txn_id(12) header = struct.pack("!HHI", STUN_BINDING_RESPONSE, len(attr), STUN_MAGIC_COOKIE) header += txn_id return header + attr class TestParseStunResponse: def test_parse_xor_mapped_address(self): txn_id = os.urandom(12) response = _build_stun_response("198.51.100.30", 54321, txn_id, use_xor=True) result = parse_stun_response(response, txn_id) assert result is not None ip, port = result assert ip == "198.51.100.30" assert port == 54321 def test_parse_mapped_address_fallback(self): txn_id = os.urandom(12) response = _build_stun_response("10.0.0.1", 1234, txn_id, use_xor=False) result = parse_stun_response(response, txn_id) assert result is not None ip, port = result assert ip == "10.0.0.1" assert port == 1234 def test_wrong_txn_id_returns_none(self): txn_id = os.urandom(12) wrong_txn = os.urandom(12) response = _build_stun_response("1.2.3.4", 5678, txn_id) result = parse_stun_response(response, wrong_txn) assert result is None def test_short_data_returns_none(self): result = parse_stun_response(b"\x00" * 10, os.urandom(12)) assert result is None def test_wrong_message_type_returns_none(self): txn_id = os.urandom(12) # Build a response but change type to something else response = _build_stun_response("1.2.3.4", 5678, txn_id) bad_response = struct.pack("!H", 0x0111) + response[2:] result = parse_stun_response(bad_response, txn_id) assert result is None class TestStunRequest: def test_successful_request(self): txn_id = os.urandom(12) mock_response = _build_stun_response("203.0.113.42", 12345, txn_id) mock_sock = MagicMock() mock_sock.recvfrom.return_value = (mock_response, ("stun.example.com", 3478)) with patch("i2p_apps.setup.stun_probe.build_binding_request", return_value=(b"\x00" * 20, txn_id)): result = stun_request("stun.example.com", 3478, sock=mock_sock) assert result is not None assert result.external_ip == "203.0.113.42" assert result.external_port == 12345 def test_timeout_returns_none(self): mock_sock = MagicMock() mock_sock.recvfrom.side_effect = socket.timeout() result = stun_request("stun.example.com", 3478, timeout=0.1, sock=mock_sock) assert result is None class TestDetectNatType: def _mock_stun_request(self, results: dict): """Create a side_effect function that returns results by server.""" def side_effect(server, port=3478, timeout=3.0, sock=None): key = server if key in results: return results[key] return None return side_effect def test_no_nat(self): """Same external as local → open internet.""" with patch("i2p_apps.setup.stun_probe.stun_request") as mock: mock.side_effect = self._mock_stun_request({ "stun.cloudflare.com": StunResult("stun.cloudflare.com", "192.168.1.1", 5000, 10.0), "stun.nextcloud.com": StunResult("stun.nextcloud.com", "192.168.1.1", 5000, 12.0), }) with patch("i2p_apps.setup.stun_probe.socket") as mock_socket: mock_sock = MagicMock() mock_sock.getsockname.return_value = ("192.168.1.1", 5000) mock_socket.socket.return_value = mock_sock mock_socket.AF_INET = socket.AF_INET mock_socket.SOCK_DGRAM = socket.SOCK_DGRAM result = detect_nat_type() assert result.nat_present is False assert result.nat_type == "open" def test_cone_nat(self): """Same mapping to both servers → cone NAT.""" with patch("i2p_apps.setup.stun_probe.stun_request") as mock: mock.side_effect = self._mock_stun_request({ "stun.cloudflare.com": StunResult("stun.cloudflare.com", "203.0.113.42", 54321, 10.0), "stun.nextcloud.com": StunResult("stun.nextcloud.com", "203.0.113.42", 54321, 12.0), }) with patch("i2p_apps.setup.stun_probe.socket") as mock_socket: mock_sock = MagicMock() mock_sock.getsockname.return_value = ("192.168.1.100", 5000) mock_socket.socket.return_value = mock_sock mock_socket.AF_INET = socket.AF_INET mock_socket.SOCK_DGRAM = socket.SOCK_DGRAM result = detect_nat_type() assert result.nat_present is True assert result.nat_type == "cone" def test_symmetric_nat(self): """Different mapping per server → symmetric NAT.""" with patch("i2p_apps.setup.stun_probe.stun_request") as mock: mock.side_effect = self._mock_stun_request({ "stun.cloudflare.com": StunResult("stun.cloudflare.com", "203.0.113.42", 54321, 10.0), "stun.nextcloud.com": StunResult("stun.nextcloud.com", "203.0.113.42", 54322, 12.0), }) with patch("i2p_apps.setup.stun_probe.socket") as mock_socket: mock_sock = MagicMock() mock_sock.getsockname.return_value = ("192.168.1.100", 5000) mock_socket.socket.return_value = mock_sock mock_socket.AF_INET = socket.AF_INET mock_socket.SOCK_DGRAM = socket.SOCK_DGRAM result = detect_nat_type() assert result.nat_present is True assert result.nat_type == "symmetric" def test_single_server_only(self): """Only one server responds → unknown NAT type.""" with patch("i2p_apps.setup.stun_probe.stun_request") as mock: mock.side_effect = self._mock_stun_request({ "stun.cloudflare.com": StunResult("stun.cloudflare.com", "203.0.113.42", 54321, 10.0), }) with patch("i2p_apps.setup.stun_probe.socket") as mock_socket: mock_sock = MagicMock() mock_sock.getsockname.return_value = ("192.168.1.100", 5000) mock_socket.socket.return_value = mock_sock mock_socket.AF_INET = socket.AF_INET mock_socket.SOCK_DGRAM = socket.SOCK_DGRAM result = detect_nat_type() assert result.nat_present is True assert result.nat_type == "unknown" def test_no_servers_respond(self): """No servers respond → error.""" with patch("i2p_apps.setup.stun_probe.stun_request", return_value=None): with patch("i2p_apps.setup.stun_probe.socket") as mock_socket: mock_sock = MagicMock() mock_sock.getsockname.return_value = ("192.168.1.100", 5000) mock_socket.socket.return_value = mock_sock mock_socket.AF_INET = socket.AF_INET mock_socket.SOCK_DGRAM = socket.SOCK_DGRAM result = detect_nat_type() assert result.error is not None