test_qcc_proxy.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. import contextlib
  2. import hashlib
  3. import io
  4. import json
  5. import os
  6. from pathlib import Path
  7. import tempfile
  8. import threading
  9. import unittest
  10. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  11. from unittest.mock import patch
  12. from urllib.parse import parse_qs, urlsplit
  13. from scripts import test_qcc_proxy as probe
  14. class QccProbeTests(unittest.TestCase):
  15. def test_signature_refreshes_for_each_request(self):
  16. for timestamp in (1700000000, 1700000861):
  17. with patch.object(probe.time, 'time', return_value=timestamp):
  18. result = probe.probe('search', self.values, 'synthetic', 2)
  19. self.assertTrue(result['ok'])
  20. headers = self.calls[-1][2]
  21. self.assertEqual(headers['Timespan'], str(timestamp))
  22. self.assertEqual(headers['Token'], hashlib.md5(
  23. ('fake-key' + str(timestamp) + 'fake-secret').encode()).hexdigest().upper())
  24. self.assertNotEqual(self.calls[0][2]['Token'], self.calls[1][2]['Token'])
  25. def test_clock_diagnostics_and_invalid_dates(self):
  26. date = 'Tue, 14 Nov 2023 22:27:41 GMT'
  27. result = probe.clock_diagnostics('1700000000', [date], .35)
  28. self.assertEqual(result['response_date_minus_request_seconds'], 861)
  29. self.assertTrue(result['clock_skew_suspected'])
  30. self.assertFalse(probe.clock_diagnostics('1700000861', [date], .35)['clock_skew_suspected'])
  31. self.assertTrue(probe.clock_diagnostics('1700001722', [date], .35)['clock_skew_suspected'])
  32. for dates in ([], ['bad'], [date, date]):
  33. self.assertNotIn('clock_skew_suspected', probe.clock_diagnostics('1700000000', dates, .35))
  34. def test_env_file_only_ignores_stale_credentials(self):
  35. with tempfile.TemporaryDirectory() as folder:
  36. path = Path(folder) / '.env'
  37. path.write_text('QCC_KEY=file-key\nQCC_SECRET_KEY=file-secret\n', encoding='utf-8')
  38. with patch.dict(os.environ, {'QCC_KEY': 'old-key', 'QCC_SECRET_KEY': 'old-secret'}):
  39. self.assertEqual(probe.load_settings(path)['QCC_KEY'], 'old-key')
  40. values = probe.load_settings(path, env_file_only=True)
  41. self.assertEqual(values['QCC_KEY'], 'file-key')
  42. self.assertEqual(values['QCC_SECRET_KEY'], 'file-secret')
  43. def setUp(self):
  44. self.calls = []
  45. self.status = 200
  46. self.broken = False
  47. self.raw_body = None
  48. self.payloads = {
  49. 'fuzzySearch': {'Status': '200', 'Result': [{'Name': '合成企业', 'KeyNo': 'synthetic'}]},
  50. 'basicDetails': {'Status': '200', 'Result': {'Name': '合成企业', 'KeyNo': 'synthetic'}},
  51. 'honor': {'Status': '200', 'Result': {'VerifyResult': 0, 'Data': None}},
  52. }
  53. test = self
  54. class Handler(BaseHTTPRequestHandler):
  55. protocol_version = 'HTTP/1.1'
  56. def log_message(self, *_):
  57. pass
  58. def do_GET(self):
  59. query = parse_qs(urlsplit(self.path).query)
  60. test.calls.append((self.path, query, dict(self.headers)))
  61. self.send_response(test.status)
  62. if test.status == 302:
  63. self.send_header('Location', '/other')
  64. route = query.get('servertype', ['fuzzySearch'])[0]
  65. body = test.raw_body if test.raw_body is not None else json.dumps(test.payloads[route]).encode()
  66. self.send_header('Content-Type', 'text/html' if test.raw_body else 'application/json')
  67. self.send_header('X-Request-ID', 'synthetic-trace')
  68. if test.broken:
  69. self.send_header('Transfer-Encoding', 'chunked')
  70. self.end_headers()
  71. self.wfile.write(b'broken\r\n')
  72. self.close_connection = True
  73. else:
  74. self.send_header('Content-Length', str(len(body)))
  75. self.end_headers()
  76. self.wfile.write(body)
  77. self.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
  78. self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
  79. self.thread.start()
  80. self.values = {'QCC_KEY': 'fake-key', 'QCC_SECRET_KEY': 'fake-secret'}
  81. for name, route in zip(probe.URL_KEYS.values(), self.payloads):
  82. self.values[name] = f'http://127.0.0.1:{self.server.server_port}/proxy/?servertype={route}&proxyToken=fake%2B%26token'
  83. def tearDown(self):
  84. self.server.shutdown()
  85. self.server.server_close()
  86. self.thread.join()
  87. def test_three_get_requests_signatures_query_and_no_sensitive_output(self):
  88. with patch.dict(os.environ, {'HTTP_PROXY': 'http://invalid.example:1'}):
  89. for api in probe.URL_KEYS:
  90. result = probe.probe(api, self.values, '合成企业', 2)
  91. self.assertTrue(result['ok'], result)
  92. self.assertNotIn('fake', json.dumps(result))
  93. self.assertNotIn('合成企业', json.dumps(result, ensure_ascii=False))
  94. self.assertEqual(len(self.calls), 3)
  95. for path, query, headers in self.calls:
  96. self.assertIn('proxyToken=fake%2B%26token&key=', path)
  97. self.assertEqual(headers['Connection'], 'keep-alive')
  98. self.assertEqual(headers['Token'], hashlib.md5(('fake-key' + headers['Timespan'] + 'fake-secret').encode()).hexdigest().upper())
  99. self.assertEqual(query['key'], ['fake-key'])
  100. self.assertEqual(self.calls[0][1]['searchKey'], ['合成企业'])
  101. self.assertEqual(self.calls[1][1]['keyword'], ['合成企业'])
  102. self.assertEqual(self.calls[2][1]['pageSize'], ['20'])
  103. def test_business_failure_and_empty_distinction(self):
  104. self.payloads['fuzzySearch'] = {'Status': '201'}
  105. self.assertEqual(probe.probe('search', self.values, 'synthetic', 2)['code'], 'no_matches')
  106. self.payloads['basicDetails'] = {'Status': '201'}
  107. result = probe.probe('basic', self.values, 'synthetic', 2)
  108. self.assertFalse(result['ok'])
  109. self.assertEqual(result['code'], 'provider_failure')
  110. self.assertFalse(probe.validate_payload('honor', {'Status': '200', 'Result': {'VerifyResult': 0, 'Data': [{}]}})['ok'])
  111. def test_invalid_url_and_case_are_not_sent(self):
  112. original = self.values['QCC_SEARCH_URL']
  113. for value in (original.replace('servertype', 'Servertype'), original.replace('proxyToken=', 'proxyToken-'), original.replace('127.0', '127. 0')):
  114. self.values['QCC_SEARCH_URL'] = value
  115. self.assertFalse(probe.probe('search', self.values, 'synthetic', 2)['ok'])
  116. self.assertEqual(self.calls, [])
  117. def test_redirect_http_error_and_broken_response(self):
  118. for status in (302, 500):
  119. self.status = status
  120. self.assertEqual(probe.probe('search', self.values, 'synthetic', 2)['code'], 'http_error')
  121. self.assertEqual(len(self.calls), 2)
  122. self.status, self.broken = 200, True
  123. self.assertEqual(probe.probe('search', self.values, 'synthetic', 2)['code'], 'response_incomplete')
  124. def test_env_and_cli(self):
  125. with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {}, clear=True):
  126. path = Path(directory) / '.env'
  127. path.write_text('export QCC_KEY="fake-key" # note\nQCC_SECRET_KEY=fake-secret\n', encoding='utf-8-sig')
  128. self.assertEqual(probe.load_settings(path)['QCC_KEY'], 'fake-key')
  129. with patch.object(probe, 'load_settings', return_value=self.values), patch('sys.argv', ['test_qcc_proxy.py', '--company', '合成企业']), contextlib.redirect_stdout(io.StringIO()) as output:
  130. self.assertEqual(probe.main(), 0)
  131. self.assertEqual(set(json.loads(output.getvalue())['checks']), {'search', 'basic', 'honor'})
  132. def test_timeout_closes_and_does_not_leak(self):
  133. with patch.object(probe, 'HTTPConnection') as conn:
  134. conn.return_value.request.side_effect = TimeoutError('fake-secret')
  135. result = probe.probe('search', self.values, 'synthetic', 2)
  136. self.assertEqual(result['code'], 'timeout')
  137. self.assertNotIn('fake', json.dumps(result))
  138. conn.return_value.close.assert_called_once()
  139. def test_400_html_body_is_visible_and_sensitive_echoes_are_redacted(self):
  140. self.status = 400
  141. timestamp = '1700000000'
  142. signature = hashlib.md5(('fake-key' + timestamp + 'fake-secret').encode()).hexdigest().upper()
  143. self.raw_body = ('<html>Bad Request: invalid upstream request fake-key fake-secret '
  144. 'fake+&token fake%2b%26token 合成企业 ' + signature + '</html>').encode()
  145. with patch.object(probe.time, 'time', return_value=int(timestamp)):
  146. result = probe.probe('search', self.values, '合成企业', 2)
  147. self.assertIn('invalid upstream request', result['error_body_preview'])
  148. self.assertEqual(result['response_headers']['X-Request-ID'], ['synthetic-trace'])
  149. self.assertIn('elapsed_seconds', result)
  150. for value in ('fake-key', 'fake-secret', 'fake+&token', 'fake%2b%26token', '合成企业', signature):
  151. self.assertNotIn(value, json.dumps(result, ensure_ascii=False))
  152. def test_provider_message_visible_result_omitted_and_preview_bounded(self):
  153. self.payloads['fuzzySearch'] = {'Status': '115', 'Message': '身份验证错误 fake-key',
  154. 'Result': {'Name': 'private-company'}, 'OrderNumber': 'test-order'}
  155. result = probe.probe('search', self.values, '合成企业', 2)
  156. self.assertIn('身份验证错误', result['error_body_preview'])
  157. self.assertNotIn('private-company', result['error_body_preview'])
  158. self.assertNotIn('fake-key', result['error_body_preview'])
  159. self.raw_body = b'x' * 3000
  160. self.status = 400
  161. result = probe.probe('search', self.values, 'synthetic', 2)
  162. self.assertEqual(len(result['error_body_preview']), 2000)
  163. self.assertTrue(result['error_body_truncated'])
  164. def test_success_never_outputs_response_body(self):
  165. result = probe.probe('search', self.values, '合成企业', 2)
  166. self.assertTrue(result['ok'])
  167. self.assertNotIn('error_body_preview', result)
  168. self.assertNotIn('合成企业', json.dumps(result, ensure_ascii=False))
  169. def test_direct_mode_matches_proxy_business_parameters_and_headers(self):
  170. with patch.object(probe.time, 'time', return_value=1700000000):
  171. proxy = probe.probe('search', self.values, '合成企业', 2)
  172. self.values['QCC_SEARCH_URL'] = f'http://127.0.0.1:{self.server.server_port}/FuzzySearch/GetList'
  173. direct = probe.probe('search', self.values, '合成企业', 2, mode='direct')
  174. self.assertTrue(proxy['ok'])
  175. self.assertTrue(direct['ok'])
  176. self.assertTrue(self.calls[1][0].startswith('/FuzzySearch/GetList?key='))
  177. for key in ('key', 'searchKey', 'pageIndex'):
  178. self.assertEqual(self.calls[0][1][key], self.calls[1][1][key])
  179. for key in ('Token', 'Timespan', 'Connection', 'Accept'):
  180. self.assertEqual(self.calls[0][2][key], self.calls[1][2][key])
  181. self.assertFalse(probe.probe('search', self.values, '合成企业', 2)['ok'])
  182. def test_direct_mode_rejects_proxy_url_before_request(self):
  183. self.assertEqual(probe.probe('search', self.values, 'synthetic', 2, mode='direct')['code'],
  184. 'direct_url_must_not_have_query')
  185. self.assertEqual(self.calls, [])