Files
DDNS/tests/test_provider_callback.py
27f0f1c0ac fix: correct DNS sync results and configuration retention (#739)
* fix: correct DNS sync results and configuration retention

Keep address families independent, reject failed update responses, preserve complete IPv6 addresses, and retain Studio drafts and effective extra settings. Add offline regressions and wire Studio tests into documentation builds.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* test(provider): make disk cache regressions deterministic

Align the cache fixture wall clock and file mtime so Windows Python 3.12 clock granularity cannot turn a freshly saved cache into a future-dated file. Keep real disk reads, all cache assertions, production expiry policy and CI matrices unchanged.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: NewFuture <6290356+NewFuture@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-06 12:45:46 +08:00

549 lines
23 KiB
Python

# coding=utf-8
"""
Unit tests for CallbackProvider
@author: GitHub Copilot
"""
import os
import sys
import json
import logging
import random
import platform
import shutil
import tempfile
from time import sleep
from base_test import BaseProviderTestCase, is_network_error, patch, unittest
from ddns.__main__ import update_ip
from ddns.cache import Cache
from ddns.config import Config
from ddns.provider.callback import CallbackProvider
from ddns.util.http import HttpResponse
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 TestCallbackProviderHTTPResponses(BaseProviderTestCase):
"""Use real response handling and isolated on-disk caches without network requests."""
def setUp(self):
super(TestCallbackProviderHTTPResponses, self).setUp()
self.temp_dir = tempfile.mkdtemp(prefix="ddns-callback-test-")
self.addCleanup(shutil.rmtree, self.temp_dir)
self.cache_path = os.path.join(self.temp_dir, "records.cache")
# Keep file timestamps aligned with the clock, including on coarse Windows clocks.
self.cache_timestamp = 1700000000
cache_clock = patch("ddns.cache.time", return_value=self.cache_timestamp)
cache_clock.start()
self.addCleanup(cache_clock.stop)
self.domain = "www.example.com"
self.address = "192.0.2.9"
self.url = "https://example.invalid/update?domain=__DOMAIN__&ip=__IP__"
self.post_body = '{"domain": "__DOMAIN__", "value": "__IP__"}'
def _sync_once(self, status, body, token):
config = Config(
json_config={
"dns": "callback",
"id": self.url,
"token": token,
"ipv4": [self.domain],
"cache": self.cache_path,
}
)
provider = CallbackProvider(config.id, config.token)
self.mock_logger(provider)
cache = Cache.new(config.cache, config.md5(), provider.logger, config.cache_max_age)
try:
response = HttpResponse(status, "Test response", {}, body)
with patch("ddns.__main__.get_ip", return_value=self.address), patch(
"ddns.provider._base.request", return_value=response
) as mock_request:
result = update_ip(provider, cache, ["public"], [self.domain], "A", config)
return result, list(mock_request.call_args_list)
finally:
cache.close()
os.utime(self.cache_path, (self.cache_timestamp, self.cache_timestamp))
def _read_cache(self):
with open(self.cache_path, "r") as cache_file:
return json.load(cache_file)
def _assert_failure_retries(self, status, token):
result, calls = self._sync_once(status, "Nonempty error response", token)
self.assertFalse(result)
self.assertEqual([call[0][0] for call in calls], ["POST" if token else "GET"])
self.assertEqual(self._read_cache(), {})
result, calls = self._sync_once(204, "", token)
self.assertTrue(result)
self.assertEqual(len(calls), 1)
self.assertEqual(self._read_cache(), {self.domain + ":A": self.address})
result, calls = self._sync_once(429, "Must not be requested after success", token)
self.assertTrue(result)
self.assertEqual(calls, [])
def test_get_404_is_not_cached(self):
self._assert_failure_retries(404, "")
def test_post_404_is_not_cached(self):
self._assert_failure_retries(404, self.post_body)
def test_get_429_is_not_cached(self):
self._assert_failure_retries(429, "")
def test_post_429_is_not_cached(self):
self._assert_failure_retries(429, self.post_body)
def test_get_400_is_not_cached(self):
self._assert_failure_retries(400, "")
def test_post_400_is_not_cached(self):
self._assert_failure_retries(400, self.post_body)
def test_empty_2xx_response_is_success(self):
provider = CallbackProvider(self.url, "")
self.mock_logger(provider)
for status in (200, 201, 204, 299):
with patch(
"ddns.provider._base.request", return_value=HttpResponse(status, "Success", {}, "")
) as mock_request:
self.assertTrue(provider.set_record(self.domain, self.address))
mock_request.assert_called_once()
def test_other_non_2xx_responses_fail(self):
provider = CallbackProvider(self.url, "")
self.mock_logger(provider)
for status in (199, 300, 302, 304, 403, 409, 500):
with patch(
"ddns.provider._base.request", return_value=HttpResponse(status, "Failure", {}, "Error body")
) as mock_request:
self.assertFalse(provider.set_record(self.domain, self.address))
mock_request.assert_called_once()
def test_http_200_business_error_body_is_not_interpreted(self):
provider = CallbackProvider(self.url, "")
self.mock_logger(provider)
body = '{"success": false, "error": "Custom application response"}'
with patch("ddns.provider._base.request", return_value=HttpResponse(200, "OK", {}, body)) as mock_request:
self.assertTrue(provider.set_record(self.domain, self.address))
mock_request.assert_called_once()
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__",
]
random.shuffle(test_endpoints)
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")
self.configure_test_http(provider)
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
if is_network_error(e, include_ssl=True):
continue
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"]
random.shuffle(test_endpoints)
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)
self.configure_test_http(provider)
# 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
if is_network_error(e, include_ssl=True):
continue
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.configure_test_http(provider)
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()