test_provider_tencentcloud.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. # coding=utf-8
  2. """
  3. Unit tests for TencentCloudProvider
  4. 腾讯云 DNSPod 提供商单元测试
  5. @author: NewFuture
  6. """
  7. from base_test import BaseProviderTestCase, unittest, patch, MagicMock
  8. from ddns.provider.tencentcloud import TencentCloudProvider
  9. class TestTencentCloudProvider(BaseProviderTestCase):
  10. """Test TencentCloudProvider functionality"""
  11. def setUp(self):
  12. """Set up test fixtures"""
  13. super(TestTencentCloudProvider, self).setUp()
  14. self.provider = TencentCloudProvider(self.id, self.token)
  15. self.logger = self.mock_logger(self.provider)
  16. def test_init(self):
  17. """Test provider initialization"""
  18. self.assertProviderInitialized(self.provider)
  19. self.assertEqual(self.provider.service, "dnspod")
  20. self.assertEqual(self.provider.version_date, "2021-03-23")
  21. self.assertEqual(self.provider.endpoint, "https://dnspod.tencentcloudapi.com")
  22. self.assertEqual(self.provider.content_type, "application/json")
  23. def test_validate_success(self):
  24. """Test successful validation"""
  25. # Should not raise any exception
  26. self.provider._validate()
  27. def test_validate_missing_id(self):
  28. """Test validation with missing id"""
  29. with self.assertRaises(ValueError) as context:
  30. TencentCloudProvider("", self.token, self.logger)
  31. self.assertIn("id", str(context.exception))
  32. def test_validate_missing_token(self):
  33. """Test validation with missing token"""
  34. with self.assertRaises(ValueError) as context:
  35. TencentCloudProvider(self.id, "", self.logger)
  36. self.assertIn("token", str(context.exception))
  37. @patch.object(TencentCloudProvider, "_http")
  38. def test_query_zone_id_success(self, mock_http):
  39. """Test successful zone ID query"""
  40. domain = "example.com"
  41. expected_domain_id = 12345678
  42. mock_http.return_value = {
  43. "Response": {"DomainInfo": {"Domain": domain, "DomainId": expected_domain_id, "Status": "enable"}}
  44. }
  45. zone_id = self.provider._query_zone_id(domain)
  46. self.assertEqual(zone_id, str(expected_domain_id))
  47. @patch.object(TencentCloudProvider, "_http")
  48. def test_query_zone_id_not_found(self, mock_http):
  49. """Test zone ID query when domain not found"""
  50. domain = "nonexistent.com"
  51. mock_http.return_value = {
  52. "Response": {
  53. "Error": {"Code": "InvalidParameterValue.DomainNotExists", "Message": "当前域名有误,请返回重新操作。"}
  54. }
  55. }
  56. zone_id = self.provider._query_zone_id(domain)
  57. self.assertIsNone(zone_id)
  58. @patch.object(TencentCloudProvider, "_http")
  59. def test_query_zone_id_invalid_response(self, mock_http):
  60. """Test zone ID query with invalid response format"""
  61. domain = "example.com"
  62. mock_http.return_value = {"Response": {}}
  63. zone_id = self.provider._query_zone_id(domain)
  64. self.assertIsNone(zone_id)
  65. @patch.object(TencentCloudProvider, "_http")
  66. def test_query_record_found(self, mock_http):
  67. """Test successful record query"""
  68. mock_http.return_value = {
  69. "Response": {
  70. "RecordList": [
  71. {"RecordId": 123456, "Name": "www", "Type": "A", "Value": "1.2.3.4", "Line": "默认", "TTL": 600}
  72. ]
  73. }
  74. }
  75. record = self.provider._query_record("12345678", "www", "example.com", "A", None, {})
  76. self.assertIsNotNone(record)
  77. if record: # Type narrowing for mypy
  78. self.assertEqual(record["RecordId"], 123456)
  79. self.assertEqual(record["Name"], "www")
  80. self.assertEqual(record["Type"], "A")
  81. # Verify HTTP call was made correctly
  82. mock_http.assert_called_once()
  83. call_args = mock_http.call_args
  84. self.assertEqual(call_args[0][0], "POST") # method
  85. self.assertEqual(call_args[0][1], "/") # path
  86. @patch.object(TencentCloudProvider, "_http")
  87. def test_query_record_not_found(self, mock_http):
  88. """Test record query when record not found"""
  89. mock_http.return_value = {"Response": {"RecordList": []}}
  90. record = self.provider._query_record("12345678", "www", "example.com", "A", None, {}) # type: dict # type: ignore
  91. self.assertIsNone(record)
  92. @patch.object(TencentCloudProvider, "_http")
  93. def test_query_record_root_domain(self, mock_http):
  94. """Test record query for root domain (@)"""
  95. mock_http.return_value = {
  96. "Response": {"RecordList": [{"RecordId": 1234, "Name": "@", "Type": "A", "Value": "1.2.3.4"}]}
  97. }
  98. record = self.provider._query_record("1234", "@", "example.com", "A", None, {}) # type: dict # type: ignore
  99. self.assertIsNotNone(record)
  100. self.assertEqual(record["Name"], "@")
  101. @patch.object(TencentCloudProvider, "_http")
  102. def test_create_record_success(self, mock_http):
  103. """Test successful record creation"""
  104. mock_http.return_value = {"Response": {"RecordId": 789012}}
  105. result = self.provider._create_record("12345678", "www", "example.com", "1.2.3.4", "A", 600, None, {})
  106. self.assertTrue(result)
  107. # Verify HTTP call was made
  108. mock_http.assert_called_once()
  109. @patch.object(TencentCloudProvider, "_http")
  110. def test_create_record_root_domain(self, mock_http):
  111. """Test record creation for root domain"""
  112. mock_http.return_value = {"Response": {"RecordId": 789012}}
  113. result = self.provider._create_record("12345678", "@", "example.com", "1.2.3.4", "A", None, None, {})
  114. self.assertTrue(result)
  115. # Verify HTTP call was made
  116. mock_http.assert_called_once()
  117. @patch.object(TencentCloudProvider, "_http")
  118. def test_create_record_with_mx(self, mock_http):
  119. """Test record creation with MX priority"""
  120. mock_http.return_value = {"Response": {"RecordId": 789012}}
  121. result = self.provider._create_record(
  122. "12345678", "mail", "example.com", "mail.example.com", "MX", None, None, {"MX": 10}
  123. )
  124. self.assertTrue(result)
  125. # Verify HTTP call was made
  126. mock_http.assert_called_once()
  127. @patch.object(TencentCloudProvider, "_http")
  128. def test_create_record_failure(self, mock_http):
  129. """Test record creation failure"""
  130. mock_http.return_value = {"Response": {}} # No RecordId in response
  131. result = self.provider._create_record("12345678", "www", "example.com", "1.2.3.4", "A", None, None, {})
  132. self.assertFalse(result)
  133. @patch.object(TencentCloudProvider, "_http")
  134. def test_update_record_success(self, mock_http):
  135. """Test successful record update"""
  136. mock_http.return_value = {"Response": {"RecordId": 123456}}
  137. old_record = {"RecordId": 123456, "Name": "www", "Type": "A", "Value": "1.2.3.4", "Line": "默认", "TTL": 300}
  138. result = self.provider._update_record("12345678", old_record, "5.6.7.8", "A", 600, None, {})
  139. self.assertTrue(result)
  140. # Verify HTTP call was made
  141. mock_http.assert_called_once()
  142. @patch.object(TencentCloudProvider, "_http")
  143. def test_update_record_preserve_old_values(self, mock_http):
  144. """Test record update preserves old values when not specified"""
  145. mock_http.return_value = {"Response": {"RecordId": 123456}}
  146. old_record = {
  147. "RecordId": 123456,
  148. "Name": "www",
  149. "Type": "A",
  150. "Value": "1.2.3.4",
  151. "Line": "电信",
  152. "TTL": 300,
  153. "MX": 10,
  154. "Weight": 5,
  155. "Remark": "Old remark",
  156. }
  157. result = self.provider._update_record("12345678", old_record, "5.6.7.8", "A", None, None, {})
  158. self.assertTrue(result)
  159. # Verify HTTP call was made
  160. mock_http.assert_called_once()
  161. @patch.object(TencentCloudProvider, "_http")
  162. def test_update_record_missing_record_id(self, mock_http):
  163. """Test record update with missing RecordId"""
  164. mock_http.return_value = {"Response": {}} # No RecordId in response
  165. old_record = {"Name": "www", "Type": "A"}
  166. result = self.provider._update_record("12345678", old_record, "5.6.7.8", "A", None, None, {})
  167. self.assertFalse(result) # Returns False because response doesn't contain RecordId
  168. mock_http.assert_called_once() # Request is still made
  169. @patch.object(TencentCloudProvider, "_http")
  170. def test_update_record_failure(self, mock_http):
  171. """Test record update failure"""
  172. mock_http.return_value = {"Response": {}} # No RecordId in response
  173. old_record = {"RecordId": 123456}
  174. result = self.provider._update_record("12345678", old_record, "5.6.7.8", "A", None, None, {})
  175. self.assertFalse(result)
  176. @patch.object(TencentCloudProvider, "_http")
  177. def test_update_record_extra_priority_over_old_record(self, mock_http):
  178. """Test that extra parameters take priority over old_record values"""
  179. import json
  180. mock_http.return_value = {"Response": {"RecordId": 123456}}
  181. old_record = {
  182. "RecordId": 123456,
  183. "Domain": "example.com",
  184. "DomainId": 12345678,
  185. "Name": "www",
  186. "Line": "默认",
  187. "Remark": "Old remark",
  188. }
  189. # extra should override old_record's Remark
  190. extra = {"Remark": "New remark from extra", "Status": "ENABLE"}
  191. result = self.provider._update_record("12345678", old_record, "5.6.7.8", "A", 600, "联通", extra)
  192. # Verify the call was made with correct parameters
  193. self.assertTrue(result)
  194. call_kwargs = mock_http.call_args[1]
  195. # extra["Remark"] should be set with priority
  196. body_dict = json.loads(call_kwargs["body"]) # Parse the JSON body
  197. self.assertEqual(body_dict["Remark"], "New remark from extra")
  198. self.assertEqual(body_dict["Status"], "ENABLE")
  199. @patch("ddns.provider.tencentcloud.strftime")
  200. @patch("ddns.provider.tencentcloud.time")
  201. @patch.object(TencentCloudProvider, "_http")
  202. def test_request_success(self, mock_http, mock_time, mock_strftime):
  203. """Test successful API request"""
  204. # Mock time functions to get consistent results
  205. mock_time.return_value = 1609459200
  206. mock_strftime.return_value = "20210101"
  207. mock_http.return_value = {"Response": {"RecordId": 123456, "RequestId": "test-request-id"}}
  208. result = self.provider._request("DescribeRecordList", Domain="example.com")
  209. self.assertIsNotNone(result)
  210. if result: # Type narrowing for mypy
  211. self.assertEqual(result["RecordId"], 123456)
  212. mock_http.assert_called_once()
  213. @patch("ddns.provider.tencentcloud.strftime")
  214. @patch("ddns.provider.tencentcloud.time")
  215. @patch.object(TencentCloudProvider, "_http")
  216. def test_request_api_error(self, mock_http, mock_time, mock_strftime):
  217. """Test API request with error response"""
  218. mock_time.return_value = 1609459200
  219. mock_strftime.return_value = "20210101"
  220. mock_http.return_value = {"Response": {"Error": {"Code": "InvalidParameter", "Message": "Invalid domain name"}}}
  221. result = self.provider._request("DescribeRecordList", Domain="invalid")
  222. self.assertIsNone(result)
  223. @patch("ddns.provider.tencentcloud.strftime")
  224. @patch("ddns.provider.tencentcloud.time")
  225. @patch.object(TencentCloudProvider, "_http")
  226. def test_request_unexpected_response(self, mock_http, mock_time, mock_strftime):
  227. """Test API request with unexpected response format"""
  228. mock_time.return_value = 1609459200
  229. mock_strftime.return_value = "20210101"
  230. mock_http.return_value = {"UnexpectedField": "value"}
  231. result = self.provider._request("DescribeRecordList", Domain="example.com")
  232. self.assertIsNone(result)
  233. @patch("ddns.provider.tencentcloud.strftime")
  234. @patch("ddns.provider.tencentcloud.time")
  235. @patch.object(TencentCloudProvider, "_http")
  236. def test_request_exception(self, mock_http, mock_time, mock_strftime):
  237. """Test API request with exception"""
  238. mock_time.return_value = 1609459200
  239. mock_strftime.return_value = "20210101"
  240. mock_http.side_effect = Exception("Network error")
  241. # The implementation doesn't catch exceptions, so it will propagate
  242. with self.assertRaises(Exception) as cm:
  243. self.provider._request("DescribeRecordList", Domain="example.com")
  244. self.assertEqual(str(cm.exception), "Network error")
  245. @patch.object(TencentCloudProvider, "_http")
  246. def test_set_record_create_new(self, mock_http):
  247. """Test set_record creating a new record"""
  248. # Mock HTTP responses for the workflow
  249. responses = [
  250. # DescribeDomain response (get domain ID)
  251. {"Response": {"DomainInfo": {"Domain": "example.com", "DomainId": 12345678}}},
  252. # DescribeRecordList response (no existing records)
  253. {"Response": {"RecordList": []}},
  254. # CreateRecord response (record created successfully)
  255. {"Response": {"RecordId": 123456}},
  256. ]
  257. mock_http.side_effect = responses
  258. result = self.provider.set_record("www.example.com", "1.2.3.4", "A")
  259. self.assertTrue(result)
  260. self.assertEqual(mock_http.call_count, 3)
  261. @patch.object(TencentCloudProvider, "_http")
  262. def test_set_record_update_existing(self, mock_http):
  263. """Test set_record updating an existing record"""
  264. # Mock HTTP responses for the workflow
  265. responses = [
  266. # DescribeDomain response (get domain ID)
  267. {"Response": {"DomainInfo": {"Domain": "example.com", "DomainId": 12345678}}},
  268. # DescribeRecordList response (existing record found)
  269. {
  270. "Response": {
  271. "RecordList": [
  272. {
  273. "RecordId": 123456,
  274. "Name": "www",
  275. "Type": "A",
  276. "Value": "1.2.3.4",
  277. "DomainId": 12345678,
  278. "Line": "默认",
  279. }
  280. ]
  281. }
  282. },
  283. # ModifyRecord response (record updated successfully)
  284. {"Response": {"RecordId": 123456}},
  285. ]
  286. mock_http.side_effect = responses
  287. result = self.provider.set_record("www.example.com", "5.6.7.8", "A")
  288. self.assertTrue(result)
  289. self.assertEqual(mock_http.call_count, 3)
  290. def test_line_configuration_support(self):
  291. """Test that TencentCloudProvider supports line configuration"""
  292. with patch.object(self.provider, "_request") as mock_request:
  293. mock_request.return_value = {"RecordId": 123456}
  294. # Test create record with line parameter
  295. result = self.provider._create_record(12345678, "www", "example.com", "1.2.3.4", "A", 300, "电信", {})
  296. self.assertTrue(result)
  297. mock_request.assert_called_once_with(
  298. "CreateRecord",
  299. Domain="example.com",
  300. DomainId=12345678,
  301. SubDomain="www",
  302. RecordType="A",
  303. Value="1.2.3.4",
  304. RecordLine="电信",
  305. TTL=300,
  306. Remark="Managed by [DDNS](https://ddns.newfuture.cc)",
  307. )
  308. def test_update_record_with_line(self):
  309. """Test _update_record method with line parameter"""
  310. old_record = {"RecordId": 123456, "Name": "www", "Line": "默认", "Domain": "example.com", "DomainId": 12345678}
  311. with patch.object(self.provider, "_request") as mock_request:
  312. mock_request.return_value = {"RecordId": 123456}
  313. # Test with custom line parameter - note that TencentCloud uses old_record.Line when line parameter
  314. # doesn't override
  315. result = self.provider._update_record(12345678, old_record, "5.6.7.8", "A", 600, "联通", {})
  316. self.assertTrue(result)
  317. mock_request.assert_called_once_with(
  318. "ModifyRecord",
  319. Domain="example.com",
  320. DomainId=12345678,
  321. SubDomain="www",
  322. RecordId=123456,
  323. RecordType="A",
  324. RecordLine="默认", # TencentCloud uses old_record line when available
  325. Value="5.6.7.8",
  326. TTL=600,
  327. Remark="Managed by [DDNS](https://ddns.newfuture.cc)",
  328. )
  329. class TestTencentCloudProviderIntegration(BaseProviderTestCase):
  330. """Integration tests for TencentCloudProvider"""
  331. def setUp(self):
  332. """Set up test fixtures"""
  333. super(TestTencentCloudProviderIntegration, self).setUp()
  334. self.provider = TencentCloudProvider(self.id, self.token)
  335. self.logger = self.mock_logger(self.provider)
  336. @patch.object(TencentCloudProvider, "_http")
  337. def test_full_domain_resolution_flow(self, mock_http):
  338. """Test complete domain resolution flow"""
  339. # Mock HTTP responses for the workflow
  340. responses = [
  341. # DescribeDomain response (get domain ID)
  342. {"Response": {"DomainInfo": {"Domain": "example.com", "DomainId": 12345678}}},
  343. # DescribeRecordList response (no existing records)
  344. {"Response": {"RecordList": []}},
  345. # CreateRecord response (record created successfully)
  346. {"Response": {"RecordId": 123456}},
  347. ]
  348. mock_http.side_effect = responses
  349. result = self.provider.set_record("test.example.com", "1.2.3.4", "A", ttl=600)
  350. self.assertTrue(result)
  351. self.assertEqual(mock_http.call_count, 3)
  352. # Verify the CreateRecord call parameters
  353. create_call = mock_http.call_args_list[2]
  354. call_body = create_call[1]["body"]
  355. self.assertIn("DomainId", call_body)
  356. self.assertIn("CreateRecord", create_call[1]["headers"]["X-TC-Action"])
  357. @patch.object(TencentCloudProvider, "_http")
  358. def test_custom_domain_format(self, mock_http):
  359. """Test custom domain format with ~ separator"""
  360. # Mock HTTP responses
  361. responses = [
  362. # DescribeDomain response (get domain ID)
  363. {"Response": {"DomainInfo": {"Domain": "example.com", "DomainId": 12345678}}},
  364. # DescribeRecordList response (no existing records)
  365. {"Response": {"RecordList": []}},
  366. # CreateRecord response (record created successfully)
  367. {"Response": {"RecordId": 123456}},
  368. ]
  369. mock_http.side_effect = responses
  370. result = self.provider.set_record("test~example.com", "1.2.3.4", "A")
  371. self.assertTrue(result)
  372. # Verify the CreateRecord action was called
  373. create_call = mock_http.call_args_list[2]
  374. headers = create_call[1]["headers"]
  375. self.assertEqual(headers["X-TC-Action"], "CreateRecord")
  376. # Verify the body contains the right domain data
  377. call_body = create_call[1]["body"]
  378. self.assertIn("12345678", call_body) # DomainId instead of domain name
  379. self.assertIn("test", call_body)
  380. @patch.object(TencentCloudProvider, "_http")
  381. def test_update_existing_record(self, mock_http):
  382. """Test updating an existing record"""
  383. # Mock HTTP responses for the workflow
  384. responses = [
  385. # DescribeDomain response (get domain ID)
  386. {"Response": {"DomainInfo": {"Domain": "example.com", "DomainId": 12345678}}},
  387. # DescribeRecordList response (existing record found)
  388. {
  389. "Response": {
  390. "RecordList": [
  391. {
  392. "RecordId": 12345,
  393. "Name": "test",
  394. "Type": "A",
  395. "Value": "1.2.3.4",
  396. "DomainId": 12345678,
  397. "Line": "默认",
  398. }
  399. ]
  400. }
  401. },
  402. # ModifyRecord response (record updated successfully)
  403. {"Response": {"RecordId": 12345}},
  404. ]
  405. mock_http.side_effect = responses
  406. result = self.provider.set_record("test.example.com", "5.6.7.8", "A", ttl=300)
  407. self.assertTrue(result)
  408. self.assertEqual(mock_http.call_count, 3)
  409. # Verify the ModifyRecord call
  410. modify_call = mock_http.call_args_list[2]
  411. self.assertIn("ModifyRecord", modify_call[1]["headers"]["X-TC-Action"])
  412. @patch.object(TencentCloudProvider, "_http")
  413. def test_api_error_handling(self, mock_http):
  414. """Test API error handling"""
  415. # Mock API error response for DescribeDomain
  416. mock_http.return_value = {"Response": {"Error": {"Code": "InvalidParameter", "Message": "Invalid domain name"}}}
  417. # This should return False because zone_id cannot be resolved
  418. result = self.provider.set_record("test.example.com", "1.2.3.4", "A")
  419. self.assertFalse(result)
  420. # Two calls are made: split domain name first, then DescribeDomain for main domain
  421. self.assertGreater(mock_http.call_count, 0)
  422. class TestTencentCloudProviderRealRequest(BaseProviderTestCase):
  423. """TencentCloud Provider 真实请求测试类"""
  424. def setUp(self):
  425. """Set up test fixtures"""
  426. super(TestTencentCloudProviderRealRequest, self).setUp()
  427. def test_auth_failure_real_request(self):
  428. """Test authentication failure with real API request"""
  429. # 使用无效的认证信息创建 provider
  430. invalid_provider = TencentCloudProvider("invalid_id", "invalid_token")
  431. # Mock logger to capture error logs
  432. invalid_provider.logger = MagicMock()
  433. # 尝试查询域名信息,应该返回认证失败
  434. result = invalid_provider._query_zone_id("example.com")
  435. # 认证失败时应该返回 None (因为 API 会返回错误)
  436. self.assertIsNone(result)
  437. # 验证错误日志被记录
  438. # 应该有错误日志调用,因为 API 返回认证错误
  439. self.assertGreaterEqual(invalid_provider.logger.error.call_count, 1)
  440. # 检查日志内容包含认证相关的错误信息
  441. error_calls = invalid_provider.logger.error.call_args_list
  442. logged_messages = [str(call) for call in error_calls]
  443. # 至少有一个日志应该包含腾讯云 API 错误信息
  444. has_auth_error = any(
  445. "tencentcloud api error" in msg.lower() or "authfailure" in msg.lower() or "unauthorized" in msg.lower()
  446. for msg in logged_messages
  447. )
  448. self.assertTrue(
  449. has_auth_error, "Expected TencentCloud authentication error in logs: {0}".format(logged_messages)
  450. )
  451. if __name__ == "__main__":
  452. unittest.main()