test_qcc_transport.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. """Production QCC adapter against a local synthetic gateway only."""
  2. import hashlib
  3. import json
  4. import os
  5. from pathlib import Path
  6. import tempfile
  7. import threading
  8. import unittest
  9. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  10. from unittest.mock import patch
  11. from urllib.parse import parse_qs, urlsplit
  12. from src.step3_question_answer import qcc
  13. from scripts import deploy, deploy_check
  14. class QccTransportTests(unittest.TestCase):
  15. def setUp(self):
  16. self.calls = []
  17. self.mode = 'ok'
  18. test = self
  19. class Handler(BaseHTTPRequestHandler):
  20. protocol_version = 'HTTP/1.1'
  21. def handle(self):
  22. try:
  23. super().handle()
  24. except (ConnectionAbortedError, ConnectionResetError):
  25. pass # Client deliberately closes immediately on redirects/errors.
  26. def log_message(self, *_):
  27. pass
  28. def do_GET(self):
  29. params = parse_qs(urlsplit(self.path).query)
  30. test.calls.append((self.path, params, dict(self.headers)))
  31. route = params['servertype'][0]
  32. company = {'KeyNo': 'synthetic-id', 'Name': '合成企业', 'CreditCode': 'synthetic-credit'}
  33. if route == 'fuzzySearch':
  34. result = [company]
  35. elif route == 'basicDetails':
  36. result = company
  37. if test.mode == 'identity':
  38. result = {**company, 'KeyNo': 'different'}
  39. else:
  40. page = int(params['pageIndex'][0])
  41. result = {'VerifyResult': 1, 'Data': [{'Name': 'synthetic'}] * (20 if page == 1 else 1)}
  42. payload = {'Status': '115' if test.mode == 'auth' else '200', 'Result': result}
  43. if route == 'honor':
  44. payload['Paging'] = {'TotalRecords': 21}
  45. body = json.dumps(payload).encode()
  46. self.send_response(302 if test.mode == 'redirect' else 200)
  47. if test.mode == 'redirect':
  48. self.send_header('Location', '/do-not-follow')
  49. if test.mode == 'chunk':
  50. self.send_header('Transfer-Encoding', 'chunked')
  51. self.end_headers()
  52. self.wfile.write(b'bad-chunk\r\n')
  53. self.close_connection = True
  54. else:
  55. self.send_header('Content-Length', str(len(body) + (10 if test.mode == 'short' else 0)))
  56. self.end_headers()
  57. self.wfile.write(body)
  58. if test.mode == 'short':
  59. self.close_connection = True
  60. self.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
  61. self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
  62. self.thread.start()
  63. base = f'http://127.0.0.1:{self.server.server_port}/proxy/?proxyToken=fake%2B%26token&servertype='
  64. self.config = qcc.Config('synthetic-key', 'synthetic-secret', timeout=2,
  65. search_url=base + 'fuzzySearch', basic_url=base + 'basicDetails', honor_url=base + 'honor')
  66. def tearDown(self):
  67. self.server.shutdown()
  68. self.server.server_close()
  69. self.thread.join()
  70. def test_production_three_interfaces_and_deploy_check(self):
  71. client = qcc.QccClient(self.config)
  72. with patch.dict(os.environ, {'HTTP_PROXY': 'http://invalid:1'}):
  73. report = deploy_check.qcc(Path('.'), '合成企业', client)
  74. self.assertTrue(report['ok'], report)
  75. self.assertEqual(report['interfaces']['1001']['records'], 21)
  76. self.assertEqual(report['interfaces']['1001']['pages'], 2)
  77. self.assertEqual(len(self.calls), 4)
  78. for path, params, headers in self.calls:
  79. self.assertIn('proxyToken=fake%2B%26token&', path)
  80. self.assertNotIn('合成企业', path)
  81. self.assertEqual(headers['Connection'], 'keep-alive')
  82. self.assertEqual(headers['Accept'], '*/*')
  83. self.assertEqual(params['key'], ['synthetic-key'])
  84. expected = hashlib.md5(('synthetic-key' + headers['Timespan'] + 'synthetic-secret').encode()).hexdigest().upper()
  85. self.assertEqual(headers['Token'], expected)
  86. self.assertEqual(self.calls[1][1]['keyword'], ['合成企业'])
  87. def test_errors_and_identity_do_not_become_success(self):
  88. client = qcc.QccClient(self.config)
  89. for mode, code in [('auth', 'qcc_auth_failed'), ('redirect', 'http_302'),
  90. ('short', 'response_incomplete'), ('chunk', 'response_incomplete')]:
  91. self.mode = mode
  92. before = len(self.calls)
  93. with self.assertRaisesRegex(qcc.QccError, '^' + code + '$'):
  94. client.search('合成企业')
  95. self.assertEqual(len(self.calls), before + 1)
  96. self.mode = 'identity'
  97. with self.assertRaisesRegex(qcc.QccError, 'company_identity_mismatch'):
  98. client.basic({'KeyNo': 'synthetic-id', 'Name': '合成企业'})
  99. def test_config_validation_and_environment_isolation(self):
  100. for suffix in ('&key=x', '&pageIndex=2', '&proxyToken=duplicate'):
  101. self.assertFalse(qcc.valid_api_url(self.config.search_url + suffix))
  102. self.assertFalse(qcc.valid_api_url(self.config.search_url.replace('servertype', 'serverType')))
  103. self.assertTrue(qcc.valid_api_url(qcc.DEFAULT_SEARCH_URL))
  104. self.assertTrue(qcc.valid_api_url(self.config.search_url.replace('http:', 'https:')))
  105. with tempfile.TemporaryDirectory() as folder:
  106. path = Path(folder) / '.env'
  107. path.write_text('QCC_KEY=synthetic-key\nQCC_SECRET_KEY=synthetic-secret\nQCC_SEARCH_URL=' + self.config.search_url)
  108. with patch.dict(os.environ, {'DEPLOY_API_ENV_FILE_ONLY': '1', 'QCC_KEY': 'old',
  109. 'DEEPSEEK_BASE_URL': 'old', 'DMS_TOKEN': 'preserved'}):
  110. deploy.apply_api_env_policy()
  111. self.assertEqual(qcc.load_config(path).key, 'synthetic-key')
  112. self.assertNotIn('DEEPSEEK_BASE_URL', os.environ)
  113. self.assertNotIn('DMS_TOKEN', os.environ)
  114. if __name__ == '__main__':
  115. unittest.main()