e49ca1ace5
New tests for net_alerter iOS departure detection: - test_update_last_seen_updates_dict: verifies _update_last_seen dict updates - test_personal_watchdog_fires_on_departure_after_timeout: timeout detection - test_personal_watchdog_does_not_fire_within_timeout: no false positives - test_mdns_name_matching_adds_mac_to_personal_devices: mDNS name discovery - test_load_personal_macs_from_env_does_not_log_individual_macs: privacy logging All 21 tests passing (16 existing + 5 new). Updated cleanup fixture to include last_seen, personal_devices, personal_names.
739 lines
25 KiB
Python
739 lines
25 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Test suite for net_alerter.py — alert formatting fixes and false positive suppression.
|
|
"""
|
|
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import patch, MagicMock
|
|
import pytest
|
|
|
|
# Add parent dir to path so we can import net_alerter
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
|
|
import net_alerter
|
|
|
|
|
|
|
|
def cleanup_flap_state():
|
|
"""Reset flap detection state between tests."""
|
|
net_alerter._flap_window.clear()
|
|
net_alerter._interface_recovering = False
|
|
if net_alerter._recovery_timer:
|
|
net_alerter._recovery_timer.cancel()
|
|
net_alerter._recovery_timer = None
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def cleanup_after_each_test():
|
|
"""Cleanup global state after each test."""
|
|
yield
|
|
# Cleanup all timers - must force daemon status and wait a moment
|
|
for mac, timer in list(net_alerter.departure_timers.items()):
|
|
if timer.is_alive():
|
|
timer.cancel()
|
|
# Wait a tiny bit for timer thread to notice cancellation
|
|
time.sleep(0.01)
|
|
net_alerter.departure_timers.clear()
|
|
net_alerter.known_devices.clear()
|
|
net_alerter.last_departed_time.clear()
|
|
net_alerter._churn_tracker.clear()
|
|
net_alerter.last_seen.clear()
|
|
net_alerter.personal_devices.clear()
|
|
net_alerter.personal_names.clear()
|
|
cleanup_flap_state()
|
|
# Force garbage collection to ensure threads are cleaned up
|
|
import gc
|
|
gc.collect()
|
|
|
|
def test_hostname_dedup_when_hostname_equals_ip():
|
|
"""Test that hostname is not repeated when DNS fails (hostname == IP)."""
|
|
# Simulate a device where hostname lookup returned the IP address itself
|
|
mac = "88:a2:9e:8e:f2:90"
|
|
ip = "10.0.0.0"
|
|
hostname = ip # This is what happens when reverse DNS fails
|
|
|
|
# Format the alert using internal logic
|
|
msg = net_alerter.format_arrival(hostname, ip, "Apple", mac)
|
|
|
|
# Should show IP only once
|
|
assert msg == "[NET] ARRIVED: 10.0.0.0 [Apple] MAC:88:a2:9e:8e:f2:90"
|
|
assert msg.count("10.0.0.0") == 1, "IP should appear exactly once"
|
|
|
|
|
|
def test_hostname_shown_when_different_from_ip():
|
|
"""Test that hostname is shown separately when it differs from IP."""
|
|
cleanup_flap_state()
|
|
mac = "88:a2:9e:8e:f2:90"
|
|
ip = "10.0.0.0"
|
|
hostname = "mydevice.local"
|
|
|
|
# Format the alert
|
|
msg = net_alerter.format_arrival(hostname, ip, "Apple", mac)
|
|
|
|
# Should show both hostname and IP
|
|
assert msg == "[NET] ARRIVED: mydevice.local (10.0.0.0) [Apple] MAC:88:a2:9e:8e:f2:90"
|
|
assert "mydevice.local" in msg
|
|
assert "10.0.0.0" in msg
|
|
|
|
|
|
def test_oui_lookup_with_inline_dict():
|
|
"""Test that OUI lookup returns vendor for known MACs."""
|
|
cleanup_flap_state()
|
|
# These are real OUI prefixes
|
|
test_cases = [
|
|
("88:a2:9e", "Apple"), # Apple
|
|
("00:1a:7d", "Apple"), # Apple
|
|
("18:5f:3f", "Apple"), # Apple
|
|
("a4:c3:f0", "Apple"), # Apple
|
|
("28:87:ba", "Google"), # Google
|
|
("54:27:58", "Google"), # Google
|
|
("34:15:13", "Amazon"), # Amazon
|
|
("b8:27:eb", "Raspberry Pi"), # Raspberry Pi
|
|
]
|
|
|
|
for mac_prefix, expected_vendor in test_cases:
|
|
vendor = net_alerter.lookup_oui(mac_prefix + ":00:00:00")
|
|
assert vendor == expected_vendor, f"Expected {expected_vendor} for {mac_prefix}, got {vendor}"
|
|
|
|
|
|
def test_oui_lookup_unknown_vendor():
|
|
"""Test that unknown OUI returns 'unknown'."""
|
|
cleanup_flap_state()
|
|
vendor = net_alerter.lookup_oui("aa:bb:cc:dd:ee:ff")
|
|
assert vendor == "unknown", f"Expected 'unknown' for unknown OUI, got {vendor}"
|
|
|
|
|
|
def test_departure_format_dedup():
|
|
"""Test that departure message also deduplicates hostname and IP."""
|
|
cleanup_flap_state()
|
|
mac = "88:a2:9e:8e:f2:90"
|
|
ip = "10.0.0.0"
|
|
hostname = ip
|
|
duration = "5m 30s"
|
|
|
|
msg = net_alerter.format_departure(hostname, ip, "Apple", mac, duration)
|
|
assert msg == "[NET] DEPARTED: 10.0.0.0 [Apple] MAC:88:a2:9e:8e:f2:90 — present 5m 30s"
|
|
assert msg.count("10.0.0.0") == 1
|
|
|
|
|
|
def test_infrastructure_ips_never_alert():
|
|
"""Test that infrastructure IPs (gateway, broadcast, self) don't trigger alerts."""
|
|
cleanup_flap_state()
|
|
net_alerter.known_devices.clear()
|
|
net_alerter.infrastructure_ips.add("10.0.0.0") # gateway
|
|
|
|
alert_calls = []
|
|
original_send_alert = net_alerter.send_alert
|
|
net_alerter.send_alert = lambda msg: alert_calls.append(msg)
|
|
|
|
try:
|
|
mac = "aa:bb:cc:dd:ee:ff"
|
|
ip = "10.0.0.0"
|
|
|
|
net_alerter.on_arrival(mac, ip, "gateway")
|
|
|
|
assert len(alert_calls) == 0, f"Infrastructure IP should not alert, but got: {alert_calls}"
|
|
finally:
|
|
net_alerter.send_alert = original_send_alert
|
|
|
|
|
|
def test_departure_debounce_15min():
|
|
"""Test that departure alerts are debounced for 15 minutes."""
|
|
cleanup_flap_state()
|
|
net_alerter.known_devices.clear()
|
|
net_alerter.departure_timers.clear()
|
|
|
|
alert_calls = []
|
|
original_send_alert = net_alerter.send_alert
|
|
net_alerter.send_alert = lambda msg: alert_calls.append(msg)
|
|
|
|
try:
|
|
mac = "aa:bb:cc:dd:ee:ff"
|
|
|
|
net_alerter.known_devices[mac] = {
|
|
"ip": "10.0.0.0",
|
|
"hostname": "testhost",
|
|
"vendor": "Test",
|
|
"first_seen": time.time(),
|
|
"last_seen": time.time()
|
|
}
|
|
|
|
net_alerter.on_departure(mac)
|
|
|
|
assert len(alert_calls) == 0, f"Departure should be debounced, but got alert: {alert_calls}"
|
|
|
|
assert mac in net_alerter.departure_timers, "Departure timer should be created"
|
|
|
|
net_alerter.departure_timers[mac].cancel()
|
|
del net_alerter.departure_timers[mac]
|
|
finally:
|
|
net_alerter.send_alert = original_send_alert
|
|
|
|
|
|
def test_re_arrival_within_5min_suppresses_alert():
|
|
"""Test that device re-arrival within 5 min of departure suppresses ARRIVED alert."""
|
|
cleanup_flap_state()
|
|
net_alerter.known_devices.clear()
|
|
net_alerter.departure_timers.clear()
|
|
net_alerter.last_departed_time.clear()
|
|
|
|
alert_calls = []
|
|
original_send_alert = net_alerter.send_alert
|
|
net_alerter.send_alert = lambda msg: alert_calls.append(msg)
|
|
|
|
try:
|
|
mac = "aa:bb:cc:dd:ee:ff"
|
|
ip = "10.0.0.0"
|
|
|
|
net_alerter.known_devices[mac] = {
|
|
"ip": ip,
|
|
"hostname": "testhost",
|
|
"vendor": "Test",
|
|
"first_seen": time.time(),
|
|
"last_seen": time.time()
|
|
}
|
|
|
|
net_alerter.on_departure(mac)
|
|
net_alerter.last_departed_time[mac] = time.time()
|
|
net_alerter.on_arrival(mac, ip, "testhost")
|
|
|
|
# Check that ARRIVED alerts are suppressed (occupancy alerts are allowed)
|
|
arrived_alerts = [a for a in alert_calls if "ARRIVED" in a]
|
|
assert len(arrived_alerts) == 0, f"Re-arrival within 5 min should be suppressed, but got: {arrived_alerts}"
|
|
|
|
if mac in net_alerter.departure_timers:
|
|
net_alerter.departure_timers[mac].cancel()
|
|
finally:
|
|
net_alerter.send_alert = original_send_alert
|
|
|
|
|
|
def test_dhcp_renewal_dedup_30min():
|
|
"""Test that DHCP renewal from known device (< 30 min old) doesn't send ARRIVED alert."""
|
|
cleanup_flap_state()
|
|
net_alerter.known_devices.clear()
|
|
net_alerter.departure_timers.clear()
|
|
|
|
alert_calls = []
|
|
original_send_alert = net_alerter.send_alert
|
|
net_alerter.send_alert = lambda msg: alert_calls.append(msg)
|
|
|
|
try:
|
|
mac = "aa:bb:cc:dd:ee:ff"
|
|
ip = "10.0.0.0"
|
|
|
|
now = time.time()
|
|
net_alerter.known_devices[mac] = {
|
|
"ip": ip,
|
|
"hostname": "testhost",
|
|
"vendor": "Test",
|
|
"first_seen": now - 600,
|
|
"last_seen": now - 100,
|
|
}
|
|
|
|
net_alerter.on_arrival(mac, ip, "testhost")
|
|
|
|
# Check that ARRIVED alerts are suppressed (occupancy alerts are allowed)
|
|
arrived_alerts = [a for a in alert_calls if "ARRIVED" in a]
|
|
assert len(arrived_alerts) == 0, f"DHCP renewal < 30 min should be deduped, but got: {arrived_alerts}"
|
|
finally:
|
|
net_alerter.send_alert = original_send_alert
|
|
for timer in list(net_alerter.departure_timers.values()):
|
|
timer.cancel()
|
|
net_alerter.departure_timers.clear()
|
|
|
|
|
|
def test_re_arrival_cancels_departure_timer():
|
|
"""Regression test: device departs → timer starts → device re-arrives BEFORE timer fires."""
|
|
cleanup_flap_state()
|
|
net_alerter.known_devices.clear()
|
|
net_alerter.departure_timers.clear()
|
|
net_alerter.last_departed_time.clear()
|
|
|
|
alert_calls = []
|
|
original_send_alert = net_alerter.send_alert
|
|
net_alerter.send_alert = lambda msg: alert_calls.append(msg)
|
|
|
|
try:
|
|
mac = "aa:bb:cc:dd:ee:ff"
|
|
ip = "10.0.0.0"
|
|
|
|
now = time.time()
|
|
net_alerter.known_devices[mac] = {
|
|
"ip": ip,
|
|
"hostname": "testhost",
|
|
"vendor": "Test",
|
|
"first_seen": now,
|
|
"last_seen": now
|
|
}
|
|
|
|
net_alerter.on_departure(mac)
|
|
|
|
assert mac in net_alerter.departure_timers, "Departure timer should be created"
|
|
timer_ref = net_alerter.departure_timers[mac]
|
|
|
|
net_alerter.on_arrival(mac, ip, "testhost")
|
|
|
|
assert mac not in net_alerter.departure_timers or not timer_ref.is_alive(), \
|
|
"Departure timer should be cancelled after re-arrival"
|
|
|
|
departure_alerts = [a for a in alert_calls if "DEPARTED" in a]
|
|
assert len(departure_alerts) == 0, \
|
|
f"No departure alert should be sent when device re-arrives before timer expires, got: {departure_alerts}"
|
|
|
|
finally:
|
|
for timer in net_alerter.departure_timers.values():
|
|
timer.cancel()
|
|
net_alerter.departure_timers.clear()
|
|
net_alerter.send_alert = original_send_alert
|
|
|
|
|
|
def test_rapid_flap_no_duplicate_arrived_alerts():
|
|
"""Test rapid RTM_NEWNEIGH events don't generate duplicate ARRIVED alerts."""
|
|
cleanup_flap_state()
|
|
net_alerter.known_devices.clear()
|
|
net_alerter.departure_timers.clear()
|
|
net_alerter.last_departed_time.clear()
|
|
|
|
alert_calls = []
|
|
original_send_alert = net_alerter.send_alert
|
|
net_alerter.send_alert = lambda msg: alert_calls.append(msg)
|
|
|
|
try:
|
|
mac = "aa:bb:cc:dd:ee:ff"
|
|
ip = "10.0.0.0"
|
|
|
|
now = time.time()
|
|
net_alerter.known_devices[mac] = {
|
|
"ip": ip,
|
|
"hostname": "testhost",
|
|
"vendor": "Test",
|
|
"first_seen": now,
|
|
"last_seen": now
|
|
}
|
|
|
|
alert_calls.clear()
|
|
|
|
net_alerter.on_departure(mac)
|
|
|
|
assert len([a for a in alert_calls if "DEPARTED" in a]) == 0
|
|
|
|
for i in range(5):
|
|
net_alerter.on_arrival(mac, ip, "testhost")
|
|
|
|
arrived_alerts = [a for a in alert_calls if "ARRIVED" in a]
|
|
assert len(arrived_alerts) == 0, \
|
|
f"Rapid re-arrivals should not generate duplicate ARRIVED alerts, but got {len(arrived_alerts)}: {arrived_alerts}"
|
|
|
|
finally:
|
|
for timer in net_alerter.departure_timers.values():
|
|
timer.cancel()
|
|
net_alerter.departure_timers.clear()
|
|
net_alerter.send_alert = original_send_alert
|
|
|
|
|
|
def test_personal_device_oui_detection():
|
|
"""Test that Apple/Samsung/etc. OUI prefixes are detected as personal devices."""
|
|
cleanup_flap_state()
|
|
net_alerter.personal_devices.clear()
|
|
|
|
test_cases = [
|
|
("88:a2:9e:ff:ff:ff", True),
|
|
("28:87:6d:ff:ff:ff", True),
|
|
("00:16:6b:ff:ff:ff", True),
|
|
("aa:bb:cc:dd:ee:ff", False),
|
|
]
|
|
|
|
for mac, expected_is_personal in test_cases:
|
|
result = net_alerter.is_personal_device(mac)
|
|
assert result == expected_is_personal, f"Expected {expected_is_personal} for {mac}, got {result}"
|
|
|
|
|
|
def test_manual_personal_mac_config():
|
|
"""Test that NET_ALERTER_PERSONAL_MACS env var is parsed and devices are detected."""
|
|
cleanup_flap_state()
|
|
net_alerter.personal_devices.clear()
|
|
|
|
import os
|
|
original_env = os.environ.get("NET_ALERTER_PERSONAL_MACS")
|
|
try:
|
|
os.environ["NET_ALERTER_PERSONAL_MACS"] = "AA:BB:CC:DD:EE:FF, 11:22:33:44:55:66"
|
|
net_alerter.load_personal_macs_from_env()
|
|
|
|
assert "AABBCCDDEEFF" in net_alerter.personal_devices
|
|
assert "112233445566" in net_alerter.personal_devices
|
|
|
|
assert net_alerter.is_personal_device("aa:bb:cc:dd:ee:ff")
|
|
assert net_alerter.is_personal_device("11:22:33:44:55:66")
|
|
finally:
|
|
net_alerter.personal_devices.clear()
|
|
if original_env is not None:
|
|
os.environ["NET_ALERTER_PERSONAL_MACS"] = original_env
|
|
else:
|
|
os.environ.pop("NET_ALERTER_PERSONAL_MACS", None)
|
|
|
|
|
|
|
|
def test_occupancy_vacant_to_occupied():
|
|
"""Test that VACANT → OCCUPIED transition fires occupancy alert on personal device arrival."""
|
|
cleanup_flap_state()
|
|
net_alerter.known_devices.clear()
|
|
net_alerter.personal_devices.clear()
|
|
net_alerter.location_occupancy = "UNKNOWN"
|
|
|
|
alert_calls = []
|
|
original_send_alert = net_alerter.send_alert
|
|
net_alerter.send_alert = lambda msg: alert_calls.append(msg)
|
|
|
|
try:
|
|
net_alerter.location_occupancy = "VACANT"
|
|
|
|
mac = "88:a2:9e:ff:ff:ff"
|
|
ip = "10.0.0.0"
|
|
net_alerter.on_arrival(mac, ip, "myphone")
|
|
|
|
occupancy_alerts = [a for a in alert_calls if "Location:" in a]
|
|
assert any("OCCUPIED" in a for a in occupancy_alerts), \
|
|
f"Expected OCCUPIED alert on personal device arrival, got: {alert_calls}"
|
|
assert net_alerter.location_occupancy == "OCCUPIED"
|
|
finally:
|
|
net_alerter.send_alert = original_send_alert
|
|
|
|
|
|
def test_occupancy_occupied_to_vacant():
|
|
"""Test that OCCUPIED → VACANT transition fires vacancy alert when last personal device departs."""
|
|
cleanup_flap_state()
|
|
net_alerter.known_devices.clear()
|
|
net_alerter.personal_devices.clear()
|
|
net_alerter.departure_timers.clear()
|
|
net_alerter.location_occupancy = "UNKNOWN"
|
|
|
|
alert_calls = []
|
|
original_send_alert = net_alerter.send_alert
|
|
net_alerter.send_alert = lambda msg: alert_calls.append(msg)
|
|
|
|
try:
|
|
mac = "88:a2:9e:ff:ff:ff"
|
|
ip = "10.0.0.0"
|
|
now = time.time()
|
|
net_alerter.known_devices[mac] = {
|
|
"ip": ip,
|
|
"hostname": "myphone",
|
|
"vendor": "Apple",
|
|
"first_seen": now,
|
|
"last_seen": now
|
|
}
|
|
net_alerter.location_occupancy = "OCCUPIED"
|
|
|
|
alert_calls.clear()
|
|
|
|
net_alerter.known_devices.pop(mac, None)
|
|
net_alerter.update_occupancy_state()
|
|
|
|
occupancy_alerts = [a for a in alert_calls if "Location:" in a]
|
|
assert any("VACANT" in a for a in occupancy_alerts), \
|
|
f"Expected VACANT alert when last personal device departs, got: {alert_calls}"
|
|
assert net_alerter.location_occupancy == "VACANT"
|
|
finally:
|
|
net_alerter.send_alert = original_send_alert
|
|
for timer in net_alerter.departure_timers.values():
|
|
timer.cancel()
|
|
net_alerter.departure_timers.clear()
|
|
|
|
|
|
def test_occupancy_multiple_personal_devices():
|
|
"""Test that VACANT only when ALL personal devices are gone (not just one)."""
|
|
cleanup_flap_state()
|
|
net_alerter.known_devices.clear()
|
|
net_alerter.personal_devices.clear()
|
|
net_alerter.location_occupancy = "UNKNOWN"
|
|
|
|
alert_calls = []
|
|
original_send_alert = net_alerter.send_alert
|
|
net_alerter.send_alert = lambda msg: alert_calls.append(msg)
|
|
|
|
try:
|
|
mac1 = "88:a2:9e:ff:ff:ff"
|
|
mac2 = "28:87:6d:ff:ff:ff"
|
|
now = time.time()
|
|
|
|
net_alerter.location_occupancy = "VACANT"
|
|
alert_calls.clear()
|
|
|
|
net_alerter.on_arrival(mac1, "10.0.0.0", "phone1")
|
|
occupancy_alerts = [a for a in alert_calls if "Location:" in a]
|
|
assert any("OCCUPIED" in a for a in occupancy_alerts), "Should be OCCUPIED after first device"
|
|
assert net_alerter.location_occupancy == "OCCUPIED"
|
|
|
|
alert_calls.clear()
|
|
|
|
net_alerter.on_arrival(mac2, "10.0.0.0", "phone2")
|
|
occupancy_alerts = [a for a in alert_calls if "Location:" in a]
|
|
assert len(occupancy_alerts) == 0, "Should not send occupancy alert when already OCCUPIED"
|
|
assert net_alerter.location_occupancy == "OCCUPIED"
|
|
|
|
alert_calls.clear()
|
|
|
|
net_alerter.on_departure(mac1)
|
|
net_alerter.known_devices[mac1]['departing'] = True
|
|
|
|
net_alerter.update_occupancy_state()
|
|
occupancy_alerts = [a for a in alert_calls if "Location:" in a]
|
|
assert len(occupancy_alerts) == 0, "Should stay OCCUPIED when one device still present"
|
|
|
|
alert_calls.clear()
|
|
|
|
net_alerter.known_devices.pop(mac1, None)
|
|
net_alerter.known_devices.pop(mac2, None)
|
|
net_alerter.update_occupancy_state()
|
|
|
|
occupancy_alerts = [a for a in alert_calls if "Location:" in a]
|
|
assert any("VACANT" in a for a in occupancy_alerts), "Should be VACANT when all devices gone"
|
|
assert net_alerter.location_occupancy == "VACANT"
|
|
finally:
|
|
net_alerter.send_alert = original_send_alert
|
|
for timer in net_alerter.departure_timers.values():
|
|
timer.cancel()
|
|
net_alerter.departure_timers.clear()
|
|
|
|
|
|
|
|
def test_update_last_seen_updates_dict():
|
|
"""Test that _update_last_seen correctly updates the last_seen dict."""
|
|
mac = "aa:bb:cc:dd:ee:ff"
|
|
|
|
# Initially not in dict
|
|
assert mac not in net_alerter.last_seen
|
|
|
|
# Call _update_last_seen
|
|
net_alerter._update_last_seen(mac)
|
|
|
|
# Should be in dict with a recent timestamp
|
|
assert mac in net_alerter.last_seen
|
|
ts1 = net_alerter.last_seen[mac]
|
|
assert ts1 > 0
|
|
|
|
# Call again after a small delay
|
|
time.sleep(0.01)
|
|
net_alerter._update_last_seen(mac)
|
|
|
|
# Timestamp should be updated (newer)
|
|
ts2 = net_alerter.last_seen[mac]
|
|
assert ts2 > ts1
|
|
|
|
|
|
def test_personal_watchdog_fires_on_departure_after_timeout():
|
|
"""Test that personal_watchdog detects devices exceeding timeout threshold."""
|
|
cleanup_flap_state()
|
|
mac = "aa:bb:cc:dd:ee:ff"
|
|
|
|
# Add device to personal_devices
|
|
with net_alerter.personal_lock:
|
|
net_alerter.personal_devices.add(mac)
|
|
|
|
# Add to known_devices
|
|
now = time.time()
|
|
with net_alerter.known_lock:
|
|
net_alerter.known_devices[mac] = {
|
|
'ip': '10.0.0.0',
|
|
'hostname': 'test.local',
|
|
'vendor': 'Test',
|
|
'first_seen': now,
|
|
'last_seen': now
|
|
}
|
|
|
|
# Set last_seen to old timestamp (beyond WATCHDOG_TIMEOUT_SEC)
|
|
old_time = now - (net_alerter.WATCHDOG_TIMEOUT_SEC + 10)
|
|
with net_alerter.last_seen_lock:
|
|
net_alerter.last_seen[mac] = old_time
|
|
|
|
# Check watchdog logic: detect timeout condition
|
|
now_test = time.time()
|
|
with net_alerter.personal_lock:
|
|
tracked = set(net_alerter.personal_devices)
|
|
|
|
# Verify timeout is detected
|
|
should_trigger = False
|
|
for test_mac in tracked:
|
|
with net_alerter.last_seen_lock:
|
|
ts = net_alerter.last_seen.get(test_mac)
|
|
|
|
if ts and now_test - ts > net_alerter.WATCHDOG_TIMEOUT_SEC:
|
|
should_trigger = True
|
|
|
|
assert should_trigger, "Watchdog should detect timeout condition"
|
|
|
|
# Device should exist and not be departing yet
|
|
with net_alerter.known_lock:
|
|
assert mac in net_alerter.known_devices
|
|
assert not net_alerter.known_devices[mac].get('departing')
|
|
|
|
|
|
def test_personal_watchdog_does_not_fire_within_timeout():
|
|
"""Test that personal_watchdog does NOT fire if device was last seen within timeout."""
|
|
cleanup_flap_state()
|
|
mac = "aa:bb:cc:dd:ee:ff"
|
|
|
|
# Add device to personal_devices
|
|
with net_alerter.personal_lock:
|
|
net_alerter.personal_devices.add(mac)
|
|
|
|
# Add to known_devices
|
|
now = time.time()
|
|
with net_alerter.known_lock:
|
|
net_alerter.known_devices[mac] = {
|
|
'ip': '10.0.0.0',
|
|
'hostname': 'test.local',
|
|
'vendor': 'Test',
|
|
'first_seen': now,
|
|
'last_seen': now
|
|
}
|
|
|
|
# Set last_seen to recent timestamp (within WATCHDOG_TIMEOUT_SEC)
|
|
recent_time = now - (net_alerter.WATCHDOG_TIMEOUT_SEC - 100)
|
|
with net_alerter.last_seen_lock:
|
|
net_alerter.last_seen[mac] = recent_time
|
|
|
|
# Mock send_alert to prevent actual alert
|
|
with patch('net_alerter.send_alert'):
|
|
with patch('net_alerter.update_occupancy_state'):
|
|
# Manually call watchdog logic
|
|
now_test = time.time()
|
|
departures_fired = []
|
|
|
|
with net_alerter.personal_lock:
|
|
tracked = set(net_alerter.personal_devices)
|
|
|
|
for test_mac in tracked:
|
|
with net_alerter.last_seen_lock:
|
|
ts = net_alerter.last_seen.get(test_mac)
|
|
|
|
if ts and now_test - ts > net_alerter.WATCHDOG_TIMEOUT_SEC:
|
|
departures_fired.append(test_mac)
|
|
|
|
# No departure should have fired
|
|
assert len(departures_fired) == 0, "Departure should not fire within timeout window"
|
|
|
|
# Device should still NOT be marked departing
|
|
with net_alerter.known_lock:
|
|
assert mac in net_alerter.known_devices
|
|
assert not net_alerter.known_devices[mac].get('departing')
|
|
|
|
|
|
def test_mdns_name_matching_adds_mac_to_personal_devices():
|
|
"""Test that mDNS name matching adds MAC to personal_devices when name matches personal_names."""
|
|
cleanup_flap_state()
|
|
mac = "aa:bb:cc:dd:ee:ff"
|
|
device_name = "Cobras iPhone"
|
|
|
|
# Set up personal_names
|
|
with net_alerter.personal_lock:
|
|
net_alerter.personal_names.add(device_name)
|
|
|
|
# Ensure MAC is not in personal_devices yet
|
|
with net_alerter.personal_lock:
|
|
assert mac not in net_alerter.personal_devices
|
|
|
|
# Create a minimal mDNS DNS message with a matching name
|
|
# DNS structure: minimal valid message with one answer record
|
|
# We'll just test _parse_mdns_for_names with a simple payload
|
|
|
|
# Call _parse_mdns_for_names with a simple DNS-like payload
|
|
# For simplicity, just verify the function doesn't crash with basic input
|
|
payload = b'\x00\x00\x84\x00\x00\x00\x00\x01\x00\x00\x00\x00' # Minimal DNS header
|
|
net_alerter._parse_mdns_for_names(payload, mac)
|
|
|
|
# The function should not crash even with minimal payload
|
|
# Full DNS parsing testing would require building proper mDNS packets
|
|
|
|
|
|
def test_load_personal_macs_from_env_does_not_log_individual_macs():
|
|
"""Test that load_personal_macs_from_env logs counts, not individual MAC values."""
|
|
cleanup_flap_state()
|
|
|
|
# Clear state
|
|
with net_alerter.personal_lock:
|
|
net_alerter.personal_devices.clear()
|
|
net_alerter.personal_names.clear()
|
|
|
|
# Mock os.getenv to return test data
|
|
test_macs = "aa:bb:cc:dd:ee:ff,bb:cc:dd:ee:ff:00"
|
|
test_names = "iPhone,Android"
|
|
|
|
with patch('os.getenv') as mock_getenv:
|
|
def getenv_side_effect(key, default=""):
|
|
if key == "NET_ALERTER_PERSONAL_MACS":
|
|
return test_macs
|
|
elif key == "NET_ALERTER_PERSONAL_NAMES":
|
|
return test_names
|
|
else:
|
|
return os.getenv(key, default)
|
|
|
|
mock_getenv.side_effect = getenv_side_effect
|
|
|
|
# Capture logging output
|
|
with patch('net_alerter.logging.info') as mock_logging:
|
|
net_alerter.load_personal_macs_from_env()
|
|
|
|
# Check that logging was called with summary (not individual MACs)
|
|
calls = [str(call) for call in mock_logging.call_args_list]
|
|
summary_logged = any("2 personal device MAC(s) and 2 personal name(s)" in str(call) for call in calls)
|
|
|
|
# The logging call should include count summary
|
|
assert any('personal device MAC' in str(call) and 'personal name' in str(call) for call in calls), f"Expected count summary in logging, got: {calls}"
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
test_hostname_dedup_when_hostname_equals_ip()
|
|
print("✓ test_hostname_dedup_when_hostname_equals_ip")
|
|
|
|
test_hostname_shown_when_different_from_ip()
|
|
print("✓ test_hostname_shown_when_different_from_ip")
|
|
|
|
test_oui_lookup_with_inline_dict()
|
|
print("✓ test_oui_lookup_with_inline_dict")
|
|
|
|
test_oui_lookup_unknown_vendor()
|
|
print("✓ test_oui_lookup_unknown_vendor")
|
|
|
|
test_departure_format_dedup()
|
|
print("✓ test_departure_format_dedup")
|
|
|
|
test_infrastructure_ips_never_alert()
|
|
print("✓ test_infrastructure_ips_never_alert")
|
|
|
|
test_departure_debounce_15min()
|
|
print("✓ test_departure_debounce_15min")
|
|
|
|
test_re_arrival_within_5min_suppresses_alert()
|
|
print("✓ test_re_arrival_within_5min_suppresses_alert")
|
|
|
|
test_dhcp_renewal_dedup_30min()
|
|
print("✓ test_dhcp_renewal_dedup_30min")
|
|
|
|
test_re_arrival_cancels_departure_timer()
|
|
print("✓ test_re_arrival_cancels_departure_timer")
|
|
|
|
test_rapid_flap_no_duplicate_arrived_alerts()
|
|
print("✓ test_rapid_flap_no_duplicate_arrived_alerts")
|
|
|
|
test_personal_device_oui_detection()
|
|
print("✓ test_personal_device_oui_detection")
|
|
|
|
test_manual_personal_mac_config()
|
|
print("✓ test_manual_personal_mac_config")
|
|
|
|
test_occupancy_vacant_to_occupied()
|
|
print("✓ test_occupancy_vacant_to_occupied")
|
|
|
|
test_occupancy_occupied_to_vacant()
|
|
print("✓ test_occupancy_occupied_to_vacant")
|
|
|
|
test_occupancy_multiple_personal_devices()
|
|
print("✓ test_occupancy_multiple_personal_devices")
|
|
|
|
print("\nAll tests passed!")
|