test_provider_callback.py 19 KB

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