get_lldp_neighbors() was never implemented, so the switch reported no
neighbours and NetOrk's network map had nothing to draw for the site it
serves — on the reference installation two APs, a firewall and a Proxmox host
behind a 1820 8G PoE+ (J9982A) all stood unconnected.
The web UI does carry the data. /htdocs/pages/switching/lldp_remote.lsp is
served with the rows embedded in the page's aDataSet, so the existing _rows()
helper reads it with no new plumbing:
['7', '3', '00:...:44', '00:...:46', 'enp3s0', 'host.example.com',
'bridge, WLAN access point, router, station only', 'bridge', '192.0.2.10']
Column 3 is the neighbour's port ID, which on many peers is a MAC, while
column 4 is its port description — a name a person can read. The description
wins, with the ID used only when it is missing. The chassis ID is passed
through as `mac` when it looks like one, since netOrk resolves a neighbour by
MAC before falling back to its name.
Fixture captured from the live switch and anonymised in the style of the
existing ones.
Closes #1
226 lines
8.0 KiB
Python
226 lines
8.0 KiB
Python
"""Unit tests for OfficeConnectDriver — fixture-driven, no real device."""
|
|
|
|
import pathlib
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from napalm_hpe_officeconnect.officeconnect import OfficeConnectDriver
|
|
|
|
FIXTURES = pathlib.Path(__file__).parent.parent / "fixtures"
|
|
|
|
PAGE_FOR_PATH = {
|
|
OfficeConnectDriver.DASHBOARD: "base_dashboard",
|
|
OfficeConnectDriver.PORT_SUMMARY: "base_port_summary",
|
|
OfficeConnectDriver.MAC_TABLE: "base_mac_address_table",
|
|
OfficeConnectDriver.VLAN_STATUS: "switching_vlan_status",
|
|
OfficeConnectDriver.LLDP_REMOTE: "switching_lldp_remote",
|
|
}
|
|
|
|
|
|
def fake_fetch(path: str) -> str:
|
|
"""Serve a captured page for the requested path."""
|
|
if path.startswith("/htdocs/pages/switching/vlan_per_port.lsp"):
|
|
vlan = path.rsplit("=", 1)[-1]
|
|
name = f"switching_vlan_per_port_{vlan if vlan == '10' else '1'}"
|
|
else:
|
|
name = PAGE_FOR_PATH[path]
|
|
return (FIXTURES / f"{name}.html").read_text(encoding="utf-8")
|
|
|
|
|
|
@pytest.fixture
|
|
def driver():
|
|
with patch("napalm_hpe_officeconnect.officeconnect.OfficeConnectClient") as client_cls:
|
|
client = client_cls.return_value
|
|
client.fetch.side_effect = fake_fetch
|
|
client.is_authenticated = True
|
|
d = OfficeConnectDriver("192.0.2.20", "admin", "secret")
|
|
d._client = client
|
|
yield d
|
|
|
|
|
|
# ===========================================================================
|
|
# Fingerprinting contract — consumed by netOrk discovery without a connection
|
|
# ===========================================================================
|
|
|
|
|
|
def test_driver_identity():
|
|
assert OfficeConnectDriver.DRIVER_NAME == "hpe_officeconnect"
|
|
assert OfficeConnectDriver.VENDOR == "HPE"
|
|
|
|
|
|
def test_snmp_object_id_is_specific_enough_to_not_collide_with_procurve():
|
|
"""The ProCurve driver claims 1.3.6.1.4.1.11 — the bare HPE arc. This
|
|
driver must claim the longer OfficeConnect arc or the two compete."""
|
|
assert OfficeConnectDriver.SNMP_OBJECT_ID_PREFIX == "1.3.6.1.4.1.11.2.3.7.11"
|
|
assert "1.3.6.1.4.1.11.2.3.7.11.170".startswith(OfficeConnectDriver.SNMP_OBJECT_ID_PREFIX)
|
|
|
|
|
|
def test_http_fingerprint_requires_officeconnect():
|
|
mandatory = [r for r in OfficeConnectDriver.HTTP_FINGERPRINT if r.mandatory]
|
|
assert [r.pattern for r in mandatory] == ["officeconnect"]
|
|
|
|
|
|
def test_snmp_fingerprint_matches_real_sysdescr():
|
|
"""sysDescr as returned by a J9982A over SNMPv2c."""
|
|
sys_descr = (
|
|
"hpe officeconnect switch 1820 8g poe+ (65w) j9982a, pt.02.19, "
|
|
"linux 3.6.5-58b5074c, u-boot 2012.10-00116-g3ab515c (jul 30 2014 - 10:52:01)"
|
|
)
|
|
matched = [r.pattern for r in OfficeConnectDriver.SNMP_FINGERPRINT if r.pattern in sys_descr]
|
|
assert "officeconnect" in matched
|
|
assert "1820" in matched
|
|
|
|
|
|
def test_snmp_fingerprint_has_no_mandatory_rule():
|
|
"""HTTP already carries the hard requirement. A mandatory SNMP rule would
|
|
disqualify the driver for any device answering SNMP but not HTTP."""
|
|
assert not any(r.mandatory for r in OfficeConnectDriver.SNMP_FINGERPRINT)
|
|
|
|
|
|
# ===========================================================================
|
|
# Connection lifecycle
|
|
# ===========================================================================
|
|
|
|
|
|
def test_open_logs_in(driver):
|
|
driver.open()
|
|
driver._client.login.assert_called_once()
|
|
|
|
|
|
def test_close_logs_out(driver):
|
|
driver.close()
|
|
driver._client.logout.assert_called_once()
|
|
|
|
|
|
def test_is_alive_reflects_session_state(driver):
|
|
assert driver.is_alive() == {"is_alive": True}
|
|
|
|
|
|
# ===========================================================================
|
|
# get_facts
|
|
# ===========================================================================
|
|
|
|
|
|
def test_get_facts(driver):
|
|
facts = driver.get_facts()
|
|
assert facts["vendor"] == "HPE"
|
|
assert facts["hostname"] == "testswitch"
|
|
assert facts["serial_number"] == "CN0FIXTURE0"
|
|
assert facts["os_version"] == "PT.02.19"
|
|
assert facts["model"] == "HPE OfficeConnect Switch 1820 8G PoE+ (65W) J9982A"
|
|
assert facts["uptime"] == 67480
|
|
assert facts["interface_list"][:3] == ["1", "2", "3"]
|
|
assert "TRK1" in facts["interface_list"]
|
|
|
|
|
|
# ===========================================================================
|
|
# get_interfaces
|
|
# ===========================================================================
|
|
|
|
|
|
def test_get_interfaces_returns_ports_and_trunks(driver):
|
|
interfaces = driver.get_interfaces()
|
|
assert len(interfaces) == 12
|
|
assert set(interfaces) >= {"1", "8", "TRK1", "TRK4"}
|
|
|
|
|
|
def test_get_interfaces_link_state(driver):
|
|
interfaces = driver.get_interfaces()
|
|
assert interfaces["3"]["is_up"] is True
|
|
assert interfaces["1"]["is_up"] is False
|
|
# Admin state is independent of link state on this platform.
|
|
assert interfaces["1"]["is_enabled"] is True
|
|
|
|
|
|
def test_get_interfaces_speed_is_mbit(driver):
|
|
interfaces = driver.get_interfaces()
|
|
assert interfaces["3"]["speed"] == 1000.0
|
|
assert interfaces["6"]["speed"] == 100.0 # "100 Mbps Full Duplex"
|
|
assert interfaces["1"]["speed"] == 0.0 # link down — no speed reported
|
|
|
|
|
|
def test_get_interfaces_mtu(driver):
|
|
assert driver.get_interfaces()["1"]["mtu"] == 1518
|
|
|
|
|
|
# ===========================================================================
|
|
# get_vlans
|
|
# ===========================================================================
|
|
|
|
|
|
def test_get_vlans_lists_every_vlan(driver):
|
|
vlans = driver.get_vlans()
|
|
assert set(vlans) == {"1", "10", "20", "30", "40", "50", "60"}
|
|
assert vlans["1"]["name"] == "default"
|
|
assert vlans["10"]["name"] == "VLAN0010"
|
|
|
|
|
|
def test_get_vlans_membership_excludes_non_members(driver):
|
|
"""Participation is Untagged / Tagged / Excluded — only the first two
|
|
make an interface a member."""
|
|
vlans = driver.get_vlans()
|
|
assert vlans["10"]["interfaces"] == ["2", "3", "4", "8"]
|
|
|
|
|
|
def test_get_vlans_detail_splits_tagged_and_untagged(driver):
|
|
detail = driver.get_vlans_detail()
|
|
assert detail["1"]["untagged"] == ["1", "5", "7", "TRK1", "TRK2", "TRK3", "TRK4"]
|
|
assert detail["1"]["tagged"] == ["8"]
|
|
|
|
|
|
# ===========================================================================
|
|
# get_mac_address_table
|
|
# ===========================================================================
|
|
|
|
|
|
def test_get_mac_address_table(driver):
|
|
table = driver.get_mac_address_table()
|
|
assert len(table) == 17
|
|
first = table[0]
|
|
assert first["mac"] == "00:11:22:33:44:01"
|
|
assert first["interface"] == "7"
|
|
assert first["vlan"] == 1
|
|
assert first["static"] is False
|
|
assert first["active"] is True
|
|
|
|
|
|
# ===========================================================================
|
|
# Deliberate gaps — documented, not silently faked
|
|
# ===========================================================================
|
|
|
|
|
|
def test_get_interfaces_counters_is_not_implemented(driver):
|
|
"""port_summary_stats.lsp ships an empty dataset on PT.02.19; returning
|
|
zeros would look like real counters. See README."""
|
|
with pytest.raises(NotImplementedError):
|
|
driver.get_interfaces_counters()
|
|
|
|
|
|
# ===========================================================================
|
|
# LLDP — the map has nothing to draw for this site without it
|
|
# ===========================================================================
|
|
|
|
|
|
def test_get_lldp_neighbors_keys_on_local_port(driver):
|
|
neighbors = driver.get_lldp_neighbors()
|
|
assert set(neighbors) == {"7", "8"}
|
|
|
|
|
|
def test_get_lldp_neighbors_reports_hostname_and_remote_port(driver):
|
|
"""The remote port description beats its MAC — `enp3s0` names something."""
|
|
entry = driver.get_lldp_neighbors()["7"][0]
|
|
assert entry["hostname"] == "host-1.example.com"
|
|
assert entry["port"] == "enp3s0"
|
|
|
|
|
|
def test_get_lldp_neighbors_carries_the_chassis_mac(driver):
|
|
"""netOrk resolves a neighbour by MAC before falling back to its name."""
|
|
entry = driver.get_lldp_neighbors()["7"][0]
|
|
assert entry["mac"] == "00:11:22:33:44:01"
|
|
|
|
|
|
def test_get_lldp_neighbors_without_neighbours_is_empty(driver):
|
|
driver._client.fetch.side_effect = lambda path: "<html><body>no table</body></html>"
|
|
assert driver.get_lldp_neighbors() == {}
|