feat: NAPALM driver for HPE OfficeConnect 1820/1920S
These switches have no CLI at all — no SSH, no Telnet and no ArubaOS-Switch REST API — so the ProCurve driver cannot serve them despite the shared vendor. The only management surface is the web UI, which ships its table data as JavaScript array literals; those parse with ast.literal_eval, so the driver needs no HTML parser and no dependency beyond napalm/requests. Read-only by design: the platform exposes a single administrator account with no privilege levels, and serves HTTPS only after a certificate has been uploaded, so the polling credential is necessarily the admin credential over a plain channel. Implements get_facts, get_interfaces, get_vlans, get_vlans_detail and get_mac_address_table, plus HTTP/SNMP fingerprints for discovery. Tested against an HPE OfficeConnect 1820 8G PoE+ (65W), J9982A, PT.02.19.
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
"""Unit tests for the OfficeConnect HTTP client — no real device required."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from napalm.base.exceptions import ConnectionClosedException, ConnectionException
|
||||
|
||||
from napalm_hpe_officeconnect.client import OfficeConnectClient
|
||||
|
||||
|
||||
def make_response(status_code=200, text="", json_data=None):
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.text = text
|
||||
if json_data is None:
|
||||
resp.json.side_effect = ValueError("no json")
|
||||
else:
|
||||
resp.json.return_value = json_data
|
||||
return resp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with patch("napalm_hpe_officeconnect.client.requests.Session") as session_cls:
|
||||
c = OfficeConnectClient("192.0.2.20", "admin", "secret")
|
||||
c._session = session_cls.return_value
|
||||
yield c
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# base_url
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_base_url_defaults_to_http():
|
||||
"""These switches ship without a certificate, so HTTP is the default."""
|
||||
c = OfficeConnectClient("192.0.2.20", "admin", "secret")
|
||||
assert c.base_url == "http://192.0.2.20"
|
||||
|
||||
|
||||
def test_base_url_honours_https_opt_in():
|
||||
c = OfficeConnectClient("192.0.2.20", "admin", "secret", scheme="https")
|
||||
assert c.base_url == "https://192.0.2.20"
|
||||
|
||||
|
||||
def test_base_url_includes_non_default_port():
|
||||
c = OfficeConnectClient("192.0.2.20", "admin", "secret", port=8080)
|
||||
assert c.base_url == "http://192.0.2.20:8080"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# login
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_login_posts_credentials_and_succeeds(client):
|
||||
client._session.get.return_value = make_response(text="login page")
|
||||
client._session.post.return_value = make_response(
|
||||
json_data={"redirect": "/htdocs/pages/main/main.lsp", "error": ""}
|
||||
)
|
||||
|
||||
client.login()
|
||||
|
||||
# The RID cookie is handed out by the login page, so it must be fetched first.
|
||||
client._session.get.assert_called_once()
|
||||
assert client._session.get.call_args[0][0].endswith("/htdocs/login/login.lsp")
|
||||
|
||||
(url,) = client._session.post.call_args[0]
|
||||
assert url.endswith("/htdocs/login/login.lua")
|
||||
assert client._session.post.call_args[1]["data"] == {
|
||||
"username": "admin",
|
||||
"password": "secret",
|
||||
}
|
||||
assert client.is_authenticated is True
|
||||
|
||||
|
||||
def test_login_raises_with_device_message_on_bad_credentials(client):
|
||||
client._session.get.return_value = make_response(text="login page")
|
||||
client._session.post.return_value = make_response(
|
||||
json_data={"redirect": "", "error": "Invalid username or password"}
|
||||
)
|
||||
|
||||
with pytest.raises(ConnectionException, match="Invalid username or password"):
|
||||
client.login()
|
||||
assert client.is_authenticated is False
|
||||
|
||||
|
||||
def test_login_raises_when_response_is_not_json(client):
|
||||
"""A firmware that redirects instead of answering must not look like success."""
|
||||
client._session.get.return_value = make_response(text="login page")
|
||||
client._session.post.return_value = make_response(status_code=303, text="")
|
||||
|
||||
with pytest.raises(ConnectionException):
|
||||
client.login()
|
||||
assert client.is_authenticated is False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# fetch
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_fetch_returns_page_text(client):
|
||||
client._authenticated = True
|
||||
client._session.get.return_value = make_response(text="<html>data</html>")
|
||||
|
||||
assert client.fetch("/htdocs/pages/base/dashboard.lsp") == "<html>data</html>"
|
||||
|
||||
|
||||
def test_fetch_raises_when_session_expired(client):
|
||||
"""An expired session answers 303 to the login page rather than 401."""
|
||||
client._authenticated = True
|
||||
client._session.get.return_value = make_response(status_code=303, text="")
|
||||
|
||||
with pytest.raises(ConnectionClosedException):
|
||||
client.fetch("/htdocs/pages/base/dashboard.lsp")
|
||||
|
||||
|
||||
def test_fetch_requires_login_first(client):
|
||||
with pytest.raises(ConnectionException):
|
||||
client.fetch("/htdocs/pages/base/dashboard.lsp")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# logout — must be reliable, the session table is small
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_logout_releases_the_session(client):
|
||||
client._authenticated = True
|
||||
client._session.get.return_value = make_response(status_code=303)
|
||||
|
||||
client.logout()
|
||||
|
||||
assert client._session.get.call_args[0][0].endswith("/htdocs/pages/main/logout.lsp")
|
||||
assert client.is_authenticated is False
|
||||
|
||||
|
||||
def test_logout_swallows_errors(client):
|
||||
"""close() runs logout on the failure path too; it must never mask the
|
||||
original exception, and an unreachable switch is not a new problem."""
|
||||
client._authenticated = True
|
||||
client._session.get.side_effect = OSError("network gone")
|
||||
|
||||
client.logout() # must not raise
|
||||
|
||||
assert client.is_authenticated is False
|
||||
|
||||
|
||||
def test_logout_is_a_noop_when_not_logged_in(client):
|
||||
client.logout()
|
||||
client._session.get.assert_not_called()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""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",
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Unit tests for the OfficeConnect HTML/JS parsers.
|
||||
|
||||
Fixtures are real pages captured from an HPE OfficeConnect 1820 8G PoE+
|
||||
(J9982A, firmware PT.02.19), scrubbed of serial number, MAC addresses,
|
||||
IP addresses and hostname.
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_hpe_officeconnect import parsers
|
||||
|
||||
FIXTURES = pathlib.Path(__file__).parent.parent / "fixtures"
|
||||
|
||||
|
||||
def fixture(name: str) -> str:
|
||||
return (FIXTURES / f"{name}.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# extract_data_set — the DataTables payload every list page ships
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_extract_data_set_returns_one_row_per_port():
|
||||
"""port_summary carries 8 physical ports plus 4 trunk interfaces."""
|
||||
rows = parsers.extract_data_set(fixture("base_port_summary"))
|
||||
assert len(rows) == 12
|
||||
assert all(isinstance(r, list) for r in rows)
|
||||
|
||||
|
||||
def test_extract_data_set_preserves_cell_values():
|
||||
rows = parsers.extract_data_set(fixture("base_port_summary"))
|
||||
# Columns: [checkbox, interface, description, admin, phy type, status, ...]
|
||||
assert rows[0][1] == "1"
|
||||
assert rows[0][3] == "Enabled"
|
||||
assert rows[2][5] == "Link Up"
|
||||
assert rows[2][7] == "1000 Mbps"
|
||||
|
||||
|
||||
def test_extract_data_set_handles_rows_without_markup():
|
||||
"""mac_address_table rows are plain values, no embedded checkbox cell."""
|
||||
rows = parsers.extract_data_set(fixture("base_mac_address_table"))
|
||||
assert len(rows) == 17
|
||||
assert rows[0] == ["1", "00:11:22:33:44:01", "7", "7", "Learned"]
|
||||
|
||||
|
||||
def test_extract_data_set_handles_empty_cells():
|
||||
"""Counter columns are empty strings on a port that never linked up."""
|
||||
rows = parsers.extract_data_set(fixture("base_port_summary_stats"))
|
||||
assert rows[0] == ["1", "", "", "", "", "", "", "", ""]
|
||||
|
||||
|
||||
def test_extract_data_set_returns_empty_list_when_absent():
|
||||
assert parsers.extract_data_set("<html><body>nothing here</body></html>") == []
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# extract_column_titles — used to assert the firmware layout hasn't shifted
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_extract_column_titles():
|
||||
titles = parsers.extract_column_titles(fixture("switching_vlan_status"))
|
||||
assert titles == ["VLAN ID", "Name", "Type"]
|
||||
|
||||
|
||||
def test_extract_column_titles_skips_markup_only_header():
|
||||
"""The leading checkbox column has an <input> as its title, not text."""
|
||||
titles = parsers.extract_column_titles(fixture("base_mac_address_table"))
|
||||
assert titles == ["VLAN ID", "MAC Address", "Interface", "Interface Index", "Status"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# strip_markup — the checkbox cell must collapse to an empty string
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected",
|
||||
[
|
||||
('<input type="checkbox" value="1"><label></label>', ""),
|
||||
("plain value", "plain value"),
|
||||
(" spaced&out ", "spaced&out"),
|
||||
("", ""),
|
||||
],
|
||||
)
|
||||
def test_strip_markup(raw, expected):
|
||||
assert parsers.strip_markup(raw) == expected
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# parse_uptime
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected",
|
||||
[
|
||||
("0 days, 18 hours, 44 mins, 40 secs", 67480),
|
||||
("3 days, 0 hours, 0 mins, 0 secs", 259200),
|
||||
("0 days, 0 hours, 0 mins, 1 secs", 1),
|
||||
],
|
||||
)
|
||||
def test_parse_uptime(raw, expected):
|
||||
assert parsers.parse_uptime(raw) == expected
|
||||
|
||||
|
||||
def test_parse_uptime_returns_negative_one_when_unparseable():
|
||||
"""NAPALM's convention for 'uptime unknown' is -1, not 0."""
|
||||
assert parsers.parse_uptime("nonsense") == -1
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# parse_facts — dashboard.lsp
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_parse_facts():
|
||||
facts = parsers.parse_facts(fixture("base_dashboard"))
|
||||
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["sys_object_id"] == "1.3.6.1.4.1.11.2.3.7.11.170"
|
||||
|
||||
|
||||
def test_parse_facts_tolerates_missing_fields():
|
||||
facts = parsers.parse_facts("<html><body></body></html>")
|
||||
assert facts["hostname"] == ""
|
||||
assert facts["serial_number"] == ""
|
||||
assert facts["uptime"] == -1
|
||||
Reference in New Issue
Block a user