test_public_server_coverage.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. """
  2. Coverage补充测试:public_server.py
  3. 目标路径:
  4. - extract_client_ip — 空 client_address
  5. - do_GET — 非 /health 路径 → 404
  6. - do_POST — Content-Type 正确但 body 为无效 JSON → -32700 parse error
  7. - PublicMcpHttpHandler.handle_json_rpc — 未知 method → -32601 error
  8. """
  9. import json
  10. import socket
  11. import threading
  12. import unittest
  13. from http.server import HTTPServer
  14. from unittest.mock import MagicMock, patch
  15. from public_server import (
  16. PublicMcpHttpHandler,
  17. create_http_handler,
  18. extract_client_ip,
  19. serve_public,
  20. )
  21. # ---------------------------------------------------------------------------
  22. # extract_client_ip 边界情况
  23. # ---------------------------------------------------------------------------
  24. class ExtractClientIpTest(unittest.TestCase):
  25. def test_returns_empty_string_when_client_address_is_none(self):
  26. result = extract_client_ip({}, None)
  27. self.assertEqual('', result)
  28. def test_returns_empty_string_when_client_address_is_empty_tuple(self):
  29. result = extract_client_ip({}, ())
  30. self.assertEqual('', result)
  31. def test_returns_ip_from_client_address(self):
  32. result = extract_client_ip({'X-Forwarded-For': '1.2.3.4'}, ('10.0.0.1', 12345))
  33. self.assertEqual('10.0.0.1', result)
  34. # ---------------------------------------------------------------------------
  35. # PublicMcpHttpHandler.handle_json_rpc — 未知 method
  36. # ---------------------------------------------------------------------------
  37. class FakeGateway:
  38. def registered_tool_names(self):
  39. return ('query_order',)
  40. def list_tools(self, gateway_session_id):
  41. return [{'name': 'query_order', 'description': '', 'input_schema': {'type': 'object'}}]
  42. def call_tool(self, *args, **kwargs):
  43. return {'data': {}}
  44. class HandleJsonRpcUnknownMethodTest(unittest.TestCase):
  45. def test_unknown_method_returns_method_not_found(self):
  46. handler = PublicMcpHttpHandler(FakeGateway())
  47. response = handler.handle_json_rpc(
  48. headers={},
  49. message={'jsonrpc': '2.0', 'id': 1, 'method': 'unknown/method'},
  50. )
  51. self.assertIn('error', response)
  52. self.assertEqual(-32601, response['error']['code'])
  53. def test_tools_list_preserves_metadata_without_input_schema(self):
  54. gateway = FakeGateway()
  55. gateway.list_tools = MagicMock(return_value=[{'name': 'query_order'}])
  56. handler = PublicMcpHttpHandler(gateway)
  57. response = handler.handle_json_rpc(
  58. headers={'X-Gateway-Session': 'GWS_A'},
  59. message={'jsonrpc': '2.0', 'id': 1, 'method': 'tools/list'},
  60. )
  61. tool = response['result']['tools'][0]
  62. self.assertEqual({'name': 'query_order'}, tool)
  63. self.assertNotIn('inputSchema', tool)
  64. class ServePublicLifecycleTest(unittest.TestCase):
  65. def test_rate_limiter_cleanup_and_keyboard_interrupt_shutdown(self):
  66. class StopCleanupLoop(Exception):
  67. pass
  68. server = MagicMock()
  69. server.serve_forever.side_effect = KeyboardInterrupt
  70. limiter = MagicMock()
  71. with patch('public_server.SimpleRateLimiter', return_value=limiter) as limiter_class, \
  72. patch('public_server.ThreadingHTTPServer', return_value=server) as server_class, \
  73. patch('public_server.threading.Thread') as thread_class:
  74. serve_public(
  75. FakeGateway(),
  76. host='127.0.0.1',
  77. port='9876',
  78. rate_limit_max_requests=12,
  79. rate_limit_window_seconds=34,
  80. )
  81. limiter_class.assert_called_once_with(max_requests=12, window_seconds=34)
  82. self.assertEqual(('127.0.0.1', 9876), server_class.call_args.args[0])
  83. thread_class.assert_called_once_with(
  84. target=thread_class.call_args.kwargs['target'],
  85. daemon=True,
  86. )
  87. thread_class.return_value.start.assert_called_once_with()
  88. server.serve_forever.assert_called_once_with()
  89. server.shutdown.assert_called_once_with()
  90. cleanup_target = thread_class.call_args.kwargs['target']
  91. with patch('public_server.time.sleep', side_effect=[None, StopCleanupLoop]):
  92. with self.assertRaises(StopCleanupLoop):
  93. cleanup_target()
  94. limiter.cleanup.assert_called_once_with(max_age_seconds=34)
  95. # ---------------------------------------------------------------------------
  96. # 集成测试:启动真实 HTTP 服务器,覆盖 do_GET 404 和 do_POST JSON 错误
  97. # ---------------------------------------------------------------------------
  98. def _find_free_port():
  99. with socket.socket() as s:
  100. s.bind(('127.0.0.1', 0))
  101. return s.getsockname()[1]
  102. class HttpHandlerIntegrationTest(unittest.TestCase):
  103. """
  104. 启动一个真实的 ThreadingHTTPServer(随机端口),测试
  105. HTTP 层面的边界路径。
  106. """
  107. @classmethod
  108. def setUpClass(cls):
  109. port = _find_free_port()
  110. gateway = FakeGateway()
  111. handler_class = create_http_handler(gateway, rate_limiter=None)
  112. cls.server = HTTPServer(('127.0.0.1', port), handler_class)
  113. cls.server_thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
  114. cls.server_thread.start()
  115. cls.base_url = 'http://127.0.0.1:{0}'.format(port)
  116. @classmethod
  117. def tearDownClass(cls):
  118. cls.server.shutdown()
  119. def _get(self, path):
  120. import urllib.request
  121. req = urllib.request.Request(cls_url := self.base_url + path, method='GET')
  122. try:
  123. with urllib.request.urlopen(req, timeout=5) as resp:
  124. return resp.status, resp.read()
  125. except Exception as e:
  126. # urllib raises HTTPError for non-2xx
  127. if hasattr(e, 'code'):
  128. return e.code, b''
  129. raise
  130. def _post(self, path, body, content_type='application/json'):
  131. import urllib.request
  132. data = body.encode('utf-8') if isinstance(body, str) else body
  133. req = urllib.request.Request(
  134. self.base_url + path,
  135. data=data,
  136. headers={'Content-Type': content_type, 'Content-Length': str(len(data))},
  137. method='POST',
  138. )
  139. try:
  140. with urllib.request.urlopen(req, timeout=5) as resp:
  141. return resp.status, json.loads(resp.read())
  142. except Exception as e:
  143. if hasattr(e, 'code') and hasattr(e, 'read'):
  144. body_bytes = e.read()
  145. return e.code, json.loads(body_bytes) if body_bytes else {}
  146. raise
  147. # do_GET /health → 200
  148. def test_get_health_returns_200(self):
  149. status, body = self._get('/health')
  150. self.assertEqual(200, status)
  151. # do_GET 非 /health → 404(覆盖 lines 113-114)
  152. def test_get_unknown_path_returns_404(self):
  153. import urllib.request
  154. req = urllib.request.Request(self.base_url + '/not-a-real-path', method='GET')
  155. try:
  156. urllib.request.urlopen(req, timeout=5)
  157. self.fail('Expected 404')
  158. except Exception as e:
  159. self.assertEqual(404, e.code)
  160. # do_POST 无效 JSON → -32700 parse error(覆盖 lines 127-130)
  161. def test_post_invalid_json_returns_parse_error(self):
  162. status, body = self._post('/mcp', 'this is not json at all')
  163. self.assertEqual(200, status)
  164. self.assertIn('error', body)
  165. self.assertEqual(-32700, body['error']['code'])
  166. self.assertEqual('Parse error', body['error']['message'])
  167. # do_POST 到非 /mcp 路径 → 404
  168. def test_post_wrong_path_returns_404(self):
  169. import urllib.request
  170. data = json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'initialize'}).encode()
  171. req = urllib.request.Request(
  172. self.base_url + '/wrong',
  173. data=data,
  174. headers={'Content-Type': 'application/json', 'Content-Length': str(len(data))},
  175. method='POST',
  176. )
  177. try:
  178. urllib.request.urlopen(req, timeout=5)
  179. self.fail('Expected 404')
  180. except Exception as e:
  181. self.assertEqual(404, e.code)
  182. # do_POST 正常 initialize → 200
  183. def test_post_initialize_returns_200(self):
  184. status, body = self._post('/mcp', json.dumps({
  185. 'jsonrpc': '2.0',
  186. 'id': 1,
  187. 'method': 'initialize',
  188. 'params': {},
  189. }))
  190. self.assertEqual(200, status)
  191. self.assertIn('result', body)
  192. self.assertIn('protocolVersion', body['result'])
  193. if __name__ == '__main__':
  194. unittest.main()