| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454 |
- # coding=utf-8
- """
- Unit tests for CallbackProvider
- @author: GitHub Copilot
- """
- import os
- import sys
- import logging
- import random
- import platform
- from time import sleep
- from base_test import BaseProviderTestCase, unittest, patch
- from ddns.provider.callback import CallbackProvider
- class TestCallbackProvider(BaseProviderTestCase):
- """Test cases for CallbackProvider"""
- def setUp(self):
- """Set up test fixtures"""
- super(TestCallbackProvider, self).setUp()
- self.id = "https://example.com/callback?domain=__DOMAIN__&ip=__IP__"
- self.token = "" # Use empty string instead of None for token
- def test_init_with_basic_config(self):
- """Test CallbackProvider initialization with basic configuration"""
- provider = CallbackProvider(self.id, self.token)
- self.assertEqual(provider.id, self.id)
- self.assertEqual(provider.token, self.token)
- self.assertFalse(provider.decode_response)
- def test_init_with_token_config(self):
- """Test CallbackProvider initialization with token configuration"""
- token = '{"api_key": "__DOMAIN__", "value": "__IP__"}'
- provider = CallbackProvider(self.id, token)
- self.assertEqual(provider.token, token)
- def test_validate_success(self):
- """Test _validate method with valid configuration"""
- provider = CallbackProvider(self.id, self.token)
- # Should not raise any exception since we have a valid id
- provider._validate()
- def test_validate_failure_no_id(self):
- """Test _validate method with missing id"""
- # _validate is called in __init__, so we need to test it directly
- with self.assertRaises(ValueError) as cm:
- CallbackProvider(None, self.token) # type: ignore
- self.assertIn("id must be configured", str(cm.exception))
- def test_validate_failure_empty_id(self):
- """Test _validate method with empty id"""
- # _validate is called in __init__, so we need to test it directly
- with self.assertRaises(ValueError) as cm:
- CallbackProvider("", self.token)
- self.assertIn("id must be configured", str(cm.exception))
- def test_replace_vars_basic(self):
- """Test _replace_vars method with basic replacements"""
- provider = CallbackProvider(self.id, self.token)
- test_str = "Hello __NAME__, your IP is __IP__"
- mapping = {"__NAME__": "World", "__IP__": "192.168.1.1"}
- result = provider._replace_vars(test_str, mapping)
- expected = "Hello World, your IP is 192.168.1.1"
- self.assertEqual(result, expected)
- def test_replace_vars_no_matches(self):
- """Test _replace_vars method with no matching variables"""
- provider = CallbackProvider(self.id, self.token)
- test_str = "No variables here"
- mapping = {"__NAME__": "World"}
- result = provider._replace_vars(test_str, mapping)
- self.assertEqual(result, test_str)
- def test_replace_vars_partial_matches(self):
- """Test _replace_vars method with partial matches"""
- provider = CallbackProvider(self.id, self.token)
- test_str = "__DOMAIN__ and __UNKNOWN__ and __IP__"
- mapping = {"__DOMAIN__": "example.com", "__IP__": "1.2.3.4"}
- result = provider._replace_vars(test_str, mapping)
- expected = "example.com and __UNKNOWN__ and 1.2.3.4"
- self.assertEqual(result, expected)
- def test_replace_vars_empty_string(self):
- """Test _replace_vars method with empty string"""
- provider = CallbackProvider(self.id, self.token)
- result = provider._replace_vars("", {"__TEST__": "value"})
- self.assertEqual(result, "")
- def test_replace_vars_empty_mapping(self):
- """Test _replace_vars method with empty mapping"""
- provider = CallbackProvider(self.id, self.token)
- test_str = "__DOMAIN__ test"
- result = provider._replace_vars(test_str, {})
- self.assertEqual(result, test_str)
- def test_replace_vars_none_values(self):
- """Test _replace_vars method with None values (should convert to string)"""
- provider = CallbackProvider(self.id, self.token)
- test_str = "TTL: __TTL__, Line: __LINE__"
- mapping = {"__TTL__": None, "__LINE__": None}
- result = provider._replace_vars(test_str, mapping)
- expected = "TTL: None, Line: None"
- self.assertEqual(result, expected)
- def test_replace_vars_numeric_values(self):
- """Test _replace_vars method with numeric values (should convert to string)"""
- provider = CallbackProvider(self.id, self.token)
- test_str = "Port: __PORT__, TTL: __TTL__"
- mapping = {"__PORT__": 8080, "__TTL__": 300}
- result = provider._replace_vars(test_str, mapping)
- expected = "Port: 8080, TTL: 300"
- self.assertEqual(result, expected)
- @patch("ddns.provider.callback.time")
- @patch.object(CallbackProvider, "_http")
- def test_set_record_get_method(self, mock_http, mock_time):
- """Test set_record method using GET method (no token)"""
- mock_time.return_value = 1634567890.123
- mock_http.return_value = "Success"
- provider = CallbackProvider(self.id, None) # type: ignore
- result = provider.set_record("example.com", "192.168.1.1", "A", 300, "default")
- # Verify the result
- self.assertTrue(result)
- # Verify _http was called with correct parameters
- mock_http.assert_called_once()
- args, kwargs = mock_http.call_args
- self.assertEqual(args[0], "GET") # method # Check that URL contains replaced variables
- url = args[1]
- self.assertIn("example.com", url)
- self.assertIn("192.168.1.1", url)
- @patch("ddns.provider.callback.time")
- @patch.object(CallbackProvider, "_http")
- def test_set_record_post_method_dict_token(self, mock_http, mock_time):
- """Test set_record method using POST method with dict token"""
- mock_time.return_value = 1634567890.123
- mock_http.return_value = "Success"
- token = {"api_key": "test_key", "domain": "__DOMAIN__", "ip": "__IP__"}
- provider = CallbackProvider(self.id, token) # type: ignore
- result = provider.set_record("example.com", "192.168.1.1", "A", 300, "default")
- # Verify the result
- self.assertTrue(result) # Verify _http was called with correct parameters
- mock_http.assert_called_once()
- args, kwargs = mock_http.call_args
- self.assertEqual(args[0], "POST") # method
- # URL should be replaced with actual values even for POST
- url = args[1]
- self.assertIn("example.com", url)
- self.assertIn("192.168.1.1", url)
- # Check params were properly replaced
- params = kwargs["body"]
- self.assertEqual(params["api_key"], "test_key")
- self.assertEqual(params["domain"], "example.com")
- self.assertEqual(params["ip"], "192.168.1.1")
- @patch("ddns.provider.callback.time")
- @patch.object(CallbackProvider, "_http")
- def test_set_record_post_method_json_token(self, mock_http, mock_time):
- """Test set_record method using POST method with JSON string token"""
- mock_time.return_value = 1634567890.123
- mock_http.return_value = "Success"
- token = '{"api_key": "test_key", "domain": "__DOMAIN__", "ip": "__IP__"}'
- provider = CallbackProvider(self.id, token)
- result = provider.set_record("example.com", "192.168.1.1", "A", 300, "default")
- # Verify the result
- self.assertTrue(result) # Verify _http was called with correct parameters
- mock_http.assert_called_once()
- args, kwargs = mock_http.call_args
- self.assertEqual(args[0], "POST") # method
- # URL should be replaced with actual values even for POST
- url = args[1]
- self.assertIn("example.com", url)
- self.assertIn("192.168.1.1", url)
- # Check params were properly replaced
- params = kwargs["body"]
- self.assertEqual(params["api_key"], "test_key")
- self.assertEqual(params["domain"], "example.com")
- self.assertEqual(params["ip"], "192.168.1.1")
- @patch("ddns.provider.callback.time")
- @patch.object(CallbackProvider, "_http")
- def test_set_record_post_method_mixed_types(self, mock_http, mock_time):
- """Test set_record method with mixed type values in POST parameters"""
- mock_time.return_value = 1634567890.123
- mock_http.return_value = "Success"
- token = {"api_key": 12345, "domain": "__DOMAIN__", "timeout": 30, "enabled": True}
- provider = CallbackProvider(self.id, token) # type: ignore
- result = provider.set_record("example.com", "192.168.1.1")
- # Verify the result
- self.assertTrue(result)
- # Verify _http was called with correct parameters
- mock_http.assert_called_once()
- args, kwargs = mock_http.call_args
- self.assertEqual(args[0], "POST") # method
- # Check that non-string values were not processed, but string values were replaced
- params = kwargs["body"]
- self.assertEqual(params["api_key"], 12345) # unchanged (not a string)
- self.assertEqual(params["domain"], "example.com") # replaced (was a string)
- self.assertEqual(params["timeout"], 30) # unchanged (not a string)
- self.assertEqual(params["enabled"], True) # unchanged (not a string)
- @patch("ddns.provider.callback.time")
- @patch.object(CallbackProvider, "_http")
- def test_set_record_http_failure(self, mock_http, mock_time):
- """Test set_record method when HTTP request fails"""
- mock_time.return_value = 1634567890.123
- mock_http.return_value = None # Simulate failure
- provider = CallbackProvider(self.id, None) # type: ignore
- result = provider.set_record("example.com", "192.168.1.1")
- # Verify the result is False on failure
- self.assertFalse(result)
- @patch("ddns.provider.callback.time")
- @patch.object(CallbackProvider, "_http")
- def test_set_record_http_none_response(self, mock_http, mock_time):
- """Test set_record method with None HTTP response"""
- mock_time.return_value = 1634567890.123
- mock_http.return_value = None # None response
- provider = CallbackProvider(self.id, None) # type: ignore
- result = provider.set_record("example.com", "192.168.1.1")
- # Empty string is falsy, so result should be False
- self.assertFalse(result)
- @patch("ddns.provider.callback.jsondecode")
- def test_json_decode_error_handling(self, mock_jsondecode):
- """Test handling of JSON decode errors in POST method"""
- mock_jsondecode.side_effect = ValueError("Invalid JSON")
- token = "invalid json"
- provider = CallbackProvider(self.id, token)
- # This should raise an exception when trying to decode invalid JSON
- with self.assertRaises(ValueError):
- provider.set_record("example.com", "192.168.1.1")
- class TestCallbackProviderRealIntegration(BaseProviderTestCase):
- """Real integration tests for CallbackProvider using httpbin.org"""
- def setUp(self):
- """Set up real test fixtures and skip on unsupported CI environments"""
- super(TestCallbackProviderRealIntegration, self).setUp()
- # Skip on Python 3.10/3.13 or 32bit in CI
- is_ci = os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS") or os.environ.get("GITHUB_REF_NAME")
- pyver = sys.version_info
- sys_platform = sys.platform.lower()
- machine = platform.machine().lower()
- is_mac = sys_platform == "darwin"
- # On macOS CI, require arm64; on others, require amd64/x86_64
- if is_ci:
- if is_mac:
- if not ("arm" in machine or "aarch64" in machine):
- self.skipTest("On macOS CI, only arm64 is supported for integration tests.")
- else:
- if not ("amd64" in machine or "x86_64" in machine):
- self.skipTest("On non-macOS CI, only amd64/x86_64 is supported for integration tests.")
- if pyver[:2] in [(3, 10), (3, 13)] or platform.architecture()[0] == "32bit":
- self.skipTest("Skip real HTTP integration on CI for Python 3.10/3.13 or 32bit platform")
- def _setup_provider_with_mock_logger(self, provider):
- """Helper method to setup provider with a mock logger."""
- mock_logger = self.mock_logger(provider)
- # Ensure the logger is configured to capture info calls
- mock_logger.setLevel(logging.INFO)
- return mock_logger
- def _random_delay(self):
- """Add a random delay of 0-3 seconds to avoid rate limiting"""
- if os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS") or os.environ.get("GITHUB_REF_NAME"):
- # In CI environments, use a shorter delay to speed up tests
- delay = random.uniform(0, 2)
- else:
- delay = random.uniform(0, 1)
- sleep(delay)
- def _assert_callback_result_logged(self, mock_logger, *expected_strings):
- """
- Helper to assert that 'Callback result: %s' was logged with expected content.
- """
- info_calls = mock_logger.info.call_args_list
- response_logged = False
- for call in info_calls:
- if len(call[0]) >= 2 and call[0][0] == "Callback result: %s":
- response_content = str(call[0][1])
- # Check if the response contains the expected strings
- if all(expected in response_content for expected in expected_strings):
- response_logged = True
- break
- # Also check if this is a firewall/network blocking response
- blocking_keywords = ["firewall", "deny", "blocked", "policy", "rule"]
- if any(keyword.lower() in response_content.lower() for keyword in blocking_keywords):
- # Skip test if network is blocked
- raise unittest.SkipTest("Network request blocked by firewall/policy: {}".format(response_content))
- self.assertTrue(
- response_logged,
- "Expected logger.info to log 'Callback result' containing: {}".format(", ".join(expected_strings)),
- )
- def test_real_callback_get_method(self):
- """Test real callback using GET method with httpbin/httpbingo and verify logger calls"""
- # 尝试多个测试端点以提高可靠性
- test_endpoints = [
- "http://httpbin.org/get?domain=__DOMAIN__&ip=__IP__&record_type=__RECORDTYPE__",
- "http://httpbingo.org/get?domain=__DOMAIN__&ip=__IP__&record_type=__RECORDTYPE__",
- ]
- domain = "test.example.com"
- ip = "111.111.111.111"
- last_exception = None
- for endpoint_id in test_endpoints:
- try:
- provider = CallbackProvider(endpoint_id, "", ssl="auto")
- mock_logger = self._setup_provider_with_mock_logger(provider)
- self._random_delay() # Add random delay before real request
- result = provider.set_record(domain, ip, "A")
- if result:
- self.assertTrue(result)
- self._assert_callback_result_logged(mock_logger, domain, ip)
- return # 成功则退出
- else:
- # 如果结果为False,可能是5xx错误,尝试下一个端点
- continue
- except Exception as e:
- last_exception = e
- # 网络问题时继续尝试下一个端点
- error_msg = str(e).lower()
- network_keywords = [
- "timeout",
- "connection",
- "resolution",
- "unreachable",
- "network",
- "ssl",
- "certificate",
- ]
- if any(keyword in error_msg for keyword in network_keywords):
- continue # 尝试下一个端点
- else:
- # 其他异常重新抛出
- raise
- # 如果所有端点都失败,跳过测试
- error_info = " - Last error: {}".format(str(last_exception)) if last_exception else ""
- self.skipTest("All network endpoints unavailable for GET callback test{}".format(error_info))
- def test_real_callback_post_method_with_json(self):
- """Test real callback using POST method with JSON data and verify logger calls"""
- # 尝试多个测试端点以提高可靠性
- test_endpoints = ["http://httpbingo.org/post", "http://httpbin.org/post"]
- token = '{"domain": "__DOMAIN__", "ip": "__IP__", "record_type": "__RECORDTYPE__", "ttl": "__TTL__"}'
- domain = "test.example.com"
- ip = "203.0.113.2"
- last_exception = None
- for endpoint_id in test_endpoints:
- try:
- provider = CallbackProvider(endpoint_id, token)
- # Setup provider with mock logger
- mock_logger = self._setup_provider_with_mock_logger(provider)
- self._random_delay() # Add random delay before real request
- result = provider.set_record(domain, ip, "A", 300)
- if result:
- # httpbin/httpbingo returns JSON with our posted data, so it should be truthy
- self.assertTrue(result)
- # Verify that logger.info was called with response containing domain and IP
- self._assert_callback_result_logged(mock_logger, domain, ip)
- return # 成功则退出
- else:
- # 如果结果为False,可能是5xx错误,尝试下一个端点
- continue
- except Exception as e:
- last_exception = e
- # 网络问题时继续尝试下一个端点
- error_msg = str(e).lower()
- network_keywords = [
- "timeout",
- "connection",
- "resolution",
- "unreachable",
- "network",
- "ssl",
- "certificate",
- ]
- if any(keyword in error_msg for keyword in network_keywords):
- continue # 尝试下一个端点
- else:
- # 其他异常重新抛出
- raise
- # 如果所有端点都失败,跳过测试
- error_info = " - Last error: {}".format(str(last_exception)) if last_exception else ""
- self.skipTest("All network endpoints unavailable for POST callback test{}".format(error_info))
- def test_real_callback_error_handling(self):
- """Test real callback error handling with invalid URL"""
- # Use an invalid URL to test error handling
- id = "http://postman-echo.com/status/400" # This returns HTTP 400
- provider = CallbackProvider(id, "")
- self._random_delay() # Add random delay before real request
- result = provider.set_record("test.example.com", "203.0.113.5")
- self.assertFalse(result)
- if __name__ == "__main__":
- unittest.main()
|