"""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=[>#]) 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: '[>#]' 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")