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:
oliver 2026-10-16 18:52:05 +08:00
parent d8da7acb7d
commit 9085cac742
63 changed files with 3251 additions and 1092 deletions

3
tests/fixtures/zte_vpnv6/README.md vendored Normal file
View file

@ -0,0 +1,3 @@
# ZTE vpnv6 只读回放样本
这3个小样本于2026-10-15从工作区现有 `D:/project/chatgpt/test/show-zte/` 同名文件原样复制,作为 `test_bgp_peer_ipv4_and_ipv6_wrap` 的仓库内测试输入。内容为 show bgp 的summary及vrf邻居in/out只读输出,不含配置日志或口令。未在本次任务中连接设备采集。保留字节原貌;请勿把它们解释为合成数据或本次现场实测。

13
tests/fixtures/zte_vpnv6/show-bgp-vpnv6 vendored Normal file
View file

@ -0,0 +1,13 @@
MDN-BCP-CN1-ZM8SP#show bgp vpnv6 unicast summary | one-line
08:04:10 Indonesia Sat Sep 19 2026
BGP router ID : 124.195.96.248
Local AS : 4761
All peers : 2
Established : 2
All prefixes : 0
Neighbor Ver As MsgRcvd MsgSend Up/Down State/PfxRcd
FC00:A000:0:61:114::75
4 4761 14476867 2679817 5w4d 0
FC00:A000:0:61:114::76
4 4761 14609910 2680356 5w4d 0
MDN-BCP-CN1-ZM8SP#

View file

@ -0,0 +1,40 @@
MDN-BCP-CN1-ZM8SP#show bgp vpnv6 unicast vrf vpn.giims neighbor in 2407::45:0:0:5:1 | one-line
09:50:02 Indonesia Sat Sep 19 2026
Routes Learned From This Neighbor:
Status codes: * valid, < last valid, i - internal, s - stale
Origin codes: i - IGP, e - EGP, ? - incomplete
Local router ID 124.195.96.248, Local AS 4761, Local port 179
Remote router ID 10.83.48.3, Remote AS 65254, Remote port 54486
Total number of routes: 9
Valid routes : 9
Invalid routes : 0
Network Next Hop Metric LocPrf RtPrf Path
* 2407::45:0:0:2:780/125
2407::45:0:0:5:1
20 65254 ?
* 2407::45:0:0:2:7c4/128
2407::45:0:0:5:1
20 65254 ?
* 2407::45:0:0:2:7c5/128
2407::45:0:0:5:1
20 65254 ?
* 2407::45:0:0:2:800/123
2407::45:0:0:5:1
20 65254 ?
* 2407::45:0:0:2:844/128
2407::45:0:0:5:1
20 65254 ?
* 2407::45:0:0:2:845/128
2407::45:0:0:5:1
20 65254 ?
* 2407::45:0:0:2:880/123
2407::45:0:0:5:1
20 65254 ?
* 2407::45:0:0:2:8c4/128
2407::45:0:0:5:1
20 65254 ?
* 2407::45:0:0:2:8c5/128
2407::45:0:0:5:1
20 65254 ?
MDN-BCP-CN1-ZM8SP#

View file

@ -0,0 +1,11 @@
MDN-BCP-CN1-ZM8SP#show bgp vpnv6 unicast vrf vpn.giims neighbor out 2407::45:0:0:5:1 | one-line
09:51:15 Indonesia Sat Sep 19 2026
Routes Sent To This Neighbor:
Origin codes: i - IGP, e - EGP, ? - incomplete
Total number of routes: 1
Network Next Hop From Metric LocPrf Path
2407::45:0:0:5:0/127
2407::45:0:0:5:0
4761 ?
MDN-BCP-CN1-ZM8SP#

View file

