Files
NetAlertX/test/plugins/test_fritzbox.py
sebingel 1d4fd09444 Fix robustness issues in Fritz!Box plugin before PR
Two independent reliability problems were identified during PR readiness
review. First, FritzConnection had no explicit timeout, meaning an
unreachable or slow Fritz!Box would block the plugin process indefinitely
until the OS TCP timeout fired (typically 2+ minutes), making the 60s
RUN_TIMEOUT in config.json ineffective. Second, hashlib.md5() called
without usedforsecurity=False raises ValueError on FIPS-enforced systems
(common in enterprise Docker hosts), silently breaking the guest WiFi
synthetic device feature for those users.

Changes:
- Add timeout=10 to FritzConnection(...) call (fritzbox.py:57)
  The fritzconnection library accepts a timeout parameter directly in
  __init__; it applies per individual HTTP request to the Fritz!Box,
  bounding each TR-064 call including the initial connection handshake.

- Add usedforsecurity=False to hashlib.md5() call (fritzbox.py:191)
  The MD5 hash is used only for deterministic MAC derivation (not for
  any security purpose), so the flag is semantically correct and lifts
  the FIPS restriction without changing the computed value.

- Update test assertion to include timeout=10 (test_fritzbox.py:307)
  assert_called_once_with checks the exact call signature; the test
  expectation must match the updated production code.

The plugin now fails fast on unreachable Fritz!Box (within 10s per
request) and works correctly on FIPS-enabled hosts. Default behavior
for standard deployments is unchanged.
2026-04-06 07:48:59 +00:00

424 lines
18 KiB
Python

