Files
NetAlertX/test/plugins/test_pihole_monitor.py
Mauricio Camayo d6b4696ac9 fix: track per-source delta so one instance's reset can't mask the other's spike
Addresses CodeRabbit review on PR #1765 (pullrequestreview-5069337680).

pihole_monitor.py:
- last_raw is now tracked per source ({"primary": N, "secondary": M}
  per device) instead of one combined value. Combining raw totals
  across sources before diffing let a counter reset on one instance
  silently net out against real traffic on the other - e.g. primary
  +2000 (a real spike) and secondary resetting 1000->5 (-995) would
  combine into a raw delta of only 1005, hiding most of the primary's
  actual spike behind the secondary's unrelated restart.
- New aggregate_source_deltas(): diffs each source independently via
  compute_delta(), then sums only the valid deltas. A source with no
  valid delta this run (bootstrapping or just reset) contributes
  nothing and doesn't block the others; each source keeps its own
  reference point going forward.
- State loaded from before this change (last_raw as a plain number,
  not per-source) is now tolerated instead of crashing - treated as no
  prior reference point, so every source just bootstraps fresh on the
  next run.

README.md:
- Fixed a self-contradicting line: a less frequent schedule means
  larger per-run deltas, so PIHOLEMON_MIN_BLOCKED may need *raising*,
  not lowering as it previously said.
- Corrected PIHOLEMON_HISTORY_DAYS guidance: it's a retention window,
  not a detection delay. A new device becomes evaluable on its 3rd
  successful run (1st anchors the counter, 2nd records the first
  delta, 3rd has a baseline to compare against), not after the full
  retention window.

Tests: 54 (up from 48). New coverage: aggregate_source_deltas() unit
tests including the exact dual-source reset-masking scenario, a
main()-level integration test for the same, and a regression test for
tolerating pre-per-source state. Both the reset-masking fix and the
legacy-state guard verified via mutation testing (reverted each,
confirmed the relevant tests fail, restored). 99% line+branch coverage
maintained.
2026-08-31 12:51:46 -05:00

900 lines
42 KiB
Python

"""Tests for the pihole_monitor (PIHOLEMON) plugin.
pihole_monitor.py is loaded with its NetAlertX-internal dependencies
(plugin_helper, logger, helper, const, conf, pytz, utils.*) stubbed out,
the same approach test_mikrotik_scan.py uses - it keeps these tests
runnable without the full devcontainer environment and without any live
Pi-hole. `requests` itself is left real; individual HTTP calls are mocked
per test.
Layout:
- PiholeSource.auth() / fetch_top_blocked_clients(): unit tests against
a mocked `requests`, covering the auth success/failure paths and the
None-sentinel-on-failure contract (vs. a genuine empty {}).
- netalertx_device_owners(): unit tests for the batched (one request for
every device, not one per device) owner lookup.
- build_ip_to_mac(): pure-function unit tests for the multi-IP-per-MAC
fix (a device must not lose its other IPs to the by-MAC merge).
- compute_delta(): pure-function unit tests turning Pi-hole's raw,
cumulative-since-FTL-started count into a real per-run increment -
None (not 0) on a first-ever run or a counter reset, a genuine 0
distinct from that None otherwise.
- aggregate_source_deltas(): pure-function unit tests for combining
per-source deltas correctly - a counter reset on one source must not
net out against real traffic on another (they're diffed
independently, then summed - never combined as raw totals first).
- main(): integration tests with PiholeSource's network-touching
methods stubbed at the object level, covering source aggregation,
the stats_complete gate (a failed fetch must not corrupt a device's
history with a false zero), the history_days age-based clamp/trim
(not a run count - see trim_history()), the zero-baseline anomaly fix
(a device with an all-zero history must still be flagged, not silently
exempted), the bootstrap/counter-reset runs that establish or
re-anchor last_raw without recording a bogus delta, the dual-source
reset-masking regression, tolerance of a pre-per-source state file
(last_raw as a plain number, from before this round), and
per-instance Verify SSL.
"""
import importlib.util
import sys
import types
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import requests
def _is_mac(value):
"""Same shape as plugin_helper.is_mac, without pytz as a dependency."""
import re
s = str(value).lower().strip()
return bool(re.match(r"^[0-9a-f]{2}([-:]?)[0-9a-f]{2}(\1[0-9a-f]{2}){4}$", s))
def _load_pihole_monitor_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, is_mac=_is_mac)
stub("logger", mylog=MagicMock(), Logger=MagicMock())
stub("helper", get_setting_value=MagicMock(return_value="UTC"))
stub("const", logPath="/tmp", dbFolderPath="/tmp/db")
stub("conf", tz=None)
stub("pytz", timezone=MagicMock(return_value="UTC"))
stub("utils")
stub("utils.datetime_utils", timeNowUTC=MagicMock())
stub("utils.crypto_utils", string_to_fake_mac=lambda s: "fa:ce:00:00:00:01")
module_path = Path(__file__).resolve().parents[2] / "server" / "plugins" / "pihole_monitor" / "pihole_monitor.py"
spec = importlib.util.spec_from_file_location("pihole_monitor", 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_monitor = _load_pihole_monitor_module()
def _resp(json_data):
resp = MagicMock()
resp.raise_for_status = MagicMock()
resp.json = MagicMock(return_value=json_data)
return resp
# ---------------------------------------------------------------------------
# PiholeSource.auth()
# ---------------------------------------------------------------------------
def test_auth_success_stores_sid_and_csrf():
source = pihole_monitor.PiholeSource("primary", "http://ph1/", "pw", True, 5)
with patch("requests.post", return_value=_resp({"session": {"valid": True, "sid": "abc", "csrf": "xyz"}})):
assert source.auth() is True
assert source.sid == "abc"
assert source.csrf == "xyz"
def test_auth_invalid_session_returns_false():
source = pihole_monitor.PiholeSource("primary", "http://ph1/", "wrongpw", True, 5)
with patch("requests.post", return_value=_resp({"session": {"valid": False}})):
assert source.auth() is False
assert source.sid is None
def test_auth_connection_error_returns_false_without_raising():
import requests as real_requests
source = pihole_monitor.PiholeSource("primary", "http://unreachable/", "pw", True, 5)
with patch("requests.post", side_effect=real_requests.exceptions.ConnectionError("no route")):
assert source.auth() is False
assert source.sid is None
def test_auth_unconfigured_source_short_circuits():
source = pihole_monitor.PiholeSource("secondary", "", "", True, 5)
with patch("requests.post") as mock_post:
assert source.auth() is False
mock_post.assert_not_called()
def test_auth_timeout_returns_false():
source = pihole_monitor.PiholeSource("primary", "http://ph1/", "pw", True, 5)
with patch("requests.post", side_effect=requests.exceptions.Timeout("slow")):
assert source.auth() is False
assert source.sid is None
def test_auth_unexpected_error_returns_false():
source = pihole_monitor.PiholeSource("primary", "http://ph1/", "pw", True, 5)
with patch("requests.post", side_effect=ValueError("boom")):
assert source.auth() is False
def test_auth_unparseable_json_returns_false():
resp = MagicMock()
resp.raise_for_status = MagicMock()
resp.json = MagicMock(side_effect=ValueError("not json"))
source = pihole_monitor.PiholeSource("primary", "http://ph1/", "pw", True, 5)
with patch("requests.post", return_value=resp):
assert source.auth() is False
def test_auth_disables_insecure_warning_when_verify_ssl_off():
source = pihole_monitor.PiholeSource("primary", "http://ph1/", "pw", False, 5)
session_resp = _resp({"session": {"valid": True, "sid": "s", "csrf": "c"}})
with patch("requests.post", return_value=session_resp), \
patch("requests.packages.urllib3.disable_warnings") as mock_disable:
assert source.auth() is True
mock_disable.assert_called_once()
# ---------------------------------------------------------------------------
# PiholeSource.deauth()
# ---------------------------------------------------------------------------
def test_deauth_clears_session_on_success():
source = pihole_monitor.PiholeSource("primary", "http://ph1/", "pw", True, 5)
source.sid = "sid"
source.csrf = "csrf"
with patch("requests.delete", return_value=_resp({})) as mock_delete:
source.deauth()
mock_delete.assert_called_once()
assert source.sid is None
assert source.csrf is None
def test_deauth_swallows_request_errors():
source = pihole_monitor.PiholeSource("primary", "http://ph1/", "pw", True, 5)
source.sid = "sid"
with patch("requests.delete", side_effect=requests.exceptions.ConnectionError("gone")):
source.deauth() # must not raise
assert source.sid is None
def test_deauth_noop_without_an_active_session():
source = pihole_monitor.PiholeSource("primary", "http://ph1/", "pw", True, 5)
with patch("requests.delete") as mock_delete:
source.deauth()
mock_delete.assert_not_called()
# ---------------------------------------------------------------------------
# PiholeSource.fetch_devices()
# ---------------------------------------------------------------------------
def test_fetch_devices_success_returns_device_list():
source = pihole_monitor.PiholeSource("primary", "http://ph1/", "pw", True, 5)
source.sid = "sid"
source.csrf = "csrf" # also exercises _headers() including X-FTL-CSRF
payload = {"devices": [{"hwaddr": "aa:bb:cc:dd:ee:01"}]}
with patch("requests.get", return_value=_resp(payload)) as mock_get:
result = source.fetch_devices(max_clients=500)
assert result == [{"hwaddr": "aa:bb:cc:dd:ee:01"}]
assert mock_get.call_args.kwargs["params"] == {"max_devices": "500", "max_addresses": "2"}
def test_fetch_devices_failure_returns_empty_list():
source = pihole_monitor.PiholeSource("primary", "http://ph1/", "pw", True, 5)
source.sid = "sid"
with patch("requests.get", side_effect=requests.exceptions.Timeout("slow")):
assert source.fetch_devices(max_clients=500) == []
def test_fetch_devices_without_session_returns_empty_list():
source = pihole_monitor.PiholeSource("primary", "http://ph1/", "pw", True, 5)
with patch("requests.get") as mock_get:
assert source.fetch_devices(max_clients=500) == []
mock_get.assert_not_called()
# ---------------------------------------------------------------------------
# netalertx_device_owners() - one batched request, not one per device
# ---------------------------------------------------------------------------
def test_netalertx_device_owners_returns_empty_dict_without_url():
assert pihole_monitor.netalertx_device_owners(None, "token", 5) == {}
def test_netalertx_device_owners_returns_mac_keyed_dict_on_success():
payload = {"data": {"devices": {"devices": [
{"devMac": "aa:bb:cc:dd:ee:01", "devOwner": "Mauricio"},
{"devMac": "aa:bb:cc:dd:ee:02", "devOwner": ""},
]}}}
with patch("requests.post", return_value=_resp(payload)) as mock_post:
owners = pihole_monitor.netalertx_device_owners("http://nax/graphql", "tok", 5)
assert owners == {"aa:bb:cc:dd:ee:01": "Mauricio", "aa:bb:cc:dd:ee:02": ""}
assert mock_post.call_args.kwargs["headers"] == {"Authorization": "Bearer tok"}
# No per-device filter - the whole device list comes back in one request.
assert "variables" not in mock_post.call_args.kwargs["json"]
def test_netalertx_device_owners_returns_empty_dict_when_none_known():
payload = {"data": {"devices": {"devices": []}}}
with patch("requests.post", return_value=_resp(payload)):
owners = pihole_monitor.netalertx_device_owners("http://nax/graphql", "", 5)
assert owners == {}
def test_netalertx_device_owners_returns_empty_dict_on_request_error():
with patch("requests.post", side_effect=requests.exceptions.ConnectionError("down")):
owners = pihole_monitor.netalertx_device_owners("http://nax/graphql", "tok", 5)
assert owners == {}
def test_netalertx_device_owners_fetches_once_regardless_of_device_count():
"""Regression guard for the N-round-trips bug: on a network with many
devices, this must still be exactly one HTTP call, not one per device."""
payload = {"data": {"devices": {"devices": [
{"devMac": f"aa:bb:cc:dd:ee:{i:02x}", "devOwner": f"user{i}"} for i in range(50)
]}}}
with patch("requests.post", return_value=_resp(payload)) as mock_post:
owners = pihole_monitor.netalertx_device_owners("http://nax/graphql", "tok", 5)
assert mock_post.call_count == 1
assert len(owners) == 50
# ---------------------------------------------------------------------------
# gather_device_entries() - skip branches and the fake-MAC fallback
# ---------------------------------------------------------------------------
def test_gather_device_entries_skips_invalid_hwaddr_empty_ips_and_placeholder_ip():
devices = [
{"hwaddr": "00:00:00:00:00:00", "ips": [{"ip": "10.0.0.1"}]}, # excluded hwaddr
{"hwaddr": "", "ips": [{"ip": "10.0.0.2"}]}, # missing hwaddr
{"hwaddr": "aa:bb:cc:dd:ee:01", "ips": []}, # no ips at all
{"hwaddr": "aa:bb:cc:dd:ee:02", "ips": [{"ip": "0.0.0.0"}]}, # only a placeholder ip
{"hwaddr": "ip-::", "ips": [{"ip": "10.0.0.4"}]}, # placeholder hwaddr, the ::-specific case
{"hwaddr": "ip-10.0.0.5", "ips": [{"ip": "10.0.0.5"}]}, # placeholder hwaddr, the general case
{"hwaddr": "aa:bb:cc:dd:ee:03", "ips": [{"ip": "10.0.0.3", "lastSeen": 1000}]}, # the one real entry
]
source = MagicMock()
source.fetch_devices.return_value = devices
entries = pihole_monitor.gather_device_entries(source, consider_online=300, fake_mac=False, max_clients=500)
assert [e["mac"] for e in entries] == ["aa:bb:cc:dd:ee:03"]
def test_gather_device_entries_fake_mac_fallback_for_invalid_hwaddr():
devices = [{"hwaddr": "not-a-real-mac", "ips": [{"ip": "10.0.0.9", "lastSeen": 1000}]}]
source = MagicMock()
source.fetch_devices.return_value = devices
entries = pihole_monitor.gather_device_entries(source, consider_online=300, fake_mac=True, max_clients=500)
assert entries[0]["mac"] == "fa:ce:00:00:00:01" # from the stubbed string_to_fake_mac
# ---------------------------------------------------------------------------
# PiholeSource.fetch_top_blocked_clients() - None sentinel vs. genuine {}
# ---------------------------------------------------------------------------
def test_fetch_top_blocked_clients_success_returns_dict():
source = pihole_monitor.PiholeSource("primary", "http://ph1/", "pw", True, 5)
source.sid = "sid"
payload = {"clients": [{"ip": "10.0.0.5", "count": 12}, {"ip": "10.0.0.6", "count": 0}]}
with patch("requests.get", return_value=_resp(payload)) as mock_get:
result = source.fetch_top_blocked_clients(count=123)
assert result == {"10.0.0.5": 12, "10.0.0.6": 0}
assert mock_get.call_args.kwargs["params"] == {"blocked": "true", "count": 123}
def test_fetch_top_blocked_clients_genuine_empty_is_not_none():
source = pihole_monitor.PiholeSource("primary", "http://ph1/", "pw", True, 5)
source.sid = "sid"
with patch("requests.get", return_value=_resp({"clients": []})):
result = source.fetch_top_blocked_clients(count=500)
assert result == {}
def test_fetch_top_blocked_clients_failure_returns_none_not_empty_dict():
import requests as real_requests
source = pihole_monitor.PiholeSource("primary", "http://ph1/", "pw", True, 5)
source.sid = "sid"
with patch("requests.get", side_effect=real_requests.exceptions.Timeout("slow")):
result = source.fetch_top_blocked_clients(count=500)
assert result is None
def test_fetch_top_blocked_clients_without_session_returns_none():
source = pihole_monitor.PiholeSource("primary", "http://ph1/", "pw", True, 5)
assert source.fetch_top_blocked_clients(count=500) is None
# ---------------------------------------------------------------------------
# build_ip_to_mac() - a multi-IP device must not lose its other IPs
# ---------------------------------------------------------------------------
def _entry(mac, ip, last_seen):
return {"mac": mac, "ip": ip, "name": "", "macVendor": "", "lastSeen": last_seen, "is_online": True}
def test_build_ip_to_mac_keeps_every_ip_of_a_multi_ip_device():
entries = [
_entry("aa:bb:cc:dd:ee:01", "10.0.0.5", 100),
_entry("aa:bb:cc:dd:ee:01", "10.0.0.6", 90), # same device, second IP, older lastSeen
]
ip_to_mac = pihole_monitor.build_ip_to_mac(entries)
assert ip_to_mac == {"10.0.0.5": "aa:bb:cc:dd:ee:01", "10.0.0.6": "aa:bb:cc:dd:ee:01"}
def test_build_ip_to_mac_freshest_mac_wins_on_ip_reassignment():
entries = [
_entry("aa:bb:cc:dd:ee:01", "10.0.0.5", 100), # older MAC on this IP
_entry("aa:bb:cc:dd:ee:02", "10.0.0.5", 200), # DHCP reassigned, newer
]
ip_to_mac = pihole_monitor.build_ip_to_mac(entries)
assert ip_to_mac == {"10.0.0.5": "aa:bb:cc:dd:ee:02"}
def test_build_ip_to_mac_differs_from_naive_merged_result():
"""Regression guard for the original bug: deriving the IP map from
merge_device_entries()'s output (one entry per MAC) drops a device's
other IPs. build_ip_to_mac() must not do that."""
entries = [
_entry("aa:bb:cc:dd:ee:01", "10.0.0.5", 100),
_entry("aa:bb:cc:dd:ee:01", "10.0.0.6", 90),
]
merged = pihole_monitor.merge_device_entries(entries)
naive_ip_to_mac = {e["ip"]: mac for mac, e in merged.items()}
assert naive_ip_to_mac == {"10.0.0.5": "aa:bb:cc:dd:ee:01"} # the bug: 10.0.0.6 missing
fixed_ip_to_mac = pihole_monitor.build_ip_to_mac(entries)
assert "10.0.0.6" in fixed_ip_to_mac
# ---------------------------------------------------------------------------
# main() - orchestration, with PiholeSource's network methods stubbed
# ---------------------------------------------------------------------------
def _device_payload(mac, ip, name="dev", vendor="Acme", last_seen=1000):
return {"hwaddr": mac, "macVendor": vendor, "ips": [{"ip": ip, "name": name, "lastSeen": last_seen}]}
class _Settings(dict):
"""get_setting_value side_effect backed by a dict, with the plugin's
own defaults for anything a test doesn't override."""
_DEFAULTS = {
"PIHOLEMON_PRIMARY_VERIFY_SSL": True,
"PIHOLEMON_SECONDARY_VERIFY_SSL": True,
"PIHOLEMON_RUN_TIMEOUT": 5,
"PIHOLEMON_GET_OFFLINE": False,
"PIHOLEMON_FAKE_MAC": False,
"PIHOLEMON_API_MAXCLIENTS": 500,
"PIHOLEMON_CONSIDER_ONLINE": 300,
# False in tests by default (config.json's real default is True) so
# a plain main() test doesn't make a live-looking requests.post call
# nobody asked for - tests that exercise owner lookup opt in and
# mock requests.post themselves.
"PIHOLEMON_GET_OWNER": False,
"GRAPHQL_PORT": 20212,
"API_TOKEN": None,
"PIHOLEMON_MULTIPLIER": 4,
"PIHOLEMON_MIN_BLOCKED": 1,
"PIHOLEMON_HISTORY_DAYS": 7,
"PIHOLEMON_PRIMARY_URL": "http://ph1/",
"PIHOLEMON_PRIMARY_PASSWORD": "pw1",
"PIHOLEMON_SECONDARY_URL": "",
"PIHOLEMON_SECONDARY_PASSWORD": "",
}
def __call__(self, key):
if key in self:
return self[key]
return self._DEFAULTS[key]
@pytest.fixture
def settings():
return _Settings()
@pytest.fixture
def isolated_state(tmp_path, settings):
"""Points STATE_FILE/RESULT_FILE at a scratch dir and wires up
get_setting_value, for every main()-level test."""
with patch.object(pihole_monitor, "STATE_FILE", str(tmp_path / "state.json")), \
patch.object(pihole_monitor, "RESULT_FILE", str(tmp_path / "last_result.log")), \
patch.object(pihole_monitor, "get_setting_value", side_effect=settings), \
patch.object(pihole_monitor.PiholeSource, "auth", return_value=True), \
patch.object(pihole_monitor.PiholeSource, "deauth", return_value=None):
yield tmp_path
def test_main_aggregates_blocked_counts_from_both_sources(isolated_state, settings):
settings["PIHOLEMON_SECONDARY_URL"] = "http://ph2/"
settings["PIHOLEMON_SECONDARY_PASSWORD"] = "pw2"
devices_by_label = {
"primary": [_device_payload("aa:bb:cc:dd:ee:01", "10.0.0.5")],
"secondary": [_device_payload("aa:bb:cc:dd:ee:01", "10.0.0.5")],
}
blocked_by_label = {"primary": {"10.0.0.5": 30}, "secondary": {"10.0.0.5": 15}}
def _fetch_devices(self, max_clients):
return devices_by_label[self.label]
def _fetch_top_blocked(self, count):
return blocked_by_label[self.label]
with patch.object(pihole_monitor.PiholeSource, "fetch_devices", _fetch_devices), \
patch.object(pihole_monitor.PiholeSource, "fetch_top_blocked_clients", _fetch_top_blocked), \
patch.object(pihole_monitor, "Plugin_Objects") as mock_plugin_objects:
# last_raw=0 for both sources so each source's raw count comes
# straight through as its own delta (30 and 15) - this test is
# about summing per-source deltas, not about compute_delta() or
# aggregate_source_deltas()' reset handling (covered separately).
pihole_monitor.save_state({"aa:bb:cc:dd:ee:01": {"last_raw": {"primary": 0, "secondary": 0}, "history": []}})
assert pihole_monitor.main() == 0
instance = mock_plugin_objects.return_value
(call,) = instance.add_object.call_args_list
# Not imported twice (one device-import row) and its blocked counts
# from both instances are summed, not compared/overwritten.
assert call.kwargs["primaryId"] == "aa:bb:cc:dd:ee:01"
assert call.kwargs["watched3"] == "45"
def test_main_stats_complete_false_when_a_source_fetch_fails(isolated_state, settings):
"""A failed top_clients fetch must not write a false zero into a
device's history, and must not evaluate an anomaly this run."""
device = [_device_payload("aa:bb:cc:dd:ee:01", "10.0.0.5")]
with patch.object(pihole_monitor.PiholeSource, "fetch_devices", return_value=device), \
patch.object(pihole_monitor.PiholeSource, "fetch_top_blocked_clients", return_value=None), \
patch.object(pihole_monitor, "Plugin_Objects") as mock_plugin_objects:
# Seed a history (and a last_raw reference point) so a baseline
# exists and would trip the multiplier if (incorrectly) evaluated
# against a written-in zero. Timestamp 1 matches the stubbed
# timeNowUTC's default "now" (see module stub), so nothing is
# trimmed by the day-window here - not what this test is about.
pihole_monitor.save_state({"aa:bb:cc:dd:ee:01": {"last_raw": {"primary": 1000}, "history": [[1, 40], [1, 42], [1, 38]]}})
assert pihole_monitor.main() == 0
instance = mock_plugin_objects.return_value
(call,) = instance.add_object.call_args_list
assert call.kwargs["watched4"] == "normal" # not "anomaly" - stats were incomplete
state_after = pihole_monitor.load_state()
# Untouched, including last_raw - no false delta or reference-point
# update from a run whose data was incomplete.
assert state_after["aa:bb:cc:dd:ee:01"] == {"last_raw": {"primary": 1000}, "history": [[1, 40], [1, 42], [1, 38]]}
def test_main_records_anomaly_when_stats_are_complete(isolated_state, settings):
settings["PIHOLEMON_MULTIPLIER"] = 2
settings["PIHOLEMON_MIN_BLOCKED"] = 5
device = [_device_payload("aa:bb:cc:dd:ee:01", "10.0.0.5")]
with patch.object(pihole_monitor.PiholeSource, "fetch_devices", return_value=device), \
patch.object(pihole_monitor.PiholeSource, "fetch_top_blocked_clients", return_value={"10.0.0.5": 50}), \
patch.object(pihole_monitor, "Plugin_Objects") as mock_plugin_objects:
# last_raw=0 so this run's raw count (50) is also its delta -
# baseline avg 10, 50 >> 2x.
pihole_monitor.save_state({"aa:bb:cc:dd:ee:01": {"last_raw": {"primary": 0}, "history": [[1, 10], [1, 10], [1, 10]]}})
assert pihole_monitor.main() == 0
instance = mock_plugin_objects.return_value
(call,) = instance.add_object.call_args_list
assert call.kwargs["watched4"] == "anomaly"
state_after = pihole_monitor.load_state()
assert state_after["aa:bb:cc:dd:ee:01"] == {"last_raw": {"primary": 50}, "history": [[1, 10], [1, 10], [1, 10], [1, 50]]}
_DAY = 86400
_FIXED_NOW = 2_000_000 # arbitrary fixed epoch, for deterministic age-based trimming
def _fixed_now_mock():
now = MagicMock()
now.timestamp.return_value = _FIXED_NOW
return now
@pytest.mark.parametrize(
("configured_days", "expected_history"),
[
# Seed has samples aged 10, 3, and 1 days; a new one lands at age 0.
(-5, [[_FIXED_NOW - 1 * _DAY, 10], [_FIXED_NOW, 40]]), # negative - clamps to 1 day, only the freshest old sample survives
(0, [[_FIXED_NOW - 3 * _DAY, 20], [_FIXED_NOW - 1 * _DAY, 10], [_FIXED_NOW, 40]]), # falsy - falls back to 7 via `or`, drops only the 10-day-old sample
(1, [[_FIXED_NOW - 1 * _DAY, 10], [_FIXED_NOW, 40]]), # explicit 1 - same cutoff as the clamped negative case
(7, [[_FIXED_NOW - 3 * _DAY, 20], [_FIXED_NOW - 1 * _DAY, 10], [_FIXED_NOW, 40]]), # the documented default - same as the 0/fallback case
(15, [[_FIXED_NOW - 10 * _DAY, 30], [_FIXED_NOW - 3 * _DAY, 20], [_FIXED_NOW - 1 * _DAY, 10], [_FIXED_NOW, 40]]), # wide enough - nothing trimmed
],
)
def test_main_history_days_clamps_and_trims_by_age(isolated_state, settings, configured_days, expected_history):
"""Distinct, ordered seed values at distinct known ages (not len() alone)
so a wrong cutoff - e.g. a 1-day clamp that actually kept the 3-day-old
sample too, which a bare len() check would miss - shows up as a
mismatch. Also guards against day/second unit mixups (a classic
`history_days` vs `history_days * 86400` bug) since the exact surviving
ages are asserted, not just a count."""
settings["PIHOLEMON_HISTORY_DAYS"] = configured_days
device = [_device_payload("aa:bb:cc:dd:ee:01", "10.0.0.5")]
# last_raw=0 so this run's raw count (40) is also its delta - this test
# is about the day-based trim/clamp, not about compute_delta() itself.
seed = {"aa:bb:cc:dd:ee:01": {"last_raw": {"primary": 0}, "history": [
[_FIXED_NOW - 10 * _DAY, 30],
[_FIXED_NOW - 3 * _DAY, 20],
[_FIXED_NOW - 1 * _DAY, 10],
]}}
with patch.object(pihole_monitor, "timeNowUTC", return_value=_fixed_now_mock()), \
patch.object(pihole_monitor.PiholeSource, "fetch_devices", return_value=device), \
patch.object(pihole_monitor.PiholeSource, "fetch_top_blocked_clients", return_value={"10.0.0.5": 40}), \
patch.object(pihole_monitor, "Plugin_Objects"):
pihole_monitor.save_state(seed)
assert pihole_monitor.main() == 0
history = pihole_monitor.load_state()["aa:bb:cc:dd:ee:01"]["history"]
assert history == expected_history
def test_main_history_days_baseline_uses_only_samples_inside_the_window():
"""The day-window must also gate the baseline itself, not just what
gets persisted - an old, out-of-window sample must not silently drag
the average up or down."""
stale = [_FIXED_NOW - 30 * _DAY, 1000] # far outside any sane window
fresh = [_FIXED_NOW - 1 * _DAY, 10]
history = pihole_monitor.trim_history([stale, fresh], _FIXED_NOW, history_days=7)
assert history == [fresh] # the stale, high-value sample must be gone
# ---------------------------------------------------------------------------
# compute_delta() - Pi-hole's raw cumulative-since-FTL-started count turned
# into a real per-run increment (see its docstring for why this matters).
# ---------------------------------------------------------------------------
def test_compute_delta_none_when_never_seen_before():
assert pihole_monitor.compute_delta(None, 500) is None
def test_compute_delta_none_when_counter_went_backwards():
"""Pi-hole/FTL restarted (or the device dropped out of top_clients) -
current_raw < last_raw must not produce a negative delta."""
assert pihole_monitor.compute_delta(1000, 5) is None
def test_compute_delta_returns_the_real_increment():
assert pihole_monitor.compute_delta(100, 150) == 50
def test_compute_delta_zero_is_a_real_value_not_none():
"""No new blocked queries since last run is a genuine 0, distinct from
None ('we can't tell this run') - a caller conflating them would either
silently drop a legitimate quiet period or treat it as untrustworthy."""
delta = pihole_monitor.compute_delta(100, 100)
assert delta == 0
assert delta is not None
# ---------------------------------------------------------------------------
# aggregate_source_deltas() - per-source deltas summed independently, so one
# source's counter reset can't net out against real traffic on another.
# ---------------------------------------------------------------------------
def test_aggregate_source_deltas_sums_valid_deltas_from_every_source():
delta, updated = pihole_monitor.aggregate_source_deltas(
{"primary": 100, "secondary": 200},
{"primary": 150, "secondary": 250},
)
assert delta == 100 # 50 + 50
assert updated == {"primary": 150, "secondary": 250}
def test_aggregate_source_deltas_reset_source_does_not_mask_the_others_spike():
"""Regression guard for the exact bug CodeRabbit flagged: combining raw
totals across sources before diffing would let a reset on one source
net against real growth on another (primary +2000, secondary resetting
1000->5 would combine into a raw delta of only 1005). Diffing each
source first and summing only the valid deltas must instead surface
the primary's full 2000, with the secondary contributing nothing this
run (not a corrective -995)."""
delta, updated = pihole_monitor.aggregate_source_deltas(
{"primary": 1000, "secondary": 1000},
{"primary": 3000, "secondary": 5}, # secondary: FTL restarted, counter reset
)
assert delta == 2000 # primary's real delta only, not 3000-1000+5-1000=1005
assert updated == {"primary": 3000, "secondary": 5} # both re-anchored regardless
def test_aggregate_source_deltas_none_when_every_source_is_invalid():
delta, updated = pihole_monitor.aggregate_source_deltas(
{}, # nothing seen before - every source is bootstrapping
{"primary": 100, "secondary": 200},
)
assert delta is None
assert updated == {"primary": 100, "secondary": 200}
def test_aggregate_source_deltas_source_absent_this_run_keeps_its_old_last_raw():
"""A source that authenticated last run but not this one (or whose
fetch failed) shouldn't have its reference point touched - only
sources actually present in raw_by_source are updated."""
delta, updated = pihole_monitor.aggregate_source_deltas(
{"primary": 100, "secondary": 200},
{"primary": 150}, # secondary absent this run
)
assert delta == 50 # primary only
assert updated == {"primary": 150, "secondary": 200} # secondary untouched
def test_main_dual_source_reset_does_not_mask_the_others_spike(isolated_state, settings):
"""Integration-level version of the same regression: a real spike on
the primary instance must not be diluted by a simultaneous counter
reset on the secondary, when both instances report the same device."""
settings["PIHOLEMON_SECONDARY_URL"] = "http://ph2/"
settings["PIHOLEMON_SECONDARY_PASSWORD"] = "pw2"
settings["PIHOLEMON_MULTIPLIER"] = 2
settings["PIHOLEMON_MIN_BLOCKED"] = 100
devices_by_label = {
"primary": [_device_payload("aa:bb:cc:dd:ee:01", "10.0.0.5")],
"secondary": [_device_payload("aa:bb:cc:dd:ee:01", "10.0.0.5")],
}
# primary: real spike (1000 -> 3000). secondary: FTL restarted (1000 -> 5).
blocked_by_label = {"primary": {"10.0.0.5": 3000}, "secondary": {"10.0.0.5": 5}}
def _fetch_devices(self, max_clients):
return devices_by_label[self.label]
def _fetch_top_blocked(self, count):
return blocked_by_label[self.label]
with patch.object(pihole_monitor.PiholeSource, "fetch_devices", _fetch_devices), \
patch.object(pihole_monitor.PiholeSource, "fetch_top_blocked_clients", _fetch_top_blocked), \
patch.object(pihole_monitor, "Plugin_Objects") as mock_plugin_objects:
pihole_monitor.save_state({"aa:bb:cc:dd:ee:01": {
"last_raw": {"primary": 1000, "secondary": 1000},
"history": [[1, 50], [1, 50]], # baseline avg 50
}})
assert pihole_monitor.main() == 0
instance = mock_plugin_objects.return_value
(call,) = instance.add_object.call_args_list
# The real signal (2000), not the raw-combined-first result (1005).
assert call.kwargs["watched3"] == "2000"
assert call.kwargs["watched4"] == "anomaly"
state_after = pihole_monitor.load_state()["aa:bb:cc:dd:ee:01"]
assert state_after["last_raw"] == {"primary": 3000, "secondary": 5}
assert state_after["history"][-1] == [1, 2000]
def test_main_tolerates_pre_per_source_state_instead_of_crashing(isolated_state, settings):
"""Before this round, last_raw was a single number, not a per-source
dict. A state file saved by that older version must not crash this
version - it's treated the same as no prior reference point (every
source bootstraps fresh this run) rather than raising."""
device = [_device_payload("aa:bb:cc:dd:ee:01", "10.0.0.5")]
with patch.object(pihole_monitor.PiholeSource, "fetch_devices", return_value=device), \
patch.object(pihole_monitor.PiholeSource, "fetch_top_blocked_clients", return_value={"10.0.0.5": 500}), \
patch.object(pihole_monitor, "Plugin_Objects") as mock_plugin_objects:
# Legacy shape: last_raw is a plain int, not {"primary": ...}.
pihole_monitor.save_state({"aa:bb:cc:dd:ee:01": {"last_raw": 1234, "history": [[1, 10], [1, 10]]}})
assert pihole_monitor.main() == 0 # must not raise
instance = mock_plugin_objects.return_value
(call,) = instance.add_object.call_args_list
assert call.kwargs["watched4"] == "normal" # bootstrapping again, not an anomaly
state_after = pihole_monitor.load_state()["aa:bb:cc:dd:ee:01"]
assert state_after["last_raw"] == {"primary": 500} # re-anchored in the new shape
assert state_after["history"] == [[1, 10], [1, 10]] # old baseline history untouched
def test_main_bootstrap_run_sets_last_raw_without_recording_a_delta(isolated_state, settings):
"""The first time a device is ever seen, there's no prior raw count to
diff against - this run must establish the reference point (last_raw)
for the next run, without fabricating a delta or evaluating an anomaly
off one."""
device = [_device_payload("aa:bb:cc:dd:ee:01", "10.0.0.5")]
with patch.object(pihole_monitor.PiholeSource, "fetch_devices", return_value=device), \
patch.object(pihole_monitor.PiholeSource, "fetch_top_blocked_clients", return_value={"10.0.0.5": 5000}), \
patch.object(pihole_monitor, "Plugin_Objects") as mock_plugin_objects:
assert pihole_monitor.main() == 0 # no prior save_state() call - genuinely first-ever run
instance = mock_plugin_objects.return_value
(call,) = instance.add_object.call_args_list
assert call.kwargs["watched4"] == "normal" # never an anomaly on a bootstrap run
assert "unknown" in call.kwargs["extra"]
state_after = pihole_monitor.load_state()
assert state_after["aa:bb:cc:dd:ee:01"] == {"last_raw": {"primary": 5000}, "history": []}
def test_main_counter_reset_updates_last_raw_without_touching_history(isolated_state, settings):
"""A Pi-hole/FTL restart resets the raw counter, so this run's raw value
can come back lower than what was last seen. That must reset the
reference point for future deltas, but not corrupt the existing
baseline history with a bogus negative or wrap-around delta."""
device = [_device_payload("aa:bb:cc:dd:ee:01", "10.0.0.5")]
with patch.object(pihole_monitor.PiholeSource, "fetch_devices", return_value=device), \
patch.object(pihole_monitor.PiholeSource, "fetch_top_blocked_clients", return_value={"10.0.0.5": 5}), \
patch.object(pihole_monitor, "Plugin_Objects") as mock_plugin_objects:
pihole_monitor.save_state({"aa:bb:cc:dd:ee:01": {"last_raw": {"primary": 1000}, "history": [[1, 10], [1, 10]]}})
assert pihole_monitor.main() == 0
instance = mock_plugin_objects.return_value
(call,) = instance.add_object.call_args_list
assert call.kwargs["watched4"] == "normal"
state_after = pihole_monitor.load_state()
# last_raw re-anchored to the post-restart value; the pre-restart
# baseline history is preserved exactly, not wiped or corrupted.
assert state_after["aa:bb:cc:dd:ee:01"] == {"last_raw": {"primary": 5}, "history": [[1, 10], [1, 10]]}
def test_main_returns_1_when_no_source_is_configured(isolated_state, settings):
settings["PIHOLEMON_PRIMARY_URL"] = ""
settings["PIHOLEMON_PRIMARY_PASSWORD"] = ""
assert pihole_monitor.main() == 1
def test_main_marks_stats_incomplete_when_a_source_fails_to_authenticate(tmp_path, settings):
"""Also exercises the CONSIDER_ONLINE fallback (a non-int setting falls
back to 300) alongside the per-source auth-failure branch, which needs
per-label auth behavior rather than the isolated_state fixture's
blanket auth=True."""
settings["PIHOLEMON_SECONDARY_URL"] = "http://ph2/"
settings["PIHOLEMON_SECONDARY_PASSWORD"] = "badpw"
settings["PIHOLEMON_CONSIDER_ONLINE"] = "not-a-number"
def _auth(self):
return self.label == "primary" # secondary fails to authenticate
device = [_device_payload("aa:bb:cc:dd:ee:01", "10.0.0.5")]
with patch.object(pihole_monitor, "STATE_FILE", str(tmp_path / "state.json")), \
patch.object(pihole_monitor, "RESULT_FILE", str(tmp_path / "last_result.log")), \
patch.object(pihole_monitor, "get_setting_value", side_effect=settings), \
patch.object(pihole_monitor.PiholeSource, "auth", _auth), \
patch.object(pihole_monitor.PiholeSource, "deauth", return_value=None), \
patch.object(pihole_monitor.PiholeSource, "fetch_devices", return_value=device), \
patch.object(pihole_monitor.PiholeSource, "fetch_top_blocked_clients", return_value={"10.0.0.5": 999}), \
patch.object(pihole_monitor, "Plugin_Objects") as mock_plugin_objects:
pihole_monitor.save_state({"aa:bb:cc:dd:ee:01": {"last_raw": {"primary": 5}, "history": [[1, 1], [1, 1], [1, 1]]}})
assert pihole_monitor.main() == 0
instance = mock_plugin_objects.return_value
(call,) = instance.add_object.call_args_list
assert call.kwargs["watched4"] == "normal" # secondary's auth failure marks stats incomplete
# Untouched, including last_raw - state.
assert pihole_monitor.load_state()["aa:bb:cc:dd:ee:01"] == {"last_raw": {"primary": 5}, "history": [[1, 1], [1, 1], [1, 1]]}
def test_main_links_offline_device_resolves_owner_skips_invalid_mac_and_tracks_unknown_ip(isolated_state, settings):
"""One run covering four branches at once: an offline device still
gets its blocked traffic linked to its real MAC (not a bare IP), an
online device gets its devOwner resolved via GraphQL, a device with an
invalid hardware address is skipped entirely, and blocked traffic on
an IP no device was ever seen on falls back to being tracked under
that bare IP."""
settings["PIHOLEMON_GET_OWNER"] = True
settings["API_TOKEN"] = "tok"
now = MagicMock()
now.timestamp.return_value = 2_000_000
devices = [
{"hwaddr": "aa:bb:cc:dd:ee:01", "macVendor": "Acme",
"ips": [{"ip": "10.0.0.1", "name": "online-dev", "lastSeen": 2_000_000 - 10}]}, # online
{"hwaddr": "aa:bb:cc:dd:ee:02", "macVendor": "Acme",
"ips": [{"ip": "10.0.0.2", "name": "offline-dev", "lastSeen": 2_000_000 - 10_000}]}, # offline
{"hwaddr": "not-a-real-mac", "macVendor": "Acme",
"ips": [{"ip": "10.0.0.3", "name": "bad-mac-dev", "lastSeen": 2_000_000 - 10}]}, # invalid MAC
]
blocked = {"10.0.0.1": 5, "10.0.0.2": 5, "10.0.0.99": 5} # .99: never any device's IP
owner_resp = _resp({"data": {"devices": {"devices": [
{"devMac": "aa:bb:cc:dd:ee:01", "devOwner": "Mauricio"}
]}}})
with patch.object(pihole_monitor, "timeNowUTC", return_value=now), \
patch.object(pihole_monitor.PiholeSource, "fetch_devices", return_value=devices), \
patch.object(pihole_monitor.PiholeSource, "fetch_top_blocked_clients", return_value=blocked), \
patch("requests.post", return_value=owner_resp), \
patch.object(pihole_monitor, "Plugin_Objects") as mock_plugin_objects:
assert pihole_monitor.main() == 0
instance = mock_plugin_objects.return_value
calls_by_primary_id = {c.kwargs["primaryId"]: c for c in instance.add_object.call_args_list}
assert "owner: Mauricio" in calls_by_primary_id["aa:bb:cc:dd:ee:01"].kwargs["extra"]
assert calls_by_primary_id["aa:bb:cc:dd:ee:02"].kwargs["foreignKey"] == "aa:bb:cc:dd:ee:02"
assert "not-a-real-mac" not in calls_by_primary_id
assert calls_by_primary_id["10.0.0.99"].kwargs["foreignKey"] == "null"
def test_main_flags_anomaly_against_an_all_zero_baseline(isolated_state, settings):
"""Regression guard: baseline == 0.0 is falsy in Python, so a naive
`bool(... and baseline and ...)` check would silently exempt a device
with a real, all-zero history - exactly the device most worth flagging
the first time it blocks anything at all."""
settings["PIHOLEMON_MULTIPLIER"] = 4
settings["PIHOLEMON_MIN_BLOCKED"] = 1
device = [_device_payload("aa:bb:cc:dd:ee:01", "10.0.0.5")]
with patch.object(pihole_monitor.PiholeSource, "fetch_devices", return_value=device), \
patch.object(pihole_monitor.PiholeSource, "fetch_top_blocked_clients", return_value={"10.0.0.5": 5}), \
patch.object(pihole_monitor, "Plugin_Objects") as mock_plugin_objects:
# last_raw=0 so this run's raw count (5) is also its delta.
pihole_monitor.save_state({"aa:bb:cc:dd:ee:01": {"last_raw": {"primary": 0}, "history": [[1, 0], [1, 0], [1, 0]]}}) # genuinely never blocked before
assert pihole_monitor.main() == 0
instance = mock_plugin_objects.return_value
(call,) = instance.add_object.call_args_list
assert call.kwargs["watched4"] == "anomaly"
assert "avg=0.0" in call.kwargs["extra"]
assert "ratio=" not in call.kwargs["extra"] # dividing by a zero baseline is skipped, not attempted
def test_main_applies_independent_verify_ssl_per_instance(isolated_state, settings):
"""PIHOLEMON_PRIMARY_VERIFY_SSL and PIHOLEMON_SECONDARY_VERIFY_SSL must
reach each instance independently - a self-signed secondary shouldn't
force verification off (or on) for the primary too."""
settings["PIHOLEMON_SECONDARY_URL"] = "http://ph2/"
settings["PIHOLEMON_SECONDARY_PASSWORD"] = "pw2"
settings["PIHOLEMON_PRIMARY_VERIFY_SSL"] = True
settings["PIHOLEMON_SECONDARY_VERIFY_SSL"] = False
seen_verify_ssl = {}
def _auth(self):
seen_verify_ssl[self.label] = self.verify_ssl
return True
with patch.object(pihole_monitor.PiholeSource, "auth", _auth), \
patch.object(pihole_monitor.PiholeSource, "fetch_devices", return_value=[]), \
patch.object(pihole_monitor.PiholeSource, "fetch_top_blocked_clients", return_value={}), \
patch.object(pihole_monitor, "Plugin_Objects"):
assert pihole_monitor.main() == 0
assert seen_verify_ssl == {"primary": True, "secondary": False}