""" 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, ) 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