test_telegram_network.py
1 """Tests for gateway.platforms.telegram_network – fallback transport layer. 2 3 Background 4 ---------- 5 api.telegram.org resolves to an IP (e.g. 149.154.166.110) that is unreachable 6 from some networks. The workaround: route TCP through a different IP in the 7 same Telegram-owned 149.154.160.0/20 block (e.g. 149.154.167.220) while 8 keeping TLS SNI and the Host header as api.telegram.org so Telegram's edge 9 servers still accept the request. This is the programmatic equivalent of: 10 11 curl --resolve api.telegram.org:443:149.154.167.220 https://api.telegram.org/bot<token>/getMe 12 13 The TelegramFallbackTransport implements this: try the primary (DNS-resolved) 14 path first, and on ConnectTimeout / ConnectError fall through to configured 15 fallback IPs in order, then "stick" to whichever IP works. 16 """ 17 18 import httpx 19 import pytest 20 21 from gateway.platforms import telegram_network as tnet 22 23 24 # --------------------------------------------------------------------------- 25 # Helpers 26 # --------------------------------------------------------------------------- 27 28 class FakeTransport(httpx.AsyncBaseTransport): 29 """Records calls and raises / returns based on a host→action mapping.""" 30 31 def __init__(self, calls, behavior): 32 self.calls = calls 33 self.behavior = behavior 34 self.closed = False 35 36 async def handle_async_request(self, request: httpx.Request) -> httpx.Response: 37 self.calls.append( 38 { 39 "url_host": request.url.host, 40 "host_header": request.headers.get("host"), 41 "sni_hostname": request.extensions.get("sni_hostname"), 42 "path": request.url.path, 43 } 44 ) 45 action = self.behavior.get(request.url.host, "ok") 46 if action == "timeout": 47 raise httpx.ConnectTimeout("timed out") 48 if action == "connect_error": 49 raise httpx.ConnectError("connect error") 50 if isinstance(action, Exception): 51 raise action 52 return httpx.Response(200, request=request, text="ok") 53 54 async def aclose(self) -> None: 55 self.closed = True 56 57 58 def _fake_transport_factory(calls, behavior): 59 """Returns a factory that creates FakeTransport instances.""" 60 instances = [] 61 62 def factory(**kwargs): 63 t = FakeTransport(calls, behavior) 64 instances.append(t) 65 return t 66 67 factory.instances = instances 68 return factory 69 70 71 def _telegram_request(path="/botTOKEN/getMe"): 72 return httpx.Request("GET", f"https://api.telegram.org{path}") 73 74 75 # ═══════════════════════════════════════════════════════════════════════════ 76 # IP parsing & validation 77 # ═══════════════════════════════════════════════════════════════════════════ 78 79 class TestParseFallbackIpEnv: 80 def test_filters_invalid_and_ipv6(self, caplog): 81 ips = tnet.parse_fallback_ip_env("149.154.167.220, bad, 2001:67c:4e8:f004::9,149.154.167.220") 82 assert ips == ["149.154.167.220", "149.154.167.220"] 83 assert "Ignoring invalid Telegram fallback IP" in caplog.text 84 assert "Ignoring non-IPv4 Telegram fallback IP" in caplog.text 85 86 def test_none_returns_empty(self): 87 assert tnet.parse_fallback_ip_env(None) == [] 88 89 def test_empty_string_returns_empty(self): 90 assert tnet.parse_fallback_ip_env("") == [] 91 92 def test_whitespace_only_returns_empty(self): 93 assert tnet.parse_fallback_ip_env(" , , ") == [] 94 95 def test_single_valid_ip(self): 96 assert tnet.parse_fallback_ip_env("149.154.167.220") == ["149.154.167.220"] 97 98 def test_multiple_valid_ips(self): 99 ips = tnet.parse_fallback_ip_env("149.154.167.220, 149.154.167.221") 100 assert ips == ["149.154.167.220", "149.154.167.221"] 101 102 def test_rejects_leading_zeros(self, caplog): 103 """Leading zeros are ambiguous (octal?) so ipaddress rejects them.""" 104 ips = tnet.parse_fallback_ip_env("149.154.167.010") 105 assert ips == [] 106 assert "Ignoring invalid" in caplog.text 107 108 109 class TestNormalizeFallbackIps: 110 def test_deduplication_happens_at_transport_level(self): 111 """_normalize does not dedup; TelegramFallbackTransport.__init__ does.""" 112 raw = ["149.154.167.220", "149.154.167.220"] 113 assert tnet._normalize_fallback_ips(raw) == ["149.154.167.220", "149.154.167.220"] 114 115 def test_empty_strings_skipped(self): 116 assert tnet._normalize_fallback_ips(["", " ", "149.154.167.220"]) == ["149.154.167.220"] 117 118 119 # ═══════════════════════════════════════════════════════════════════════════ 120 # Request rewriting 121 # ═══════════════════════════════════════════════════════════════════════════ 122 123 class TestRewriteRequestForIp: 124 def test_preserves_host_and_sni(self): 125 request = _telegram_request() 126 rewritten = tnet._rewrite_request_for_ip(request, "149.154.167.220") 127 128 assert rewritten.url.host == "149.154.167.220" 129 assert rewritten.headers["host"] == "api.telegram.org" 130 assert rewritten.extensions["sni_hostname"] == "api.telegram.org" 131 assert rewritten.url.path == "/botTOKEN/getMe" 132 133 def test_preserves_method_and_path(self): 134 request = httpx.Request("POST", "https://api.telegram.org/botTOKEN/sendMessage") 135 rewritten = tnet._rewrite_request_for_ip(request, "149.154.167.220") 136 137 assert rewritten.method == "POST" 138 assert rewritten.url.path == "/botTOKEN/sendMessage" 139 140 141 # ═══════════════════════════════════════════════════════════════════════════ 142 # Fallback transport – core behavior 143 # ═══════════════════════════════════════════════════════════════════════════ 144 145 class TestFallbackTransport: 146 """Primary path fails → try fallback IPs → stick to whichever works.""" 147 148 @pytest.mark.asyncio 149 async def test_falls_back_on_connect_timeout_and_becomes_sticky(self, monkeypatch): 150 calls = [] 151 behavior = {"api.telegram.org": "timeout", "149.154.167.220": "ok"} 152 monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", _fake_transport_factory(calls, behavior)) 153 154 transport = tnet.TelegramFallbackTransport(["149.154.167.220"]) 155 resp = await transport.handle_async_request(_telegram_request()) 156 157 assert resp.status_code == 200 158 assert transport._sticky_ip == "149.154.167.220" 159 # First attempt was primary (api.telegram.org), second was fallback 160 assert calls[0]["url_host"] == "api.telegram.org" 161 assert calls[1]["url_host"] == "149.154.167.220" 162 assert calls[1]["host_header"] == "api.telegram.org" 163 assert calls[1]["sni_hostname"] == "api.telegram.org" 164 165 # Second request goes straight to sticky IP 166 calls.clear() 167 resp2 = await transport.handle_async_request(_telegram_request()) 168 assert resp2.status_code == 200 169 assert calls[0]["url_host"] == "149.154.167.220" 170 171 @pytest.mark.asyncio 172 async def test_falls_back_on_connect_error(self, monkeypatch): 173 calls = [] 174 behavior = {"api.telegram.org": "connect_error", "149.154.167.220": "ok"} 175 monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", _fake_transport_factory(calls, behavior)) 176 177 transport = tnet.TelegramFallbackTransport(["149.154.167.220"]) 178 resp = await transport.handle_async_request(_telegram_request()) 179 180 assert resp.status_code == 200 181 assert transport._sticky_ip == "149.154.167.220" 182 183 @pytest.mark.asyncio 184 async def test_does_not_fallback_on_non_connect_error(self, monkeypatch): 185 """Errors like ReadTimeout are not connection issues — don't retry.""" 186 calls = [] 187 behavior = {"api.telegram.org": httpx.ReadTimeout("read timeout"), "149.154.167.220": "ok"} 188 monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", _fake_transport_factory(calls, behavior)) 189 190 transport = tnet.TelegramFallbackTransport(["149.154.167.220"]) 191 192 with pytest.raises(httpx.ReadTimeout): 193 await transport.handle_async_request(_telegram_request()) 194 195 assert [c["url_host"] for c in calls] == ["api.telegram.org"] 196 197 @pytest.mark.asyncio 198 async def test_all_ips_fail_raises_last_error(self, monkeypatch): 199 calls = [] 200 behavior = {"api.telegram.org": "timeout", "149.154.167.220": "timeout"} 201 monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", _fake_transport_factory(calls, behavior)) 202 203 transport = tnet.TelegramFallbackTransport(["149.154.167.220"]) 204 205 with pytest.raises(httpx.ConnectTimeout): 206 await transport.handle_async_request(_telegram_request()) 207 208 assert [c["url_host"] for c in calls] == ["api.telegram.org", "149.154.167.220"] 209 assert transport._sticky_ip is None 210 211 @pytest.mark.asyncio 212 async def test_multiple_fallback_ips_tried_in_order(self, monkeypatch): 213 calls = [] 214 behavior = { 215 "api.telegram.org": "timeout", 216 "149.154.167.220": "timeout", 217 "149.154.167.221": "ok", 218 } 219 monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", _fake_transport_factory(calls, behavior)) 220 221 transport = tnet.TelegramFallbackTransport(["149.154.167.220", "149.154.167.221"]) 222 resp = await transport.handle_async_request(_telegram_request()) 223 224 assert resp.status_code == 200 225 assert transport._sticky_ip == "149.154.167.221" 226 assert [c["url_host"] for c in calls] == [ 227 "api.telegram.org", 228 "149.154.167.220", 229 "149.154.167.221", 230 ] 231 232 @pytest.mark.asyncio 233 async def test_sticky_ip_tried_first_but_falls_through_if_stale(self, monkeypatch): 234 """If the sticky IP stops working, the transport retries others.""" 235 calls = [] 236 behavior = { 237 "api.telegram.org": "timeout", 238 "149.154.167.220": "ok", 239 "149.154.167.221": "ok", 240 } 241 monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", _fake_transport_factory(calls, behavior)) 242 243 transport = tnet.TelegramFallbackTransport(["149.154.167.220", "149.154.167.221"]) 244 245 # First request: primary fails → .220 works → becomes sticky 246 await transport.handle_async_request(_telegram_request()) 247 assert transport._sticky_ip == "149.154.167.220" 248 249 # Now .220 goes bad too 250 calls.clear() 251 behavior["149.154.167.220"] = "timeout" 252 253 resp = await transport.handle_async_request(_telegram_request()) 254 assert resp.status_code == 200 255 # Tried sticky (.220) first, then fell through to .221 256 assert [c["url_host"] for c in calls] == ["149.154.167.220", "149.154.167.221"] 257 assert transport._sticky_ip == "149.154.167.221" 258 259 260 class TestFallbackTransportPassthrough: 261 """Requests that don't need fallback behavior.""" 262 263 @pytest.mark.asyncio 264 async def test_non_telegram_host_bypasses_fallback(self, monkeypatch): 265 calls = [] 266 behavior = {} 267 monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", _fake_transport_factory(calls, behavior)) 268 269 transport = tnet.TelegramFallbackTransport(["149.154.167.220"]) 270 request = httpx.Request("GET", "https://example.com/path") 271 resp = await transport.handle_async_request(request) 272 273 assert resp.status_code == 200 274 assert calls[0]["url_host"] == "example.com" 275 assert transport._sticky_ip is None 276 277 @pytest.mark.asyncio 278 async def test_empty_fallback_list_uses_primary_only(self, monkeypatch): 279 calls = [] 280 behavior = {} 281 monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", _fake_transport_factory(calls, behavior)) 282 283 transport = tnet.TelegramFallbackTransport([]) 284 resp = await transport.handle_async_request(_telegram_request()) 285 286 assert resp.status_code == 200 287 assert calls[0]["url_host"] == "api.telegram.org" 288 289 @pytest.mark.asyncio 290 async def test_primary_succeeds_no_fallback_needed(self, monkeypatch): 291 calls = [] 292 behavior = {"api.telegram.org": "ok"} 293 monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", _fake_transport_factory(calls, behavior)) 294 295 transport = tnet.TelegramFallbackTransport(["149.154.167.220"]) 296 resp = await transport.handle_async_request(_telegram_request()) 297 298 assert resp.status_code == 200 299 assert transport._sticky_ip is None 300 assert len(calls) == 1 301 302 303 class TestFallbackTransportInit: 304 def test_deduplicates_fallback_ips(self, monkeypatch): 305 monkeypatch.setattr( 306 tnet.httpx, "AsyncHTTPTransport", lambda **kw: FakeTransport([], {}) 307 ) 308 transport = tnet.TelegramFallbackTransport(["149.154.167.220", "149.154.167.220"]) 309 assert transport._fallback_ips == ["149.154.167.220"] 310 311 def test_filters_invalid_ips_at_init(self, monkeypatch): 312 monkeypatch.setattr( 313 tnet.httpx, "AsyncHTTPTransport", lambda **kw: FakeTransport([], {}) 314 ) 315 transport = tnet.TelegramFallbackTransport(["149.154.167.220", "not-an-ip"]) 316 assert transport._fallback_ips == ["149.154.167.220"] 317 318 def test_uses_proxy_env_for_primary_and_fallback_transports(self, monkeypatch): 319 seen_kwargs = [] 320 321 def factory(**kwargs): 322 seen_kwargs.append(kwargs.copy()) 323 return FakeTransport([], {}) 324 325 for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy", "TELEGRAM_PROXY", "NO_PROXY", "no_proxy"): 326 monkeypatch.delenv(key, raising=False) 327 monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080") 328 monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", factory) 329 330 transport = tnet.TelegramFallbackTransport(["149.154.167.220"]) 331 332 assert transport._fallback_ips == ["149.154.167.220"] 333 assert len(seen_kwargs) == 2 334 assert all(kwargs["proxy"] == "http://proxy.example:8080" for kwargs in seen_kwargs) 335 336 def test_no_proxy_bypasses_fallback_ip_cidr(self, monkeypatch): 337 seen_kwargs = [] 338 339 def factory(**kwargs): 340 seen_kwargs.append(kwargs.copy()) 341 return FakeTransport([], {}) 342 343 for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy", "TELEGRAM_PROXY", "NO_PROXY", "no_proxy"): 344 monkeypatch.delenv(key, raising=False) 345 monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080") 346 monkeypatch.setenv("NO_PROXY", "149.154.160.0/20") 347 monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", factory) 348 349 transport = tnet.TelegramFallbackTransport(["149.154.167.220"]) 350 351 assert transport._fallback_ips == ["149.154.167.220"] 352 assert len(seen_kwargs) == 2 353 assert all("proxy" not in kwargs for kwargs in seen_kwargs) 354 355 356 class TestFallbackTransportClose: 357 @pytest.mark.asyncio 358 async def test_aclose_closes_all_transports(self, monkeypatch): 359 factory = _fake_transport_factory([], {}) 360 monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", factory) 361 362 transport = tnet.TelegramFallbackTransport(["149.154.167.220", "149.154.167.221"]) 363 await transport.aclose() 364 365 # 1 primary + 2 fallback transports 366 assert len(factory.instances) == 3 367 assert all(t.closed for t in factory.instances) 368 369 370 # ═══════════════════════════════════════════════════════════════════════════ 371 # Config layer – TELEGRAM_FALLBACK_IPS env → config.extra 372 # ═══════════════════════════════════════════════════════════════════════════ 373 374 class TestConfigFallbackIps: 375 def test_env_var_populates_config_extra(self, monkeypatch): 376 from gateway.config import GatewayConfig, Platform, PlatformConfig, _apply_env_overrides 377 378 monkeypatch.setenv("TELEGRAM_FALLBACK_IPS", "149.154.167.220,149.154.167.221") 379 config = GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="tok")}) 380 _apply_env_overrides(config) 381 382 assert config.platforms[Platform.TELEGRAM].extra["fallback_ips"] == [ 383 "149.154.167.220", "149.154.167.221", 384 ] 385 386 def test_env_var_creates_platform_if_missing(self, monkeypatch): 387 from gateway.config import GatewayConfig, Platform, _apply_env_overrides 388 389 monkeypatch.setenv("TELEGRAM_FALLBACK_IPS", "149.154.167.220") 390 config = GatewayConfig(platforms={}) 391 _apply_env_overrides(config) 392 393 assert Platform.TELEGRAM in config.platforms 394 assert config.platforms[Platform.TELEGRAM].extra["fallback_ips"] == ["149.154.167.220"] 395 396 def test_env_var_strips_whitespace(self, monkeypatch): 397 from gateway.config import GatewayConfig, Platform, PlatformConfig, _apply_env_overrides 398 399 monkeypatch.setenv("TELEGRAM_FALLBACK_IPS", " 149.154.167.220 , 149.154.167.221 ") 400 config = GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="tok")}) 401 _apply_env_overrides(config) 402 403 assert config.platforms[Platform.TELEGRAM].extra["fallback_ips"] == [ 404 "149.154.167.220", "149.154.167.221", 405 ] 406 407 def test_empty_env_var_does_not_populate(self, monkeypatch): 408 from gateway.config import GatewayConfig, Platform, PlatformConfig, _apply_env_overrides 409 410 monkeypatch.setenv("TELEGRAM_FALLBACK_IPS", "") 411 config = GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="tok")}) 412 _apply_env_overrides(config) 413 414 assert "fallback_ips" not in config.platforms[Platform.TELEGRAM].extra 415 416 417 # ═══════════════════════════════════════════════════════════════════════════ 418 # Adapter layer – _fallback_ips() reads config correctly 419 # ═══════════════════════════════════════════════════════════════════════════ 420 421 class TestAdapterFallbackIps: 422 def _make_adapter(self, extra=None): 423 import sys 424 from unittest.mock import MagicMock 425 426 # Ensure telegram mock is in place 427 if "telegram" not in sys.modules or not hasattr(sys.modules["telegram"], "__file__"): 428 mod = MagicMock() 429 mod.ext.ContextTypes.DEFAULT_TYPE = type(None) 430 mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2" 431 mod.constants.ChatType.GROUP = "group" 432 mod.constants.ChatType.SUPERGROUP = "supergroup" 433 mod.constants.ChatType.CHANNEL = "channel" 434 mod.constants.ChatType.PRIVATE = "private" 435 for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"): 436 sys.modules.setdefault(name, mod) 437 438 from gateway.config import PlatformConfig 439 from gateway.platforms.telegram import TelegramAdapter 440 441 config = PlatformConfig(enabled=True, token="test-token") 442 if extra: 443 config.extra.update(extra) 444 return TelegramAdapter(config) 445 446 def test_list_in_extra(self): 447 adapter = self._make_adapter(extra={"fallback_ips": ["149.154.167.220"]}) 448 assert adapter._fallback_ips() == ["149.154.167.220"] 449 450 def test_csv_string_in_extra(self): 451 adapter = self._make_adapter(extra={"fallback_ips": "149.154.167.220,149.154.167.221"}) 452 assert adapter._fallback_ips() == ["149.154.167.220", "149.154.167.221"] 453 454 def test_empty_extra(self): 455 adapter = self._make_adapter() 456 assert adapter._fallback_ips() == [] 457 458 def test_no_extra_attr(self): 459 adapter = self._make_adapter() 460 adapter.config.extra = None 461 assert adapter._fallback_ips() == [] 462 463 def test_invalid_ips_filtered(self): 464 adapter = self._make_adapter(extra={"fallback_ips": ["149.154.167.220", "not-valid"]}) 465 assert adapter._fallback_ips() == ["149.154.167.220"] 466 467 468 # ═══════════════════════════════════════════════════════════════════════════ 469 # DoH auto-discovery 470 # ═══════════════════════════════════════════════════════════════════════════ 471 472 def _doh_answer(*ips: str) -> dict: 473 """Build a minimal DoH JSON response with A records.""" 474 return {"Answer": [{"type": 1, "data": ip} for ip in ips]} 475 476 477 class FakeDoHClient: 478 """Mock httpx.AsyncClient for DoH queries.""" 479 480 def __init__(self, responses: dict): 481 # responses: URL prefix → (status, json_body) | Exception 482 self._responses = responses 483 self.requests_made: list[dict] = [] 484 485 @staticmethod 486 def _make_response(status, body, url): 487 """Build an httpx.Response with a request attached (needed for raise_for_status).""" 488 request = httpx.Request("GET", url) 489 return httpx.Response(status, json=body, request=request) 490 491 async def get(self, url, *, params=None, headers=None, **kwargs): 492 self.requests_made.append({"url": url, "params": params, "headers": headers}) 493 for prefix, action in self._responses.items(): 494 if url.startswith(prefix): 495 if isinstance(action, Exception): 496 raise action 497 status, body = action 498 return self._make_response(status, body, url) 499 return self._make_response(200, {}, url) 500 501 async def __aenter__(self): 502 return self 503 504 async def __aexit__(self, *args): 505 pass 506 507 508 class TestDiscoverFallbackIps: 509 """Tests for discover_fallback_ips() — DoH-based auto-discovery.""" 510 511 def _patch_doh(self, monkeypatch, responses, system_dns_ips=None): 512 """Wire up fake DoH client and system DNS.""" 513 client = FakeDoHClient(responses) 514 monkeypatch.setattr(tnet.httpx, "AsyncClient", lambda **kw: client) 515 516 if system_dns_ips is not None: 517 addrs = [(None, None, None, None, (ip, 443)) for ip in system_dns_ips] 518 monkeypatch.setattr(tnet.socket, "getaddrinfo", lambda *a, **kw: addrs) 519 else: 520 def _fail(*a, **kw): 521 raise OSError("dns failed") 522 monkeypatch.setattr(tnet.socket, "getaddrinfo", _fail) 523 return client 524 525 @pytest.mark.asyncio 526 async def test_google_and_cloudflare_ips_collected(self, monkeypatch): 527 self._patch_doh(monkeypatch, { 528 "https://dns.google": (200, _doh_answer("149.154.167.220")), 529 "https://cloudflare-dns.com": (200, _doh_answer("149.154.167.221")), 530 }, system_dns_ips=["149.154.166.110"]) 531 532 ips = await tnet.discover_fallback_ips() 533 assert "149.154.167.220" in ips 534 assert "149.154.167.221" in ips 535 536 @pytest.mark.asyncio 537 async def test_system_dns_ip_excluded(self, monkeypatch): 538 """The IP from system DNS is the one that doesn't work — exclude it.""" 539 self._patch_doh(monkeypatch, { 540 "https://dns.google": (200, _doh_answer("149.154.166.110", "149.154.167.220")), 541 "https://cloudflare-dns.com": (200, _doh_answer("149.154.166.110")), 542 }, system_dns_ips=["149.154.166.110"]) 543 544 ips = await tnet.discover_fallback_ips() 545 assert ips == ["149.154.167.220"] 546 547 @pytest.mark.asyncio 548 async def test_doh_results_deduplicated(self, monkeypatch): 549 self._patch_doh(monkeypatch, { 550 "https://dns.google": (200, _doh_answer("149.154.167.220")), 551 "https://cloudflare-dns.com": (200, _doh_answer("149.154.167.220")), 552 }, system_dns_ips=["149.154.166.110"]) 553 554 ips = await tnet.discover_fallback_ips() 555 assert ips == ["149.154.167.220"] 556 557 @pytest.mark.asyncio 558 async def test_doh_timeout_falls_back_to_seed(self, monkeypatch): 559 self._patch_doh(monkeypatch, { 560 "https://dns.google": httpx.TimeoutException("timeout"), 561 "https://cloudflare-dns.com": httpx.TimeoutException("timeout"), 562 }, system_dns_ips=["149.154.166.110"]) 563 564 ips = await tnet.discover_fallback_ips() 565 assert ips == tnet._SEED_FALLBACK_IPS 566 567 @pytest.mark.asyncio 568 async def test_doh_connect_error_falls_back_to_seed(self, monkeypatch): 569 self._patch_doh(monkeypatch, { 570 "https://dns.google": httpx.ConnectError("refused"), 571 "https://cloudflare-dns.com": httpx.ConnectError("refused"), 572 }, system_dns_ips=["149.154.166.110"]) 573 574 ips = await tnet.discover_fallback_ips() 575 assert ips == tnet._SEED_FALLBACK_IPS 576 577 @pytest.mark.asyncio 578 async def test_doh_malformed_json_falls_back_to_seed(self, monkeypatch): 579 self._patch_doh(monkeypatch, { 580 "https://dns.google": (200, {"Status": 0}), # no Answer key 581 "https://cloudflare-dns.com": (200, {"garbage": True}), 582 }, system_dns_ips=["149.154.166.110"]) 583 584 ips = await tnet.discover_fallback_ips() 585 assert ips == tnet._SEED_FALLBACK_IPS 586 587 @pytest.mark.asyncio 588 async def test_one_provider_fails_other_succeeds(self, monkeypatch): 589 self._patch_doh(monkeypatch, { 590 "https://dns.google": httpx.TimeoutException("timeout"), 591 "https://cloudflare-dns.com": (200, _doh_answer("149.154.167.220")), 592 }, system_dns_ips=["149.154.166.110"]) 593 594 ips = await tnet.discover_fallback_ips() 595 assert ips == ["149.154.167.220"] 596 597 @pytest.mark.asyncio 598 async def test_system_dns_failure_keeps_all_doh_ips(self, monkeypatch): 599 """If system DNS fails, nothing gets excluded — all DoH IPs kept.""" 600 self._patch_doh(monkeypatch, { 601 "https://dns.google": (200, _doh_answer("149.154.166.110", "149.154.167.220")), 602 "https://cloudflare-dns.com": (200, _doh_answer()), 603 }, system_dns_ips=None) # triggers OSError 604 605 ips = await tnet.discover_fallback_ips() 606 assert "149.154.166.110" in ips 607 assert "149.154.167.220" in ips 608 609 @pytest.mark.asyncio 610 async def test_all_doh_ips_same_as_system_dns_uses_seed(self, monkeypatch): 611 """DoH returns only the same blocked IP — seed list is the fallback.""" 612 self._patch_doh(monkeypatch, { 613 "https://dns.google": (200, _doh_answer("149.154.166.110")), 614 "https://cloudflare-dns.com": (200, _doh_answer("149.154.166.110")), 615 }, system_dns_ips=["149.154.166.110"]) 616 617 ips = await tnet.discover_fallback_ips() 618 assert ips == tnet._SEED_FALLBACK_IPS 619 620 @pytest.mark.asyncio 621 async def test_cloudflare_gets_accept_header(self, monkeypatch): 622 client = self._patch_doh(monkeypatch, { 623 "https://dns.google": (200, _doh_answer("149.154.167.220")), 624 "https://cloudflare-dns.com": (200, _doh_answer("149.154.167.221")), 625 }, system_dns_ips=["149.154.166.110"]) 626 627 await tnet.discover_fallback_ips() 628 629 cf_reqs = [r for r in client.requests_made if "cloudflare" in r["url"]] 630 assert cf_reqs 631 assert cf_reqs[0]["headers"]["Accept"] == "application/dns-json" 632 633 @pytest.mark.asyncio 634 async def test_non_a_records_ignored(self, monkeypatch): 635 """AAAA records (type 28) and CNAME (type 5) should be skipped.""" 636 answer = { 637 "Answer": [ 638 {"type": 5, "data": "telegram.org"}, # CNAME 639 {"type": 28, "data": "2001:67c:4e8:f004::9"}, # AAAA 640 {"type": 1, "data": "149.154.167.220"}, # A ✓ 641 ] 642 } 643 self._patch_doh(monkeypatch, { 644 "https://dns.google": (200, answer), 645 "https://cloudflare-dns.com": (200, _doh_answer()), 646 }, system_dns_ips=["149.154.166.110"]) 647 648 ips = await tnet.discover_fallback_ips() 649 assert ips == ["149.154.167.220"] 650 651 @pytest.mark.asyncio 652 async def test_invalid_ip_in_doh_response_skipped(self, monkeypatch): 653 answer = {"Answer": [ 654 {"type": 1, "data": "not-an-ip"}, 655 {"type": 1, "data": "149.154.167.220"}, 656 ]} 657 self._patch_doh(monkeypatch, { 658 "https://dns.google": (200, answer), 659 "https://cloudflare-dns.com": (200, _doh_answer()), 660 }, system_dns_ips=["149.154.166.110"]) 661 662 ips = await tnet.discover_fallback_ips() 663 assert ips == ["149.154.167.220"]