Files
NetAlertX/test/plugins/test_pihole_api_scan.py
sebingel 98f5b81cc8 marks PIHOLEAPI rows of offline devices as not present
With PIHOLEAPI_GET_OFFLINE enabled, PIHOLEAPI imports devices it considers
offline, but every row still counted as a live sighting. The plugin
computed the online state, yet never passed it on, and its config.json
mapped nothing to CurrentScan.scanPresence, so each row got the column
default 1. An offline device imported only for its name, vendor and IP
was marked online. ADGUARDIMP had the same problem in #1813 and fixed it
the same way.

Changes:
- main() passes the online state as helpVal4 '1' or '0' to add_object()
  (server/plugins/pihole_api_scan/pihole_api_scan.py).
- config.json maps helpVal4 to scanPresence, the same column definition
  adguard_import uses. The GET_OFFLINE description says offline devices
  are imported without being marked online; the README says the same.
- test/db_test_helpers.py: make_plugin_event_row() takes an optional
  help_val4, and CREATE_CURRENT_SCAN gets the
  CHECK (scanPresence IN (0, 1)) of the real schema (db_upgrade.py), so a
  test sees the same constraint as production.
- New test/scan/test_piholeapi_scan_presence_mapping.py runs the real
  PIHOLEAPI config.json through process_plugin_events() and checks that a
  helpVal4 '0' row lands in CurrentScan with scanPresence 0.
- test/plugins/test_pihole_api_scan.py: main() writes helpVal4 '0' for an
  offline device and '1' for every IP row of an online device. Both new
  tests fail without this change.

Impact:
Devices imported only because of PIHOLEAPI_GET_OFFLINE keep their name,
vendor and IP data but no longer mark the device as online. Live check
against Pi-hole v6.8: 28 rows with helpVal4 '0', 5 with '1'; the absent
phone got devPresentLastScan 0 and only a New Device event. With
GET_OFFLINE disabled (the default) every imported row is online, so
nothing changes there.
2026-11-01 09:29:50 +00:00

217 lines
9.5 KiB
Python