@ -6,12 +6,56 @@ import unittest
from unittest.mock import MagicMock, patch
from fastapi import HTTPException
from sqlalchemy import create_engine, event, inspect
from sqlalchemy.orm import Session
from netx_api.db import Base
from netx_api.biz_state.service import get_batch, list_batch_metric_rows
from netx_api.models import BizStateBatch, BizStateBatchCommand
from netx_api.models import BizStateBatch, BizStateBatchCommand, BizStateMetricRow
class BatchWorkbookApiTests(unittest.TestCase):
def _stored_summary(self, batch, commands, metric_counts):
"""Exercise SQL summary projection against an isolated DB, with raw text deferred."""
engine = create_engine("sqlite+pysqlite:///:memory:")
self.addCleanup(engine.dispose)
Base.metadata.create_all(engine)
with Session(engine, expire_on_commit=False) as db:
db.add(batch)
db.add_all(commands)
for metric_id, count in metric_counts:
db.add_all([
BizStateMetricRow(batch_id=batch.id, metric_id=metric_id, seq=i)
for i in range(count)
])
db.commit()
db.expunge_all()
selected_commands = []
column_loads = []
@event.listens_for(db, "loaded_as_persistent")
def check_deferred(session, row):
if isinstance(row, BizStateBatchCommand):
selected_commands.append(row)
self.assertIn("raw_text", inspect(row).unloaded)
@event.listens_for(db, "do_orm_execute")
def track_column_loads(state):
if state.is_column_load:
column_loads.append(state.statement)
with patch(
"netx_api.biz_state.service.batch_protect_info",
return_value={"protected": False, "reasons": []},
):
result = get_batch(db, batch.id)
self.assertEqual(len(selected_commands), len(commands))
self.assertEqual(column_loads, [], "summary must never lazy-load raw CLI text")
for row in selected_commands:
self.assertIn("raw_text", inspect(row).unloaded)
return result
def test_get_batch_summary_has_sheets_not_metrics(self) -> None:
batch = BizStateBatch(
id="b1",
@ -31,35 +75,7 @@ class BatchWorkbookApiTests(unittest.TestCase):
row_count=2,
raw_text="RAW",
)
db = MagicMock()
db.get.side_effect = lambda model, pk: batch if pk == "b1" else None
cmd_q = MagicMock()
cmd_q.filter.return_value.order_by.return_value.all.return_value = [cmd]
metric_count_q = MagicMock()
metric_count_q.filter.return_value.group_by.return_value.all.return_value = [("arp", 2)]
lldp_count_q = MagicMock()
lldp_count_q.filter.return_value.scalar.return_value = 0
def query(*_args, **_kwargs):
# First call in get_batch after protect: BatchCommand; then metric count; then lldp
# Distinguish by call count
n = query.n
query.n += 1
if n == 0:
return cmd_q
if n == 1:
return metric_count_q
return lldp_count_q
query.n = 0
db.query.side_effect = query
with patch(
"netx_api.biz_state.service.batch_protect_info",
return_value={"protected": False, "reasons": []},
):
out = get_batch(db, "b1")
out = self._stored_summary(batch, [cmd], [("arp", 2)])
self.assertIn("sheets", out)
self.assertNotIn("metrics", out)
@ -93,32 +109,7 @@ class BatchWorkbookApiTests(unittest.TestCase):
raw_text="a\nb\nc\n",
message="",
)
db = MagicMock()
db.get.side_effect = lambda model, pk: batch if pk == "b1" else None
cmd_q = MagicMock()
cmd_q.filter.return_value.order_by.return_value.all.return_value = [cmd]
metric_count_q = MagicMock()
metric_count_q.filter.return_value.group_by.return_value.all.return_value = [("arp", 3)]
lldp_count_q = MagicMock()
lldp_count_q.filter.return_value.scalar.return_value = 0
def query(*_args, **_kwargs):
n = query.n
query.n += 1
if n == 0:
return cmd_q
if n == 1:
return metric_count_q
return lldp_count_q
query.n = 0
db.query.side_effect = query
with patch(
"netx_api.biz_state.service.batch_protect_info",
return_value={"protected": False, "reasons": []},
):
out = get_batch(db, "b1")
out = self._stored_summary(batch, [cmd], [("arp", 3)])
self.assertEqual(out["message"], "stopped")
self.assertEqual(out["commands"][0]["raw_line_count"], 3)
@ -148,34 +139,7 @@ class BatchWorkbookApiTests(unittest.TestCase):
declared_total=1669101,
message="declared=1669101;parsed=100",
)
db = MagicMock()
db.get.side_effect = lambda model, pk: batch if pk == "b1" else None
cmd_q = MagicMock()
cmd_q.filter.return_value.order_by.return_value.all.return_value = [cmd]
metric_count_q = MagicMock()
metric_count_q.filter.return_value.group_by.return_value.all.return_value = [
("bgp_route", 100)
]
lldp_count_q = MagicMock()
lldp_count_q.filter.return_value.scalar.return_value = 0
def query(*_args, **_kwargs):
n = query.n
query.n += 1
if n == 0:
return cmd_q
if n == 1:
return metric_count_q
return lldp_count_q
query.n = 0
db.query.side_effect = query
with patch(
"netx_api.biz_state.service.batch_protect_info",
return_value={"protected": False, "reasons": []},
):
out = get_batch(db, "b1")
out = self._stored_summary(batch, [cmd], [("bgp_route", 100)])
self.assertEqual(out["commands"][0]["raw_line_count"], 119303)
self.assertEqual(out["commands"][0]["declared_total"], 1669101)
@ -263,34 +227,7 @@ class BatchWorkbookApiTests(unittest.TestCase):
row_count=1,
),
]
db = MagicMock()
db.get.side_effect = lambda model, pk: batch if pk == "b1" else None
cmd_q = MagicMock()
cmd_q.filter.return_value.order_by.return_value.all.return_value = cmds
metric_count_q = MagicMock()
metric_count_q.filter.return_value.group_by.return_value.all.return_value = [
("bgp_peer", 3)
]
lldp_count_q = MagicMock()
lldp_count_q.filter.return_value.scalar.return_value = 0
def query(*_args, **_kwargs):
n = query.n
query.n += 1
if n == 0:
return cmd_q
if n == 1:
return metric_count_q
return lldp_count_q
query.n = 0
db.query.side_effect = query
with patch(
"netx_api.biz_state.service.batch_protect_info",
return_value={"protected": False, "reasons": []},
):
out = get_batch(db, "b1")
out = self._stored_summary(batch, cmds, [("bgp_peer", 3)])
bgp = next(s for s in out["sheets"] if s["metric_id"] == "bgp_peer")
self.assertEqual(bgp["title"], "BGP Status Summary")

