test_provider_callback.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. # coding=utf-8
  2. """
  3. Unit tests for CallbackProvider
  4. @author: GitHub Copilot
  5. """
  6. import os
  7. import sys
  8. import logging
  9. import random
  10. import platform
  11. from time import sleep
  12. from base_test import BaseProviderTestCase, unittest, patch
  13. from ddns.provider.callback import CallbackProvider
  14. class TestCallbackProvider(BaseProviderTestCase):
  15. """Test cases for CallbackProvider"""
  16. def setUp(self):
  17. """Set up test fixtures"""
  18. super(TestCallbackProvider, self).setUp()
  19. self.id = "https://example.com/callback?domain=__DOMAIN__&ip=__IP__"
  20. self.token = "" # Use empty string instead of None for token
  21. def test_init_with_basic_config(self):
  22. """Test CallbackProvider initialization with basic configuration"""
  23. provider = CallbackProvider(self.id, self.token)
  24. self.assertEqual(provider.id, self.id)
  25. self.assertEqual(provider.token, self.token)
  26. self.assertFalse(provider.decode_response)
  27. def test_init_with_token_config(self):
  28. """Test CallbackProvider initialization with token configuration"""
  29. token = '{"api_key": "__DOMAIN__", "value": "__IP__"}'
  30. provider = CallbackProvider(self.id, token)
  31. self.assertEqual(provider.token, token)
  32. def test_validate_success(self):
  33. """Test _validate method with valid configuration"""
  34. provider = CallbackProvider(self.id, self.token)
  35. # Should not raise any exception since we have a valid id
  36. provider._validate()
  37. def test_validate_failure_no_id(self):
  38. """Test _validate method with missing id"""
  39. # _validate is called in __init__, so we need to test it directly
  40. with self.assertRaises(ValueError) as cm:
  41. CallbackProvider(None, self.token) # type: ignore
  42. self.assertIn("id must be configured", str(cm.exception))
  43. def test_validate_failure_empty_id(self):
  44. """Test _validate method with empty id"""
  45. # _validate is called in __init__, so we need to test it directly
  46. with self.assertRaises(ValueError) as cm:
  47. CallbackProvider("", self.token)
  48. self.assertIn("id must be configured", str(cm.exception))
  49. def test_replace_vars_basic(self):
  50. """Test _replace_vars method with basic replacements"""
  51. provider = CallbackProvider(self.id, self.token)
  52. test_str = "Hello __NAME__, your IP is __IP__"
  53. mapping = {"__NAME__": "World", "__IP__": "192.168.1.1"}
  54. result = provider._replace_vars(test_str, mapping)
  55. expected = "Hello World, your IP is 192.168.1.1"
  56. self.assertEqual(result, expected)
  57. def test_replace_vars_no_matches(self):
  58. """Test _replace_vars method with no matching variables"""
  59. provider = CallbackProvider(self.id, self.token)
  60. test_str = "No variables here"
  61. mapping = {"__NAME__": "World"}
  62. result = provider._replace_vars(test_str, mapping)
  63. self.assertEqual(result, test_str)
  64. def test_replace_vars_partial_matches(self):
  65. """Test _replace_vars method with partial matches"""
  66. provider = CallbackProvider(self.id, self.token)
  67. test_str = "__DOMAIN__ and __UNKNOWN__ and __IP__"
  68. mapping = {"__DOMAIN__": "example.com", "__IP__": "1.2.3.4"}
  69. result = provider._replace_vars(test_str, mapping)
  70. expected = "example.com and __UNKNOWN__ and 1.2.3.4"
  71. self.assertEqual(result, expected)
  72. def test_replace_vars_empty_string(self):
  73. """Test _replace_vars method with empty string"""
  74. provider = CallbackProvider(self.id, self.token)
  75. result = provider._replace_vars("", {"__TEST__": "value"})
  76. self.assertEqual(result, "")
  77. def test_replace_vars_empty_mapping(self):
  78. """Test _replace_vars method with empty mapping"""
  79. provider = CallbackProvider(self.id, self.token)
  80. test_str = "__DOMAIN__ test"
  81. result = provider._replace_vars(test_str, {})
  82. self.assertEqual(result, test_str)
  83. def test_replace_vars_none_values(self):
  84. """Test _replace_vars method with None values (should convert to string)"""
  85. provider = CallbackProvider(self.id, self.token)
  86. test_str = "TTL: __TTL__, Line: __LINE__"
  87. mapping = {"__TTL__": None, "__LINE__": None}
  88. result = provider._replace_vars(test_str, mapping)
  89. expected = "TTL: None, Line: None"
  90. self.assertEqual(result, expected)
  91. def test_replace_vars_numeric_values(self):
  92. """Test _replace_vars method with numeric values (should convert to string)"""
  93. provider = CallbackProvider(self.id, self.token)
  94. test_str = "Port: __PORT__, TTL: __TTL__"
  95. mapping = {"__PORT__": 8080, "__TTL__": 300}
  96. result = provider._replace_vars(test_str, mapping)
  97. expected = "Port: 8080, TTL: 300"
  98. self.assertEqual(result, expected)
  99. @patch("ddns.provider.callback.time")
  100. @patch.object(CallbackProvider, "_http")
  101. def test_set_record_get_method(self, mock_http, mock_time):
  102. """Test set_record method using GET method (no token)"""
  103. mock_time.return_value = 1634567890.123
  104. mock_http.return_value = "Success"
  105. provider = CallbackProvider(self.id, None) # type: ignore
  106. result = provider.set_record("example.com", "192.168.1.1", "A", 300, "default")
  107. # Verify the result
  108. self.assertTrue(result)
  109. # Verify _http was called with correct parameters
  110. mock_http.assert_called_once()
  111. args, kwargs = mock_http.call_args
  112. self.assertEqual(args[0], "GET") # method # Check that URL contains replaced variables
  113. url = args[1]
  114. self.assertIn("example.com", url)
  115. self.assertIn("192.168.1.1", url)
  116. @patch("ddns.provider.callback.time")
  117. @patch.object(CallbackProvider, "_http")
  118. def test_set_record_post_method_dict_token(self, mock_http, mock_time):
  119. """Test set_record method using POST method with dict token"""
  120. mock_time.return_value = 1634567890.123
  121. mock_http.return_value = "Success"
  122. token = {"api_key": "test_key", "domain": "__DOMAIN__", "ip": "__IP__"}
  123. provider = CallbackProvider(self.id, token) # type: ignore
  124. result = provider.set_record("example.com", "192.168.1.1", "A", 300, "default")
  125. # Verify the result
  126. self.assertTrue(result) # Verify _http was called with correct parameters
  127. mock_http.assert_called_once()
  128. args, kwargs = mock_http.call_args
  129. self.assertEqual(args[0], "POST") # method
  130. # URL should be replaced with actual values even for POST
  131. url = args[1]
  132. self.assertIn("example.com", url)
  133. self.assertIn("192.168.1.1", url)
  134. # Check params were properly replaced
  135. params = kwargs["body"]
  136. self.assertEqual(params["api_key"], "test_key")
  137. self.assertEqual(params["domain"], "example.com")
  138. self.assertEqual(params["ip"], "192.168.1.1")
  139. @patch("ddns.provider.callback.time")
  140. @patch.object(CallbackProvider, "_http")
  141. def test_set_record_post_method_json_token(self, mock_http, mock_time):
  142. """Test set_record method using POST method with JSON string token"""
  143. mock_time.return_value = 1634567890.123
  144. mock_http.return_value = "Success"
  145. token = '{"api_key": "test_key", "domain": "__DOMAIN__", "ip": "__IP__"}'
  146. provider = CallbackProvider(self.id, token)
  147. result = provider.set_record("example.com", "192.168.1.1", "A", 300, "default")
  148. # Verify the result
  149. self.assertTrue(result) # Verify _http was called with correct parameters
  150. mock_http.assert_called_once()
  151. args, kwargs = mock_http.call_args
  152. self.assertEqual(args[0], "POST") # method
  153. # URL should be replaced with actual values even for POST
  154. url = args[1]
  155. self.assertIn("example.com", url)
  156. self.assertIn("192.168.1.1", url)
  157. # Check params were properly replaced
  158. params = kwargs["body"]
  159. self.assertEqual(params["api_key"], "test_key")
  160. self.assertEqual(params["domain"], "example.com")
  161. self.assertEqual(params["ip"], "192.168.1.1")
  162. @patch("ddns.provider.callback.time")
  163. @patch.object(CallbackProvider, "_http")
  164. def test_set_record_post_method_mixed_types(self, mock_http, mock_time):
  165. """Test set_record method with mixed type values in POST parameters"""
  166. mock_time.return_value = 1634567890.123
  167. mock_http.return_value = "Success"
  168. token = {"api_key": 12345, "domain": "__DOMAIN__", "timeout": 30, "enabled": True}
  169. provider = CallbackProvider(self.id, token) # type: ignore
  170. result = provider.set_record("example.com", "192.168.1.1")
  171. # Verify the result
  172. self.assertTrue(result)
  173. # Verify _http was called with correct parameters
  174. mock_http.assert_called_once()
  175. args, kwargs = mock_http.call_args
  176. self.assertEqual(args[0], "POST") # method
  177. # Check that non-string values were not processed, but string values were replaced
  178. params = kwargs["body"]
  179. self.assertEqual(params["api_key"], 12345) # unchanged (not a string)
  180. self.assertEqual(params["domain"], "example.com") # replaced (was a string)
  181. self.assertEqual(params["timeout"], 30) # unchanged (not a string)
  182. self.assertEqual(params["enabled"], True) # unchanged (not a string)
  183. @patch("ddns.provider.callback.time")
  184. @patch.object(CallbackProvider, "_http")
  185. def test_set_record_http_failure(self, mock_http, mock_time):
  186. """Test set_record method when HTTP request fails"""
  187. mock_time.return_value = 1634567890.123
  188. mock_http.return_value = None # Simulate failure
  189. provider = CallbackProvider(self.id, None) # type: ignore
  190. result = provider.set_record("example.com", "192.168.1.1")
  191. # Verify the result is False on failure
  192. self.assertFalse(result)
  193. @patch("ddns.provider.callback.time")
  194. @patch.object(CallbackProvider, "_http")
  195. def test_set_record_http_none_response(self, mock_http, mock_time):
  196. """Test set_record method with None HTTP response"""
  197. mock_time.return_value = 1634567890.123
  198. mock_http.return_value = None # None response
  199. provider = CallbackProvider(self.id, None) # type: ignore
  200. result = provider.set_record("example.com", "192.168.1.1")
  201. # Empty string is falsy, so result should be False
  202. self.assertFalse(result)
  203. @patch("ddns.provider.callback.jsondecode")
  204. def test_json_decode_error_handling(self, mock_jsondecode):
  205. """Test handling of JSON decode errors in POST method"""
  206. mock_jsondecode.side_effect = ValueError("Invalid JSON")
  207. token = "invalid json"
  208. provider = CallbackProvider(self.id, token)
  209. # This should raise an exception when trying to decode invalid JSON
  210. with self.assertRaises(ValueError):
  211. provider.set_record("example.com", "192.168.1.1")
  212. class TestCallbackProviderRealIntegration(BaseProviderTestCase):
  213. """Real integration tests for CallbackProvider using httpbin.org"""
  214. def setUp(self):
  215. """Set up real test fixtures and skip on unsupported CI environments"""
  216. super(TestCallbackProviderRealIntegration, self).setUp()
  217. # Skip on Python 3.10/3.13 or 32bit in CI
  218. is_ci = os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS") or os.environ.get("GITHUB_REF_NAME")
  219. pyver = sys.version_info
  220. sys_platform = sys.platform.lower()
  221. machine = platform.machine().lower()
  222. is_mac = sys_platform == "darwin"
  223. # On macOS CI, require arm64; on others, require amd64/x86_64
  224. if is_ci:
  225. if is_mac:
  226. if not ("arm" in machine or "aarch64" in machine):
  227. self.skipTest("On macOS CI, only arm64 is supported for integration tests.")
  228. else:
  229. if not ("amd64" in machine or "x86_64" in machine):
  230. self.skipTest("On non-macOS CI, only amd64/x86_64 is supported for integration tests.")
  231. if pyver[:2] in [(3, 10), (3, 13)] or platform.architecture()[0] == "32bit":
  232. self.skipTest("Skip real HTTP integration on CI for Python 3.10/3.13 or 32bit platform")
  233. def _setup_provider_with_mock_logger(self, provider):
  234. """Helper method to setup provider with a mock logger."""
  235. mock_logger = self.mock_logger(provider)
  236. # Ensure the logger is configured to capture info calls
  237. mock_logger.setLevel(logging.INFO)
  238. return mock_logger
  239. def _random_delay(self):
  240. """Add a random delay of 0-3 seconds to avoid rate limiting"""
  241. if os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS") or os.environ.get("GITHUB_REF_NAME"):
  242. # In CI environments, use a shorter delay to speed up tests
  243. delay = random.uniform(0, 2)
  244. else:
  245. delay = random.uniform(0, 1)
  246. sleep(delay)
  247. def _assert_callback_result_logged(self, mock_logger, *expected_strings):
  248. """
  249. Helper to assert that 'Callback result: %s' was logged with expected content.
  250. """
  251. info_calls = mock_logger.info.call_args_list
  252. response_logged = False
  253. for call in info_calls:
  254. if len(call[0]) >= 2 and call[0][0] == "Callback result: %s":
  255. response_content = str(call[0][1])
  256. # Check if the response contains the expected strings
  257. if all(expected in response_content for expected in expected_strings):
  258. response_logged = True
  259. break
  260. # Also check if this is a firewall/network blocking response
  261. blocking_keywords = ["firewall", "deny", "blocked", "policy", "rule"]
  262. if any(keyword.lower() in response_content.lower() for keyword in blocking_keywords):
  263. # Skip test if network is blocked
  264. raise unittest.SkipTest("Network request blocked by firewall/policy: {}".format(response_content))
  265. self.assertTrue(
  266. response_logged,
  267. "Expected logger.info to log 'Callback result' containing: {}".format(", ".join(expected_strings)),
  268. )
  269. def test_real_callback_get_method(self):
  270. """Test real callback using GET method with httpbin/httpbingo and verify logger calls"""
  271. # 尝试多个测试端点以提高可靠性
  272. test_endpoints = [
  273. "http://httpbin.org/get?domain=__DOMAIN__&ip=__IP__&record_type=__RECORDTYPE__",
  274. "http://httpbingo.org/get?domain=__DOMAIN__&ip=__IP__&record_type=__RECORDTYPE__",
  275. ]
  276. domain = "test.example.com"
  277. ip = "111.111.111.111"
  278. last_exception = None
  279. for endpoint_id in test_endpoints:
  280. try:
  281. provider = CallbackProvider(endpoint_id, "", ssl="auto")
  282. mock_logger = self._setup_provider_with_mock_logger(provider)
  283. self._random_delay() # Add random delay before real request
  284. result = provider.set_record(domain, ip, "A")
  285. if result:
  286. self.assertTrue(result)
  287. self._assert_callback_result_logged(mock_logger, domain, ip)
  288. return # 成功则退出
  289. else:
  290. # 如果结果为False,可能是5xx错误,尝试下一个端点
  291. continue
  292. except Exception as e:
  293. last_exception = e
  294. # 网络问题时继续尝试下一个端点
  295. error_msg = str(e).lower()
  296. network_keywords = [
  297. "timeout",
  298. "connection",
  299. "resolution",
  300. "unreachable",
  301. "network",
  302. "ssl",
  303. "certificate",
  304. ]
  305. if any(keyword in error_msg for keyword in network_keywords):
  306. continue # 尝试下一个端点
  307. else:
  308. # 其他异常重新抛出
  309. raise
  310. # 如果所有端点都失败,跳过测试
  311. error_info = " - Last error: {}".format(str(last_exception)) if last_exception else ""
  312. self.skipTest("All network endpoints unavailable for GET callback test{}".format(error_info))
  313. def test_real_callback_post_method_with_json(self):
  314. """Test real callback using POST method with JSON data and verify logger calls"""
  315. # 尝试多个测试端点以提高可靠性
  316. test_endpoints = ["http://httpbingo.org/post", "http://httpbin.org/post"]
  317. token = '{"domain": "__DOMAIN__", "ip": "__IP__", "record_type": "__RECORDTYPE__", "ttl": "__TTL__"}'
  318. domain = "test.example.com"
  319. ip = "203.0.113.2"
  320. last_exception = None
  321. for endpoint_id in test_endpoints:
  322. try:
  323. provider = CallbackProvider(endpoint_id, token)
  324. # Setup provider with mock logger
  325. mock_logger = self._setup_provider_with_mock_logger(provider)
  326. self._random_delay() # Add random delay before real request
  327. result = provider.set_record(domain, ip, "A", 300)
  328. if result:
  329. # httpbin/httpbingo returns JSON with our posted data, so it should be truthy
  330. self.assertTrue(result)
  331. # Verify that logger.info was called with response containing domain and IP
  332. self._assert_callback_result_logged(mock_logger, domain, ip)
  333. return # 成功则退出
  334. else:
  335. # 如果结果为False,可能是5xx错误,尝试下一个端点
  336. continue
  337. except Exception as e:
  338. last_exception = e
  339. # 网络问题时继续尝试下一个端点
  340. error_msg = str(e).lower()
  341. network_keywords = [
  342. "timeout",
  343. "connection",
  344. "resolution",
  345. "unreachable",
  346. "network",
  347. "ssl",
  348. "certificate",
  349. ]
  350. if any(keyword in error_msg for keyword in network_keywords):
  351. continue # 尝试下一个端点
  352. else:
  353. # 其他异常重新抛出
  354. raise
  355. # 如果所有端点都失败,跳过测试
  356. error_info = " - Last error: {}".format(str(last_exception)) if last_exception else ""
  357. self.skipTest("All network endpoints unavailable for POST callback test{}".format(error_info))
  358. def test_real_callback_error_handling(self):
  359. """Test real callback error handling with invalid URL"""
  360. # Use an invalid URL to test error handling
  361. id = "http://postman-echo.com/status/400" # This returns HTTP 400
  362. provider = CallbackProvider(id, "")
  363. self._random_delay() # Add random delay before real request
  364. result = provider.set_record("test.example.com", "203.0.113.5")
  365. self.assertFalse(result)
  366. if __name__ == "__main__":
  367. unittest.main()