test_provider_tencentcloud.py 21 KB

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