"""Tests for the adguard_import (ADGUARDIMP) plugin's presence detection. script.py is loaded with its NetAlertX-internal dependencies (plugin_helper, logger, helper, const, conf, pytz, utils.crypto_utils) stubbed out - same approach test_wificanary.py/test_dockerdisc.py use - so these run without the full devcontainer environment. `ag_request()` is mocked per test rather than actually calling AdGuard Home's API. Regression coverage for GitHub issue #1813: devices imported from AdGuard Home showed as permanently Online. Root cause: scanPresence was never mapped, so it defaulted to 1 (server/db/schema/app.sql) for every row on every run - but auto_clients (from /control/clients) is AdGuard's historical DNS-seen roster, not a live-presence feed. Fix: scanPresence is now computed per device from whether it currently holds an active *dynamic* DHCP lease (/control/dhcp/status's `leases`, not `static_leases` - a static reservation is a permanent binding, not evidence of current connectivity), passed via helpVal4 (config.json maps helpVal4 -> scanPresence). """ import importlib.util import os import sys import types from pathlib import Path from unittest.mock import MagicMock, patch import pytest INSTALL_PATH = os.getenv("NETALERTX_APP", "/app") sys.path.extend([f"{INSTALL_PATH}/server/plugins", f"{INSTALL_PATH}/server"]) def _load_adguard_import_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, string_to_fake_mac=lambda ip: f"fake:{ip}") stub("logger", mylog=MagicMock(), Logger=MagicMock()) stub("helper", get_setting_value=MagicMock(return_value="UTC")) stub("const", logPath="/tmp") stub("utils.crypto_utils", string_to_fake_mac=lambda ip: f"fake:{ip}") stub("conf", tz=None) stub("pytz", timezone=MagicMock(return_value="UTC")) module_path = Path(__file__).resolve().parents[2] / "server" / "plugins" / "adguard_import" / "adguard_import.py" spec = importlib.util.spec_from_file_location("adguard_import_script", 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 adguard_import = _load_adguard_import_module() def _settings(overrides=None): base = { "ADGUARDIMP_SERVER": "adguard.local", "ADGUARDIMP_PORT": 80, "ADGUARDIMP_PROTOCOL": "http", "ADGUARDIMP_USER": "", "ADGUARDIMP_PASS": "", "ADGUARDIMP_FAKE_MAC": False, "ADGUARDIMP_RUN_TIMEOUT": 30, } base.update(overrides or {}) return base def _run_main(clients_response, dhcp_response, settings_overrides=None): """Run main() with ag_request mocked to return the given API payloads, and capture every add_object() call's kwargs, keyed by primaryId (MAC).""" settings = _settings(settings_overrides) calls = {} def fake_add_object(**kwargs): calls[kwargs["primaryId"]] = kwargs def fake_ag_request(path, *args, **kwargs): if path == "/control/clients": return clients_response if path == "/control/dhcp/status": return dhcp_response raise AssertionError(f"unexpected path: {path}") with patch.object(adguard_import, "get_setting_value", side_effect=lambda k: settings.get(k)): with patch.object(adguard_import, "ag_request", side_effect=fake_ag_request): adguard_import.plugin_objects.add_object = fake_add_object adguard_import.plugin_objects.write_result_file = MagicMock() adguard_import.main() return calls class TestPresenceFromDynamicLease: def test_device_with_active_dynamic_lease_is_present(self): clients = {"auto_clients": [{"ip": "192.168.1.10", "name": "laptop", "source": "DHCP"}]} dhcp = {"leases": [{"ip": "192.168.1.10", "mac": "aa:bb:cc:dd:ee:01"}], "static_leases": []} calls = _run_main(clients, dhcp) assert calls["AA:BB:CC:DD:EE:01"]["helpVal4"] == "1" def test_device_known_only_from_auto_clients_is_not_present(self): """The exact bug from #1813: a device AdGuard has historically seen via DNS traffic, with no current DHCP lease, must not be asserted as currently online.""" clients = {"auto_clients": [{"ip": "192.168.1.20", "name": "old-phone", "source": "RDNS"}]} dhcp = {"leases": [], "static_leases": [{"ip": "192.168.1.20", "mac": "aa:bb:cc:dd:ee:02"}]} calls = _run_main(clients, dhcp) assert calls["AA:BB:CC:DD:EE:02"]["helpVal4"] == "0" def test_static_reservation_alone_is_not_present(self): """A static DHCP reservation is a permanent binding, not evidence the device is currently connected - only an active entry in the dynamic `leases` array counts.""" clients = {"auto_clients": [{"ip": "192.168.1.30", "name": "printer", "source": "DHCP"}]} dhcp = {"leases": [], "static_leases": [{"ip": "192.168.1.30", "mac": "aa:bb:cc:dd:ee:03"}]} calls = _run_main(clients, dhcp) assert calls["AA:BB:CC:DD:EE:03"]["helpVal4"] == "0" def test_device_with_both_static_and_dynamic_entry_is_present(self): """A device can have a static reservation *and* currently be connected (holding the active lease that reservation grants it) - the dynamic-lease signal should still mark it present.""" clients = {"auto_clients": [{"ip": "192.168.1.40", "name": "server", "source": "DHCP"}]} dhcp = { "leases": [{"ip": "192.168.1.40", "mac": "aa:bb:cc:dd:ee:04"}], "static_leases": [{"ip": "192.168.1.40", "mac": "aa:bb:cc:dd:ee:04"}], } calls = _run_main(clients, dhcp) assert calls["AA:BB:CC:DD:EE:04"]["helpVal4"] == "1" def test_fake_mac_device_is_never_present(self): """A device with no real MAC (identified only via a synthesized fake MAC) can never match a real DHCP lease's MAC, so it can't be asserted present.""" clients = {"auto_clients": [{"ip": "192.168.1.50", "name": "unknown", "source": "RDNS"}]} dhcp = {"leases": [], "static_leases": []} calls = _run_main(clients, dhcp, settings_overrides={"ADGUARDIMP_FAKE_MAC": True}) assert len(calls) == 1 (only_call,) = calls.values() assert only_call["helpVal4"] == "0" def test_multiple_devices_mixed_presence(self): clients = { "auto_clients": [ {"ip": "192.168.1.60", "name": "online-device", "source": "DHCP"}, {"ip": "192.168.1.61", "name": "offline-device", "source": "RDNS"}, ] } dhcp = { "leases": [{"ip": "192.168.1.60", "mac": "aa:bb:cc:dd:ee:60"}], "static_leases": [{"ip": "192.168.1.61", "mac": "aa:bb:cc:dd:ee:61"}], } calls = _run_main(clients, dhcp) assert calls["AA:BB:CC:DD:EE:60"]["helpVal4"] == "1" assert calls["AA:BB:CC:DD:EE:61"]["helpVal4"] == "0" if __name__ == "__main__": sys.exit(pytest.main([__file__, "-v"]))