Files
NetAlertX/test/plugins/test_snmp_discovery.py

129 lines
5.1 KiB
Python

"""Tests for the SNMP discovery plugin (server/plugins/snmp_discovery/script.py).
Regression test for https://github.com/netalertx/NetAlertX/issues/1775:
the primary "mib-2.3.1.1.2.15..." parsing branch built the MAC directly
from snmpwalk's raw (uppercase) hex dump without going through
normalize_mac(), unlike the plugin's other two parsing branches - so the
same device could be reported under two different MAC cases depending on
which SNMP output format matched that scan cycle, producing spurious
connect/disconnect events for what is really one device.
Run from inside the NetAlertX container, or locally - NetAlertX-specific
modules are stubbed out automatically before the script is imported.
pytest "test/plugins/test_snmp_discovery.py" -v
"""
import importlib.util
import sys
import types
from pathlib import Path
from unittest.mock import MagicMock, patch
def _load_snmp_discovery_module():
missing_module = object()
previous_modules = {}
def stub(name, **attributes):
previous_modules[name] = sys.modules.get(name, missing_module)
module = types.ModuleType(name)
for attribute, value in attributes.items():
setattr(module, attribute, value)
sys.modules[name] = module
stub(
"plugin_helper",
Plugin_Objects=MagicMock,
handleEmpty=lambda v: v if v not in (None, "") else "(unknown)",
normalize_mac=lambda mac: mac.strip().lower().replace("-", ":"),
)
stub("logger", mylog=MagicMock(), Logger=MagicMock())
stub("helper", get_setting_value=MagicMock(return_value=60))
stub("const", logPath="/tmp")
stub("conf", tz=None)
stub("pytz", timezone=MagicMock(return_value="UTC"))
module_path = Path(__file__).resolve().parents[2] / "server" / "plugins" / "snmp_discovery" / "script.py"
spec = importlib.util.spec_from_file_location("snmp_discovery", module_path)
module = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(module)
finally:
for name, previous_module in previous_modules.items():
if previous_module is missing_module:
sys.modules.pop(name, None)
else:
sys.modules[name] = previous_module
return module
snmp_discovery = _load_snmp_discovery_module()
ROUTER_CMD = "snmpwalk -v2c -c public -Oqn 192.168.1.14 .1.3.6.1.2.1.3.1.1.2"
def _run_main_with_output(output):
"""Run main() with subprocess.check_output faked to return `output`,
and Plugin_Objects faked so add_object() calls can be inspected."""
plugin_objects = MagicMock()
with patch.object(snmp_discovery, "Plugin_Objects", return_value=plugin_objects), \
patch("subprocess.check_output", return_value=output), \
patch.object(sys, "argv", ["script.py", f"routers={ROUTER_CMD}"]):
snmp_discovery.main()
return plugin_objects
def test_numeric_oid_branch_normalizes_mac_case():
"""The branch that historically skipped normalize_mac() (GH #1775)."""
output = 'mib-2.3.1.1.2.15.1.192.168.1.14 "2C F4 32 18 61 43 "\n'
plugin_objects = _run_main_with_output(output)
assert plugin_objects.add_object.call_count == 1
call_kwargs = plugin_objects.add_object.call_args_list[0].kwargs
assert call_kwargs["primaryId"] == "2c:f4:32:18:61:43"
assert call_kwargs["foreignKey"] == "2c:f4:32:18:61:43"
def test_numeric_oid_branch_tolerates_repeated_whitespace_between_bytes():
"""Some snmpwalk output has runs of repeated spaces or embedded tabs
between hex bytes rather than a single space. Splitting on a literal
single space (the pre-fix behaviour) turns each extra space into an
empty token, which normalize_mac() then zero-pads into a fabricated
"00" octet, and leaves a tab glued to its neighboring byte instead of
splitting it out - silently corrupting the MAC rather than just its
case."""
output = 'mib-2.3.1.1.2.15.1.192.168.1.14 "2C F4\t32 18 61 43 "\n'
plugin_objects = _run_main_with_output(output)
assert plugin_objects.add_object.call_count == 1
call_kwargs = plugin_objects.add_object.call_args_list[0].kwargs
assert call_kwargs["primaryId"] == "2c:f4:32:18:61:43"
assert call_kwargs["foreignKey"] == "2c:f4:32:18:61:43"
def test_all_three_output_formats_agree_on_mac_case():
"""The same physical MAC, reported through each of the plugin's three
supported snmpwalk output formats, must normalize to the same devMac -
otherwise NetAlertX's plugin-object diffing (idsHash in server/plugin.py)
treats them as different devices and fires spurious connect/disconnect
events for what is really one device."""
outputs = [
'mib-2.3.1.1.2.15.1.192.168.1.14 "2C F4 32 18 61 43 "\n',
"IP-MIB::ipNetToMediaPhysAddress.17.192.168.1.14 = STRING: 2C:F4:32:18:61:43\n",
"ipNetToMediaPhysAddress[3][192.168.1.14] 2C:F4:32:18:61:43\n",
]
macs_seen = set()
for output in outputs:
plugin_objects = _run_main_with_output(output)
assert plugin_objects.add_object.call_count == 1
macs_seen.add(plugin_objects.add_object.call_args_list[0].kwargs["primaryId"])
assert macs_seen == {"2c:f4:32:18:61:43"}