from __future__ import annotations import json import os import socket import sys import tempfile import time import unittest import uuid from concurrent.futures import ThreadPoolExecutor from copy import deepcopy from pathlib import Path from threading import Event, Lock, Thread from types import SimpleNamespace from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from core.external_systems.openapi import OpenApiClient, OpenApiConfig, OpenApiError def _openapi_client(username: str, password: str, cfg: OpenApiConfig) -> OpenApiClient: return OpenApiClient({"username": username, "password": password}, cfg) class ExternalCredentialCryptoTests(unittest.TestCase): def test_requires_master_key_and_never_falls_back_to_plaintext(self): from core.external_systems.crypto import encrypt_secret with patch.dict(os.environ, {}, clear=True): with self.assertRaisesRegex(RuntimeError, "ZCBOT_CREDENTIAL_MASTER_KEY"): encrypt_secret("secret") def test_roundtrip_uses_ciphertext(self): from core.external_systems.crypto import decrypt_secret, encrypt_secret with patch.dict( os.environ, {"ZCBOT_CREDENTIAL_MASTER_KEY": "unit-test-key-at-least-32-characters"}, clear=False, ): stored = encrypt_secret("mes-password") self.assertTrue(stored.startswith("v2:primary:")) self.assertNotIn("mes-password", stored) self.assertEqual(decrypt_secret(stored), "mes-password") def test_ciphertext_is_bound_to_context_and_supports_key_rotation(self): from core.external_systems.crypto import decrypt_secret, encrypt_secret old_key = "old-unit-test-key-at-least-32-characters" new_key = "new-unit-test-key-at-least-32-characters" with patch.dict( os.environ, { "ZCBOT_CREDENTIAL_MASTER_KEY": old_key, "ZCBOT_CREDENTIAL_KEY_ID": "old", }, clear=False, ): stored = encrypt_secret("secret", aad="user:def:password") with patch.dict( os.environ, { "ZCBOT_CREDENTIAL_MASTER_KEY": new_key, "ZCBOT_CREDENTIAL_KEY_ID": "new", "ZCBOT_CREDENTIAL_PREVIOUS_KEYS": json.dumps({"old": old_key}), }, clear=False, ): self.assertEqual( decrypt_secret(stored, aad="user:def:password"), "secret", ) with self.assertRaisesRegex(RuntimeError, "绑定上下文"): decrypt_secret(stored, aad="other:def:password") def test_rejects_short_master_key(self): from core.external_systems.crypto import configured, encrypt_secret with patch.dict( os.environ, {"ZCBOT_CREDENTIAL_MASTER_KEY": "too-short"}, clear=False ): self.assertFalse(configured()) with self.assertRaisesRegex(RuntimeError, "至少需要 32"): encrypt_secret("mes-password") class ExternalConnectionRevisionTests(unittest.TestCase): def test_sensitive_definition_change_keeps_credentials_for_reverify(self): from core.external_systems.service import _mark_connections_for_reverify connection = SimpleNamespace( credentials={"token": "ciphertext"}, credential_hint="***", status="active", last_verified_at="old", last_error=None, ) _mark_connections_for_reverify([connection]) self.assertEqual(connection.credentials, {"token": "ciphertext"}) self.assertEqual(connection.status, "needs_reverify") self.assertIsNone(connection.last_verified_at) def test_missing_credentials_remains_needs_credentials(self): from core.external_systems.service import _mark_connections_for_reverify connection = SimpleNamespace( credentials={}, credential_hint="***", status="active", last_verified_at="old", last_error=None, ) _mark_connections_for_reverify([connection]) self.assertEqual(connection.credentials, {}) self.assertEqual(connection.status, "needs_credentials") def test_legacy_missing_auth_defaults_are_semantically_unchanged(self): from core.external_systems.service import _classify_definition_config_change legacy = { "base_url": "https://factory.invalid", "openapi_url": "https://factory.invalid/swagger.json", "login_path": "/api/auth/token/", } materialized = { **legacy, "auth_type": "password_jwt", "username_field": "username", "password_field": "password", "token_field": "access", "auth_header_name": "Authorization", "auth_header_template": "Bearer {token}", } _, _, changed, impact = _classify_definition_config_change( "generic_openapi", legacy, materialized ) self.assertEqual(changed, frozenset()) self.assertEqual(impact, "none") def test_query_guidance_change_is_runtime_only(self): from core.external_systems.service import _classify_definition_config_change config = { "base_url": "https://factory.invalid", "openapi_url": "https://factory.invalid/swagger.json", } _, _, changed, impact = _classify_definition_config_change( "generic_openapi", config, {**config, "query_guidance": "先查数据集目录"}, ) self.assertEqual(changed, frozenset({"query_guidance"})) self.assertEqual(impact, "runtime") def test_target_change_requires_reverify_without_credential_reset(self): from core.external_systems.service import _classify_definition_config_change old = { "base_url": "https://factory.invalid", "openapi_url": "https://factory.invalid/swagger.json", } new = { "base_url": "https://factory-new.invalid", "openapi_url": "https://factory-new.invalid/swagger.json", } _, _, changed, impact = _classify_definition_config_change( "generic_openapi", old, new ) self.assertEqual( changed, frozenset({"base_url", "openapi_url"}) ) self.assertEqual(impact, "reverify") def test_runtime_change_advances_only_active_connections(self): from core.external_systems.service import _advance_active_connections active = SimpleNamespace( credentials={"token": "ciphertext"}, status="active", verified_revision=1, ) invalid = SimpleNamespace( credentials={"token": "ciphertext"}, status="invalid", verified_revision=1, ) missing = SimpleNamespace( credentials={}, status="active", verified_revision=1, last_verified_at="old", last_error=None, ) _advance_active_connections([active, invalid, missing], revision=2) self.assertEqual(active.verified_revision, 2) self.assertEqual(invalid.verified_revision, 1) self.assertEqual(missing.status, "needs_credentials") class GenericMcpConnectorTests(unittest.TestCase): def _config(self, **overrides): from core.external_systems.mcp import McpConfig values = { "base_url": "https://factory.invalid", "mcp_url": "https://factory.invalid/mcp", "auth_type": "api_key", "auth_header_name": "Authorization", "auth_header_template": "Bearer {token}", "recommended_operation_ids": ["mcp/search_datasets"], } values.update(overrides) return McpConfig.from_mapping(values) def test_provider_uses_generic_mcp_without_tool_allowlist(self): from core.external_systems.registry import get_provider from core.external_systems.service import _normalized_config provider = get_provider("generic_mcp") normalized = _normalized_config( "generic_mcp", { "mcp_url": "https://factory.invalid/mcp", "auth_type": "api_key", }, ) self.assertEqual(provider.connector, "mcp") self.assertEqual(normalized["mcp_url"], "https://factory.invalid/mcp") self.assertEqual(normalized["operation_policies"], {}) self.assertNotIn("openapi_url", normalized) def test_mcp_url_and_login_origin_must_match(self): from core.external_systems.mcp import McpConfig, McpConnectorError with self.assertRaisesRegex(McpConnectorError, "同源"): McpConfig.from_mapping( { "base_url": "https://login.invalid", "mcp_url": "https://mcp.invalid/mcp", } ) def test_search_discovers_every_remote_tool_and_prioritizes_recommended(self): from core.external_systems.mcp import McpClient client = McpClient({"api_key": "secret"}, self._config()) catalog = { "server": {"name": "factory", "version": "1"}, "tools": [ { "name": "delete_future_tool", "description": "服务器后来新增的工具", "inputSchema": {"type": "object"}, }, { "name": "search_datasets", "description": "搜索数据集目录", "inputSchema": { "type": "object", "properties": {"query": {"type": "string"}}, }, }, ], } with patch.object(client, "_catalog", return_value=catalog): results = client.search("服务器后来新增") recommended = client.search("数据集") self.assertIn( "mcp/delete_future_tool", {item["operation_id"] for item in results}, ) self.assertEqual(recommended[0]["operation_id"], "mcp/search_datasets") self.assertEqual( recommended[0]["input_schema"]["properties"]["query"]["type"], "string", ) def test_call_uses_remote_name_and_structured_content(self): from core.external_systems.mcp import McpClient client = McpClient({"api_key": "secret"}, self._config()) catalog = { "server": {"name": "factory", "version": "1"}, "tools": [{"name": "get_wpr", "inputSchema": {"type": "object"}}], } with ( patch.object(client, "_catalog", return_value=catalog), patch.object( client, "_run", return_value={"structuredContent": {"number": "WPR-001"}}, ) as called, ): result = client.call("mcp/get_wpr", {"identifier": "WPR-001"}) called.assert_called_once_with( "call", ("get_wpr", {"identifier": "WPR-001"}) ) self.assertEqual(result["data"], {"number": "WPR-001"}) self.assertEqual(result["operation_id"], "mcp/get_wpr") def test_call_rejects_openapi_body_and_redacts_remote_secret(self): from core.external_systems.mcp import McpClient, McpConnectorError client = McpClient({"api_key": "secret"}, self._config()) catalog = { "server": {"name": "factory", "version": "1"}, "tools": [{"name": "run", "inputSchema": {"type": "object"}}], } with patch.object(client, "_catalog", return_value=catalog): with self.assertRaisesRegex(McpConnectorError, "arguments"): client.call("mcp/run", body={"query": {}}) with ( patch.object( client, "_run", return_value={ "isError": True, "content": [{"type": "text", "token": "must-not-leak"}], }, ), self.assertRaises(McpConnectorError) as raised, ): client.call("mcp/run") self.assertIn("[REDACTED]", str(raised.exception)) self.assertNotIn("must-not-leak", str(raised.exception)) def test_definition_target_change_requires_reverify(self): from core.external_systems.service import _classify_definition_config_change old = {"mcp_url": "https://factory.invalid/mcp"} new = {"mcp_url": "https://factory.invalid/mcp-v2"} _, _, changed, impact = _classify_definition_config_change( "generic_mcp", old, new ) self.assertEqual(changed, frozenset({"mcp_url"})) self.assertEqual(impact, "reverify") def test_streamable_http_server_is_discovered_and_called_end_to_end(self): import uvicorn from mcp.server import MCPServer from mcp.server.transport_security import TransportSecuritySettings from core.external_systems.mcp import McpClient, McpConnectorError with socket.socket() as probe_socket: probe_socket.bind(("127.0.0.1", 0)) port = probe_socket.getsockname()[1] server_impl = MCPServer( name="test-mcp", title="Test MCP", description="zcbot connector integration test", version="1.0", ) @server_impl.tool() def echo_material(name: str) -> dict[str, str]: """返回材料名称。""" return {"name": name} @server_impl.tool() def oversized_result() -> dict[str, str]: """返回超过客户端安全边界的测试内容。""" return {"data": "x" * 70000} app = server_impl.streamable_http_app( streamable_http_path="/mcp", json_response=True, transport_security=TransportSecuritySettings( enable_dns_rebinding_protection=True, allowed_hosts=[f"127.0.0.1:{port}"], allowed_origins=[], ), host="127.0.0.1", ) server = uvicorn.Server( uvicorn.Config( app, host="127.0.0.1", port=port, log_level="warning", ) ) thread = Thread(target=server.run, daemon=True) thread.start() deadline = time.time() + 5 while not server.started and time.time() < deadline: time.sleep(0.01) self.assertTrue(server.started) client = McpClient( {"api_key": "test-key"}, self._config( base_url=f"http://127.0.0.1:{port}", mcp_url=f"http://127.0.0.1:{port}/mcp", expected_server_name="test-mcp", recommended_operation_ids=[], ), ) try: connection = client.test_connection() found = client.search("材料") result = client.call( "mcp/echo_material", {"name": "低碳水泥"}, ) limited_client = McpClient( {"api_key": "test-key"}, self._config( base_url=f"http://127.0.0.1:{port}", mcp_url=f"http://127.0.0.1:{port}/mcp", expected_server_name="test-mcp", recommended_operation_ids=[], max_response_bytes=65536, ), ) with self.assertRaisesRegex(McpConnectorError, "安全下载上限"): limited_client.call("mcp/oversized_result") finally: server.should_exit = True thread.join(timeout=5) self.assertEqual(connection["server"]["name"], "test-mcp") self.assertEqual(connection["operation_count"], 2) self.assertEqual(connection["tool_count"], 2) self.assertEqual(found[0]["operation_id"], "mcp/echo_material") self.assertEqual(result["data"], {"name": "低碳水泥"}) def _cfg(*, allowed=frozenset(), recommended=(), operation_mode="query"): return OpenApiConfig( base_url="https://factory.invalid", openapi_url="https://factory.invalid/swagger.json", login_path="/api/auth/token/", operation_policies={operation_id: "read" for operation_id in allowed}, timeout_seconds=5, max_result_bytes=65536, max_total_result_bytes=262144, verify_tls=True, query_guidance="先查数据集目录", recommended_operation_ids=tuple(recommended), operation_mode=operation_mode, ) _SPEC = { "swagger": "2.0", "definitions": { "DataExec": { "type": "object", "required": ["query"], "properties": { "query": {"title": "查询字典参数", "type": "object"}, "is_test": {"type": "boolean", "default": False}, }, } }, "paths": { "/api/bi/dataset/": { "get": { "operationId": "bi_dataset_list", "summary": "复杂统计查询的数据集目录", "tags": ["BI", "数据集", "报表"], "parameters": [], } }, "/api/qm/ftestwork/{batch}/": { "get": { "operationId": "qm_ftestwork_read", "summary": "查询成品检验批次", "tags": ["quality"], "parameters": [ {"name": "batch", "in": "path", "required": True, "type": "string"}, { "name": "page_size", "in": "query", "required": False, "type": "integer", }, ], } }, "/api/bi/dataset/{code}/exec/": { "post": { "operationId": "bi_dataset_exec", "summary": "执行只读数据集", "parameters": [ {"name": "code", "in": "path", "required": True, "type": "string"}, { "name": "payload", "in": "body", "required": True, "schema": {"$ref": "#/definitions/DataExec"}, }, ], } }, }, } class _Response: def __init__(self, status_code=200, payload=None): self.status_code = status_code self._payload = payload self.headers = {"content-type": "application/json"} self.text = json.dumps(payload, ensure_ascii=False) def __enter__(self): return self def __exit__(self, *args): return False def iter_bytes(self): yield self.text.encode("utf-8") def json(self): return self._payload class _Http: def __init__(self): self.calls = [] def __enter__(self): return self def __exit__(self, *args): return False def post(self, url, **kwargs): self.calls.append(("POST", url, kwargs)) return _Response(payload={"access": "remote-jwt"}) def get(self, url, **kwargs): self.calls.append(("GET", url, kwargs)) return _Response(payload=_SPEC) def request(self, method, url, **kwargs): self.calls.append((method, url, kwargs)) return _Response(payload={"count": 1, "results": [{"batch": "B/1"}]}) def stream(self, method, url, **kwargs): if method.upper() == "POST" and "/auth/" in url: return self.post(url, **kwargs) if method.upper() == "GET" and ("swagger" in url or "openapi" in url): return self.get(url, **kwargs) return self.request(method.upper(), url, **kwargs) class ExternalRuntimeCacheTests(unittest.TestCase): def test_lru_defers_client_close_until_active_lease_finishes(self): from core.external_systems.runtime_cache import ExternalRuntimeCache class Client: def __init__(self): self.closed = False def close(self): self.closed = True cache = ExternalRuntimeCache(max_entries=2) first = Client() second = Client() third = Client() with cache.client("first", lambda: first): with cache.client("second", lambda: second): pass with cache.client("third", lambda: third): pass self.assertFalse(first.closed) self.assertTrue(first.closed) cache.clear() self.assertTrue(second.closed) self.assertTrue(third.closed) class OpenApiConnectorTests(unittest.TestCase): def setUp(self): from core.external_systems.openapi import _SPEC_CACHE _SPEC_CACHE.clear() def test_admin_mapping_builds_bounded_runtime_config(self): cfg = OpenApiConfig.from_mapping( { "base_url": "https://factory.invalid/", "openapi_url": "https://factory.invalid/swagger.json", "operation_policies": { "bi_dataset_exec": "read", "report_preview": "export", }, "timeout_seconds": 999, "max_result_bytes": 1, "verify_tls": True, } ) self.assertEqual(cfg.base_url, "https://factory.invalid") self.assertEqual(cfg.timeout_seconds, 60) self.assertEqual(cfg.max_result_bytes, 4096) self.assertEqual(cfg.max_total_result_bytes, 262144) self.assertEqual(cfg.operation_mode, "query") self.assertEqual( cfg.operation_policies, { "bi_dataset_exec": "read", "report_preview": "export", }, ) self.assertEqual(cfg.query_guidance, "") self.assertEqual(cfg.recommended_operation_ids, ()) def test_admin_mapping_rejects_embedded_url_credentials(self): with self.assertRaisesRegex(OpenApiError, "不能内嵌凭据"): OpenApiConfig.from_mapping( { "base_url": "https://user:secret@factory.invalid", "openapi_url": "https://factory.invalid/swagger.json", } ) def test_admin_mapping_requires_same_origin_openapi_document(self): with self.assertRaisesRegex(OpenApiError, "必须与 base_url 同源"): OpenApiConfig.from_mapping( { "base_url": "https://factory.invalid", "openapi_url": "https://spec.attacker.invalid/swagger.json", } ) def test_generic_api_key_auth_uses_declared_header_without_login(self): from core.external_systems.openapi import ( _SPEC_CACHE, OpenApiClient, OpenApiConfig, ) from core.external_systems.registry import credential_fields, merged_config _SPEC_CACHE.clear() config = merged_config( "generic_openapi", { "base_url": "https://erp.invalid", "openapi_url": "https://erp.invalid/openapi.json", "auth_type": "api_key", "auth_header_name": "X-ERP-Key", "auth_header_template": "Key {token}", }, ) client = OpenApiClient( {"api_key": "private-key"}, OpenApiConfig.from_mapping(config) ) http = _Http() with patch.object(client, "_client", return_value=http): result = client.test_connection() self.assertGreater(result["operation_count"], 0) self.assertFalse(any(call[0] == "POST" for call in http.calls)) get_call = next(call for call in http.calls if call[0] == "GET") self.assertEqual(get_call[2]["headers"], {"X-ERP-Key": "Key private-key"}) self.assertEqual( credential_fields("generic_openapi", config)[0]["name"], "api_key" ) def test_openapi_spec_cache_is_isolated_by_connection_namespace(self): from core.external_systems.openapi import ( _SPEC_CACHE, OpenApiClient, OpenApiConfig, ) from core.external_systems.registry import merged_config _SPEC_CACHE.clear() config = OpenApiConfig.from_mapping( merged_config( "generic_openapi", { "base_url": "https://erp.invalid", "openapi_url": "https://erp.invalid/openapi.json", "auth_type": "bearer_token", }, ) ) for namespace in ("definition:user-a", "definition:user-b"): client = OpenApiClient( {"token": namespace}, config, cache_namespace=namespace ) http = _Http() with patch.object(client, "_client", return_value=http): client.test_connection() self.assertTrue(any(call[0] == "GET" for call in http.calls)) self.assertEqual(len(_SPEC_CACHE), 2) def test_search_discovers_operation_without_exposing_credentials(self): http = _Http() client = _openapi_client("mes-user", "mes-password", _cfg()) with patch.object(client, "_client", return_value=http): result = client.search("成品检验") self.assertEqual(result[0]["operation_id"], "qm_ftestwork_read") rendered = json.dumps(result, ensure_ascii=False) self.assertNotIn("mes-password", rendered) self.assertNotIn("remote-jwt", rendered) def test_runtime_reuses_http_auth_spec_and_compiled_catalog(self): from core.external_systems.openapi import compile_operation_catalog http = _Http() first = _openapi_client("mes-user", "mes-password", _cfg()) second = _openapi_client("mes-user", "mes-password", _cfg()) with ( patch.object(first, "_client", return_value=http) as first_factory, patch.object(second, "_client", return_value=_Http()) as second_factory, patch( "core.external_systems.openapi.compile_operation_catalog", wraps=compile_operation_catalog, ) as compile_catalog, ): first.search("成品检验") second.search("成品检验") first.call("qm_ftestwork_read", arguments={"batch": "B1"}) second.call("qm_ftestwork_read", arguments={"batch": "B1"}) self.assertEqual(first_factory.call_count, 1) self.assertEqual(second_factory.call_count, 0) self.assertEqual(compile_catalog.call_count, 1) self.assertEqual( sum( 1 for method, url, _ in http.calls if method == "POST" and "/auth/" in url ), 1, ) self.assertEqual( sum( 1 for method, url, _ in http.calls if method == "GET" and "swagger" in url ), 1, ) self.assertEqual( sum( 1 for method, url, _ in http.calls if method == "GET" and "/ftestwork/" in url ), 2, ) def test_concurrent_identical_query_is_singleflight_only(self): class SlowHttp(_Http): def __init__(self): super().__init__() self.query_started = Event() self.release_query = Event() self.query_count = 0 self.query_lock = Lock() def request(self, method, url, **kwargs): if method == "GET" and "/ftestwork/" in url: with self.query_lock: self.query_count += 1 self.query_started.set() self.release_query.wait(timeout=2) return super().request(method, url, **kwargs) http = SlowHttp() client = _openapi_client("mes-user", "mes-password", _cfg()) with patch.object(client, "_client", return_value=http): client.search("成品检验") # 预热认证、规格和 catalog,只测业务请求单飞。 with ThreadPoolExecutor(max_workers=2) as executor: first = executor.submit( client.call, "qm_ftestwork_read", {"batch": "B1"}, ) self.assertTrue(http.query_started.wait(timeout=1)) second = executor.submit( client.call, "qm_ftestwork_read", {"batch": "B1"}, ) time.sleep(0.05) http.release_query.set() first_result = first.result(timeout=2) second_result = second.result(timeout=2) self.assertEqual(http.query_count, 1) self.assertEqual(first_result, second_result) self.assertIsNot(first_result, second_result) def test_concurrent_cold_search_coalesces_login_and_spec_fetch(self): class SlowDiscoveryHttp(_Http): def post(self, url, **kwargs): response = super().post(url, **kwargs) time.sleep(0.05) return response def get(self, url, **kwargs): response = super().get(url, **kwargs) time.sleep(0.05) return response http = SlowDiscoveryHttp() first = _openapi_client("mes-user", "mes-password", _cfg()) second = _openapi_client("mes-user", "mes-password", _cfg()) with ( patch.object(first, "_client", return_value=http), patch.object(second, "_client", return_value=http), ThreadPoolExecutor(max_workers=2) as executor, ): results = list( executor.map(lambda client: client.search("成品检验"), (first, second)) ) self.assertTrue(all(result for result in results)) self.assertEqual( sum( 1 for method, url, _ in http.calls if method == "POST" and "/auth/" in url ), 1, ) self.assertEqual( sum( 1 for method, url, _ in http.calls if method == "GET" and "swagger" in url ), 1, ) def test_cached_password_token_refreshes_once_after_401(self): class RefreshHttp(_Http): def __init__(self): super().__init__() self.business_attempts = 0 def request(self, method, url, **kwargs): if method == "GET" and "/ftestwork/" in url: self.calls.append((method, url, kwargs)) self.business_attempts += 1 if self.business_attempts == 1: return _Response(payload={"detail": "expired"}, status_code=401) return super().request(method, url, **kwargs) http = RefreshHttp() client = _openapi_client("mes-user", "mes-password", _cfg()) with patch.object(client, "_client", return_value=http): result = client.call("qm_ftestwork_read", arguments={"batch": "B1"}) self.assertEqual(result["status_code"], 200) self.assertEqual(http.business_attempts, 2) self.assertEqual( sum( 1 for method, url, _ in http.calls if method == "POST" and "/auth/" in url ), 2, ) def test_search_pins_callable_admin_recommendations_without_keyword_match(self): http = _Http() client = _openapi_client( "mes-user", "mes-password", _cfg( allowed={"bi_dataset_exec"}, recommended=("bi_dataset_list", "bi_dataset_exec"), ), ) with patch.object(client, "_client", return_value=http): result = client.search("某工段上月产量") self.assertEqual( [item["operation_id"] for item in result[:2]], ["bi_dataset_list", "bi_dataset_exec"], ) self.assertTrue(all(item["recommended"] for item in result[:2])) def test_search_includes_resolved_swagger_body_schema(self): http = _Http() client = _openapi_client( "mes-user", "mes-password", _cfg(allowed={"bi_dataset_exec"}) ) with patch.object(client, "_client", return_value=http): result = client.search("执行只读数据集") operation = next( item for item in result if item["operation_id"] == "bi_dataset_exec" ) self.assertEqual(operation["body"]["parameter_name"], "payload") self.assertTrue(operation["body"]["required"]) self.assertEqual( operation["body"]["schema"]["properties"]["query"]["type"], "object", ) self.assertEqual(operation["body"]["schema"]["required"], ["query"]) def test_search_includes_openapi_3_request_body_schema(self): from core.external_systems.openapi import OpenApiClient spec = { "openapi": "3.0.0", "components": { "schemas": { "ReportQuery": { "type": "object", "properties": {"month": {"type": "string"}}, } } }, "paths": { "/reports/preview/": { "post": { "operationId": "report_preview", "summary": "预览报表", "requestBody": { "required": True, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ReportQuery" } } }, }, } } }, } client = OpenApiClient({"token": "private"}, _cfg(allowed={"report_preview"})) with ( patch.object(client, "authenticate", return_value={}), patch.object(client, "_fetch_spec", return_value=spec), ): result = client.search("预览报表") self.assertEqual(result[0]["body"]["content_type"], "application/json") self.assertEqual( result[0]["body"]["schema"]["properties"]["month"]["type"], "string", ) def test_catalog_resolves_referenced_header_parameter(self): spec = { "openapi": "3.0.0", "components": { "parameters": { "Trace": { "name": "X-Trace-Id", "in": "header", "required": True, "schema": {"type": "string"}, } } }, "paths": { "/quality/": { "get": { "operationId": "quality_read", "parameters": [{"$ref": "#/components/parameters/Trace"}], } } }, } cfg = _cfg() http = _Http() client = _openapi_client("u", "p", cfg) with ( patch.object(client, "_client", return_value=http), patch.object(client, "_fetch_spec", return_value=spec), ): client.call("quality_read", arguments={"X-Trace-Id": "trace-1"}) request = next(call for call in http.calls if call[0] == "GET") self.assertEqual(request[2]["headers"]["X-Trace-Id"], "trace-1") def test_swagger_array_query_uses_declared_collection_format(self): spec = { "swagger": "2.0", "paths": { "/quality/": { "get": { "operationId": "quality_filter", "parameters": [ { "name": "batches", "in": "query", "type": "array", "items": {"type": "string"}, "collectionFormat": "csv", } ], } } }, } cfg = _cfg() http = _Http() client = _openapi_client("u", "p", cfg) with ( patch.object(client, "_client", return_value=http), patch.object(client, "_fetch_spec", return_value=spec), ): client.call("quality_filter", arguments={"batches": ["B1", "B2"]}) request = next(call for call in http.calls if call[0] == "GET") self.assertEqual(request[2]["params"]["batches"], "B1,B2") def test_get_call_resolves_encoded_path_and_query(self): http = _Http() client = _openapi_client("mes-user", "mes-password", _cfg()) with patch.object(client, "_client", return_value=http): result = client.call( "qm_ftestwork_read", arguments={"batch": "B/1", "page_size": 50}, ) method, url, kwargs = [ call for call in http.calls if call[0] == "GET" and "/api/" in call[1] ][0] self.assertEqual(method, "GET") self.assertIn("B%2F1", url) self.assertEqual(kwargs["params"], {"page_size": 50}) self.assertEqual(result["data"]["count"], 1) def test_query_parameters_follow_openapi_schema_without_name_heuristics(self): spec = deepcopy(_SPEC) page_size = spec["paths"]["/api/qm/ftestwork/{batch}/"]["get"][ "parameters" ][1] page_size["maximum"] = 200 spec["paths"]["/api/qm/ftestwork/{batch}/"]["get"]["parameters"].extend( [ { "name": "page", "in": "query", "required": False, "type": "integer", "minimum": 0, }, { "name": "pageoff", "in": "query", "required": False, "type": "boolean", }, ] ) http = _Http() client = _openapi_client("u", "p", _cfg()) with ( patch.object(client, "_client", return_value=http), patch.object(client, "_fetch_spec", return_value=spec), ): client.call( "qm_ftestwork_read", arguments={ "batch": "B1", "page": 0, "page_size": 200, "pageoff": True, }, ) request = next(call for call in http.calls if call[0] == "GET") self.assertEqual(request[2]["params"]["page_size"], 200) self.assertEqual(request[2]["params"]["page"], 0) self.assertTrue(request[2]["params"]["pageoff"]) with ( patch.object(client, "authenticate", return_value={}), patch.object(client, "_fetch_spec", return_value=spec), self.assertRaisesRegex(OpenApiError, "page_size 必须 <= 200"), ): client.call( "qm_ftestwork_read", arguments={"batch": "B1", "page_size": 99999}, ) def test_swagger_base_path_is_added_to_operation_url(self): spec = deepcopy(_SPEC) spec["basePath"] = "/api" spec["paths"] = { path.removeprefix("/api"): value for path, value in spec["paths"].items() } http = _Http() client = _openapi_client("u", "p", _cfg()) with ( patch.object(client, "_client", return_value=http), patch.object(client, "_fetch_spec", return_value=spec), ): client.call("qm_ftestwork_read", arguments={"batch": "B1"}) request = next(call for call in http.calls if call[0] == "GET") self.assertEqual(request[1], "https://factory.invalid/api/qm/ftestwork/B1/") def test_api_base_path_does_not_change_login_url(self): http = _Http() client = _openapi_client("u", "p", _cfg()) with patch.object(client, "_client", return_value=http): client.authenticate() request = next(call for call in http.calls if call[0] == "POST") self.assertEqual(request[1], "https://factory.invalid/api/auth/token/") def test_base_path_is_not_duplicated_when_operation_already_contains_it(self): spec = {**deepcopy(_SPEC), "basePath": "/api"} http = _Http() client = _openapi_client("u", "p", _cfg()) with ( patch.object(client, "_client", return_value=http), patch.object(client, "_fetch_spec", return_value=spec), ): client.call("qm_ftestwork_read", arguments={"batch": "B1"}) request = next(call for call in http.calls if call[0] == "GET") self.assertNotIn("/api/api/", request[1]) configured_prefix = _openapi_client( "u", "p", OpenApiConfig( **{**_cfg().__dict__, "base_url": "https://factory.invalid/api"} ), ) self.assertEqual( configured_prefix._operation_url(spec, "/api/qm/ftestwork/B1/"), "https://factory.invalid/api/qm/ftestwork/B1/", ) def test_openapi_server_path_is_used_but_cross_origin_server_is_rejected(self): client = _openapi_client("u", "p", _cfg()) same_origin = {"openapi": "3.0.0", "servers": [{"url": "/v1"}], "paths": {}} self.assertEqual( client._operation_url(same_origin, "/quality/results/"), "https://factory.invalid/v1/quality/results/", ) cross_origin = { "openapi": "3.0.0", "servers": [{"url": "https://attacker.invalid/v1"}], "paths": {}, } with self.assertRaisesRegex(OpenApiError, "越出管理员配置的外部系统主机"): client._operation_url(cross_origin, "/quality/results/") def test_no_declared_base_path_keeps_existing_url_behavior(self): client = _openapi_client("u", "p", _cfg()) self.assertEqual( client._operation_url(_SPEC, "/api/qm/ftestwork/B1/"), "https://factory.invalid/api/qm/ftestwork/B1/", ) def test_post_is_denied_unless_admin_allowlists_operation(self): denied = _openapi_client("u", "p", _cfg()) with ( patch.object(denied, "authenticate", return_value="jwt"), patch.object(denied, "_fetch_spec", return_value=_SPEC), ): with self.assertRaisesRegex(OpenApiError, "只读调用范围"): denied.call("bi_dataset_exec", arguments={"code": "x", "payload": {}}) http = _Http() allowed = _openapi_client("u", "p", _cfg(allowed={"bi_dataset_exec"})) with patch.object(allowed, "_client", return_value=http): result = allowed.call( "bi_dataset_exec", arguments={"code": "yield", "payload": {"query": {"month": "2026-08"}}}, ) request = [ call for call in http.calls if call[0] == "POST" and "/dataset/" in call[1] ][0] self.assertEqual(request[2]["json"], {"query": {"month": "2026-08"}}) self.assertFalse(result["truncated"]) def test_allowlisted_post_accepts_separate_body_field(self): http = _Http() allowed = _openapi_client("u", "p", _cfg(allowed={"bi_dataset_exec"})) with patch.object(allowed, "_client", return_value=http): result = allowed.call( "bi_dataset_exec", arguments={"code": "quality"}, body={"query": {"batch": "B-1"}}, ) request = [ call for call in http.calls if call[0] == "POST" and "/dataset/" in call[1] ][0] self.assertEqual(request[2]["json"], {"query": {"batch": "B-1"}}) self.assertFalse(result["truncated"]) def test_upstream_managed_mode_allows_declared_write_method(self): spec = deepcopy(_SPEC) spec["paths"]["/api/items/{item_id}/"] = { "put": { "operationId": "item_update", "parameters": [ { "name": "item_id", "in": "path", "required": True, "type": "string", }, { "name": "payload", "in": "body", "required": True, "schema": { "type": "object", "required": ["name"], "properties": {"name": {"type": "string"}}, }, }, ], } } http = _Http() client = _openapi_client("u", "p", _cfg(operation_mode="upstream_managed")) with ( patch.object(client, "_client", return_value=http), patch.object(client, "authenticate", return_value={}), patch.object(client, "_fetch_spec", return_value=spec), ): result = client.call( "item_update", arguments={"item_id": "A/B"}, body={"name": "updated"}, ) request = next(call for call in http.calls if call[0] == "PUT") self.assertIn("/api/items/A%2FB/", request[1]) self.assertEqual(request[2]["json"], {"name": "updated"}) self.assertEqual(result["status_code"], 200) def test_query_mode_still_rejects_declared_write_method(self): spec = { "swagger": "2.0", "paths": { "/api/items/{item_id}/": { "delete": { "operationId": "item_delete", "parameters": [ { "name": "item_id", "in": "path", "required": True, "type": "string", } ], } } }, } client = _openapi_client("u", "p", _cfg()) with ( patch.object(client, "authenticate", return_value={}), patch.object(client, "_fetch_spec", return_value=spec), ): with self.assertRaisesRegex(OpenApiError, "只读调用范围"): client.call("item_delete", arguments={"item_id": "A-1"}) def test_call_preserves_payload_larger_than_inline_limit(self): class LargeHttp(_Http): def request(self, method, url, **kwargs): self.calls.append((method, url, kwargs)) return _Response(payload={"rows": "x" * 70000}) client = _openapi_client("u", "p", _cfg(allowed={"bi_dataset_exec"})) with patch.object(client, "_client", return_value=LargeHttp()): result = client.call( "bi_dataset_exec", arguments={"code": "quality"}, body={"query": {}}, ) self.assertEqual(len(result["data"]["rows"]), 70000) self.assertGreater(result["response_bytes"], client.cfg.max_result_bytes) self.assertFalse(result["truncated"]) def test_call_stops_stream_when_download_limit_is_exceeded(self): from core.external_systems.results import MAX_STORED_RESULT_BYTES class OversizedResponse(_Response): def iter_bytes(self): yield b"x" * MAX_STORED_RESULT_BYTES yield b"x" class OversizedHttp(_Http): def request(self, method, url, **kwargs): self.calls.append((method, url, kwargs)) return OversizedResponse(payload={}) client = _openapi_client("u", "p", _cfg()) with patch.object(client, "_client", return_value=OversizedHttp()): with self.assertRaisesRegex(OpenApiError, "安全下载上限"): client.call("qm_ftestwork_read", arguments={"batch": "B1"}) def test_call_surfaces_sanitized_upstream_error_detail(self): class ErrorHttp(_Http): def request(self, method, url, **kwargs): self.calls.append((method, url, kwargs)) return _Response( status_code=400, payload={ "query": ["This field is required."], "access_token": "must-not-leak", }, ) client = _openapi_client("u", "p", _cfg(allowed={"bi_dataset_exec"})) with patch.object(client, "_client", return_value=ErrorHttp()): with self.assertRaises(OpenApiError) as raised: client.call( "bi_dataset_exec", arguments={"code": "yield"}, body={"query": {}}, ) message = str(raised.exception) self.assertIn("This field is required.", message) self.assertIn("[REDACTED]", message) self.assertNotIn("must-not-leak", message) def test_rejects_unknown_arguments(self): client = _openapi_client("u", "p", _cfg()) with ( patch.object(client, "authenticate", return_value="jwt"), patch.object(client, "_fetch_spec", return_value=_SPEC), ): with self.assertRaisesRegex(OpenApiError, "接口定义之外"): client.call( "qm_ftestwork_read", arguments={"batch": "B1", "unexpected": "x"}, ) class ExternalSystemToolSafetyTests(unittest.TestCase): def test_status_tool_remains_registered_without_callable_connection(self): from core.tool_registry import ToolContext, build_tools uid = uuid.uuid4() task_id = uuid.uuid4() with ( tempfile.TemporaryDirectory() as tmp, patch.dict( os.environ, {"DOCUMENT_SEARCH_API_KEY": "", "MP_API_KEY": ""}, clear=False, ), patch( "core.tool_registry._external_system_status_available", return_value=True, ), patch( "core.tool_registry._external_systems_available", return_value=False, ), patch("core.tool_registry.smtp_configured", return_value=False), patch("core.tool_registry.wechat_push_available", return_value=False), patch("core.tool_registry.lfasr_configured", return_value=False), patch("core.tool_registry.BochaConfig.load", return_value=None), ): root = Path(tmp) tools = build_tools( ToolContext( tool_base=root, ur_path=root, working_dir_path=root, task_id=task_id, uid=uid, cfg={}, caps=SimpleNamespace(enable_run_python=False), skills=SimpleNamespace(skills={}), cancel_check=None, scheduled_run=True, deferred_actions=SimpleNamespace(), ark_cfg=None, img_provider="", img_key="", img_cfg=None, img_provider_cfg=None, video_variant="", office_to_pdf_available=False, ) ) self.assertIn("external_system_list", tools) self.assertNotIn("external_system_search", tools) self.assertNotIn("external_system_call", tools) def test_tools_are_scoped_to_constructor_user(self): from tools.external_systems import ExternalSystemListTool uid = uuid.uuid4() with patch( "tools.external_systems.list_external_systems", return_value=[ { "external_system_id": str(uuid.uuid4()), "status": "active", "username_masked": "me***r", }, { "external_system_id": str(uuid.uuid4()), "status": "needs_reverify", "username_masked": "me***r", }, ], ) as listed: output = ExternalSystemListTool(uid).execute() listed.assert_called_once_with(uid) self.assertNotIn("password", output.lower()) payload = json.loads(output) self.assertEqual(len(payload["systems"]), 2) self.assertIn("测试连接", payload["systems"][1]["status_guidance"]) def test_search_returns_admin_guidance_with_recommended_operations(self): from tools.external_systems import ExternalSystemSearchTool uid = uuid.uuid4() client = SimpleNamespace( cfg=SimpleNamespace( query_guidance="统计查询先查看数据集目录", recommended_operation_ids=("bi_dataset_list",), ), search=lambda query, limit: [{"operation_id": "bi_dataset_list"}], ) with patch( "tools.external_systems._row_and_client", return_value=(SimpleNamespace(), client), ): output = ExternalSystemSearchTool(uid).execute(str(uuid.uuid4()), "产量") payload = json.loads(output) self.assertEqual(payload["query_guidance"], "统计查询先查看数据集目录") self.assertEqual(payload["recommended_operation_ids"], ["bi_dataset_list"]) def test_large_call_spills_and_result_reader_pages_without_data_loss(self): from tools.external_systems import ( ExternalSystemCallTool, ExternalSystemResultExportTool, ExternalSystemResultReadTool, ) uid = uuid.uuid4() task_id = uuid.uuid4() system_id = str(uuid.uuid4()) budget: dict[str, int] = {} client = SimpleNamespace( cfg=SimpleNamespace(max_result_bytes=512, max_total_result_bytes=4096), call=lambda *args, **kwargs: { "operation_id": "detail_list", "status_code": 200, "truncated": False, "data": [{"id": index, "noise": "x" * 100} for index in range(20)], }, ) with ( tempfile.TemporaryDirectory() as tmp, patch( "tools.external_systems._row_and_client", return_value=(SimpleNamespace(), client), ), ): call_tool = ExternalSystemCallTool( uid, task_id=task_id, result_budget=budget, base_dir=Path(tmp), ) spilled = json.loads(call_tool.execute(system_id, "detail_list")) self.assertFalse(spilled["inline_complete"]) self.assertFalse(spilled["truncated"]) self.assertRegex(spilled["result_ref"], r"^extres_[0-9a-f]{32}$") self.assertNotIn("x" * 100, json.dumps(spilled)) cache_path = ( Path(tmp) / ".zcbot_cache" / str(task_id) / "external_results" / f"{spilled['result_ref']}.json" ) self.assertTrue(cache_path.is_file()) read_tool = ExternalSystemResultReadTool( uid, task_id=task_id, result_budget=budget, base_dir=Path(tmp), ) page = json.loads( read_tool.execute( spilled["result_ref"], json_pointer="/data", offset=5, limit=2, fields=["id"], ) ) other_task = ExternalSystemResultReadTool( uid, task_id=uuid.uuid4(), base_dir=Path(tmp), ) cross_task = other_task.execute(spilled["result_ref"]) export_tool = ExternalSystemResultExportTool( uid, task_id=task_id, base_dir=Path(tmp), user_root=Path(tmp), ) exported = export_tool.execute( spilled["result_ref"], filename="detail_snapshot.json", ) export_path = Path(tmp) / "data" / "external" / "detail_snapshot.json" export_payload = json.loads(export_path.read_text(encoding="utf-8")) self.assertEqual(page["total_items"], 20) self.assertEqual(page["data"], [{"id": 5}, {"id": 6}]) self.assertTrue(page["has_more"]) self.assertIn("不存在或已过期", cross_task) self.assertEqual( exported.artifacts[0].path, "data/external/detail_snapshot.json" ) self.assertEqual(export_payload["_zcbot"]["result_ref"], spilled["result_ref"]) self.assertEqual( export_payload["_zcbot"]["provenance"]["operation_id"], "detail_list", ) self.assertEqual(len(export_payload["response"]["data"]), 20) def test_result_store_reads_legacy_cache_location(self): from core.external_systems.results import ExternalResultStore with tempfile.TemporaryDirectory() as tmp: task_id = str(uuid.uuid4()) store = ExternalResultStore(Path(tmp), task_id) stored = store.store("system-1", {"data": [1, 2, 3]}) current = store.root / f"{stored.result_ref}.json" legacy = store.legacy_root / current.name legacy.parent.mkdir(parents=True) current.replace(legacy) loaded = store.load(stored.result_ref) self.assertEqual(loaded["result"], {"data": [1, 2, 3]}) if __name__ == "__main__": unittest.main()