mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 19:23:22 +08:00
- netx_api/mcp_remote: ops / topology / bizMonitor tools, Bearer auth, per-tool RBAC filtering, ?groups= filter, readOnlyHint and group meta. - Mount the official MCP Streamable HTTP app at /mcp (stateless); AuthAuditMiddleware is pure ASGI and skips /mcp. - Let uvicorn chunk MCP responses; some Windows LSPs rewrite Content-Length replies on */mcp* paths into broken chunked framing. - NETX_MCP_ALLOWED_HOSTS opts into DNS-rebinding checks (off by default so LAN and reverse-proxy hosts work; Bearer is the gate). - Remove the three stdio Python MCP packages; tests moved to /mcp, including real-TCP framing regressions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
77 lines
4.2 KiB
Python
77 lines
4.2 KiB
Python
"""无设备/网络副作用地验证 MCP 执行边界与终态失败语义。"""
|
|
import json
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from netx_api.mcp_remote.ops.http_tools import call_http_tool
|
|
|
|
|
|
@pytest.mark.parametrize("command", [
|
|
"reboot", "configure terminal", "show version; reboot", "show version && reboot",
|
|
"show version | reboot", "show version | redirect flash:config", "show $(reboot)",
|
|
"show `reboot`", "display version\nreboot", "show version\u2028reboot",
|
|
"show version > config", "show version\x00", "/system reboot",
|
|
])
|
|
@pytest.mark.parametrize("batch", [False, True])
|
|
def test_unsafe_cli_never_reaches_http(command, batch):
|
|
args = {"targets": [{"ne_id": "test-only", "commands": [command]}]} if batch else {
|
|
"ne_id": "test-only", "commands": [command], "async": True}
|
|
with patch("netx_api.mcp_remote.ops.http_tools.http_post_json") as post:
|
|
result = call_http_tool("execManagedNe", args)
|
|
assert result.get("isError") is True
|
|
post.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize("command", ["show version", "display version", "show bgp summary | one-line"])
|
|
def test_readonly_cli_still_executes(command):
|
|
with patch("netx_api.mcp_remote.ops.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": True}}) as post:
|
|
result = call_http_tool("execManagedNe", {"ne_id": "test-only", "commands": [command]})
|
|
assert not result.get("isError")
|
|
assert post.call_args.args[0] == "/v1/managed-ne/exec"
|
|
assert post.call_args.args[1]["commands"] == [command]
|
|
|
|
|
|
def test_async_ack_explains_polling_without_forcing_turn_end():
|
|
with patch("netx_api.mcp_remote.ops.http_tools.http_post_json", return_value={"ok": True, "data": {
|
|
"ok": True, "job_id": "test-job", "status": "running", "hint": "end the turn"}}):
|
|
result = call_http_tool("execManagedNe", {"ne_id": "test-only", "commands": ["show version"], "async": True})
|
|
data = json.loads(result["content"][0]["text"])
|
|
assert data["poll_after_ms"] == 5000 and data["job_id"] == "test-job"
|
|
assert "end the turn" not in data["hint"]
|
|
|
|
|
|
def test_failed_exec_job_is_tool_error_and_preserves_evidence():
|
|
terminal = {"ok": True, "terminal": True, "status": "failed", "result": {
|
|
"ok": False, "detail": "SSH connection failed", "commands": ["display version"]}}
|
|
with patch("netx_api.mcp_remote.ops.http_tools.http_json", return_value={"ok": True, "data": terminal}):
|
|
result = call_http_tool("getNeExecJob", {"job_id": "test-job"})
|
|
assert result.get("isError") is True
|
|
data = json.loads(result["content"][0]["text"])
|
|
assert data["data"]["result"]["detail"] == "SSH connection failed"
|
|
|
|
|
|
@pytest.mark.parametrize("failed", [1, 2])
|
|
@pytest.mark.parametrize("async_job", [False, True])
|
|
def test_batch_target_failures_are_tool_errors_even_when_backend_ok(failed, async_job):
|
|
result = {"ok": True, "summary": {"total": 2, "ok": 2-failed, "failed": failed},
|
|
"results": [{"ok": failed == 1, "output": "version"}, {"ok": False, "detail": "SSH banner timeout"}]}
|
|
terminal = {"ok": True, "terminal": True, "status": "succeeded", "result": result}
|
|
if async_job:
|
|
with patch("netx_api.mcp_remote.ops.http_tools.http_json", return_value={"ok": True, "data": terminal}):
|
|
out = call_http_tool("getNeExecJob", {"job_id": "test-job"})
|
|
assert json.loads(out["content"][0]["text"])["data"] == terminal
|
|
else:
|
|
with patch("netx_api.mcp_remote.ops.http_tools.http_post_json", return_value={"ok": True, "data": result}):
|
|
out = call_http_tool("execManagedNe", {"ne_ids": ["m1", "m2"], "commands": ["show version"], "async": False})
|
|
assert json.loads(out["content"][0]["text"])["data"] == result
|
|
assert out["isError"] is True
|
|
|
|
|
|
@pytest.mark.parametrize("target_ok", [False, True])
|
|
def test_batch_checks_individual_results_without_summary(target_ok):
|
|
terminal = {"ok": True, "terminal": True, "status": "succeeded", "result": {
|
|
"ok": True, "results": [{"ok": target_ok, "output": "version"}]}}
|
|
with patch("netx_api.mcp_remote.ops.http_tools.http_json", return_value={"ok": True, "data": terminal}):
|
|
out = call_http_tool("getNeExecJob", {"job_id": "test-job"})
|
|
assert bool(out.get("isError")) is not target_ok
|