netx/tests/test_topology.py
oliver d05ba0f1f7 Add LLDP collect page with conservative fabric edge lifecycle.
Move scheduled discovery under Network → LLDP links, mark absent edges missing after one successful scan, purge only after four miss cycles, and expose unmatched/raw job detail.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 03:39:15 +08:00

703 lines
23 KiB
Python

"""Unit tests for fabric topology + LLDP parsers (no CDP discovery)."""
from __future__ import annotations
import time
import unittest
from unittest.mock import patch
from uuid import uuid4
from netx_api import topology_lldp as lldp
from netx_api import topology_service as svc
from netx_api.db import Base, SessionLocal, engine
from netx_api.device_types import LLDP_DISCOVERED_NE_SOURCE, WEBCRT_NE_SOURCE
from netx_api.models import (
ManagedNE,
TopoDiscoverJob,
TopoDiscoverJobItem,
TopoFabricEdge,
TopoFabricNode,
TopoView,
TopoViewEdgeStyle,
TopoViewNode,
)
from netx_api.topology_schemas import (
FabricDiscoverRequest,
TopologyViewCreate,
ViewNodesAdd,
ViewPositionsPatch,
ViewNodeIn,
)
CISCO_LLDP_BRIEF = """
Capability codes:
(R) Router, (B) Bridge
Device ID Local Intf Hold-time Capability Port ID
R1 Gi0/0 120 R Gi0/1
R3 Gi0/1 120 R Gi0/0
"""
CISCO_LLDP_DETAIL = """
------------------------------------------------
Local Intf: Gi0/1
Chassis id: 707b.5c6e.d130
Port id: Ethernet1/0/1
System Name: r1
Management Addresses:
IP: 192.168.0.1
"""
HUAWEI_LLDP = """
GigabitEthernet0/0/1 has 1 neighbor(s):
Neighbor index : 1
Chassis ID : 00e0-fc12-3456
Port ID : GigabitEthernet0/0/2
System name : r1
Management address : 192.168.0.127
Local Interface: GigabitEthernet0/0/1
"""
ZTE_LLDP_BRIEF = """
Local Interface Chassis ID Port ID System Name
gei-0/1/0/1 0011.2233.4455 gei-0/1/0/2 R1
"""
class LldpParserTests(unittest.TestCase):
def test_cisco_brief(self) -> None:
hits = lldp.parse_cisco_lldp(CISCO_LLDP_BRIEF)
self.assertGreaterEqual(len(hits), 1)
self.assertTrue(any(h.remote_name.upper().startswith("R1") for h in hits))
def test_huawei(self) -> None:
hits = lldp.parse_huawei_lldp(HUAWEI_LLDP)
self.assertGreaterEqual(len(hits), 1)
self.assertEqual(hits[0].remote_name.lower(), "r1")
def test_pick_command_lldp_only(self) -> None:
cmd, tag = lldp.pick_neighbor_command(protocol="cdp", vendor="Cisco", device_type="cisco_ios")
self.assertEqual(tag, "lldp")
self.assertIn("lldp", cmd.lower())
class FabricTopologyTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
Base.metadata.create_all(bind=engine)
def setUp(self) -> None:
self.db = SessionLocal()
# Shared SQLite DB across tests — wipe fabric/view state for isolation.
for model in (
TopoViewEdgeStyle,
TopoViewNode,
TopoView,
TopoFabricEdge,
TopoFabricNode,
TopoDiscoverJobItem,
TopoDiscoverJob,
):
self.db.query(model).delete()
self.db.commit()
def tearDown(self) -> None:
self.db.close()
def test_view_crud_and_positions(self) -> None:
suffix = uuid4().hex[:8]
ne = ManagedNE(
id=f"ne-{suffix}",
name=f"R-{suffix}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"10.0.0.{(int(suffix[:2], 16) % 200) + 1}",
)
self.db.add(ne)
self.db.commit()
view = svc.create_view(self.db, TopologyViewCreate(name=f"V-{suffix}"))
graph = svc.add_nodes_to_view(
self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne.id])
)
self.assertEqual(len(graph.nodes), 1)
fid = graph.nodes[0].fabric_node_id
graph2 = svc.patch_view_positions(
self.db,
view.id,
ViewPositionsPatch(positions=[ViewNodeIn(fabric_node_id=fid, x=120, y=80)]),
)
self.assertEqual(graph2.nodes[0].x, 120)
self.assertEqual(graph2.nodes[0].y, 80)
summary = svc.get_fabric_summary(self.db)
self.assertGreaterEqual(summary.node_count, 1)
svc.delete_view(self.db, view.id)
self.db.delete(ne)
self.db.commit()
def test_lldp_discover_writes_fabric_edge(self) -> None:
suffix = uuid4().hex[:8]
ne_a = ManagedNE(
id=f"nea-{suffix}",
name="R2",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"203.0.113.{(int(suffix[:2], 16) % 100) + 1}",
)
ne_b = ManagedNE(
id=f"neb-{suffix}",
name="R1",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"203.0.113.{(int(suffix[2:4], 16) % 100) + 101}",
)
self.db.add(ne_a)
self.db.add(ne_b)
self.db.commit()
# Ensure fabric nodes exist for matching
svc.ensure_fabric_node_for_managed(self.db, ne_a)
svc.ensure_fabric_node_for_managed(self.db, ne_b)
self.db.commit()
fake_exec = {
"ok": True,
"output": CISCO_LLDP_DETAIL,
"commands": ["show lldp neighbors detail"],
}
with patch.object(svc, "execute_managed_ne_commands", return_value=fake_exec):
job = svc.start_discover_job(
self.db,
FabricDiscoverRequest(scope="ne_ids", ne_ids=[ne_a.id], concurrency=1),
)
# Wait for background thread
for _ in range(50):
out = svc.get_discover_job(self.db, job.id)
if out.status in {"done", "failed"}:
break
time.sleep(0.05)
out = svc.get_discover_job(self.db, job.id)
self.assertEqual(out.status, "done", out.error)
edges = svc.list_fabric_edges(self.db, page=1, page_size=50)
self.assertGreaterEqual(edges["total"], 1)
self.db.delete(ne_a)
self.db.delete(ne_b)
self.db.commit()
def test_merge_duplicate_and_project_skips_orphans(self) -> None:
suffix = uuid4().hex[:8]
ne_a = ManagedNE(
id=f"nea-{suffix}",
name="R2",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"203.0.113.{(int(suffix[:2], 16) % 80) + 10}",
)
ne_b = ManagedNE(
id=f"neb-{suffix}",
name="R1",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"203.0.113.{(int(suffix[2:4], 16) % 80) + 100}",
)
self.db.add(ne_a)
self.db.add(ne_b)
self.db.commit()
fa = svc.ensure_fabric_node_for_managed(self.db, ne_a)
fb = svc.ensure_fabric_node_for_managed(self.db, ne_b)
orphan = TopoFabricNode(
id=uuid4().hex,
managed_ne_id=None,
ume_ne_id=None,
name="r1",
ip="",
vendor="",
device_type="",
attrs={"from_lldp_unmatched": True},
)
# Simulate raced duplicate of R2 (same managed id not allowed by constraint —
# use name/ip collision path with a second orphan twin).
orphan_r2 = TopoFabricNode(
id=uuid4().hex,
managed_ne_id=None,
ume_ne_id=None,
name="R2",
ip=ne_a.ip_address,
vendor="",
device_type="",
attrs={"from_lldp_unmatched": True},
)
self.db.add(orphan)
self.db.add(orphan_r2)
self.db.commit()
# Path: orphan_r2 -- fb -- fa -- orphan (the bug shape)
svc.upsert_fabric_edge(
self.db, a_node_id=orphan_r2.id, b_node_id=fb.id, a_port="g0", b_port="g1", source="lldp"
)
svc.upsert_fabric_edge(
self.db, a_node_id=fb.id, b_node_id=fa.id, a_port="g2", b_port="g3", source="lldp"
)
svc.upsert_fabric_edge(
self.db, a_node_id=fa.id, b_node_id=orphan.id, a_port="g4", b_port="g5", source="lldp"
)
self.db.commit()
view = svc.create_view(self.db, TopologyViewCreate(name=f"V-{suffix}"))
graph = svc.add_nodes_to_view(
self.db,
view.id,
ViewNodesAdd(fabric_node_ids=[fa.id, fb.id, orphan.id, orphan_r2.id]),
)
self.assertEqual(len(graph.nodes), 4)
merged = svc.merge_duplicate_fabric_nodes(self.db)
self.assertGreaterEqual(merged["merged"], 2)
self.db.expire_all()
self.assertIsNone(self.db.get(TopoFabricNode, orphan.id))
self.assertIsNone(self.db.get(TopoFabricNode, orphan_r2.id))
# Re-place inventory nodes only, then project should stay at 2.
graph2 = svc.add_nodes_to_view(
self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne_a.id, ne_b.id])
)
# View may still have stale placements pointing at deleted ids — project cleans.
projected = svc.project_fabric_neighbors_to_view(self.db, view.id)
self.assertEqual(len(projected.nodes), 2)
labels = sorted((n.name or n.label).upper() for n in projected.nodes)
self.assertEqual(labels, ["R1", "R2"])
self.assertGreaterEqual(len(projected.edges), 1)
endpoint_ids = {n.fabric_node_id for n in projected.nodes}
for e in projected.edges:
self.assertIn(e.a_node_id, endpoint_ids)
self.assertIn(e.b_node_id, endpoint_ids)
self.db.delete(ne_a)
self.db.delete(ne_b)
self.db.commit()
def test_match_hit_prefers_inventory(self) -> None:
suffix = uuid4().hex[:8]
ne = ManagedNE(
id=f"ne-{suffix}",
name="R1",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"198.51.100.{(int(suffix[:2], 16) % 80) + 20}",
)
self.db.add(ne)
self.db.commit()
inv = svc.ensure_fabric_node_for_managed(self.db, ne)
orphan = TopoFabricNode(
id=uuid4().hex,
name="r1",
ip="",
attrs={"from_lldp_unmatched": True},
)
self.db.add(orphan)
self.db.commit()
hit = lldp.NeighborHit(
remote_name="r1",
remote_ip="",
local_port="Gi0/0",
remote_port="Gi0/1",
)
peer = svc._match_hit_to_fabric_node(self.db, hit, self_id="self")
self.assertEqual(peer.id, inv.id)
self.db.delete(ne)
self.db.commit()
def test_lldp_unmatched_creates_ssh_placeholder_ne(self) -> None:
suffix = uuid4().hex[:8]
peer_name = f"Peer-{suffix}"
ne_a = ManagedNE(
id=f"nea-{suffix}",
name=f"Core-{suffix}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"203.0.113.{(int(suffix[:2], 16) % 80) + 40}",
source="manual",
)
self.db.add(ne_a)
self.db.commit()
svc.ensure_fabric_node_for_managed(self.db, ne_a)
self.db.commit()
lldp_out = f"""
------------------------------------------------
Local Intf: Gi0/1
Chassis id: 707b.5c6e.d130
Port id: Ethernet1/0/1
System Name: {peer_name}
Management Addresses:
IP: 198.51.100.200
"""
fake_exec = {
"ok": True,
"output": lldp_out,
"commands": ["show lldp neighbors detail"],
}
with patch.object(svc, "execute_managed_ne_commands", return_value=fake_exec):
job = svc.start_discover_job(
self.db,
FabricDiscoverRequest(
scope="ne_ids", ne_ids=[ne_a.id], concurrency=1, auto_add_unmatched=True
),
)
for _ in range(50):
out = svc.get_discover_job(self.db, job.id)
if out.status in {"done", "failed"}:
break
time.sleep(0.05)
out = svc.get_discover_job(self.db, job.id)
self.assertEqual(out.status, "done", out.error)
placeholders = (
self.db.query(ManagedNE)
.filter(ManagedNE.source == LLDP_DISCOVERED_NE_SOURCE)
.all()
)
placeholder = next(
(p for p in placeholders if svc._norm_host(p.name) == svc._norm_host(peer_name)),
None,
)
self.assertIsNotNone(placeholder)
assert placeholder is not None
self.assertEqual(placeholder.ip_address, "")
self.assertEqual(placeholder.username, "")
self.assertEqual(placeholder.password_enc, "")
self.assertEqual(placeholder.protocol, "ssh")
self.assertEqual(placeholder.device_type, "generic")
self.assertEqual(placeholder.source_ref, "198.51.100.200")
view = svc.create_view(self.db, TopologyViewCreate(name=f"V-{suffix}"))
svc.add_nodes_to_view(self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne_a.id]))
projected = svc.project_fabric_neighbors_to_view(self.db, view.id)
self.assertEqual(len(projected.nodes), 2)
self.assertGreaterEqual(len(projected.edges), 1)
self.db.delete(ne_a)
self.db.delete(placeholder)
self.db.commit()
def test_match_prefers_real_ne_over_webcrt_same_ip(self) -> None:
suffix = uuid4().hex[:8]
ip = f"198.51.100.{(int(suffix[:2], 16) % 80) + 30}"
real = ManagedNE(
id=f"real-{suffix}",
name="R2",
vendor="Cisco",
device_type="cisco_ios",
ip_address=ip,
source="manual",
)
webcrt = ManagedNE(
id=f"wcrt-{suffix}",
name=f"{ip} (1)",
vendor="Other",
device_type="generic",
ip_address=ip,
source=WEBCRT_NE_SOURCE,
)
self.db.add(real)
self.db.add(webcrt)
self.db.commit()
inv = svc.ensure_fabric_node_for_managed(self.db, real)
ghost = svc.ensure_fabric_node_for_managed(self.db, webcrt)
self.db.commit()
hit = lldp.NeighborHit(remote_name="", remote_ip=ip, local_port="Gi0/0", remote_port="Gi0/1")
peer = svc._match_hit_to_fabric_node(self.db, hit, self_id="self")
self.assertEqual(peer.id, inv.id)
self.assertNotEqual(peer.id, ghost.id)
merged = svc.merge_duplicate_fabric_nodes(self.db)
self.assertGreaterEqual(merged["merged"], 1)
self.db.expire_all()
self.assertIsNone(self.db.get(TopoFabricNode, ghost.id))
self.db.delete(real)
self.db.delete(webcrt)
self.db.commit()
def test_neighborhood(self) -> None:
suffix = uuid4().hex[:8]
ne_a = ManagedNE(
id=f"nea-{suffix}",
name=f"A-{suffix}",
ip_address=f"198.51.100.{(int(suffix[:2], 16) % 80) + 10}",
vendor="ZTE",
device_type="zte_zxros",
)
ne_b = ManagedNE(
id=f"neb-{suffix}",
name=f"B-{suffix}",
ip_address=f"198.51.100.{(int(suffix[2:4], 16) % 80) + 100}",
vendor="ZTE",
device_type="zte_zxros",
)
self.db.add(ne_a)
self.db.add(ne_b)
self.db.commit()
fa = svc.ensure_fabric_node_for_managed(self.db, ne_a)
fb = svc.ensure_fabric_node_for_managed(self.db, ne_b)
self.db.commit()
svc.upsert_fabric_edge(
self.db,
a_node_id=fa.id,
b_node_id=fb.id,
a_port="gei-0/1/0/1",
b_port="gei-0/1/0/2",
source="lldp",
)
self.db.commit()
nb = svc.get_fabric_neighborhood(self.db, fa.id, depth=1)
self.assertEqual(len(nb.nodes), 2)
self.assertEqual(len(nb.edges), 1)
self.db.delete(ne_a)
self.db.delete(ne_b)
self.db.commit()
def _pair_nodes(self, suffix: str) -> tuple[TopoFabricNode, TopoFabricNode, ManagedNE, ManagedNE]:
ne_a = ManagedNE(
id=f"nea-{suffix}",
name=f"A-{suffix}",
ip_address=f"198.51.100.{(int(suffix[:2], 16) % 80) + 10}",
vendor="Cisco",
device_type="cisco_ios",
)
ne_b = ManagedNE(
id=f"neb-{suffix}",
name=f"B-{suffix}",
ip_address=f"198.51.100.{(int(suffix[2:4], 16) % 80) + 100}",
vendor="Cisco",
device_type="cisco_ios",
)
self.db.add(ne_a)
self.db.add(ne_b)
self.db.commit()
fa = svc.ensure_fabric_node_for_managed(self.db, ne_a)
fb = svc.ensure_fabric_node_for_managed(self.db, ne_b)
self.db.commit()
return fa, fb, ne_a, ne_b
def test_edge_missing_after_one_absent_cycle(self) -> None:
suffix = uuid4().hex[:8]
fa, fb, ne_a, ne_b = self._pair_nodes(suffix)
edge, _ = svc.upsert_fabric_edge(
self.db,
a_node_id=fa.id,
b_node_id=fb.id,
a_port="Gi0/0",
b_port="Gi0/1",
source="lldp",
)
self.db.commit()
newly, purged = svc._apply_missing_and_purge(
self.db,
scanned_ok={fa.id},
touched_edge_ids=set(),
)
self.db.commit()
self.assertEqual(newly, 1)
self.assertEqual(purged, 0)
self.db.refresh(edge)
self.assertEqual(edge.status, "missing")
self.assertEqual(int((edge.attrs or {}).get("miss_count") or 0), 1)
self.db.delete(ne_a)
self.db.delete(ne_b)
self.db.commit()
def test_edge_purge_after_four_missing_cycles(self) -> None:
suffix = uuid4().hex[:8]
fa, fb, ne_a, ne_b = self._pair_nodes(suffix)
edge, _ = svc.upsert_fabric_edge(
self.db,
a_node_id=fa.id,
b_node_id=fb.id,
a_port="Gi0/0",
b_port="Gi0/1",
source="lldp",
)
edge_id = edge.id
self.db.commit()
for cycle in range(1, 5):
newly, purged = svc._apply_missing_and_purge(
self.db,
scanned_ok={fa.id},
touched_edge_ids=set(),
)
self.db.commit()
if cycle < 4:
self.assertEqual(purged, 0)
row = self.db.get(TopoFabricEdge, edge_id)
self.assertIsNotNone(row)
self.assertEqual(int((row.attrs or {}).get("miss_count") or 0), cycle)
else:
self.assertEqual(purged, 1)
self.assertIsNone(self.db.get(TopoFabricEdge, edge_id))
self.db.delete(ne_a)
self.db.delete(ne_b)
self.db.commit()
def test_peer_replace_marks_old_edge_missing(self) -> None:
suffix = uuid4().hex[:8]
fa, fb, ne_a, ne_b = self._pair_nodes(suffix)
ne_c = ManagedNE(
id=f"nec-{suffix}",
name=f"C-{suffix}",
ip_address=f"198.51.100.{(int(suffix[4:6], 16) % 80) + 50}",
vendor="Cisco",
device_type="cisco_ios",
)
self.db.add(ne_c)
self.db.commit()
fc = svc.ensure_fabric_node_for_managed(self.db, ne_c)
self.db.commit()
old, _ = svc.upsert_fabric_edge(
self.db,
a_node_id=fa.id,
b_node_id=fb.id,
a_port="Gi0/0",
b_port="Gi0/1",
source="lldp",
)
new, _ = svc.upsert_fabric_edge(
self.db,
a_node_id=fa.id,
b_node_id=fc.id,
a_port="Gi0/0",
b_port="Gi0/2",
source="lldp",
)
self.db.commit()
handled = svc._mark_replaced_port_peers(
self.db,
self_id=fa.id,
local_port="Gi0/0",
peer_id=fc.id,
new_edge_id=new.id,
)
self.db.commit()
self.assertIn(old.id, handled)
self.db.refresh(old)
self.assertEqual(old.status, "missing")
self.assertEqual((old.attrs or {}).get("replaced_by_edge_id"), new.id)
self.assertEqual(int((old.attrs or {}).get("miss_count") or 0), 1)
# Same-job: replaced id in touched → no second miss bump.
newly, purged = svc._apply_missing_and_purge(
self.db,
scanned_ok={fa.id},
touched_edge_ids={new.id, *handled},
)
self.db.commit()
self.assertEqual(newly, 0)
self.assertEqual(purged, 0)
self.db.refresh(old)
self.assertEqual(int((old.attrs or {}).get("miss_count") or 0), 1)
self.db.delete(ne_a)
self.db.delete(ne_b)
self.db.delete(ne_c)
self.db.commit()
def test_edge_reactivate_clears_miss_attrs(self) -> None:
suffix = uuid4().hex[:8]
fa, fb, ne_a, ne_b = self._pair_nodes(suffix)
edge, _ = svc.upsert_fabric_edge(
self.db,
a_node_id=fa.id,
b_node_id=fb.id,
a_port="Gi0/0",
b_port="Gi0/1",
source="lldp",
)
self.db.commit()
svc._apply_missing_and_purge(
self.db, scanned_ok={fa.id}, touched_edge_ids=set()
)
self.db.commit()
self.db.refresh(edge)
self.assertEqual(edge.status, "missing")
edge2, action = svc.upsert_fabric_edge(
self.db,
a_node_id=fa.id,
b_node_id=fb.id,
a_port="Gi0/0",
b_port="Gi0/1",
source="lldp",
)
self.db.commit()
self.assertEqual(action, "updated")
self.assertEqual(edge2.id, edge.id)
self.assertEqual(edge2.status, "active")
attrs = edge2.attrs or {}
self.assertNotIn("miss_count", attrs)
self.assertNotIn("first_missing_at", attrs)
self.assertNotIn("replaced_by_edge_id", attrs)
self.db.delete(ne_a)
self.db.delete(ne_b)
self.db.commit()
def test_scan_fail_does_not_mark_missing(self) -> None:
"""Endpoint not in scanned_ok (SSH/parse fail) → leave edge active."""
suffix = uuid4().hex[:8]
fa, fb, ne_a, ne_b = self._pair_nodes(suffix)
edge, _ = svc.upsert_fabric_edge(
self.db,
a_node_id=fa.id,
b_node_id=fb.id,
a_port="Gi0/0",
b_port="Gi0/1",
source="lldp",
)
self.db.commit()
newly, purged = svc._apply_missing_and_purge(
self.db,
scanned_ok=set(), # scan failed — nothing judged
touched_edge_ids=set(),
)
self.db.commit()
self.assertEqual(newly, 0)
self.assertEqual(purged, 0)
self.db.refresh(edge)
self.assertEqual(edge.status, "active")
# Manual edges never auto-missing.
manual, _ = svc.upsert_fabric_edge(
self.db,
a_node_id=fa.id,
b_node_id=fb.id,
a_port="Gi1/0",
b_port="Gi1/1",
source="manual",
)
self.db.commit()
newly, purged = svc._apply_missing_and_purge(
self.db,
scanned_ok={fa.id},
touched_edge_ids=set(),
)
self.db.commit()
self.db.refresh(manual)
self.assertEqual(manual.status, "active")
self.assertEqual(manual.source, "manual")
self.db.delete(ne_a)
self.db.delete(ne_b)
self.db.commit()
if __name__ == "__main__":
unittest.main()