mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 18:10:55 +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
3
tests/fixtures/zte_vpnv6/README.md
vendored
Normal file
3
tests/fixtures/zte_vpnv6/README.md
vendored
Normal 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
13
tests/fixtures/zte_vpnv6/show-bgp-vpnv6
vendored
Normal 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#
|
||||
40
tests/fixtures/zte_vpnv6/show-bgp-vpnv6-neighbor-router-in-vrf
vendored
Normal file
40
tests/fixtures/zte_vpnv6/show-bgp-vpnv6-neighbor-router-in-vrf
vendored
Normal 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#
|
||||
11
tests/fixtures/zte_vpnv6/show-bgp-vpnv6-neighbor-router-out-vrf
vendored
Normal file
11
tests/fixtures/zte_vpnv6/show-bgp-vpnv6-neighbor-router-out-vrf
vendored
Normal 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#
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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'."""
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
241
tests/test_ne_exec_job_security.py
Normal file
241
tests/test_ne_exec_job_security.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue