Files
napalm-netgear/tests/unit/test_driver.py
T
Christian Manivong 5317af0be1 fix(config): drop the uptime line from get_config()
`show running-config` is preceded by a header block, and one of its lines
reports the system uptime. That value necessarily differs between any two
reads, so every caller comparing consecutive configs sees a change each
time.

Measured on a GS110TPv3 under NetOrk: 876 of 894 stored config snapshots
marked as changed, one git commit and one config_changed warning per poll,
while every other device at the same installation sat between 2 and 18. The
history was worthless for that switch — a genuine change would have been
invisible among hundreds of uptime diffs.

Only the uptime line is removed. Model, firmware version, serial and MAC are
stable and belong in a config backup; a changed firmware version is exactly
the kind of change worth recording.

netgear_plus is unaffected — its get_config() returns empty strings.

The first read after this lands reports one real change, since the line
disappears from the stored config. That is unavoidable and happens once.
2026-08-18 10:30:11 +07:00

729 lines
26 KiB
Python

"""Unit tests for NetgearSmartDriver — no real device required."""
import pytest
from unittest.mock import MagicMock, patch
from napalm_netgear.netgear_smart import NetgearSmartDriver
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def driver():
"""Return a driver instance with a mocked Netmiko connection."""
with patch("napalm_netgear.netgear_smart.ConnectHandler"):
drv = NetgearSmartDriver(
hostname="192.168.0.239",
username="admin",
password="password",
)
drv.device = MagicMock()
drv.device.base_prompt = "(GS110TP) "
yield drv
# ---------------------------------------------------------------------------
# Sample CLI output fixtures
# ---------------------------------------------------------------------------
SHOW_SYSINFO = """\
System Description: GS110TP Gigabit Smart Managed Pro Switch
Machine Model: GS110TP
Machine Type: Ethernet Switch
Burned In MAC Address: C4:AD:34:AB:12:34
System OID String: 1.3.6.1.4.1.4526.100.5.4
System Uptime: 2 days 3 hrs 15 mins 42 secs
System Location: Server Room
System Contact: admin@example.com
System Name: myswitch
"""
SHOW_VERSION = """\
Software Version............................. 6.6.3
Loader Version............................... 1.0.0.15
Boot ROM Version............................. B1.0.0.15
Hardware Version............................. V1
Serial Number................................ 1FE2A0B1C2
"""
SHOW_PORT_ALL = """\
Intf Type Admin Physical Physical Link Link LACP Actor Partner Admin
Mode Mode Status Status Trap Mode Port Port LACP
Priority Priority Timeout
--------- ------ ------- ---------- ---------- ------ ----- ----- ------ ------ -------
0/1 Enable Auto 1G/Full Up Enable Disable 128 0 Long
0/2 Enable Auto - Down Enable Disable 128 0 Long
0/3 Disable Auto - Down Enable Disable 128 0 Long
"""
SHOW_INTERFACE_ALL = """\
Interface................................ 0/1
Description.............................. uplink-to-router
MTU...................................... 1518
Interface................................ 0/2
Description.............................. server-1
MTU...................................... 1518
Interface................................ 0/3
MTU...................................... 1518
"""
SHOW_IP_INTERFACE = """\
IP Address....................................... 192.168.0.239
Subnet Mask...................................... 255.255.255.0
Default Gateway.................................. 192.168.0.1
"""
SHOW_ARP = """\
IP Address MAC Address Interface Age (min) Type
--------- ------------------ --------- --------- -------
192.168.0.1 00:11:22:33:44:55 0/0 - Local
192.168.0.100 aa:bb:cc:dd:ee:ff 0/0 5 Dynamic
"""
SHOW_MAC = """\
VLAN ID MAC Address Type Port
------- ------------------ ---------- ------
1 00:11:22:33:44:55 Dynamic 0/1
1 aa:bb:cc:dd:ee:ff Static 0/2
1 ff:ff:ff:ff:ff:ff Management CPU
"""
SHOW_VLAN = """\
VLAN ID VLAN Name VLAN Type Interface(s)
------- --------------- ---------- -----------------------------------
1 Default Default 0/1, 0/2, 0/3
10 Management Static 0/1
20 Servers Static 0/2, 0/3
"""
SHOW_LLDP_ALL = """\
Local RemID Chassis ID Port ID System Name
Interface
--------- ----- -------------------- --------------- ---------------
0/1 1 00:1a:2b:3c:4d:5e Gi0/1 core-router
"""
SHOW_LLDP_DETAIL = """\
Chassis ID Subtype............................. MAC Address
Chassis ID................................... 00:1a:2b:3c:4d:5e
Port ID Subtype.............................. Interface Name
Port ID...................................... Gi0/1
Port Description............................. WAN uplink
System Name.................................. core-router
System Description........................... Cisco IOS XE
System Capabilities.......................... Bridge, Router
Enabled Capabilities......................... Router
"""
SHOW_SNMP = """\
SNMP Community
Community Name Access Mode Status
-------------- ----------- ------
public Read Only Enable
private Read Write Enable
"""
SHOW_USERS = """\
User Access Level Session Timeout Password Strength
-------- ------------ --------------- -----------------
admin Read/Write 5 Enabled
guest Read Only 5 Enabled
"""
SHOW_SNTP = """\
SNTP Server
SNTP Server Address....................... 192.168.0.1
SNTP Server Port......................... 123
"""
# ---------------------------------------------------------------------------
# Tests: get_facts
# ---------------------------------------------------------------------------
class TestGetFacts:
def test_returns_required_keys(self, driver):
driver._send_command = lambda cmd: SHOW_SYSINFO
driver._get_interface_list = lambda: []
facts = driver.get_facts()
for key in (
"vendor",
"model",
"hostname",
"os_version",
"serial_number",
"uptime",
"interface_list",
"fqdn",
):
assert key in facts
def test_vendor(self, driver):
driver._send_command = lambda cmd: SHOW_SYSINFO if "sysinfo" in cmd else SHOW_VERSION
driver._get_interface_list = lambda: []
facts = driver.get_facts()
assert facts["vendor"] == "Netgear"
def test_model(self, driver):
driver._send_command = lambda cmd: SHOW_SYSINFO if "sysinfo" in cmd else SHOW_VERSION
driver._get_interface_list = lambda: []
facts = driver.get_facts()
assert facts["model"] == "GS110TP"
def test_hostname(self, driver):
driver._send_command = lambda cmd: SHOW_SYSINFO if "sysinfo" in cmd else SHOW_VERSION
driver._get_interface_list = lambda: []
facts = driver.get_facts()
assert facts["hostname"] == "myswitch"
def test_os_version(self, driver):
driver._send_command = lambda cmd: SHOW_SYSINFO if "sysinfo" in cmd else SHOW_VERSION
driver._get_interface_list = lambda: []
facts = driver.get_facts()
assert facts["os_version"] == "6.6.3"
def test_serial_number(self, driver):
driver._send_command = lambda cmd: SHOW_SYSINFO if "sysinfo" in cmd else SHOW_VERSION
driver._get_interface_list = lambda: []
facts = driver.get_facts()
assert facts["serial_number"] == "1FE2A0B1C2"
def test_uptime_parsing(self, driver):
driver._send_command = lambda cmd: SHOW_SYSINFO if "sysinfo" in cmd else SHOW_VERSION
driver._get_interface_list = lambda: []
facts = driver.get_facts()
expected = 2 * 86400 + 3 * 3600 + 15 * 60 + 42
assert facts["uptime"] == float(expected)
# ---------------------------------------------------------------------------
# Tests: _parse_uptime_seconds
# ---------------------------------------------------------------------------
class TestParseUptimeSeconds:
def test_full(self):
assert NetgearSmartDriver._parse_uptime_seconds("2 days 3 hrs 15 mins 42 secs") == float(
2 * 86400 + 3 * 3600 + 15 * 60 + 42
)
def test_zero(self):
assert NetgearSmartDriver._parse_uptime_seconds("0 days 0 hrs 0 mins 0 secs") == 0.0
def test_hours_only(self):
assert NetgearSmartDriver._parse_uptime_seconds("1 hrs 0 mins 0 secs") == 3600.0
# ---------------------------------------------------------------------------
# Tests: _parse_key_value
# ---------------------------------------------------------------------------
class TestParseKeyValue:
def test_colon_separator(self):
output = "System Name: myswitch"
assert NetgearSmartDriver._parse_key_value(output, "System Name") == "myswitch"
def test_dot_separator(self):
output = "Software Version............................. 6.6.3"
assert NetgearSmartDriver._parse_key_value(output, "Software Version") == "6.6.3"
def test_missing_key_returns_empty(self):
assert NetgearSmartDriver._parse_key_value("some output", "Nonexistent Key") == ""
# ---------------------------------------------------------------------------
# Tests: get_interfaces
# ---------------------------------------------------------------------------
class TestGetInterfaces:
def _mock(self, driver):
def _send(cmd):
if "port" in cmd:
return SHOW_PORT_ALL
return SHOW_INTERFACE_ALL
driver._send_command = _send
def test_all_ports_present(self, driver):
self._mock(driver)
ifaces = driver.get_interfaces()
assert "0/1" in ifaces
assert "0/2" in ifaces
assert "0/3" in ifaces
def test_link_up(self, driver):
self._mock(driver)
assert driver.get_interfaces()["0/1"]["is_up"] is True
def test_link_down(self, driver):
self._mock(driver)
assert driver.get_interfaces()["0/2"]["is_up"] is False
def test_admin_disabled(self, driver):
self._mock(driver)
assert driver.get_interfaces()["0/3"]["is_enabled"] is False
def test_speed_1g(self, driver):
self._mock(driver)
assert driver.get_interfaces()["0/1"]["speed"] == 1000.0
def test_description_from_interface_all(self, driver):
self._mock(driver)
assert driver.get_interfaces()["0/1"]["description"] == "uplink-to-router"
# ---------------------------------------------------------------------------
# Tests: get_interfaces_ip
# ---------------------------------------------------------------------------
class TestGetInterfacesIp:
def test_management_ip(self, driver):
driver._send_command = lambda cmd: SHOW_IP_INTERFACE
result = driver.get_interfaces_ip()
assert "vlan1" in result
assert "192.168.0.239" in result["vlan1"]["ipv4"]
def test_prefix_length(self, driver):
driver._send_command = lambda cmd: SHOW_IP_INTERFACE
result = driver.get_interfaces_ip()
assert result["vlan1"]["ipv4"]["192.168.0.239"]["prefix_length"] == 24
# ---------------------------------------------------------------------------
# Tests: get_arp_table
# ---------------------------------------------------------------------------
class TestGetArpTable:
def test_entry_count(self, driver):
driver._send_command = lambda cmd: SHOW_ARP
arp = driver.get_arp_table()
assert len(arp) == 2
def test_entry_keys(self, driver):
driver._send_command = lambda cmd: SHOW_ARP
entry = driver.get_arp_table()[0]
for k in ("interface", "mac", "ip", "age"):
assert k in entry
def test_mac_normalised(self, driver):
driver._send_command = lambda cmd: SHOW_ARP
macs = {e["mac"] for e in driver.get_arp_table()}
# napalm_helpers.mac normalises to "AA:BB:CC:DD:EE:FF" format
assert any(":" in mac for mac in macs)
# ---------------------------------------------------------------------------
# Tests: get_mac_address_table
# ---------------------------------------------------------------------------
class TestGetMacAddressTable:
def test_entry_count(self, driver):
driver._send_command = lambda cmd: SHOW_MAC
mac_table = driver.get_mac_address_table()
assert len(mac_table) == 3
def test_static_flag(self, driver):
driver._send_command = lambda cmd: SHOW_MAC
statics = [e for e in driver.get_mac_address_table() if e["static"]]
# "Static" and "Management" rows are static
assert len(statics) == 2
def test_dynamic_flag(self, driver):
driver._send_command = lambda cmd: SHOW_MAC
dynamics = [e for e in driver.get_mac_address_table() if not e["static"]]
assert len(dynamics) == 1
# ---------------------------------------------------------------------------
# Tests: get_vlans
# ---------------------------------------------------------------------------
class TestGetVlans:
def test_vlan_ids(self, driver):
driver._send_command = lambda cmd: SHOW_VLAN
vlans = driver.get_vlans()
assert set(vlans.keys()) == {"1", "10", "20"}
def test_vlan_name(self, driver):
driver._send_command = lambda cmd: SHOW_VLAN
assert driver.get_vlans()["10"]["name"] == "Management"
def test_vlan_interfaces(self, driver):
driver._send_command = lambda cmd: SHOW_VLAN
assert "0/1" in driver.get_vlans()["1"]["interfaces"]
# ---------------------------------------------------------------------------
# Tests: _parse_vlan_ports
# ---------------------------------------------------------------------------
class TestParseVlanPorts:
def test_single_port(self):
assert NetgearSmartDriver._parse_vlan_ports("0/1") == ["0/1"]
def test_range(self):
assert NetgearSmartDriver._parse_vlan_ports("0/1-0/4") == ["0/1", "0/2", "0/3", "0/4"]
def test_short_range(self):
assert NetgearSmartDriver._parse_vlan_ports("0/1-4") == ["0/1", "0/2", "0/3", "0/4"]
def test_comma_separated(self):
result = NetgearSmartDriver._parse_vlan_ports("0/1, 0/3, 0/5")
assert result == ["0/1", "0/3", "0/5"]
# ---------------------------------------------------------------------------
# Tests: get_lldp_neighbors
# ---------------------------------------------------------------------------
class TestGetLldpNeighbors:
def test_neighbor_present(self, driver):
driver._send_command = lambda cmd: SHOW_LLDP_ALL
neighbors = driver.get_lldp_neighbors()
assert "0/1" in neighbors
def test_neighbor_hostname(self, driver):
driver._send_command = lambda cmd: SHOW_LLDP_ALL
neighbors = driver.get_lldp_neighbors()
assert neighbors["0/1"][0]["hostname"] == "core-router"
# ---------------------------------------------------------------------------
# Tests: _parse_lldp_detail
# ---------------------------------------------------------------------------
class TestParseLldpDetail:
def test_chassis_id(self):
r = NetgearSmartDriver._parse_lldp_detail(SHOW_LLDP_DETAIL)
assert r["remote_chassis_id"] == "00:1a:2b:3c:4d:5e"
def test_port_id(self):
r = NetgearSmartDriver._parse_lldp_detail(SHOW_LLDP_DETAIL)
assert r["remote_port"] == "Gi0/1"
def test_system_name(self):
r = NetgearSmartDriver._parse_lldp_detail(SHOW_LLDP_DETAIL)
assert r["remote_system_name"] == "core-router"
def test_capabilities(self):
r = NetgearSmartDriver._parse_lldp_detail(SHOW_LLDP_DETAIL)
assert "bridge" in r["remote_system_capab"]
assert "router" in r["remote_system_capab"]
# ---------------------------------------------------------------------------
# Tests: get_snmp_information
# ---------------------------------------------------------------------------
class TestGetSnmpInformation:
def test_communities(self, driver):
def _send(cmd):
if "sysinfo" in cmd:
return SHOW_SYSINFO
return SHOW_SNMP
driver._send_command = _send
snmp = driver.get_snmp_information()
assert "public" in snmp["community"]
assert snmp["community"]["public"]["mode"] == "ro"
assert snmp["community"]["private"]["mode"] == "rw"
def test_location(self, driver):
def _send(cmd):
if "sysinfo" in cmd:
return SHOW_SYSINFO
return SHOW_SNMP
driver._send_command = _send
snmp = driver.get_snmp_information()
assert snmp["location"] == "Server Room"
# ---------------------------------------------------------------------------
# Tests: get_users
# ---------------------------------------------------------------------------
class TestGetUsers:
def test_users_present(self, driver):
driver._send_command = lambda cmd: SHOW_USERS
users = driver.get_users()
assert "admin" in users
assert "guest" in users
def test_admin_level(self, driver):
driver._send_command = lambda cmd: SHOW_USERS
assert driver.get_users()["admin"]["level"] == 15
def test_guest_level(self, driver):
driver._send_command = lambda cmd: SHOW_USERS
assert driver.get_users()["guest"]["level"] == 1
# ---------------------------------------------------------------------------
# Tests: get_ntp_servers
# ---------------------------------------------------------------------------
class TestGetNtpServers:
def test_server_found(self, driver):
driver._send_command = lambda cmd: SHOW_SNTP
servers = driver.get_ntp_servers()
assert "192.168.0.1" in servers
# ---------------------------------------------------------------------------
# Tests: config management
# ---------------------------------------------------------------------------
class TestConfigManagement:
def test_load_merge_from_string(self, driver):
driver.load_merge_candidate(config="interface 0/1\n description test\nexit")
assert driver._candidate_config is not None
assert driver._candidate_mode == "merge"
def test_load_replace_from_string(self, driver):
driver.load_replace_candidate(config="! running config\nvlan 10\n name test\nexit")
assert driver._candidate_mode == "replace"
def test_discard_clears_candidate(self, driver):
driver.load_merge_candidate(config="vlan 99\n name test\nexit")
driver.discard_config()
assert driver._candidate_config is None
assert driver._candidate_mode is None
def test_has_pending_commit_false(self, driver):
assert driver.has_pending_commit() is False
def test_has_pending_commit_true(self, driver):
driver.load_merge_candidate(config="vlan 99\n name test\nexit")
assert driver.has_pending_commit() is True
def test_compare_config_merge(self, driver):
driver.load_merge_candidate(config="vlan 99\n name test\nexit")
diff = driver.compare_config()
assert diff.startswith("+")
def test_compare_config_empty_when_no_candidate(self, driver):
assert driver.compare_config() == ""
def test_load_merge_from_file(self, driver, tmp_path):
cfg_file = tmp_path / "candidate.txt"
cfg_file.write_text("vlan 99\n name test\nexit\n")
driver.load_merge_candidate(filename=str(cfg_file))
assert "vlan 99" in driver._candidate_config
def test_load_merge_raises_without_input(self, driver):
from napalm.base.exceptions import MergeConfigException
with pytest.raises(MergeConfigException):
driver.load_merge_candidate()
def test_load_replace_raises_without_input(self, driver):
from napalm.base.exceptions import ReplaceConfigException
with pytest.raises(ReplaceConfigException):
driver.load_replace_candidate()
def test_rollback_raises_without_backup(self, driver):
from napalm.base.exceptions import CommandErrorException
with pytest.raises(CommandErrorException):
driver.rollback()
# ---------------------------------------------------------------------------
# Tests: _parse_config_blocks / _diff_to_commands
# ---------------------------------------------------------------------------
class TestConfigDiff:
BACKUP = """\
vlan database
vlan 10
name Management
exit
interface 0/1
description uplink
exit
"""
CURRENT = """\
vlan database
vlan 10
name Management
exit
interface 0/1
description changed
exit
"""
def test_diff_detects_change(self):
cmds = NetgearSmartDriver._diff_to_commands(TestConfigDiff.BACKUP, TestConfigDiff.CURRENT)
# Should contain a "no description" and "description uplink" line
joined = " ".join(cmds)
assert "description" in joined
# ---------------------------------------------------------------------------
# Tests: get_config
# ---------------------------------------------------------------------------
_RUNNING_CONFIG_WITH_HEADER = """\
show running-config
SYSTEM CONFIG FILE ::= BEGIN
! Model: GS110TPv3
! Firmware Version: 7.1.1.17 [Apr 16 2026 - 17:26:24]
! MAC Address: 28:94:01:6D:26:7D
! Serial Number: 7LE4535SA0117
! System Up Time: 3 days, 9 hours, 22 mins, 45 secs
!
vlan database
vlan 10
exit
"""
class TestGetConfigDropsRuntimeState:
"""`show running-config` prefixes a header block that includes uptime.
Uptime is state, not configuration: it differs between any two reads, so
every caller diffing consecutive results sees a change each time. On a
real GS110TPv3 that marked 876 of 894 config snapshots as changed and
buried any genuine change in the noise.
"""
def test_uptime_line_is_dropped(self, driver):
driver._send_paged_command = lambda cmd: _RUNNING_CONFIG_WITH_HEADER
cfg = driver.get_config(retrieve="running")
assert "System Up Time" not in cfg["running"]
def test_two_reads_with_different_uptimes_are_equal(self, driver):
"""The whole point: consecutive reads must compare equal."""
driver._send_paged_command = lambda cmd: _RUNNING_CONFIG_WITH_HEADER
first = driver.get_config(retrieve="running")["running"]
later = _RUNNING_CONFIG_WITH_HEADER.replace(
"3 days, 9 hours, 22 mins, 45 secs", "3 days, 9 hours, 27 mins, 51 secs"
)
driver._send_paged_command = lambda cmd: later
assert driver.get_config(retrieve="running")["running"] == first
def test_stable_header_lines_are_kept(self, driver):
"""Firmware, model and serial belong in a config backup."""
driver._send_paged_command = lambda cmd: _RUNNING_CONFIG_WITH_HEADER
running = driver.get_config(retrieve="running")["running"]
assert "! Model: GS110TPv3" in running
assert "! Firmware Version: 7.1.1.17 [Apr 16 2026 - 17:26:24]" in running
assert "! Serial Number: 7LE4535SA0117" in running
def test_actual_configuration_survives(self, driver):
driver._send_paged_command = lambda cmd: _RUNNING_CONFIG_WITH_HEADER
running = driver.get_config(retrieve="running")["running"]
assert "vlan 10" in running
def test_startup_config_is_filtered_too(self, driver):
driver._send_paged_command = lambda cmd: _RUNNING_CONFIG_WITH_HEADER
cfg = driver.get_config(retrieve="startup")
assert "System Up Time" not in cfg["startup"]
def test_config_without_the_header_is_untouched(self, driver):
plain = "vlan database\nvlan 10\nexit\n"
driver._send_paged_command = lambda cmd: plain
assert driver.get_config(retrieve="running")["running"] == plain
class TestGetConfig:
def test_running_retrieved(self, driver):
driver._send_paged_command = lambda cmd: "! running config"
cfg = driver.get_config(retrieve="running")
assert cfg["running"] == "! running config"
assert cfg["startup"] == ""
def test_startup_retrieved(self, driver):
driver._send_paged_command = lambda cmd: "! startup config"
cfg = driver.get_config(retrieve="startup")
assert cfg["startup"] == "! startup config"
assert cfg["running"] == ""
def test_uses_paged_command_not_plain_send_command(self, driver):
"""Regression: get_config() called _send_command() (Netmiko
send_command with expect_string=<base_prompt>[>#]) for
"show running-config"/"show startup-config". On "v7" CLI firmware
there is no "terminal length 0" equivalent (see
_send_paged_command's docstring), so a config long enough to
paginate emits "--More--" prompts that expect_string never matches
— send_command hangs until read_timeout and fails with
"Pattern not detected: '<prompt>[>#]' in output.". Real device:
Netgear GS110TPv3 (7 VLANs, 10 interfaces) hit this on every
scheduled config-backup poll, 30s timeout, no config ever saved.
get_mac_address_table()/get_vlans() already use
_send_paged_command() for exactly this reason — get_config() must
too."""
driver._send_command = MagicMock(
side_effect=AssertionError(
"get_config() must use _send_paged_command(), not _send_command()"
)
)
driver._send_paged_command = MagicMock(return_value="! config")
driver.get_config()
assert driver._send_paged_command.call_count == 2
driver._send_command.assert_not_called()
def test_candidate_always_empty(self, driver):
driver._send_paged_command = lambda cmd: ""
cfg = driver.get_config()
assert cfg["candidate"] == ""
# ---------------------------------------------------------------------------
# Tests: is_alive
# ---------------------------------------------------------------------------
class TestIsAlive:
def test_alive_when_connected(self, driver):
driver.device.remote_conn.transport.is_active.return_value = True
assert driver.is_alive() == {"is_alive": True}
def test_dead_when_no_device(self, driver):
driver.device = None
assert driver.is_alive() == {"is_alive": False}
# ---------------------------------------------------------------------------
# Tests: cli
# ---------------------------------------------------------------------------
class TestCli:
def test_returns_output_per_command(self, driver):
driver._send_command = lambda cmd: f"output of {cmd}"
result = driver.cli(["show sysinfo", "show version"])
assert result["show sysinfo"] == "output of show sysinfo"
assert result["show version"] == "output of show version"
def test_raises_on_non_text_encoding(self, driver):
with pytest.raises(NotImplementedError):
driver.cli(["show sysinfo"], encoding="json")