mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 19:23:22 +08:00
MCP: add bizMonitor server, align ops/topology tools, harden CLI exec
- New packages/netx-bizmonitor-mcp: 8 read-only cutover/biz-state tools mirroring the netxops bizMonitor group. - Align netx-mcp (15) and netx-topology-mcp (16) with backend HTTP contracts; surface partial batch failures as tool errors. - CLI hard gate: single-line show/display only; exec job IDs, path containment, owner and ne:exec checks. - Cisco IOSv collection: stable prompt/echo handling over CR. - Remove legacy netx_api/mcp and mcp_server.py; add mcp.all.json; sync skills and docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d8da7acb7d
commit
9085cac742
63 changed files with 3251 additions and 1092 deletions
6
packages/netx-bizmonitor-mcp/tests/conftest.py
Normal file
6
packages/netx-bizmonitor-mcp/tests/conftest.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""Always import this checkout's standalone package, not the main repository."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
340
packages/netx-bizmonitor-mcp/tests/test_mcp_bizmonitor.py
Normal file
340
packages/netx-bizmonitor-mcp/tests/test_mcp_bizmonitor.py
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from netx_bizmonitor_mcp.http_client import api_token, http_json, redact
|
||||
from netx_bizmonitor_mcp.http_tools import HTTP_MCP_TOOLS, TOOL_REQUIRED_SCOPE, call_http_tool, tools_for_scopes
|
||||
from netx_bizmonitor_mcp.server import _fetch_scopes, handle_request, run_stdio_loop
|
||||
|
||||
|
||||
def payload(result):
|
||||
return json.loads(result["content"][0]["text"])
|
||||
|
||||
|
||||
def request(method, params=None, rid=1):
|
||||
return {"jsonrpc": "2.0", "id": rid, "method": method, "params": params or {}}
|
||||
|
||||
|
||||
def test_exact_eight_tools_read_only_and_canonical_scope():
|
||||
assert {t["name"] for t in HTTP_MCP_TOOLS} == {
|
||||
"listBizMonitors", "listBizMonitorBatches", "getBizMonitorContext", "getBizMonitorBoard",
|
||||
"listBizMonitorReds", "getBizMonitorDiffs", "getBizCollectBatch", "getBizCollectCommandRaw",
|
||||
}
|
||||
assert len(HTTP_MCP_TOOLS) == 8
|
||||
assert all(t["annotations"]["readOnlyHint"] for t in HTTP_MCP_TOOLS)
|
||||
assert set(TOOL_REQUIRED_SCOPE.values()) == {"biz-monitor:read"}
|
||||
assert tools_for_scopes(["ne:read", "ops:write"]) == []
|
||||
assert len(tools_for_scopes(["biz-monitor:read"])) == 8
|
||||
assert len(tools_for_scopes(None)) == 8
|
||||
|
||||
|
||||
def test_catalog_filters_slims_projects_and_keeps_totals():
|
||||
def fake(method, path, *, params):
|
||||
assert method == "GET"
|
||||
if path == "/v1/biz-migration/projects":
|
||||
assert params == {}
|
||||
return {"ok": True, "data": {"items": [
|
||||
{"id": f"p{i}", "status": "active", "monitor_template": {"name": "Core"},
|
||||
"old_task": {"ne_name": "R1", "ne_ip": "10.0.0.1", "commands": "large"}, "large_template": "drop"}
|
||||
for i in range(3)]}}
|
||||
assert path == "/v1/biz-state/tasks" and params == {"purpose": "portrait"}
|
||||
return {"ok": True, "data": {"items": [{"id": "t", "status": "paused", "ne_name": "R1"}]}}
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json", side_effect=fake):
|
||||
result = payload(call_http_tool("listBizMonitors", {"q": "r1", "status": "ACTIVE", "purpose": "portrait", "limit": 1}))
|
||||
data = result["data"]
|
||||
assert data["counts"] == {"projects": 3, "tasks": 0, "projects_returned": 1, "tasks_returned": 0}
|
||||
assert data["projects"][0]["old_ne"] == "R1 (10.0.0.1)"
|
||||
assert "old_task" not in data["projects"][0] and "large_template" not in data["projects"][0]
|
||||
assert "netx__" not in data["next"]
|
||||
|
||||
|
||||
def test_batch_cutover_expand_runs_and_partial_error():
|
||||
def fake(method, path, *, params):
|
||||
if path.endswith("/batches"):
|
||||
return {"ok": True, "data": {"items": [{"id": "b1", "status": "done", "large": "drop"}, {"id": "b2", "status": "done"}]}}
|
||||
assert params == {"limit": 3}
|
||||
if "/b2/" in path:
|
||||
return {"ok": False, "error": "netx_http_404"}
|
||||
return {"ok": True, "data": {"items": [{"id": "r1", "summary": {"anomaly": 2, "progress": {"ok": 4, "total": 5}, "large": "drop"}}]}}
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json", side_effect=fake) as mock:
|
||||
out = call_http_tool("listBizMonitorBatches", {"project_id": "p/1", "include_runs": True, "runs_limit": 3})
|
||||
data = payload(out)["data"]
|
||||
assert "isError" not in out and mock.call_count == 3
|
||||
assert mock.call_args_list[0].args == ("GET", "/v1/biz-migration/projects/p%2F1/batches")
|
||||
assert data["cutover_batches"][1]["runs_error"] == "netx_http_404"
|
||||
assert data["cutover_batches"][0]["runs"] == [{"id": "r1", "anomaly": 2, "progress_ok": 4, "progress_total": 5}]
|
||||
assert data["counts"]["runs"] == 1 and "runs" not in data
|
||||
assert "large" not in data["cutover_batches"][0]
|
||||
|
||||
|
||||
def test_batch_collect_and_runs_modes():
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json") as mock:
|
||||
mock.return_value = {"ok": True, "data": {"items": [{"id": "c1", "status": "done", "raw_text": "drop"}]}}
|
||||
out = payload(call_http_tool("listBizMonitorBatches", {"task_id": "t", "limit": 20}))
|
||||
mock.assert_called_once_with("GET", "/v1/biz-state/tasks/t/batches", params={"limit": 20})
|
||||
assert out["data"]["collect_batches"] == [{"id": "c1", "status": "done", "task_id": "t"}]
|
||||
mock.reset_mock()
|
||||
mock.return_value = {"ok": True, "data": {"items": [{"id": "r1", "summary": {"progress_ok": 2}}]}}
|
||||
out = payload(call_http_tool("listBizMonitorBatches", {"batch_id": "b"}))
|
||||
mock.assert_called_once_with("GET", "/v1/biz-migration/batches/b/runs", params={"limit": 10})
|
||||
assert out["data"]["runs"] == [{"id": "r1", "progress_ok": 2}]
|
||||
|
||||
|
||||
def test_batch_all_caps_run_expansion_at_five():
|
||||
def fake(method, path, *, params):
|
||||
if path == "/v1/biz-migration/projects/p/batches":
|
||||
return {"ok": True, "data": {"items": [{"id": f"b{i}"} for i in range(8)]}}
|
||||
if path == "/v1/biz-state/tasks/t/batches":
|
||||
return {"ok": True, "data": {"items": [{"id": "c"}]}}
|
||||
assert path.endswith("/runs")
|
||||
return {"ok": True, "data": {"items": [{"id": "r"}]}}
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json", side_effect=fake) as mock:
|
||||
data = payload(call_http_tool("listBizMonitorBatches", {
|
||||
"project_id": "p", "task_id": "t", "include_runs": True,
|
||||
}))["data"]
|
||||
assert mock.call_count == 7 # two catalogs plus five run lookups
|
||||
assert data["kind"] == "all"
|
||||
assert data["counts"] == {"cutover_batches": 8, "collect_batches": 1, "runs": 5}
|
||||
assert sum("runs" in row for row in data["cutover_batches"]) == 5
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,args,path,params", [
|
||||
("getBizMonitorContext", {"project_id": " p/1 ", "task_id": "t"}, "/v1/biz-migration/monitor-context", {"project_id": "p/1", "task_id": "t"}),
|
||||
("getBizMonitorBoard", {"batch_id": "b/1", "run_id": "r"}, "/v1/biz-migration/batches/b%2F1/board", {"run_id": "r"}),
|
||||
("listBizMonitorReds", {"project_id": "p", "status": "open", "limit": 700}, "/v1/biz-migration/projects/p/red-tickets", {"status": "open", "limit": 500}),
|
||||
("getBizMonitorDiffs", {"run_id": "r/1", "metric_id": "m", "sheet_id": "s", "verdict": "changed", "color": "red", "q": "xe-1", "offset": -2, "limit": 10}, "/v1/biz-migration/runs/r%2F1/diffs", {"metric_id": "m", "sheet_id": "s", "verdict": "changed", "color": "red", "kw": "xe-1", "offset": 0, "limit": 10}),
|
||||
("getBizCollectBatch", {"batch_id": "c/1"}, "/v1/biz-state/batches/c%2F1", {}),
|
||||
("getBizCollectCommandRaw", {"batch_id": "c", "command_id": "cmd/1"}, "/v1/biz-state/batches/c/commands/cmd%2F1", {}),
|
||||
])
|
||||
def test_each_read_tool_forwards_exact_http(name, args, path, params):
|
||||
evidence = {"old_key": "xe-1", "new_key": "ge-2", "evidence": {"raw_command": "show interface"}}
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json", return_value={"ok": True, "data": evidence}) as mock:
|
||||
result = call_http_tool(name, args)
|
||||
mock.assert_called_once_with("GET", path, params=params)
|
||||
assert payload(result)["data"] == evidence
|
||||
|
||||
|
||||
def test_diff_default_limit_not_forwarded_and_only_kw_used():
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json", return_value={"ok": True, "data": {}}) as mock:
|
||||
call_http_tool("getBizMonitorDiffs", {"run_id": "r", "q": " A B "})
|
||||
assert mock.call_args.kwargs["params"] == {"kw": "A B"}
|
||||
|
||||
|
||||
def test_raw_not_truncated_and_credentials_removed(monkeypatch):
|
||||
monkeypatch.setenv("NETX_API_TOKEN", "nxt_do_not_leak")
|
||||
raw = "show interfaces\n" + "interface xe-1 up\n" * 10000
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json", return_value={"ok": True, "data": {
|
||||
"raw_text": raw, "device": {"password": "hidden", "ne_name": "R1"},
|
||||
"api_token": "hidden", "note": "Bearer abcd password=secret nxt_do_not_leak",
|
||||
}}):
|
||||
result = call_http_tool("getBizCollectCommandRaw", {"batch_id": "b", "command_id": "c"})
|
||||
data = payload(result)["data"]
|
||||
assert data["raw_text"] == raw and data["device"] == {"ne_name": "R1"}
|
||||
assert "api_token" not in data
|
||||
assert "abcd" not in data["note"] and "secret" not in data["note"] and "nxt_do_not_leak" not in data["note"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,args,error", [
|
||||
("getBizMonitorContext", {}, "project_id_or_task_id_required"),
|
||||
("getBizMonitorBoard", {}, "batch_id_required"),
|
||||
("listBizMonitorReds", {}, "project_id_required"),
|
||||
("getBizMonitorDiffs", {}, "run_id_required"),
|
||||
("getBizCollectBatch", {}, "batch_id_required"),
|
||||
("getBizCollectCommandRaw", {"batch_id": "b"}, "batch_id_and_command_id_required"),
|
||||
("listBizMonitorBatches", {}, "project_id_or_task_id_or_batch_id_required"),
|
||||
("listBizMonitorBatches", {"kind": "all", "project_id": "p"}, "task_id_required"),
|
||||
("listBizMonitors", {"kind": "project"}, "kind_invalid"),
|
||||
("listBizMonitors", {"limit": "two"}, "invalid_argument"),
|
||||
("getBizMonitorDiffs", {"run_id": "r", "offset": True}, "invalid_argument"),
|
||||
("getBizMonitorDiffs", {"run_id": "r", "kw": "x"}, "invalid_argument"),
|
||||
])
|
||||
def test_bad_arguments_never_make_http_call(name, args, error):
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json") as mock:
|
||||
result = call_http_tool(name, args)
|
||||
mock.assert_not_called()
|
||||
assert result["isError"] is True and payload(result)["error"] == error
|
||||
|
||||
|
||||
def test_argument_error_names_field_without_echoing_value():
|
||||
result = payload(call_http_tool("listBizMonitors", {"limit": "password=hidden"}))
|
||||
assert "limit" in result["detail"]
|
||||
assert "hidden" not in json.dumps(result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [
|
||||
{"ok": False, "error": "netx_http_404"},
|
||||
{"ok": True, "data": {"ok": False, "error": "not_found"}},
|
||||
])
|
||||
def test_upstream_failures_are_tool_errors(failure):
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json", return_value=failure):
|
||||
result = call_http_tool("getBizCollectBatch", {"batch_id": "b"})
|
||||
assert result["isError"] is True
|
||||
|
||||
|
||||
def test_fetch_scopes_and_server_scope_denial():
|
||||
with patch("netx_bizmonitor_mcp.server.http_json", return_value={"ok": True, "data": {"scopes": ["ne:read"]}}):
|
||||
assert _fetch_scopes() == ["ne:read"]
|
||||
assert handle_request(request("tools/list"))["result"]["tools"] == []
|
||||
with patch("netx_bizmonitor_mcp.server.call_http_tool") as call:
|
||||
denied = handle_request(request("tools/call", {"name": "getBizCollectBatch", "arguments": {"batch_id": "b"}}))
|
||||
call.assert_not_called()
|
||||
assert "error" not in denied and denied["result"]["isError"]
|
||||
assert payload(denied["result"])["required_scope"] == "biz-monitor:read"
|
||||
with patch("netx_bizmonitor_mcp.server.http_json", return_value={"ok": True, "data": {"user": {"scopes": ["biz-monitor:read"]}}}):
|
||||
assert _fetch_scopes() == ["biz-monitor:read"]
|
||||
with patch("netx_bizmonitor_mcp.server.http_json", return_value={"ok": False}):
|
||||
assert _fetch_scopes() is None
|
||||
|
||||
|
||||
def test_protocol_errors_and_notifications():
|
||||
for obj in ([], "x", {}, {"jsonrpc": "2.0", "id": True, "method": "ping"}):
|
||||
assert handle_request(obj)["error"]["code"] == -32600
|
||||
assert handle_request(request("unknown"))["error"]["code"] == -32601
|
||||
assert handle_request(request("tools/call", {"name": "netx__getBizCollectBatch"}))["error"]["code"] == -32602
|
||||
assert handle_request(request("tools/call", {"name": "getBizCollectBatch", "arguments": []}))["error"]["code"] == -32602
|
||||
with patch("netx_bizmonitor_mcp.server.http_json") as mock:
|
||||
for method in ("initialize", "notifications/initialized", "notifications/cancelled", "ping", "tools/call"):
|
||||
assert handle_request({"jsonrpc": "2.0", "method": method}) is None
|
||||
mock.assert_not_called()
|
||||
stdin, stdout = io.StringIO('{bad\n' + json.dumps(request("ping", rid=2)) + '\n'), io.StringIO()
|
||||
with patch("sys.stdin", stdin), patch("sys.stdout", stdout):
|
||||
run_stdio_loop()
|
||||
lines = [json.loads(line) for line in stdout.getvalue().splitlines()]
|
||||
assert lines[0]["error"]["code"] == -32700 and lines[1]["id"] == 2
|
||||
|
||||
|
||||
def test_http_auth_lang_redaction_and_error_sanitization(monkeypatch):
|
||||
monkeypatch.setenv("NETX_API_TOKEN", "nxt_secret")
|
||||
monkeypatch.setenv("NETX_API_URL", "http://example.test/netx")
|
||||
monkeypatch.setenv("NETX_LANG", "en")
|
||||
with patch("netx_bizmonitor_mcp.http_client.httpx.Client") as client:
|
||||
response = httpx.Response(200, json={"id": "b", "password": "secret", "nested": {"api_key": "bad"}})
|
||||
client.return_value.__enter__.return_value.request.return_value = response
|
||||
result = http_json("GET", "/v1/biz-state/batches/b", params={"limit": 2})
|
||||
client.assert_called_once_with(timeout=45.0, trust_env=False, follow_redirects=False)
|
||||
kwargs = client.return_value.__enter__.return_value.request.call_args.kwargs
|
||||
assert kwargs["headers"]["authorization"] == "Bearer nxt_secret"
|
||||
assert kwargs["params"] == {"lang": "en", "limit": 2}
|
||||
assert result["data"] == {"id": "b", "nested": {}}
|
||||
client.return_value.__enter__.return_value.request.return_value = httpx.Response(500, text="password=leak nxt_secret")
|
||||
result = http_json("GET", "/v1/test")
|
||||
assert result["error"] == "netx_http_500" and "leak" not in json.dumps(result)
|
||||
client.return_value.__enter__.return_value.request.side_effect = httpx.ConnectError("Bearer leak")
|
||||
assert "leak" not in json.dumps(http_json("GET", "/v1/test"))
|
||||
|
||||
|
||||
def test_invalid_url_token_and_write_never_open_client(monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("NETX_API_TOKEN", raising=False)
|
||||
monkeypatch.delenv("NETX_MCP_TOKEN_FILE", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with patch("netx_bizmonitor_mcp.http_client.httpx.Client") as client:
|
||||
monkeypatch.setenv("NETX_API_URL", "https://user:pass@example.test")
|
||||
assert http_json("GET", "/x")["error"] == "netx_api_url_invalid"
|
||||
monkeypatch.setenv("NETX_API_URL", "http://example.test")
|
||||
assert http_json("GET", "/x")["error"] == "netx_token_missing"
|
||||
assert http_json("POST", "/x")["error"] == "read_only_transport"
|
||||
client.assert_not_called()
|
||||
|
||||
|
||||
def test_stdio_subprocess_initialize_list_notifications_errors(tmp_path):
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = str(Path(__file__).resolve().parents[1] / "src")
|
||||
env["NETX_API_URL"] = "http://127.0.0.1:1"
|
||||
env.pop("NETX_API_TOKEN", None) # no network attempted when token is absent
|
||||
env.pop("NETX_MCP_TOKEN_FILE", None)
|
||||
messages = [request("initialize"), {"jsonrpc": "2.0", "method": "initialize"},
|
||||
{"jsonrpc": "2.0", "method": "notifications/initialized"}, request("ping", rid=2),
|
||||
request("tools/list", rid=3), request("tools/call", {"name": "getBizCollectBatch", "arguments": {}}, rid=4)]
|
||||
run = subprocess.run([sys.executable, "-m", "netx_bizmonitor_mcp"],
|
||||
input="\n".join(json.dumps(item) for item in messages) + "\n{bad\n",
|
||||
text=True, encoding="utf-8", capture_output=True, env=env, cwd=tmp_path, timeout=10)
|
||||
assert run.returncode == 0 and run.stderr == ""
|
||||
replies = [json.loads(line) for line in run.stdout.splitlines()]
|
||||
assert len(replies) == 5
|
||||
assert replies[0]["result"]["serverInfo"]["name"] == "netx-bizmonitor-mcp"
|
||||
assert replies[1] == {"jsonrpc": "2.0", "id": 2, "result": {}}
|
||||
assert len(replies[2]["result"]["tools"]) == 8
|
||||
assert replies[3]["result"]["isError"] is True
|
||||
assert replies[4]["error"]["code"] == -32700
|
||||
|
||||
|
||||
def test_token_priority_env_then_explicit_file_then_cwd(monkeypatch, tmp_path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
default = tmp_path / "data" / "auth" / "mcp_token"
|
||||
default.parent.mkdir(parents=True)
|
||||
default.write_text(" default-token\n", encoding="utf-8")
|
||||
explicit = tmp_path / "custom-token"
|
||||
explicit.write_text(" explicit-token\n", encoding="utf-8")
|
||||
monkeypatch.setenv("NETX_API_TOKEN", " env-token ")
|
||||
monkeypatch.setenv("NETX_MCP_TOKEN_FILE", str(explicit))
|
||||
assert api_token() == "env-token"
|
||||
monkeypatch.setenv("NETX_API_TOKEN", " ")
|
||||
assert api_token() == "explicit-token"
|
||||
explicit.write_text(" \n", encoding="utf-8")
|
||||
assert api_token() == "default-token"
|
||||
monkeypatch.delenv("NETX_MCP_TOKEN_FILE")
|
||||
assert api_token() == "default-token"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("file_error", ["missing", "directory", "invalid_utf8", "permission"])
|
||||
def test_token_file_errors_fall_back_without_exposing_contents(monkeypatch, tmp_path, capsys, file_error):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("NETX_API_TOKEN", raising=False)
|
||||
default = tmp_path / "data" / "auth" / "mcp_token"
|
||||
default.parent.mkdir(parents=True)
|
||||
default.write_text("fallback-token", encoding="utf-8")
|
||||
explicit = tmp_path / "sensitive-file"
|
||||
if file_error == "directory":
|
||||
explicit.mkdir()
|
||||
elif file_error == "invalid_utf8":
|
||||
explicit.write_bytes(b"secret\xff")
|
||||
monkeypatch.setenv("NETX_MCP_TOKEN_FILE", str(explicit))
|
||||
if file_error == "permission":
|
||||
original = Path.read_text
|
||||
def read(path, *args, **kwargs):
|
||||
if path == explicit:
|
||||
raise PermissionError("sensitive exception text")
|
||||
return original(path, *args, **kwargs)
|
||||
monkeypatch.setattr(Path, "read_text", read)
|
||||
assert api_token() == "fallback-token"
|
||||
default.unlink()
|
||||
assert api_token() == ""
|
||||
with patch("netx_bizmonitor_mcp.http_client.httpx.Client") as client:
|
||||
result = http_json("GET", "/v1/auth/me")
|
||||
client.assert_not_called()
|
||||
assert result["error"] == "netx_token_missing"
|
||||
assert "sensitive-file" not in json.dumps(result) and "sensitive exception" not in json.dumps(result)
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == captured.err == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_explicit", [True, False])
|
||||
def test_file_token_auth_and_redaction_survives_rotation(monkeypatch, tmp_path, use_explicit):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("NETX_API_TOKEN", raising=False)
|
||||
monkeypatch.delenv("NETX_MCP_TOKEN_FILE", raising=False)
|
||||
monkeypatch.setenv("NETX_API_URL", "http://example.test")
|
||||
token_file = tmp_path / "custom-token" if use_explicit else tmp_path / "data" / "auth" / "mcp_token"
|
||||
token_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
token_file.write_text("nxt_file_secret", encoding="utf-8")
|
||||
if use_explicit:
|
||||
monkeypatch.setenv("NETX_MCP_TOKEN_FILE", str(token_file))
|
||||
assert redact({"nested": ["raw nxt_file_secret"]}) == {"nested": ["raw [REDACTED]"]}
|
||||
def response(*args, **kwargs):
|
||||
assert kwargs["headers"]["authorization"] == "Bearer nxt_file_secret"
|
||||
token_file.write_text("nxt_rotated", encoding="utf-8")
|
||||
return httpx.Response(200, json={"raw_text": "prefix nxt_file_secret suffix"})
|
||||
with patch("netx_bizmonitor_mcp.http_client.httpx.Client") as client:
|
||||
client.return_value.__enter__.return_value.request.side_effect = response
|
||||
result = http_json("GET", "/v1/biz-state/batches/b/commands/c")
|
||||
assert result["data"]["raw_text"] == "prefix [REDACTED] suffix"
|
||||
assert "nxt_file_secret" not in json.dumps(result)
|
||||
Loading…
Add table
Add a link
Reference in a new issue