"""
Tests for Fritz!Box plugin (fritzbox.py).
fritzbox.py is imported directly. Its module-level side effects
(get_setting_value, Logger, Plugin_Objects) are patched out before the
first import so no live config reads, log files, or result files are
created during tests.
"""
import hashlib
import sys
import os
from unittest.mock import patch, MagicMock
import pytest
# ---------------------------------------------------------------------------
# Path setup
# ---------------------------------------------------------------------------
_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
_SERVER = os.path.join(_ROOT, "server")
_PLUGIN_DIR = os.path.join(_ROOT, "front", "plugins", "fritzbox")
for _p in [_ROOT, _SERVER, _PLUGIN_DIR]:
if _p not in sys.path:
sys.path.insert(0, _p)
# ---------------------------------------------------------------------------
# Import fritzbox with module-level side effects patched
# ---------------------------------------------------------------------------
# fritzbox.py calls get_setting_value(), Logger(), and Plugin_Objects() at
# module level. Patching these before the first import prevents live config
# reads, log-file creation, and result-file creation during tests.
with patch("helper.get_setting_value", return_value="UTC"), \
patch("logger.Logger"), \
patch("plugin_helper.Plugin_Objects"):
import fritzbox # noqa: E402
from plugin_helper import normalize_mac # noqa: E402
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def _make_host_entry(mac="AA:BB:CC:DD:EE:FF", ip="192.168.1.10",
hostname="testdevice", active=1, interface="Ethernet"):
return {
"NewMACAddress": mac,
"NewIPAddress": ip,
"NewHostName": hostname,
"NewActive": active,
"NewInterfaceType": interface,
}
@pytest.fixture
def mock_fritz_hosts():
"""
Patches fritzconnection.lib.fritzhosts in sys.modules so that
fritzbox.get_connected_devices() uses a controllable FritzHosts mock.
Yields the FritzHosts *instance* (what FritzHosts(fc) returns).
"""
hosts_instance = MagicMock()
fritz_hosts_module = MagicMock()
fritz_hosts_module.FritzHosts = MagicMock(return_value=hosts_instance)
with patch.dict("sys.modules", {
"fritzconnection": MagicMock(),
"fritzconnection.lib": MagicMock(),
"fritzconnection.lib.fritzhosts": fritz_hosts_module,
}):
yield hosts_instance
# ===========================================================================
# get_connected_devices
# ===========================================================================
class TestGetConnectedDevices:
def test_returns_active_device(self, mock_fritz_hosts):
mock_fritz_hosts.host_numbers = 1
mock_fritz_hosts.get_generic_host_entry.return_value = _make_host_entry(active=1)
devices = fritzbox.get_connected_devices(MagicMock(), active_only=True)
assert len(devices) == 1
assert devices[0]["active_status"] == "Active"
def test_active_only_filters_inactive_device(self, mock_fritz_hosts):
mock_fritz_hosts.host_numbers = 2
mock_fritz_hosts.get_generic_host_entry.side_effect = [
_make_host_entry(mac="AA:BB:CC:DD:EE:01", active=1),
_make_host_entry(mac="AA:BB:CC:DD:EE:02", active=0),
]
devices = fritzbox.get_connected_devices(MagicMock(), active_only=True)
assert len(devices) == 1
assert devices[0]["mac_address"] == "aa:bb:cc:dd:ee:01"
def test_active_only_false_includes_inactive_device(self, mock_fritz_hosts):
mock_fritz_hosts.host_numbers = 2
mock_fritz_hosts.get_generic_host_entry.side_effect = [
_make_host_entry(mac="AA:BB:CC:DD:EE:01", active=1),
_make_host_entry(mac="AA:BB:CC:DD:EE:02", active=0),
]
devices = fritzbox.get_connected_devices(MagicMock(), active_only=False)
assert len(devices) == 2
assert devices[1]["active_status"] == "Inactive"
def test_device_without_mac_is_skipped(self, mock_fritz_hosts):
mock_fritz_hosts.host_numbers = 2
mock_fritz_hosts.get_generic_host_entry.side_effect = [
_make_host_entry(mac=""),
_make_host_entry(mac="AA:BB:CC:DD:EE:01"),
]
devices = fritzbox.get_connected_devices(MagicMock(), active_only=False)
assert len(devices) == 1
assert devices[0]["mac_address"] == "aa:bb:cc:dd:ee:01"
def test_ethernet_interface_maps_to_lan(self, mock_fritz_hosts):
mock_fritz_hosts.host_numbers = 1
mock_fritz_hosts.get_generic_host_entry.return_value = _make_host_entry(interface="Ethernet")
devices = fritzbox.get_connected_devices(MagicMock(), active_only=False)
assert devices[0]["interface_type"] == "LAN"
def test_wifi_interface_maps_to_wifi(self, mock_fritz_hosts):
mock_fritz_hosts.host_numbers = 1
mock_fritz_hosts.get_generic_host_entry.return_value = _make_host_entry(interface="802.11")
devices = fritzbox.get_connected_devices(MagicMock(), active_only=False)
assert devices[0]["interface_type"] == "WiFi"
def test_unknown_interface_is_preserved(self, mock_fritz_hosts):
mock_fritz_hosts.host_numbers = 1
mock_fritz_hosts.get_generic_host_entry.return_value = _make_host_entry(interface="SomeOtherType")
devices = fritzbox.get_connected_devices(MagicMock(), active_only=False)
assert devices[0]["interface_type"] == "SomeOtherType"
def test_mac_address_is_normalized_to_lowercase(self, mock_fritz_hosts):
mock_fritz_hosts.host_numbers = 1
mock_fritz_hosts.get_generic_host_entry.return_value = _make_host_entry(mac="AA:BB:CC:DD:EE:FF")
devices = fritzbox.get_connected_devices(MagicMock(), active_only=False)
assert devices[0]["mac_address"] == "aa:bb:cc:dd:ee:ff"
def test_missing_hostname_defaults_to_unknown(self, mock_fritz_hosts):
mock_fritz_hosts.host_numbers = 1
mock_fritz_hosts.get_generic_host_entry.return_value = _make_host_entry(hostname="")
devices = fritzbox.get_connected_devices(MagicMock(), active_only=False)
assert devices[0]["hostname"] == "Unknown"
def test_failed_host_entry_does_not_abort_remaining(self, mock_fritz_hosts):
mock_fritz_hosts.host_numbers = 3
mock_fritz_hosts.get_generic_host_entry.side_effect = [
_make_host_entry(mac="AA:BB:CC:DD:EE:01"),
Exception("TR-064 timeout"),
_make_host_entry(mac="AA:BB:CC:DD:EE:03"),
]
devices = fritzbox.get_connected_devices(MagicMock(), active_only=False)
assert len(devices) == 2
def test_empty_host_list_returns_empty(self, mock_fritz_hosts):
mock_fritz_hosts.host_numbers = 0
devices = fritzbox.get_connected_devices(MagicMock(), active_only=False)
assert devices == []
# ===========================================================================
# check_guest_wifi_status
# ===========================================================================
class TestCheckGuestWifiStatus:
def test_disabled_service_returns_inactive(self):
fc = MagicMock()
fc.call_action.return_value = {"NewEnable": False, "NewSSID": ""}
result = fritzbox.check_guest_wifi_status(fc, guest_service_num=3)
assert result["active"] is False
def test_enabled_service_returns_active(self):
fc = MagicMock()
fc.call_action.return_value = {"NewEnable": True, "NewSSID": "MyGuestWiFi"}
result = fritzbox.check_guest_wifi_status(fc, guest_service_num=3)
assert result["active"] is True
assert result["ssid"] == "MyGuestWiFi"
def test_queries_correct_service_number(self):
fc = MagicMock()
fc.call_action.return_value = {"NewEnable": True, "NewSSID": "Guest"}
fritzbox.check_guest_wifi_status(fc, guest_service_num=2)
fc.call_action.assert_called_once_with("WLANConfiguration2", "GetInfo")
def test_service_exception_returns_inactive(self):
fc = MagicMock()
fc.call_action.side_effect = Exception("Service unavailable")
result = fritzbox.check_guest_wifi_status(fc, guest_service_num=3)
assert result["active"] is False
def test_empty_ssid_uses_default_label(self):
fc = MagicMock()
fc.call_action.return_value = {"NewEnable": True, "NewSSID": ""}
result = fritzbox.check_guest_wifi_status(fc, guest_service_num=3)
assert result["active"] is True
assert result["ssid"] == "Guest WiFi"
def test_service1_can_be_guest(self):
fc = MagicMock()
fc.call_action.return_value = {"NewEnable": True, "NewSSID": "Gast"}
result = fritzbox.check_guest_wifi_status(fc, guest_service_num=1)
assert result["active"] is True
fc.call_action.assert_called_once_with("WLANConfiguration1", "GetInfo")
# ===========================================================================
# create_guest_wifi_device
# ===========================================================================
class TestCreateGuestWifiDevice:
def _fc_with_mac(self, mac):
fc = MagicMock()
fc.call_action.return_value = {"NewMACAddress": mac}
return fc
def test_returns_device_dict(self):
device = fritzbox.create_guest_wifi_device(self._fc_with_mac("AA:BB:CC:DD:EE:FF"))
assert device is not None
assert "mac_address" in device
assert device["hostname"] == "Guest WiFi Network"
assert device["active_status"] == "Active"
assert device["interface_type"] == "Access Point"
assert device["ip_address"] == ""
def test_guest_mac_has_locally_administered_bit(self):
"""First byte must be 0x02 — locally-administered, unicast."""
device = fritzbox.create_guest_wifi_device(self._fc_with_mac("AA:BB:CC:DD:EE:FF"))
first_byte = int(device["mac_address"].split(":")[0], 16)
assert first_byte == 0x02
def test_guest_mac_format_is_valid(self):
"""MAC must be 6 colon-separated lowercase hex pairs."""
device = fritzbox.create_guest_wifi_device(self._fc_with_mac("AA:BB:CC:DD:EE:FF"))
parts = device["mac_address"].split(":")
assert len(parts) == 6
for part in parts:
assert len(part) == 2
int(part, 16) # raises ValueError if not valid hex
def test_guest_mac_is_deterministic(self):
"""Same Fritz!Box MAC must always produce the same guest MAC."""
fc = self._fc_with_mac("AA:BB:CC:DD:EE:FF")
mac1 = fritzbox.create_guest_wifi_device(fc)["mac_address"]
mac2 = fritzbox.create_guest_wifi_device(fc)["mac_address"]
assert mac1 == mac2
def test_different_fritzbox_macs_produce_different_guest_macs(self):
mac_a = fritzbox.create_guest_wifi_device(self._fc_with_mac("AA:BB:CC:DD:EE:01"))["mac_address"]
mac_b = fritzbox.create_guest_wifi_device(self._fc_with_mac("AA:BB:CC:DD:EE:02"))["mac_address"]
assert mac_a != mac_b
def test_no_fritzbox_mac_uses_fallback(self):
"""When DeviceInfo returns no MAC, fall back to 02:00:00:00:00:01."""
fc = MagicMock()
fc.call_action.return_value = {"NewMACAddress": ""}
device = fritzbox.create_guest_wifi_device(fc)
assert device["mac_address"] == "02:00:00:00:00:01"
def test_device_info_exception_returns_none(self):
"""If DeviceInfo call raises, create_guest_wifi_device must return None."""
fc = MagicMock()
fc.call_action.side_effect = Exception("Connection refused")
device = fritzbox.create_guest_wifi_device(fc)
assert device is None
def test_known_mac_produces_known_guest_mac(self):
"""
Regression anchor: for a fixed Fritz!Box MAC, the expected guest MAC
is precomputed here independently. If the hashing logic in
fritzbox.py changes, this test fails immediately.
"""
fritzbox_mac = "aa:bb:cc:dd:ee:ff" # normalize_mac output of "AA:BB:CC:DD:EE:FF"
digest = hashlib.md5(f"GUEST:{fritzbox_mac}".encode()).digest()
expected = "02:" + ":".join(f"{b:02x}" for b in digest[:5])
device = fritzbox.create_guest_wifi_device(self._fc_with_mac("AA:BB:CC:DD:EE:FF"))
assert device["mac_address"] == expected
# ===========================================================================
# get_fritzbox_connection
# ===========================================================================
class TestGetFritzboxConnection:
def test_successful_connection(self):
fc_instance = MagicMock()
fc_instance.modelname = "FRITZ!Box 7590"
fc_instance.system_version = "7.57"
fc_class = MagicMock(return_value=fc_instance)
fc_module = MagicMock()
fc_module.FritzConnection = fc_class
with patch.dict("sys.modules", {"fritzconnection": fc_module}):
result = fritzbox.get_fritzbox_connection("fritz.box", 49443, "admin", "pass", True)
assert result is fc_instance
fc_class.assert_called_once_with(
address="fritz.box", port=49443, user="admin", password="pass", use_tls=True, timeout=10,
)
def test_import_error_returns_none(self):
with patch.dict("sys.modules", {"fritzconnection": None}):
result = fritzbox.get_fritzbox_connection("fritz.box", 49443, "admin", "pass", True)
assert result is None
def test_connection_exception_returns_none(self):
fc_module = MagicMock()
fc_module.FritzConnection.side_effect = Exception("Connection refused")
with patch.dict("sys.modules", {"fritzconnection": fc_module}):
result = fritzbox.get_fritzbox_connection("fritz.box", 49443, "admin", "pass", True)
assert result is None
# ===========================================================================
# main
# ===========================================================================
class TestMain:
_SETTINGS = {
"FRITZBOX_HOST": "fritz.box",
"FRITZBOX_PORT": 49443,
"FRITZBOX_USER": "admin",
"FRITZBOX_PASS": "secret",
"FRITZBOX_USE_TLS": True,
"FRITZBOX_REPORT_GUEST": False,
"FRITZBOX_GUEST_SERVICE": 3,
"FRITZBOX_ACTIVE_ONLY": True,
}
def _patch_settings(self):
return patch.object(
fritzbox, "get_setting_value",
side_effect=lambda key: self._SETTINGS[key],
)
def test_connection_failure_returns_1(self):
mock_po = MagicMock()
with self._patch_settings(), \
patch.object(fritzbox, "get_fritzbox_connection", return_value=None), \
patch.object(fritzbox, "plugin_objects", mock_po):
result = fritzbox.main()
assert result == 1
mock_po.write_result_file.assert_called_once()
mock_po.add_object.assert_not_called()
def test_scan_processes_devices(self):
devices = [
{"mac_address": "aa:bb:cc:dd:ee:01", "ip_address": "192.168.1.10",
"hostname": "device1", "active_status": "Active", "interface_type": "LAN"},
{"mac_address": "aa:bb:cc:dd:ee:02", "ip_address": "192.168.1.11",
"hostname": "device2", "active_status": "Active", "interface_type": "WiFi"},
]
mock_po = MagicMock()
with self._patch_settings(), \
patch.object(fritzbox, "get_fritzbox_connection", return_value=MagicMock()), \
patch.object(fritzbox, "get_connected_devices", return_value=devices), \
patch.object(fritzbox, "plugin_objects", mock_po):
result = fritzbox.main()
assert result == 0
assert mock_po.add_object.call_count == 2
mock_po.write_result_file.assert_called_once()
def test_guest_wifi_device_appended_when_active(self):
devices = [
{"mac_address": "aa:bb:cc:dd:ee:01", "ip_address": "192.168.1.10",
"hostname": "device1", "active_status": "Active", "interface_type": "LAN"},
]
guest_device = {
"mac_address": "02:a1:b2:c3:d4:e5", "ip_address": "",
"hostname": "Guest WiFi Network", "active_status": "Active",
"interface_type": "Access Point",
}
settings = {**self._SETTINGS, "FRITZBOX_REPORT_GUEST": True}
mock_po = MagicMock()
with patch.object(fritzbox, "get_setting_value", side_effect=lambda k: settings[k]), \
patch.object(fritzbox, "get_fritzbox_connection", return_value=MagicMock()), \
patch.object(fritzbox, "get_connected_devices", return_value=devices), \
patch.object(fritzbox, "check_guest_wifi_status", return_value={"active": True, "ssid": "Guest"}), \
patch.object(fritzbox, "create_guest_wifi_device", return_value=guest_device), \
patch.object(fritzbox, "plugin_objects", mock_po):
result = fritzbox.main()
assert result == 0
assert mock_po.add_object.call_count == 2 # 1 device + 1 guest
# Verify the guest device was passed correctly
guest_call = mock_po.add_object.call_args_list[1]
assert guest_call.kwargs["primaryId"] == "02:a1:b2:c3:d4:e5"
assert guest_call.kwargs["watched3"] == "Access Point"
def test_guest_wifi_not_appended_when_inactive(self):
devices = [
{"mac_address": "aa:bb:cc:dd:ee:01", "ip_address": "192.168.1.10",
"hostname": "device1", "active_status": "Active", "interface_type": "LAN"},
]
settings = {**self._SETTINGS, "FRITZBOX_REPORT_GUEST": True}
mock_po = MagicMock()
with patch.object(fritzbox, "get_setting_value", side_effect=lambda k: settings[k]), \
patch.object(fritzbox, "get_fritzbox_connection", return_value=MagicMock()), \
patch.object(fritzbox, "get_connected_devices", return_value=devices), \
patch.object(fritzbox, "check_guest_wifi_status", return_value={"active": False, "ssid": ""}), \
patch.object(fritzbox, "plugin_objects", mock_po):
result = fritzbox.main()
assert result == 0
assert mock_po.add_object.call_count == 1 # only the real device