test_config_file_remote.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. # coding=utf-8
  2. # type: ignore[index]
  3. """
  4. Unit tests for remote configuration loading in ddns.config.file module
  5. @author: GitHub Copilot
  6. """
  7. from __future__ import unicode_literals
  8. from __init__ import unittest, patch
  9. import tempfile
  10. import shutil
  11. import os
  12. import json
  13. import sys
  14. import socket
  15. from ddns.config.file import load_config
  16. from ddns.util.http import HttpResponse
  17. # Import HTTP exceptions for Python 2/3 compatibility
  18. try: # Python 3
  19. from urllib.error import URLError, HTTPError
  20. from io import StringIO
  21. unicode = str
  22. except ImportError: # Python 2
  23. from urllib2 import URLError, HTTPError # type: ignore[no-redef]
  24. from StringIO import StringIO # type: ignore[no-redef]
  25. else:
  26. try:
  27. from StringIO import StringIO # type: ignore[no-redef]
  28. except ImportError:
  29. from io import StringIO # type: ignore[no-redef]
  30. class TestRemoteConfigFile(unittest.TestCase):
  31. """Test cases for remote configuration file loading via HTTP(S)"""
  32. def setUp(self):
  33. """Set up test fixtures"""
  34. self.temp_dir = tempfile.mkdtemp()
  35. self.addCleanup(shutil.rmtree, self.temp_dir, ignore_errors=True)
  36. # Capture stdout and stderr output for testing
  37. self.stdout_capture = StringIO()
  38. self.stderr_capture = StringIO()
  39. self.original_stdout = sys.stdout
  40. self.original_stderr = sys.stderr
  41. def tearDown(self):
  42. """Clean up after tests"""
  43. sys.stdout = self.original_stdout
  44. sys.stderr = self.original_stderr
  45. @patch("ddns.config.file.request")
  46. def test_load_config_remote_http_success(self, mock_http):
  47. """Test loading configuration from HTTP URL"""
  48. # Patch stdout to capture output
  49. import ddns.config.file
  50. original_stdout = ddns.config.file.stdout
  51. ddns.config.file.stdout = self.stdout_capture
  52. try:
  53. config_data = {"dns": "cloudflare", "id": "[email protected]", "token": "secret123"}
  54. mock_http.return_value = HttpResponse(200, "OK", {}, json.dumps(config_data))
  55. config_url = "http://example.com/config.json"
  56. result = load_config(config_url)
  57. self.assertEqual(result, config_data)
  58. mock_http.assert_called_once_with("GET", config_url, proxies=None, verify="auto", retries=3)
  59. finally:
  60. ddns.config.file.stdout = original_stdout
  61. @patch("ddns.config.file.request")
  62. def test_load_config_remote_https_success(self, mock_http):
  63. """Test loading configuration from HTTPS URL"""
  64. config_data = {"dns": "dnspod", "id": "test123", "token": "abc456"}
  65. mock_http.return_value = HttpResponse(200, "OK", {}, json.dumps(config_data))
  66. config_url = "https://secure.example.com/config.json"
  67. result = load_config(config_url, ssl=True)
  68. self.assertEqual(result, config_data)
  69. mock_http.assert_called_once_with("GET", config_url, proxies=None, verify=True, retries=3)
  70. @patch("ddns.config.file.request")
  71. def test_load_config_remote_with_proxy(self, mock_http):
  72. """Test loading configuration from remote URL with proxy settings"""
  73. config_data = {"dns": "alidns", "id": "test", "token": "xyz"}
  74. mock_http.return_value = HttpResponse(200, "OK", {}, json.dumps(config_data))
  75. config_url = "https://example.com/config.json"
  76. proxy_list = ["http://proxy1.example.com:8080", "DIRECT"]
  77. result = load_config(config_url, proxy=proxy_list, ssl=False)
  78. self.assertEqual(result, config_data)
  79. mock_http.assert_called_once_with("GET", config_url, proxies=proxy_list, verify=False, retries=3)
  80. @patch("ddns.config.file.request")
  81. def test_load_config_remote_with_embedded_auth(self, mock_http):
  82. """Test loading configuration from URL with embedded authentication"""
  83. config_data = {"dns": "cloudflare", "ttl": 300}
  84. mock_http.return_value = HttpResponse(200, "OK", {}, json.dumps(config_data))
  85. config_url = "https://user:[email protected]/secure/config.json"
  86. result = load_config(config_url)
  87. self.assertEqual(result, config_data)
  88. # The HTTP module handles embedded auth automatically
  89. mock_http.assert_called_once_with("GET", config_url, proxies=None, verify="auto", retries=3)
  90. @patch("ddns.config.file.request")
  91. def test_load_config_remote_http_error(self, mock_http):
  92. """Test handling HTTP error responses"""
  93. mock_http.return_value = HttpResponse(404, "Not Found", {}, "Not Found")
  94. config_url = "https://example.com/missing.json"
  95. with self.assertRaises(Exception) as context:
  96. load_config(config_url)
  97. self.assertIn("HTTP 404: Not Found", str(context.exception))
  98. @patch("ddns.config.file.request")
  99. def test_load_config_remote_http_500_error(self, mock_http):
  100. """Test handling HTTP 5xx server errors"""
  101. mock_http.return_value = HttpResponse(500, "Internal Server Error", {}, "Server Error")
  102. config_url = "https://example.com/config.json"
  103. with self.assertRaises(Exception) as context:
  104. load_config(config_url)
  105. self.assertIn("HTTP 500: Internal Server Error", str(context.exception))
  106. @patch("ddns.config.file.request")
  107. def test_load_config_remote_network_error(self, mock_http):
  108. """Test handling network errors during HTTP request"""
  109. mock_http.side_effect = URLError("Network is unreachable")
  110. config_url = "https://unreachable.example.com/config.json"
  111. with self.assertRaises(Exception):
  112. load_config(config_url)
  113. @patch("ddns.config.file.request")
  114. def test_load_config_remote_invalid_json(self, mock_http):
  115. """Test handling invalid JSON in remote response"""
  116. # Invalid JSON content
  117. invalid_json = '{"dns": "test", invalid syntax}'
  118. mock_http.return_value = HttpResponse(200, "OK", {}, invalid_json)
  119. config_url = "https://example.com/bad-config.json"
  120. with self.assertRaises((ValueError, SyntaxError)):
  121. load_config(config_url)
  122. @patch("ddns.config.file.request")
  123. def test_load_config_remote_ast_fallback(self, mock_http):
  124. """Test AST parsing fallback for remote content"""
  125. # Patch stdout to capture output
  126. import ddns.config.file
  127. original_stdout = ddns.config.file.stdout
  128. ddns.config.file.stdout = self.stdout_capture
  129. try:
  130. # Valid Python dict but invalid JSON (trailing comma)
  131. python_content = '{"dns": "alidns", "id": "test", "token": "xyz",}'
  132. mock_http.return_value = HttpResponse(200, "OK", {}, python_content)
  133. config_url = "https://example.com/config.py"
  134. result = load_config(config_url)
  135. expected = {"dns": "alidns", "id": "test", "token": "xyz"}
  136. self.assertEqual(result, expected)
  137. # Verify AST fallback success message
  138. stdout_output = self.stdout_capture.getvalue()
  139. self.assertIn("Successfully loaded config file with AST parser", stdout_output)
  140. finally:
  141. ddns.config.file.stdout = original_stdout
  142. @patch("ddns.config.file.request")
  143. def test_load_config_remote_v41_providers_format(self, mock_http):
  144. """Test loading remote configuration with v4.1 providers format"""
  145. config_data = {
  146. "$schema": "https://ddns.newfuture.cc/schema/v4.1.json",
  147. "ssl": "auto",
  148. "cache": True,
  149. "providers": [
  150. {"provider": "cloudflare", "id": "[email protected]", "token": "token1", "ipv4": ["test1.example.com"]},
  151. {"provider": "dnspod", "id": "[email protected]", "token": "token2", "ipv4": ["test2.example.com"]},
  152. ],
  153. }
  154. mock_http.return_value = HttpResponse(200, "OK", {}, json.dumps(config_data))
  155. config_url = "https://example.com/multi-provider.json"
  156. result = load_config(config_url)
  157. # Should return a list of configs
  158. self.assertIsInstance(result, list)
  159. self.assertEqual(len(result), 2)
  160. # Test first provider config
  161. config1 = result[0]
  162. self.assertEqual(config1["dns"], "cloudflare")
  163. self.assertEqual(config1["id"], "[email protected]")
  164. self.assertEqual(config1["ssl"], "auto") # Global config inherited
  165. # Test second provider config
  166. config2 = result[1]
  167. self.assertEqual(config2["dns"], "dnspod")
  168. self.assertEqual(config2["id"], "[email protected]")
  169. self.assertEqual(config2["ssl"], "auto") # Global config inherited
  170. @patch("ddns.config.file.request")
  171. def test_load_config_remote_with_comments(self, mock_http):
  172. """Test loading remote configuration with comments"""
  173. json_with_comments = """{
  174. // Remote configuration for DDNS
  175. "dns": "cloudflare", // DNS provider
  176. "id": "[email protected]",
  177. "token": "secret123", // API token
  178. "ttl": 300
  179. // End of config
  180. }"""
  181. mock_http.return_value = HttpResponse(200, "OK", {}, json_with_comments)
  182. config_url = "https://example.com/config-with-comments.json"
  183. result = load_config(config_url)
  184. expected = {"dns": "cloudflare", "id": "[email protected]", "token": "secret123", "ttl": 300}
  185. self.assertEqual(result, expected)
  186. @patch("ddns.config.file.request")
  187. def test_load_config_remote_unicode_content(self, mock_http):
  188. """Test loading remote configuration with unicode characters"""
  189. unicode_config = {"dns": "cloudflare", "description": "测试配置文件", "symbols": "αβγδε", "emoji": "🌍🔧⚡"}
  190. mock_http.return_value = HttpResponse(200, "OK", {}, json.dumps(unicode_config, ensure_ascii=False))
  191. config_url = "https://example.com/unicode-config.json"
  192. result = load_config(config_url)
  193. self.assertEqual(result["dns"], "cloudflare")
  194. self.assertEqual(result["description"], "测试配置文件")
  195. self.assertEqual(result["symbols"], "αβγδε")
  196. self.assertEqual(result["emoji"], "🌍🔧⚡")
  197. def test_load_config_local_file_still_works(self):
  198. """Test that local file loading still works without changes"""
  199. # Create a local test file
  200. config_data = {"dns": "local", "id": "test", "token": "local123"}
  201. config_file = os.path.join(self.temp_dir, "local.json")
  202. with open(config_file, "w") as f:
  203. json.dump(config_data, f)
  204. # Load local file
  205. result = load_config(config_file)
  206. self.assertEqual(result, config_data)
  207. def test_load_config_url_detection(self):
  208. """Test URL detection logic works correctly"""
  209. # These should be detected as URLs
  210. urls = [
  211. "http://example.com/config.json",
  212. "https://example.com/config.json",
  213. "ftp://example.com/config.json",
  214. "file://path/to/config.json",
  215. ]
  216. # These should NOT be detected as URLs
  217. non_urls = [
  218. "/path/to/config.json",
  219. "./config.json",
  220. "config.json",
  221. "C:\\path\\to\\config.json",
  222. "~/config.json",
  223. ]
  224. # Test URL detection (we'll mock the HTTP request to avoid actual network calls)
  225. with patch("ddns.config.file.request") as mock_http:
  226. mock_http.return_value = HttpResponse(200, "OK", {}, '{"dns": "test"}')
  227. for url in urls:
  228. try:
  229. load_config(url)
  230. mock_http.assert_called_with("GET", url, proxies=None, verify="auto", retries=3)
  231. except Exception:
  232. pass # We're just testing URL detection, not full functionality
  233. # Reset mock call count
  234. mock_http.reset_mock()
  235. # Test non-URLs (these should not trigger HTTP requests)
  236. for non_url in non_urls:
  237. try:
  238. load_config(non_url) # This will fail because files don't exist, but shouldn't call HTTP
  239. except Exception:
  240. pass # Expected - files don't exist
  241. # HTTP request should not have been called for non-URLs
  242. mock_http.assert_not_called()
  243. @patch("ddns.config.file.request")
  244. def test_load_config_remote_ssl_configurations(self, mock_http):
  245. """Test different SSL verification configurations"""
  246. config_data = {"dns": "test"}
  247. mock_http.return_value = HttpResponse(200, "OK", {}, json.dumps(config_data))
  248. config_url = "https://example.com/config.json"
  249. # Test different SSL settings
  250. ssl_configs = [True, False, "auto", "/path/to/cert.pem"]
  251. for ssl_config in ssl_configs:
  252. load_config(config_url, ssl=ssl_config)
  253. mock_http.assert_called_with("GET", config_url, proxies=None, verify=ssl_config, retries=3)
  254. mock_http.reset_mock()
  255. @patch("ddns.config.file.request")
  256. def test_load_config_remote_proxy_configurations(self, mock_http):
  257. """Test different proxy configurations"""
  258. config_data = {"dns": "test"}
  259. mock_http.return_value = HttpResponse(200, "OK", {}, json.dumps(config_data))
  260. config_url = "https://example.com/config.json"
  261. # Test different proxy settings
  262. proxy_configs = [
  263. None,
  264. [],
  265. ["http://proxy.example.com:8080"],
  266. ["http://proxy1.com:8080", "http://proxy2.com:8080"],
  267. ["DIRECT"],
  268. ["SYSTEM"],
  269. ["http://proxy.com:8080", "DIRECT"],
  270. ]
  271. for proxy_config in proxy_configs:
  272. load_config(config_url, proxy=proxy_config)
  273. mock_http.assert_called_with("GET", config_url, proxies=proxy_config, verify="auto", retries=3)
  274. mock_http.reset_mock()
  275. def test_load_config_real_remote_url(self):
  276. """Test loading configuration from the actual remote URL for real integration testing"""
  277. # This tests the real URL provided in the specification
  278. config_url = "https://ddns.newfuture.cc/tests/config/debug.json"
  279. # This is a real integration test - it should succeed if the URL is accessible
  280. # If the URL is not accessible due to network issues, the test will be skipped
  281. try:
  282. result = load_config(config_url)
  283. # Handle both single config (dict) and multi-provider config (list)
  284. if isinstance(result, list):
  285. # Multi-provider format - verify we got at least one configuration
  286. self.assertGreater(len(result), 0, "Should load at least one configuration")
  287. config = result[0]
  288. else:
  289. # Single provider format
  290. config = result
  291. # Verify that the config has the expected structure
  292. self.assertIsInstance(config, dict, "Config should be a dictionary")
  293. # Check for at least one expected field (debug is common in debug configs)
  294. self.assertTrue(
  295. "debug" in config or "dns" in config or "id" in config,
  296. "Config should have at least one expected field (debug, dns, or id)",
  297. )
  298. except (URLError, HTTPError, socket.timeout, socket.gaierror, socket.herror) as e:
  299. # Only skip for network connection issues (URLError, HTTPError 5xx, timeout)
  300. if isinstance(e, HTTPError):
  301. # For HTTPError, only skip if it's a server error (5xx)
  302. if e.code >= 500:
  303. self.skipTest("Real remote URL test skipped due to server error %s: %s" % (e.code, str(e)))
  304. else:
  305. # For client errors (4xx), the test should fail as it indicates a real problem
  306. self.fail("Remote URL returned client error %s: %s" % (e.code, str(e)))
  307. else:
  308. # For URLError, socket errors, skip the test
  309. self.skipTest("Real remote URL test skipped due to network error: %s" % str(e))
  310. except Exception as e:
  311. # For other exceptions (like JSON parsing errors), the test should fail
  312. self.fail("Real remote URL test failed with unexpected error: %s" % str(e))
  313. if __name__ == "__main__":
  314. unittest.main()