| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238 |
- """
- Coverage补充测试:public_server.py
- 目标路径:
- - extract_client_ip — 空 client_address
- - do_GET — 非 /health 路径 → 404
- - do_POST — Content-Type 正确但 body 为无效 JSON → -32700 parse error
- - PublicMcpHttpHandler.handle_json_rpc — 未知 method → -32601 error
- """
- import json
- import socket
- import threading
- import unittest
- from http.server import HTTPServer
- from unittest.mock import MagicMock, patch
- from public_server import (
- PublicMcpHttpHandler,
- create_http_handler,
- extract_client_ip,
- serve_public,
- )
- # ---------------------------------------------------------------------------
- # extract_client_ip 边界情况
- # ---------------------------------------------------------------------------
- class ExtractClientIpTest(unittest.TestCase):
- def test_returns_empty_string_when_client_address_is_none(self):
- result = extract_client_ip({}, None)
- self.assertEqual('', result)
- def test_returns_empty_string_when_client_address_is_empty_tuple(self):
- result = extract_client_ip({}, ())
- self.assertEqual('', result)
- def test_returns_ip_from_client_address(self):
- result = extract_client_ip({'X-Forwarded-For': '1.2.3.4'}, ('10.0.0.1', 12345))
- self.assertEqual('10.0.0.1', result)
- # ---------------------------------------------------------------------------
- # PublicMcpHttpHandler.handle_json_rpc — 未知 method
- # ---------------------------------------------------------------------------
- class FakeGateway:
- def registered_tool_names(self):
- return ('query_order',)
- def list_tools(self, gateway_session_id):
- return [{'name': 'query_order', 'description': '', 'input_schema': {'type': 'object'}}]
- def call_tool(self, *args, **kwargs):
- return {'data': {}}
- class HandleJsonRpcUnknownMethodTest(unittest.TestCase):
- def test_unknown_method_returns_method_not_found(self):
- handler = PublicMcpHttpHandler(FakeGateway())
- response = handler.handle_json_rpc(
- headers={},
- message={'jsonrpc': '2.0', 'id': 1, 'method': 'unknown/method'},
- )
- self.assertIn('error', response)
- self.assertEqual(-32601, response['error']['code'])
- def test_tools_list_preserves_metadata_without_input_schema(self):
- gateway = FakeGateway()
- gateway.list_tools = MagicMock(return_value=[{'name': 'query_order'}])
- handler = PublicMcpHttpHandler(gateway)
- response = handler.handle_json_rpc(
- headers={'X-Gateway-Session': 'GWS_A'},
- message={'jsonrpc': '2.0', 'id': 1, 'method': 'tools/list'},
- )
- tool = response['result']['tools'][0]
- self.assertEqual({'name': 'query_order'}, tool)
- self.assertNotIn('inputSchema', tool)
- class ServePublicLifecycleTest(unittest.TestCase):
- def test_rate_limiter_cleanup_and_keyboard_interrupt_shutdown(self):
- class StopCleanupLoop(Exception):
- pass
- server = MagicMock()
- server.serve_forever.side_effect = KeyboardInterrupt
- limiter = MagicMock()
- with patch('public_server.SimpleRateLimiter', return_value=limiter) as limiter_class, \
- patch('public_server.ThreadingHTTPServer', return_value=server) as server_class, \
- patch('public_server.threading.Thread') as thread_class:
- serve_public(
- FakeGateway(),
- host='127.0.0.1',
- port='9876',
- rate_limit_max_requests=12,
- rate_limit_window_seconds=34,
- )
- limiter_class.assert_called_once_with(
- max_requests=12,
- window_seconds=34,
- max_in_flight=2,
- )
- self.assertEqual(('127.0.0.1', 9876), server_class.call_args.args[0])
- thread_class.assert_called_once_with(
- target=thread_class.call_args.kwargs['target'],
- daemon=True,
- )
- thread_class.return_value.start.assert_called_once_with()
- server.serve_forever.assert_called_once_with()
- server.shutdown.assert_called_once_with()
- cleanup_target = thread_class.call_args.kwargs['target']
- with patch('public_server.time.sleep', side_effect=[None, StopCleanupLoop]):
- with self.assertRaises(StopCleanupLoop):
- cleanup_target()
- limiter.cleanup.assert_called_once_with(max_age_seconds=34)
- # ---------------------------------------------------------------------------
- # 集成测试:启动真实 HTTP 服务器,覆盖 do_GET 404 和 do_POST JSON 错误
- # ---------------------------------------------------------------------------
- def _find_free_port():
- with socket.socket() as s:
- s.bind(('127.0.0.1', 0))
- return s.getsockname()[1]
- class HttpHandlerIntegrationTest(unittest.TestCase):
- """
- 启动一个真实的 ThreadingHTTPServer(随机端口),测试
- HTTP 层面的边界路径。
- """
- @classmethod
- def setUpClass(cls):
- port = _find_free_port()
- gateway = FakeGateway()
- handler_class = create_http_handler(gateway, rate_limiter=None)
- cls.server = HTTPServer(('127.0.0.1', port), handler_class)
- cls.server_thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
- cls.server_thread.start()
- cls.base_url = 'http://127.0.0.1:{0}'.format(port)
- @classmethod
- def tearDownClass(cls):
- cls.server.shutdown()
- def _get(self, path):
- import urllib.request
- req = urllib.request.Request(cls_url := self.base_url + path, method='GET')
- try:
- with urllib.request.urlopen(req, timeout=5) as resp:
- return resp.status, resp.read()
- except Exception as e:
- # urllib raises HTTPError for non-2xx
- if hasattr(e, 'code'):
- return e.code, b''
- raise
- def _post(self, path, body, content_type='application/json'):
- import urllib.request
- data = body.encode('utf-8') if isinstance(body, str) else body
- req = urllib.request.Request(
- self.base_url + path,
- data=data,
- headers={'Content-Type': content_type, 'Content-Length': str(len(data))},
- method='POST',
- )
- try:
- with urllib.request.urlopen(req, timeout=5) as resp:
- return resp.status, json.loads(resp.read())
- except Exception as e:
- if hasattr(e, 'code') and hasattr(e, 'read'):
- body_bytes = e.read()
- return e.code, json.loads(body_bytes) if body_bytes else {}
- raise
- # do_GET /health → 200
- def test_get_health_returns_200(self):
- status, body = self._get('/health')
- self.assertEqual(200, status)
- # do_GET 非 /health → 404(覆盖 lines 113-114)
- def test_get_unknown_path_returns_404(self):
- import urllib.request
- req = urllib.request.Request(self.base_url + '/not-a-real-path', method='GET')
- try:
- urllib.request.urlopen(req, timeout=5)
- self.fail('Expected 404')
- except Exception as e:
- self.assertEqual(404, e.code)
- # do_POST 无效 JSON → -32700 parse error(覆盖 lines 127-130)
- def test_post_invalid_json_returns_parse_error(self):
- status, body = self._post('/mcp', 'this is not json at all')
- self.assertEqual(200, status)
- self.assertIn('error', body)
- self.assertEqual(-32700, body['error']['code'])
- self.assertEqual('Parse error', body['error']['message'])
- # do_POST 到非 /mcp 路径 → 404
- def test_post_wrong_path_returns_404(self):
- import urllib.request
- data = json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'initialize'}).encode()
- req = urllib.request.Request(
- self.base_url + '/wrong',
- data=data,
- headers={'Content-Type': 'application/json', 'Content-Length': str(len(data))},
- method='POST',
- )
- try:
- urllib.request.urlopen(req, timeout=5)
- self.fail('Expected 404')
- except Exception as e:
- self.assertEqual(404, e.code)
- # do_POST 正常 initialize → 200
- def test_post_initialize_returns_200(self):
- status, body = self._post('/mcp', json.dumps({
- 'jsonrpc': '2.0',
- 'id': 1,
- 'method': 'initialize',
- 'params': {},
- }))
- self.assertEqual(200, status)
- self.assertIn('result', body)
- self.assertIn('protocolVersion', body['result'])
- if __name__ == '__main__':
- unittest.main()
|