View file

@ -188,8 +188,9 @@ class ManagedNeApiTests(unittest.TestCase):
self.assertEqual(listed.json()["total"], 1, listed.text)
self.client.delete(f"/v1/managed-ne/{ne_id}")
def test_create_without_crypto_key(self):
settings.credential_secret_key = ""
@patch("netx_api.ne_service_common.credentials_configured", return_value=False)
def test_create_without_crypto_key(self, mock_configured):
# Empty settings now auto-provision a key; model an unavailable key explicitly.
r = self.client.post(
"/v1/managed-ne",
json={
@ -201,6 +202,7 @@ class ManagedNeApiTests(unittest.TestCase):
},
)
self.assertEqual(r.status_code, 503, r.text)
mock_configured.assert_called_once()
def test_delete_ume_sync_route_not_captured_by_ne_id(self):
"""DELETE /ume-sync must not match DELETE /{ne_id} with ne_id='ume-sync'."""

View file

@ -100,23 +100,10 @@ def test_stdio_initialize_and_tools_list() -> None:
proc.wait(timeout=5)
def test_legacy_netx_api_mcp_module_still_works() -> None:
proc = subprocess.Popen(
[sys.executable, "-m", "netx_api.mcp"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
cwd=str(__import__("pathlib").Path(__file__).resolve().parents[1]),
)
assert proc.stdin and proc.stdout
init_req = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + "\n"
proc.stdin.write(init_req)
proc.stdin.flush()
init_line = proc.stdout.readline()
init_resp = json.loads(init_line)
assert init_resp["result"]["serverInfo"]["mode"] == "http"
proc.terminate()
proc.wait(timeout=5)
def test_only_standalone_mcp_implementation_is_kept() -> None:
from pathlib import Path
root = Path(__file__).resolve().parents[1]
assert not (root / "netx_api/mcp_server.py").exists()
assert not list((root / "netx_api/mcp").glob("*.py"))

View file

@ -73,6 +73,7 @@ class NeExecJobTests(unittest.TestCase):
ack = start_ne_exec_job(
kind="exec",
arguments={"ne_id": "abc", "commands": ["show version"]},
owner_user_id="test-owner",
runner=lambda: {"ok": True, "output": "hi"},
)
self.assertTrue(ack.get("ok"))
@ -80,7 +81,7 @@ class NeExecJobTests(unittest.TestCase):
self.assertTrue(jid)
done = None
for _ in range(50):
polled = get_ne_exec_job(jid)
polled = get_ne_exec_job(jid, user_id="test-owner")
if polled.get("terminal"):
done = polled
break

View file

@ -0,0 +1,241 @@
"""Exec job isolation and path tests: temporary files and in-memory ASGI only."""
from __future__ import annotations
import json
import time
from types import SimpleNamespace
from urllib.parse import quote
from unittest.mock import MagicMock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
import pytest
from netx_api import managed_ne_router as routes
from netx_api import ne_exec_jobs as jobs
from netx_api.auth_deps import AuthContext, require_user
from netx_api.db import get_db
JOB_ID = "a" * 32
OTHER_JOB_ID = "b" * 32
@pytest.fixture
def job_dir(tmp_path, monkeypatch):
directory = tmp_path / "jobs"
directory.mkdir()
monkeypatch.setenv("NETX_NE_EXEC_JOB_DIR", str(directory))
return directory
def write_record(directory, *, owner="alice", job_id=JOB_ID):
record = {
"job_id": job_id, "owner_user_id": owner, "status": "succeeded", "ne_count": 1,
"result": {"ok": True, "output": "temporary-test-output"},
}
(directory / f"{job_id}.json").write_text(json.dumps(record), encoding="utf-8")
return record
@pytest.mark.parametrize("job_id", [
"../canary", r"..\canary", r"C:\temp\canary", r"\\server\share\canary",
"/tmp/canary", "a" * 31, "a" * 33, "A" * 32,
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", " " + JOB_ID, JOB_ID + "\n", "not-a-job",
])
def test_invalid_ids_never_access_job_directory(job_id):
with patch.object(jobs, "_jobs_dir") as directory:
out = jobs.get_ne_exec_job(job_id, user_id="alice")
assert out == {"ok": False, "error_code": "invalid_job_id", "error": "invalid_job_id"}
with pytest.raises(ValueError, match="^invalid_job_id$"):
jobs._job_path(job_id)
directory.assert_not_called()
assert job_id not in json.dumps(out)
def test_canary_outside_job_directory_cannot_be_read(job_dir):
canary = job_dir.parent / "canary.json"
canary.write_text(json.dumps({"status": "succeeded", "result": {"canary": "non-secret"}}), encoding="utf-8")
for job_id in ("../canary", r"..\canary", str(canary.with_suffix(""))):
out = jobs.get_ne_exec_job(job_id, user_id="alice", is_admin=True)
assert out["error"] == "invalid_job_id"
assert "result" not in out
assert str(job_dir.parent) not in json.dumps(out)
def test_valid_id_symlink_cannot_escape_directory(job_dir):
canary = job_dir.parent / "canary.json"
canary.write_text(json.dumps({"job_id": JOB_ID, "owner_user_id": "alice", "status": "succeeded", "result": {"canary": "non-secret"}}), encoding="utf-8")
link = job_dir / f"{JOB_ID}.json"
try:
link.symlink_to(canary)
except (OSError, NotImplementedError):
pytest.skip("Host does not permit temporary symlink creation")
out = jobs.get_ne_exec_job(JOB_ID, user_id="alice", is_admin=True)
assert out["error"] == "invalid_job_id"
with pytest.raises(ValueError, match="^invalid_job_id$"):
jobs._write_job({"job_id": JOB_ID, "owner_user_id": "alice"})
assert json.loads(canary.read_text(encoding="utf-8"))["result"]["canary"] == "non-secret"
def test_resolved_candidate_must_stay_inside_job_directory(job_dir):
"""Exercise containment even on Windows hosts that cannot create symlinks."""
from pathlib import Path
original_resolve = Path.resolve
candidate = job_dir / f"{JOB_ID}.json"
outside = job_dir.parent / "canary.json"
def resolve(path, *args, **kwargs):
return outside if path == candidate else original_resolve(path, *args, **kwargs)
with patch.object(Path, "resolve", resolve):
assert jobs.get_ne_exec_job(JOB_ID, user_id="alice", is_admin=True)["error"] == "invalid_job_id"
def test_persisted_owner_is_required_and_other_user_is_denied(job_dir):
write_record(job_dir)
# Reader loads the on-disk record on each call; no in-memory owner fallback.
assert jobs.get_ne_exec_job(JOB_ID, user_id="alice")["result"]["output"] == "temporary-test-output"
denied = jobs.get_ne_exec_job(JOB_ID, user_id="bob")
assert denied["error"] == "job_forbidden"
assert "result" not in denied and "owner_user_id" not in denied
assert jobs.get_ne_exec_job(JOB_ID, user_id="admin", is_admin=True)["ok"] is True
record = json.loads((job_dir / f"{JOB_ID}.json").read_text(encoding="utf-8"))
record["owner_user_id"] = "bob"
(job_dir / f"{JOB_ID}.json").write_text(json.dumps(record), encoding="utf-8")
assert jobs.get_ne_exec_job(JOB_ID, user_id="alice")["error"] == "job_forbidden"
@pytest.mark.parametrize("owner", [None, "", " "])
def test_ownerless_legacy_records_are_admin_only(job_dir, owner):
write_record(job_dir, owner=owner)
assert jobs.get_ne_exec_job(JOB_ID, user_id="alice")["error"] == "job_forbidden"
assert jobs.get_ne_exec_job(JOB_ID, user_id="admin", is_admin=True)["ok"] is True
assert jobs.get_ne_exec_job(JOB_ID, user_id="", is_admin=True)["error"] == "job_forbidden"
def test_record_cannot_impersonate_another_job_id(job_dir):
write_record(job_dir)
record = json.loads((job_dir / f"{JOB_ID}.json").read_text(encoding="utf-8"))
record["job_id"] = OTHER_JOB_ID
(job_dir / f"{JOB_ID}.json").write_text(json.dumps(record), encoding="utf-8")
assert jobs.get_ne_exec_job(JOB_ID, user_id="alice")["error"] == "job_not_found"
def test_counts_sum_mixed_batch_sources():
args = {"targets": [{"ne_id": "a"}, {"ume_ne_id": "b"}], "ne_ids": ["c", "", None], "ume_ne_ids": ["d", "e"]}
assert jobs.count_exec_ne_targets(args) == 5
with patch.object(jobs, "async_min_ne_count", return_value=4):
assert jobs.should_run_exec_async(args) is True
def test_combined_target_cap_is_enforced_before_scheduling(job_dir):
with patch.object(jobs.threading, "Thread") as thread:
out = jobs.start_ne_exec_job(kind="exec-batch", owner_user_id="alice", arguments={
"ne_ids": [f"n{i}" for i in range(11)], "ume_ne_ids": [f"u{i}" for i in range(10)],
}, runner=lambda: pytest.fail("Oversized batch must not execute"))
assert out["error"] == "too_many_targets"
assert out["max_targets"] == 20
assert list(job_dir.iterdir()) == []
thread.assert_not_called()
def test_twenty_mixed_targets_are_accepted_and_persisted(job_dir):
ack = jobs.start_ne_exec_job(kind="exec-batch", owner_user_id="alice", arguments={
"targets": [{"ne_id": f"t{i}"} for i in range(7)],
"ne_ids": [f"n{i}" for i in range(6)], "ume_ne_ids": [f"u{i}" for i in range(7)],
}, runner=lambda: {"ok": True, "summary": {"total": 20}, "results": []})
assert ack["ok"] is True and ack["ne_count"] == 20
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
done = jobs.get_ne_exec_job(ack["job_id"], user_id="alice")
if done.get("terminal"):
break
time.sleep(0.01)
assert done["status"] == "succeeded"
assert done["ne_count"] == 20
assert done["result"]["summary"]["total"] == 20
def test_job_owner_cannot_be_empty(job_dir):
with patch.object(jobs.threading, "Thread") as thread:
out = jobs.start_ne_exec_job(kind="exec", owner_user_id="", arguments={"ne_id": "n"}, runner=lambda: {})
assert out["error"] == "job_owner_required"
assert list(job_dir.iterdir()) == []
thread.assert_not_called()
@pytest.fixture
def api_client(job_dir, monkeypatch):
app = FastAPI()
app.include_router(routes.router)
auth = [AuthContext(user=SimpleNamespace(id="alice", username="alice", role="user"), auth_via="api_token", scopes=frozenset({"ne:exec"}))]
app.dependency_overrides[require_user] = lambda: auth[0]
app.dependency_overrides[get_db] = lambda: MagicMock()
monkeypatch.setattr(routes, "write_audit", lambda *args, **kwargs: None)
# The fake DB context and exec results must never open a database or device.
monkeypatch.setattr("netx_api.db.SessionLocal", MagicMock())
monkeypatch.setattr(routes, "execute_managed_ne_commands", lambda *args, **kwargs: {"ok": True, "output": "mock-cli"})
monkeypatch.setattr(routes, "execute_managed_ne_commands_batch", lambda *args, **kwargs: {"ok": True, "items": []})
with TestClient(app) as client:
yield client, auth
def test_asgi_owner_other_user_and_admin_access(api_client, job_dir):
client, auth = api_client
write_record(job_dir)
path = f"/v1/managed-ne/exec-jobs/{JOB_ID}"
assert client.get(path).json()["result"]["output"] == "temporary-test-output"
auth[0].user.id = "bob"
assert client.get(path).status_code == 403
auth[0].user.role = "admin"
assert client.get(path).status_code == 200
auth[0].user.role = "user"
auth[0].scopes = frozenset({"ne:exec", "admin:users"})
assert client.get(path).status_code == 200
def test_asgi_scope_required_even_for_owner_and_admin(api_client, job_dir):
client, auth = api_client
write_record(job_dir)
auth[0].user.role = "admin"
auth[0].scopes = frozenset({"ne:read"})
assert client.get(f"/v1/managed-ne/exec-jobs/{JOB_ID}").status_code == 403
with patch.object(routes, "start_ne_exec_job") as start:
response = client.post("/v1/managed-ne/exec-jobs", json={"ne_id": "n", "commands": ["show version"]})
assert response.status_code == 403
start.assert_not_called()
def test_asgi_invalid_windows_path_returns_structured_error(api_client, job_dir):
client, _ = api_client
response = client.get("/v1/managed-ne/exec-jobs/" + quote(r"..\canary", safe=""))
assert response.status_code == 400
assert response.json()["detail"]["error"] == "invalid_job_id"
assert str(job_dir) not in response.text
def test_asgi_create_persists_actor_and_mixed_count(api_client, job_dir):
client, auth = api_client
response = client.post("/v1/managed-ne/exec-jobs", json={
"targets": [{"ne_id": "n1"}], "ne_ids": ["n2"], "ume_ne_ids": ["n3"],
"commands": ["show version"], "owner_user_id": "forged-owner",
})
assert response.status_code == 200
ack = response.json()
assert ack["ok"] is True and ack["ne_count"] == 3
job_id = ack["job_id"]
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
done = client.get(f"/v1/managed-ne/exec-jobs/{job_id}").json()
if done.get("terminal"):
break
time.sleep(0.01)
assert done["status"] == "succeeded"
record = json.loads((job_dir / f"{job_id}.json").read_text(encoding="utf-8"))
assert record["owner_user_id"] == "alice"
assert done["ne_count"] == 3
auth[0].user.id = "forged-owner"
assert client.get(f"/v1/managed-ne/exec-jobs/{job_id}").status_code == 403

View file

@ -5,6 +5,9 @@ from __future__ import annotations
import unittest
import re
from unittest.mock import MagicMock, patch
from types import SimpleNamespace
from netmiko.base_connection import BaseConnection
from netx_api.ne_netmiko import drain_read_channel, send_show_command
@ -24,6 +27,85 @@ class DrainReadChannelTests(unittest.TestCase):
class SendShowCommandTests(unittest.TestCase):
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_real_netmiko_cisco_reader_uses_full_prompt_after_drain(self, drain: MagicMock) -> None:
conn = BaseConnection(host="127.0.0.1", device_type="cisco_ios", auto_connect=False)
conn.base_prompt = "R2"
# Drain clears leftovers; reader then sees echo + body + full prompt.
chunks = iter(["show version\nCisco IOS Software\nR2 uptime is 15 hours\nR2#", "", ""])
conn.channel = SimpleNamespace(read_channel=lambda: next(chunks, ""))
conn.write_channel = MagicMock()
conn.find_prompt = MagicMock()
out = send_show_command(conn, "show version", read_timeout=2)
self.assertIn("Cisco IOS Software", out)
self.assertIn("R2 uptime is 15 hours", out)
conn.write_channel.assert_called_once()
conn.find_prompt.assert_not_called()
drain.assert_called_once()
self.assertEqual(drain.call_args.kwargs.get("idle_reads"), 6)
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_cisco_does_not_probe_prompt_or_match_hostname_inside_output(self, drain: MagicMock) -> None:
conn = MagicMock()
conn.device_type = "cisco_ios_ssh"
conn.base_prompt = "R2"
body = "Cisco IOS Software\nR2 uptime is 15 hours\nSystem image file is flash:ios.bin"
def delayed_ios_output(**kwargs: object) -> str:
if kwargs.get("auto_find_prompt", True):
return ""
if kwargs.get("cmd_verify", True):
return ""
pattern = str(kwargs.get("expect_string", ""))
if re.search(pattern, "R2 uptime is 15 hours"):
return "Cisco IOS Software"
return body
conn.send_command.side_effect = delayed_ios_output
self.assertEqual(send_show_command(conn, "show version", read_timeout=60), body)
conn.send_command.assert_called_once()
conn.find_prompt.assert_not_called()
conn.send_command_timing.assert_not_called()
self.assertFalse(conn.send_command.call_args.kwargs["cmd_verify"])
drain.assert_called_once()
self.assertEqual(drain.call_args.kwargs.get("idle_reads"), 6)
pattern = conn.send_command.call_args.kwargs["expect_string"]
for prompt in ("R2#", "R2>", "R2# "):
self.assertIsNotNone(re.search(pattern, prompt))
for content in ("R2", "R2 uptime is 15 hours", "R2#show version", "prefix R2#"):
self.assertIsNone(re.search(pattern, content))
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_cisco_prompt_hostname_is_literal(self, drain: MagicMock) -> None:
conn = MagicMock()
conn.device_type = "cisco_ios_telnet"
conn.base_prompt = "edge-1.lab"
conn.send_command.return_value = "interfaces"
self.assertEqual(send_show_command(conn, "show ip interface brief"), "interfaces")
pattern = conn.send_command.call_args.kwargs["expect_string"]
self.assertIsNotNone(re.search(pattern, "edge-1.lab#"))
self.assertIsNone(re.search(pattern, "edge-1Xlab#"))
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_cisco_timeout_does_not_resend(self, drain: MagicMock) -> None:
conn = MagicMock()
conn.device_type = "cisco_ios"
conn.base_prompt = "R2"
conn.send_command.side_effect = TimeoutError("late show output")
with self.assertRaises(TimeoutError):
send_show_command(conn, "show version")
conn.send_command.assert_called_once()
conn.send_command_timing.assert_not_called()
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_cisco_missing_cached_prompt_fails_before_sending(self, drain: MagicMock) -> None:
conn = MagicMock()
conn.device_type = "cisco_ios"
conn.base_prompt = ""
with self.assertRaisesRegex(ValueError, "cisco_base_prompt_missing"):
send_show_command(conn, "show version")
conn.send_command.assert_not_called()
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_mikrotik_does_not_probe_prompt_after_drain(self, drain: MagicMock) -> None:
conn = MagicMock()
@ -88,6 +170,17 @@ class SendShowCommandTests(unittest.TestCase):
conn.send_command_timing.assert_not_called()
drain.assert_called_once_with(conn)
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_cisco_empty_output_raises_without_resend(self, drain: MagicMock) -> None:
conn = MagicMock()
conn.device_type = "cisco_ios_ssh"
conn.base_prompt = "R2"
conn.send_command.return_value = "\n"
with self.assertRaisesRegex(ValueError, "cisco_empty_show_output"):
send_show_command(conn, "show version", read_timeout=30)
conn.send_command.assert_called_once()
conn.send_command_timing.assert_not_called()
if __name__ == "__main__":
unittest.main()

View file

@ -1,9 +1,13 @@
from __future__ import annotations
import unittest
import re
from unittest.mock import MagicMock, patch
from types import SimpleNamespace
from netx_api.ne_session_connect import _collection_driver_class, _zte_collection_driver_class
from netmiko.base_connection import BaseConnection
from netx_api.ne_session_connect import _collection_driver_class, _zte_collection_driver_class, _cisco_ios_collection_driver_class, _base_connect_kwargs
from netx_api.ne_session_factory import (
_build_netmiko_connection,
_interactive_driver_class,
@ -50,6 +54,74 @@ class InteractiveNetmikoTests(unittest.TestCase):
self.assertTrue(direct.call_args.kwargs.get("interactive"))
class CiscoCollectionPrepTests(unittest.TestCase):
def test_only_cisco_ssh_uses_cr_enter_on_the_actual_driver(self):
for device_type in ("cisco_ios", "cisco_ios_ssh", "cisco_ios_telnet", "huawei", "linux_ssh", "mikrotik_routeros"):
with self.subTest(device_type=device_type):
kwargs = _base_connect_kwargs(device_type=device_type, host="127.0.0.1", port=22,
username="fixture", password="fixture", enable_secret="")
if device_type in ("cisco_ios", "cisco_ios_ssh"):
self.assertEqual(kwargs["default_enter"], "\r")
driver = BaseConnection(**kwargs, auto_connect=False)
self.assertEqual(driver.normalize_cmd("show version"), "show version\r")
else:
self.assertNotIn("default_enter", kwargs)
def session(self):
cls = _cisco_ios_collection_driver_class(_netmiko_driver_class("cisco_ios"))
sess = cls.__new__(cls)
sess.base_prompt = "R2.lab"
sess.RETURN = "\r"
sess._test_channel_read = MagicMock() # type: ignore[method-assign]
sess.set_base_prompt = MagicMock() # type: ignore[method-assign]
sess.write_channel = MagicMock() # type: ignore[method-assign]
sess.read_until_pattern = MagicMock() # type: ignore[method-assign]
sess.set_terminal_width = MagicMock() # type: ignore[method-assign]
sess.disable_paging = MagicMock() # type: ignore[method-assign]
sess.send_command_timing = MagicMock() # type: ignore[method-assign]
return sess
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_setup_only_establishes_hostname_prompt(self, drain: MagicMock):
sess = self.session()
order: list[str] = []
drain.side_effect = lambda *_a, **_k: order.append("drain")
sess.set_base_prompt.side_effect = lambda **kw: order.append("prompt")
sess.session_preparation()
self.assertEqual(order, ["prompt", "drain"])
sess.set_base_prompt.assert_called_once()
initial = sess._test_channel_read.call_args.kwargs["pattern"]
self.assertIsNotNone(re.search(initial, "R2.lab#"))
self.assertIsNone(re.search(initial, "R2.lab#terminal length 0"))
channel = SimpleNamespace(read_timeout_override=None, _read_buffer="", read_channel=lambda: "R2.lab#")
self.assertEqual(BaseConnection.read_until_pattern(channel, pattern=initial), "R2.lab#")
sess.set_terminal_width.assert_not_called()
sess.disable_paging.assert_not_called()
sess.write_channel.assert_not_called()
sess.send_command_timing.assert_not_called()
drain.assert_called_once()
self.assertEqual(drain.call_args.kwargs.get("idle_reads"), 8)
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_setup_timeout_stops_before_drain(self, drain: MagicMock):
sess = self.session()
sess.set_base_prompt.side_effect = TimeoutError("late prompt")
with self.assertRaises(TimeoutError):
sess.session_preparation()
drain.assert_not_called()
sess.write_channel.assert_not_called()
sess.send_command_timing.assert_not_called()
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_setup_never_sends_terminal_commands(self, drain: MagicMock):
sess = self.session()
sess.session_preparation()
sess.write_channel.assert_not_called()
sess.set_terminal_width.assert_not_called()
sess.disable_paging.assert_not_called()
drain.assert_called_once_with(sess, idle_reads=8, pause_sec=0.1)
class ZteCollectionPrepTests(unittest.TestCase):
def test_collection_driver_selects_zte_wrapper(self) -> None:
base = _netmiko_driver_class("zte_zxros_ssh")

View file

@ -10,7 +10,7 @@ from sqlalchemy.orm import sessionmaker
import netx_api.models # noqa: F401
from netx_api.db import Base
from netx_api.models import TopoFabricNode, TopoFolder, TopoView, UmeInventoryNE, UmeTopoLink, UmeTopoNode
from netx_api.models import TopoFabricNode, TopoFolder, TopoView, TopoViewNode, UmeInventoryNE, UmeTopoLink, UmeTopoNode
from netx_api.ume_topology_apply import apply_ume_topology_to_fabric
from netx_api.ume_topology_flat_coords import recompute_flat_world_coords
from netx_api.ume_topology_world import ensure_ume_world_and_sbn_folders, get_world_view
@ -217,6 +217,11 @@ class UmeHierarchyTests(unittest.TestCase):
self.assertEqual(moved.x, 777.0)
self.assertEqual(moved.y, 888.0)
placement = self.db.query(TopoViewNode).filter_by(
view_id=world.id, fabric_node_id=region.fabric_node_id,
).one()
self.assertEqual((placement.x, placement.y), (777.0, 888.0))
self.db.expire_all()
g1 = get_level_view_graph(self.db, world)
again = next(n for n in g1.nodes if n.fabric_node_id == region.fabric_node_id)
self.assertEqual(again.x, 777.0)
@ -395,6 +400,57 @@ class UmeHierarchyTests(unittest.TestCase):
self.assertEqual(moved.x, ox)
self.assertEqual(moved.y, oy)
def test_flat_drag_accepts_negative_and_distant_display_coordinates(self):
from netx_api.topology_schemas import ViewNodeIn, ViewPositionsPatch
from netx_api.topology_views_graph import patch_view_positions
from netx_api.ume_topology_world import get_world_flat_view
self._seed_tree()
apply_ume_topology_to_fabric(self.db)
ensure_ume_world_and_sbn_folders(self.db)
flat = get_world_flat_view(self.db)
initial = get_flat_view_graph(self.db, flat, lod="detail")
target = initial.nodes[0]
patch_view_positions(self.db, flat.id, ViewPositionsPatch(
positions=[ViewNodeIn(fabric_node_id=target.fabric_node_id, x=-20000, y=30000, locked=True)],
return_graph=False,
))
placement = self.db.query(TopoViewNode).filter_by(
view_id=flat.id, fabric_node_id=target.fabric_node_id,
).one()
self.assertEqual((placement.x, placement.y, placement.locked), (-20000, 30000, True))
recompute_flat_world_coords(self.db)
self.db.expire_all()
graph = get_flat_view_graph(self.db, flat, lod="detail")
moved = next(n for n in graph.nodes if n.fabric_node_id == target.fabric_node_id)
self.assertEqual((moved.x, moved.y, moved.locked), (-20000, 30000, True))
# Later dragging cannot move a locked placement without explicit unlock.
result = patch_view_positions(self.db, flat.id, ViewPositionsPatch(
positions=[ViewNodeIn(fabric_node_id=target.fabric_node_id, x=10, y=20)],
return_graph=False,
))
self.assertEqual(result.skipped_locked, 1)
self.assertEqual((placement.x, placement.y), (-20000, 30000))
def test_level_position_patch_does_not_add_members_from_another_level(self):
from netx_api.topology_schemas import ViewNodeIn, ViewPositionsPatch
from netx_api.topology_views_graph import patch_view_positions
self._seed_tree()
apply_ume_topology_to_fabric(self.db)
ensure_ume_world_and_sbn_folders(self.db)
world = get_world_view(self.db)
foreign = self.db.query(TopoFabricNode).filter_by(ume_ne_id="me-a1").one()
result = patch_view_positions(self.db, world.id, ViewPositionsPatch(
positions=[ViewNodeIn(fabric_node_id=foreign.id, x=1, y=2)],
return_graph=False,
))
self.assertEqual(result.updated, 0)
self.assertEqual(result.skipped_missing, 1)
self.assertEqual(self.db.query(TopoViewNode).filter_by(
view_id=world.id, fabric_node_id=foreign.id,
).count(), 0)
def test_create_folder_under_ume_world_remounts_to_drill(self):
from netx_api.topology_schemas import TopologyFolderCreate
from netx_api.topology_views_tree import create_folder

View file

@ -410,6 +410,9 @@ class WebcrtServiceTests(unittest.TestCase):
"password": "p",
"hop_enabled": True,
"hop_vendor": "huawei",
"hop_host": "192.0.2.10",
"hop_username": "hop-user",
"hop_password": "test-only-hop-password",
"ip_address": "10.0.0.3",
"protocol": "ssh",
},
@ -479,6 +482,7 @@ class WebcrtServiceTests(unittest.TestCase):
self.assertTrue(
svc._webcrt_creds_ready(
{
"ip_address": "192.0.2.20",
"username": "ca-oper",
"password": "",
"hop_enabled": True,
@ -493,6 +497,7 @@ class WebcrtServiceTests(unittest.TestCase):
self.assertFalse(
svc._webcrt_creds_ready(
{
"ip_address": "192.0.2.20",
"username": "ca-oper",
"password": "",
"hop_enabled": True,
@ -507,6 +512,7 @@ class WebcrtServiceTests(unittest.TestCase):
self.assertFalse(
svc._webcrt_creds_ready(
{
"ip_address": "192.0.2.20",
"username": "u",
"password": "",
"protocol": "ssh",
@ -517,6 +523,7 @@ class WebcrtServiceTests(unittest.TestCase):
self.assertTrue(
svc._webcrt_creds_ready(
{
"ip_address": "192.0.2.20",
"username": "",
"password": "",
"protocol": "telnet",

View file

@ -332,8 +332,8 @@ class ResolveAuditCommandsTests(unittest.TestCase):
)
self.assertEqual(out, [prompt + "show ip interface brief"])
def test_zte_real_backspace_redraw_reconstructs_full_command(self) -> None:
"""Real ZTE device redraws via backspace runs (not CSI cursor moves).
def test_zte_synthetic_backspace_redraw_reconstructs_full_command(self) -> None:
"""Synthetic ZTE-style replay; no missing field capture is assumed.
Up-arrow recalls ``show interface brief``, left-arrows move the cursor
after ``show``, then ``ip`` is inserted. The device redraws using
@ -344,8 +344,6 @@ class ResolveAuditCommandsTests(unittest.TestCase):
(standard terminal behaviour) so the recalled text survives the redraw
and the full edited line is reconstructed.
"""
import os
from netx_api.webcrt_channel import (
_live_input_line,
_stdout_has_inplace_edit,
@ -353,12 +351,13 @@ class ResolveAuditCommandsTests(unittest.TestCase):
render_pty_line,
)
fixture = os.path.join(os.path.dirname(__file__), "fixtures",
"zte_history_edit_stdout.txt")
with open(fixture, encoding="utf-8") as f:
stdout = f.read()
prompt = "AL5458-ACC-6120HS#"
# Recall a command, move left without deleting, then redraw the edited suffix.
# The final command never appears as one contiguous substring in the replay.
recalled = "show interface brief"
stdout = prompt + recalled + ("\x08" * len("interface brief")) + "\x1b[Kip interface brief"
self.assertNotIn("show ip interface brief", stdout)
expected = prompt + "show ip interface brief"
# render_pty_line must reconstruct the full edited command.

View file

@ -270,7 +270,7 @@ class ZteExtendedParserTests(unittest.TestCase):
self.assertTrue(all(r["afi"] == "ipv4" for r in vrf), "vrf-scoped vpnv4 → ipv4")
# Real wrapped vpnv6 summary + IPv6 neighbor in/out routes
sample_dir = Path(__file__).resolve().parents[2] / "test" / "show-zte"
sample_dir = Path(__file__).resolve().parent / "fixtures" / "zte_vpnv6"
v6_sum = (sample_dir / "show-bgp-vpnv6").read_text(encoding="utf-8", errors="replace")
v6_real = normalize_bgp_peer(
raw_text=v6_sum, command="show bgp vpnv6 unicast summary | one-line"