"""Tests for the pihole_api_scan (PIHOLEAPI) plugin's online detection.
pihole_api_scan.py is loaded with its NetAlertX-internal dependencies stubbed
out (same approach as test_adguard_import.py), so these tests run without a
Pi-hole instance. get_pihole_network_devices() is patched per test.
Pi-hole FTL refreshes ips[].lastSeen for every client it still holds in
memory, even when the client is not in the ARP cache, so lastSeen stays fresh
for about 24 hours after a device left. The device's lastQuery (time of its
last DNS query) is what tells the plugin whether a device is still active.
"""
import datetime
import importlib.util
import os
import re
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"])
# Unix time of the debug run the bug report is based on (2026-10-31 20:05:10 UTC).
NOW_TS = 1793477110
_MAC_PATTERN = re.compile(r"^([0-9a-f]{2}:){5}[0-9a-f]{2}$")
def _fixed_now(as_string=True):
"""Stand-in for timeNowUTC() that always returns NOW_TS."""
now = datetime.datetime.fromtimestamp(NOW_TS, tz=datetime.timezone.utc)
return now.strftime("%Y-%m-%d %H:%M:%S") if as_string else now
def _load_pihole_api_scan_module():
"""Import pihole_api_scan.py with NetAlertX modules stubbed, then restore sys.modules."""
missing_module = object()
previous_modules = {}
def stub(name, **attributes):
"""Put a fake module with the given attributes into sys.modules, remembering the previous entry."""
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, is_mac=lambda value: bool(_MAC_PATTERN.match(value)))
stub("utils.datetime_utils", timeNowUTC=_fixed_now)
stub("logger", mylog=MagicMock(), Logger=MagicMock())
stub("helper", get_setting_value=MagicMock(return_value="UTC"))
stub("const", logPath="/tmp")
stub("conf", tz=None)
stub("pytz", timezone=MagicMock(return_value="UTC"))
stub("utils.crypto_utils", string_to_fake_mac=MagicMock(return_value="fa:ce:00:00:00:01"))
module_path = Path(__file__).resolve().parents[2] / "server" / "plugins" / "pihole_api_scan" / "pihole_api_scan.py"
spec = importlib.util.spec_from_file_location("pihole_api_scan_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
pihole_api_scan = _load_pihole_api_scan_module()
def _api_device(hwaddr, ips, last_query, last_seen):
"""Build one /api/network/devices entry with the fields Pi-hole FTL v6 returns."""
return {
"id": 1,
"hwaddr": hwaddr,
"interface": "eth0",
"firstSeen": NOW_TS - 30 * 86400,
"lastQuery": last_query,
"numQueries": 42,
"macVendor": "Test Vendor",
"ips": [
{"ip": ip, "name": f"host{index}.lan", "lastSeen": last_seen, "nameUpdated": last_seen}
for index, ip in enumerate(ips)
],
}
def _gather(devices, get_offline=False, consider_online=300):
"""Run gather_device_entries() against the given API devices and plugin settings."""
with patch.object(pihole_api_scan, "get_pihole_network_devices", return_value=devices), \
patch.object(pihole_api_scan, "PIHOLEAPI_GET_OFFLINE", get_offline), \
patch.object(pihole_api_scan, "PIHOLEAPI_CONSIDER_ONLINE", consider_online), \
patch.object(pihole_api_scan, "PIHOLEAPI_FAKE_MAC", False):
return pihole_api_scan.gather_device_entries()
class TestOnlineFromLastQuery:
def test_stale_last_query_with_fresh_last_seen_is_skipped(self):
"""The reported bug: the phone's last DNS query is 5 h 47 min old, but
Pi-hole still refreshes its ips[].lastSeen (10 s old)."""
phone = _api_device("0a:60:b8:ad:b6:9d", ["192.168.178.64"], last_query=1793456269, last_seen=1793477100)
assert _gather([phone]) == []
def test_entry_reports_device_last_query(self):
"""The 'Last Query' column (watched3) shows the last DNS query, not ips[].lastSeen."""
laptop = _api_device("f8:01:b4:b4:67:94", ["192.168.178.52"], last_query=1793477050, last_seen=1793477105)
entries = _gather([laptop])
assert [entry["lastQuery"] for entry in entries] == ["1793477050"]
def test_recent_last_query_is_online(self):
"""A device whose last DNS query is 60 s old is imported as online."""
laptop = _api_device("f8:01:b4:b4:67:94", ["192.168.178.52"], last_query=1793477050, last_seen=1793477105)
entries = _gather([laptop])
assert [entry["isOnline"] for entry in entries] == [True]
@pytest.mark.parametrize(
"last_query, expected_online",
[
(1793476810, [True]), # exactly 300 s old: still online
(1793476809, []), # 301 s old: offline, so skipped
(1793477140, [True]), # 30 s in the future (Pi-hole clock ahead): online
],
)
def test_consider_online_window(self, last_query, expected_online):
"""The PIHOLEAPI_CONSIDER_ONLINE window is inclusive, and a lastQuery slightly in the future counts as online."""
laptop = _api_device("f8:01:b4:b4:67:94", ["192.168.178.52"], last_query=last_query, last_seen=1793477105)
entries = _gather([laptop], consider_online=300)
assert [entry["isOnline"] for entry in entries] == expected_online
@pytest.mark.parametrize(
"last_query, consider_online",
[
(None, 300), # field missing in the API response
(0, 300), # Pi-hole never saw a DNS query from this device
(0, 10_000_000_000), # a huge window must not turn "never" into online
],
)
def test_never_queried_device_is_offline(self, last_query, consider_online):
"""A device without a lastQuery (missing or 0) is offline, whatever the window."""
scale = _api_device("00:24:e4:d0:82:7c", ["192.168.178.50"], last_query=last_query, last_seen=1793477100)
if last_query is None:
del scale["lastQuery"]
entries = _gather([scale], get_offline=True, consider_online=consider_online)
assert [entry["isOnline"] for entry in entries] == [False]
def test_multi_ip_device_rows_share_online_state(self):
"""lastQuery is per device, so every IP row of a device gets the same state,
however fresh each IP's own lastSeen is."""
tv = _api_device("f8:01:b4:b4:67:94", ["192.168.178.52", "192.168.178.53"], last_query=1793453291, last_seen=1793477100)
entries = _gather([tv], get_offline=True)
assert [(entry["ip"], entry["isOnline"]) for entry in entries] == [
("192.168.178.52", False),
("192.168.178.53", False),
]
def _use_settings(monkeypatch, settings):
"""Serve settings to main() via get_setting_value, and restore the module globals main() rebinds after the test."""
for name in ("PIHOLEAPI_URL", "PIHOLEAPI_PASSWORD", "PIHOLEAPI_API_MAXCLIENTS", "PIHOLEAPI_VERIFY_SSL",
"PIHOLEAPI_RUN_TIMEOUT", "PIHOLEAPI_GET_OFFLINE", "PIHOLEAPI_CONSIDER_ONLINE"):
monkeypatch.setattr(pihole_api_scan, name, getattr(pihole_api_scan, name))
monkeypatch.setattr(pihole_api_scan, "get_setting_value", settings.get)
def _run_main(monkeypatch, devices):
"""Run main() with PIHOLEAPI_GET_OFFLINE on against the given API devices; return add_object() kwargs keyed by (MAC, IP)."""
# Auth and the devices request are patched below, so main() needs no other settings.
_use_settings(monkeypatch, {
"PIHOLEAPI_URL": "http://pi.hole/",
"PIHOLEAPI_GET_OFFLINE": True,
"PIHOLEAPI_CONSIDER_ONLINE": 300,
})
plugin_objects = MagicMock()
monkeypatch.setattr(pihole_api_scan, "pihole_api_auth", lambda: True)
monkeypatch.setattr(pihole_api_scan, "pihole_api_deauth", lambda: None)
monkeypatch.setattr(pihole_api_scan, "get_pihole_network_devices", lambda: devices)
monkeypatch.setattr(pihole_api_scan, "PIHOLEAPI_FAKE_MAC", False)
monkeypatch.setattr(pihole_api_scan, "plugin_objects", plugin_objects)
pihole_api_scan.main()
return {(call.kwargs["primaryId"], call.kwargs["secondaryId"]): call.kwargs for call in plugin_objects.add_object.call_args_list}
class TestPresenceFromOnlineState:
def test_offline_import_is_not_present(self, monkeypatch):
"""With PIHOLEAPI_GET_OFFLINE, an offline device is still imported, but its
rows must not assert presence (helpVal4 maps to CurrentScan.scanPresence)."""
phone = _api_device("0a:60:b8:ad:b6:9d", ["192.168.178.64"], last_query=1793456269, last_seen=1793477100)
tv = _api_device("f8:01:b4:b4:67:94", ["192.168.178.52", "192.168.178.53"], last_query=1793477050, last_seen=1793477100)
calls = _run_main(monkeypatch, [phone, tv])
assert {key: kwargs.get("helpVal4") for key, kwargs in calls.items()} == {
("0a:60:b8:ad:b6:9d", "192.168.178.64"): "0",
("f8:01:b4:b4:67:94", "192.168.178.52"): "1",
("f8:01:b4:b4:67:94", "192.168.178.53"): "1",
}