Skip to content

Commit cc507f2

Browse files
authored
Merge pull request #1604 from netalertx/next_release
Next release
2 parents c40d04b + 51b8cf0 commit cc507f2

5 files changed

Lines changed: 157 additions & 36 deletions

File tree

front/plugins/fritzbox/fritzbox.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,16 +166,16 @@ def check_guest_wifi_status(fc, guest_service_num):
166166
return guest_info
167167

168168

169-
def create_guest_wifi_device(fc, host):
169+
def create_guest_wifi_device(fc):
170170
"""
171171
Create a synthetic device entry for guest WiFi.
172172
Derives a deterministic fake MAC from the Fritz!Box hardware MAC address.
173-
Falls back to the configured host string if the MAC cannot be retrieved.
173+
Falls back to a fixed sentinel string if the MAC cannot be retrieved.
174174
Returns: Device dictionary
175175
"""
176176
try:
177177
fritzbox_mac = fc.call_action('DeviceInfo:1', 'GetInfo').get('NewMACAddress', '')
178-
guest_mac = string_to_fake_mac(normalize_mac(fritzbox_mac) if fritzbox_mac else host)
178+
guest_mac = string_to_fake_mac(normalize_mac(fritzbox_mac) if fritzbox_mac else 'FRITZBOX_GUEST')
179179

180180
device = {
181181
'mac_address': guest_mac,
@@ -224,7 +224,7 @@ def main():
224224
if report_guest:
225225
guest_status = check_guest_wifi_status(fc, guest_service)
226226
if guest_status['active']:
227-
guest_device = create_guest_wifi_device(fc, host)
227+
guest_device = create_guest_wifi_device(fc)
228228
if guest_device:
229229
device_data.append(guest_device)
230230

server/scan/session_events.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
from messaging.in_app import update_unread_notifications_count
2121
from const import NULL_EQUIVALENTS_SQL
2222

23+
# Predicate used in every negative-event INSERT to skip forced-online devices.
24+
# Centralised here so all three event paths stay in sync.
25+
_SQL_NOT_FORCED_ONLINE = "LOWER(COALESCE(devForceStatus, '')) != 'online'"
26+
2327

2428
# Make sure log level is initialized correctly
2529
Logger(get_setting_value("LOG_LEVEL"))
@@ -179,6 +183,7 @@ def insert_events(db):
179183
WHERE devAlertDown != 0
180184
AND devCanSleep = 0
181185
AND devPresentLastScan = 1
186+
AND {_SQL_NOT_FORCED_ONLINE}
182187
AND NOT EXISTS (SELECT 1 FROM CurrentScan
183188
WHERE devMac = scanMac
184189
) """)
@@ -194,6 +199,7 @@ def insert_events(db):
194199
AND devCanSleep = 1
195200
AND devIsSleeping = 0
196201
AND devPresentLastScan = 0
202+
AND {_SQL_NOT_FORCED_ONLINE}
197203
AND NOT EXISTS (SELECT 1 FROM CurrentScan
198204
WHERE devMac = scanMac)
199205
AND NOT EXISTS (SELECT 1 FROM Events
@@ -229,6 +235,7 @@ def insert_events(db):
229235
FROM Devices
230236
WHERE devAlertDown = 0
231237
AND devPresentLastScan = 1
238+
AND {_SQL_NOT_FORCED_ONLINE}
232239
AND NOT EXISTS (SELECT 1 FROM CurrentScan
233240
WHERE devMac = scanMac
234241
) """)

test/db_test_helpers.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ def insert_device(
171171
can_sleep: int = 0,
172172
last_connection: str | None = None,
173173
last_ip: str = "192.168.1.1",
174+
force_status: str | None = None,
174175
) -> None:
175176
"""
176177
Insert a minimal Devices row.
@@ -189,16 +190,19 @@ def insert_device(
189190
ISO-8601 UTC string; defaults to 60 minutes ago when omitted.
190191
last_ip:
191192
Value stored in devLastIP.
193+
force_status:
194+
Value for devForceStatus (``'online'``, ``'offline'``, or ``None``/
195+
``'dont_force'``).
192196
"""
193197
cur.execute(
194198
"""
195199
INSERT INTO Devices
196200
(devMac, devAlertDown, devPresentLastScan, devCanSleep,
197-
devLastConnection, devLastIP, devIsArchived, devIsNew)
198-
VALUES (?, ?, ?, ?, ?, ?, 0, 0)
201+
devLastConnection, devLastIP, devIsArchived, devIsNew, devForceStatus)
202+
VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?)
199203
""",
200204
(mac, alert_down, present_last_scan, can_sleep,
201-
last_connection or minutes_ago(60), last_ip),
205+
last_connection or minutes_ago(60), last_ip, force_status),
202206
)
203207

204208

test/plugins/test_fritzbox.py

Lines changed: 20 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
created during tests.
88
"""
99

10-
import hashlib
1110
import sys
1211
import os
1312
from unittest.mock import patch, MagicMock
1413

14+
from utils.crypto_utils import string_to_fake_mac
15+
1516
import pytest
1617

1718
# ---------------------------------------------------------------------------
@@ -59,19 +60,12 @@ def _make_host_entry(mac="AA:BB:CC:DD:EE:FF", ip="192.168.1.10",
5960
@pytest.fixture
6061
def mock_fritz_hosts():
6162
"""
62-
Patches fritzconnection.lib.fritzhosts in sys.modules so that
63-
fritzbox.get_connected_devices() uses a controllable FritzHosts mock.
64-
Yields the FritzHosts *instance* (what FritzHosts(fc) returns).
63+
Patches fritzbox.FritzHosts so that get_connected_devices() uses a
64+
controllable mock. Yields the FritzHosts *instance* (what FritzHosts(fc)
65+
returns).
6566
"""
6667
hosts_instance = MagicMock()
67-
fritz_hosts_module = MagicMock()
68-
fritz_hosts_module.FritzHosts = MagicMock(return_value=hosts_instance)
69-
70-
with patch.dict("sys.modules", {
71-
"fritzconnection": MagicMock(),
72-
"fritzconnection.lib": MagicMock(),
73-
"fritzconnection.lib.fritzhosts": fritz_hosts_module,
74-
}):
68+
with patch("fritzbox.FritzHosts", return_value=hosts_instance):
7569
yield hosts_instance
7670

7771

@@ -229,12 +223,15 @@ def test_returns_device_dict(self):
229223
assert device["active_status"] == "Active"
230224
assert device["interface_type"] == "Access Point"
231225
assert device["ip_address"] == ""
226+
# MAC must match string_to_fake_mac output (fa:ce: prefix)
227+
assert device["mac_address"].startswith("fa:ce:")
232228

233229
def test_guest_mac_has_locally_administered_bit(self):
234-
"""First byte must be 0x02 — locally-administered, unicast."""
230+
"""The locally-administered bit (0x02) must be set in the first byte.
231+
string_to_fake_mac uses the 'fa:ce:' prefix; 0xFA & 0x02 == 0x02."""
235232
device = fritzbox.create_guest_wifi_device(self._fc_with_mac("AA:BB:CC:DD:EE:FF"))
236233
first_byte = int(device["mac_address"].split(":")[0], 16)
237-
assert first_byte == 0x02
234+
assert first_byte & 0x02 != 0
238235

239236
def test_guest_mac_format_is_valid(self):
240237
"""MAC must be 6 colon-separated lowercase hex pairs."""
@@ -258,11 +255,11 @@ def test_different_fritzbox_macs_produce_different_guest_macs(self):
258255
assert mac_a != mac_b
259256

260257
def test_no_fritzbox_mac_uses_fallback(self):
261-
"""When DeviceInfo returns no MAC, fall back to 02:00:00:00:00:01."""
258+
"""When DeviceInfo returns no MAC, fall back to a sentinel-derived MAC."""
262259
fc = MagicMock()
263260
fc.call_action.return_value = {"NewMACAddress": ""}
264261
device = fritzbox.create_guest_wifi_device(fc)
265-
assert device["mac_address"] == "02:00:00:00:00:01"
262+
assert device["mac_address"] == string_to_fake_mac("FRITZBOX_GUEST")
266263

267264
def test_device_info_exception_returns_none(self):
268265
"""If DeviceInfo call raises, create_guest_wifi_device must return None."""
@@ -274,12 +271,11 @@ def test_device_info_exception_returns_none(self):
274271
def test_known_mac_produces_known_guest_mac(self):
275272
"""
276273
Regression anchor: for a fixed Fritz!Box MAC, the expected guest MAC
277-
is precomputed here independently. If the hashing logic in
278-
fritzbox.py changes, this test fails immediately.
274+
is derived via string_to_fake_mac(normalize_mac(...)). If the hashing
275+
logic in fritzbox.py or string_to_fake_mac changes, this test fails.
279276
"""
280-
fritzbox_mac = "aa:bb:cc:dd:ee:ff" # normalize_mac output of "AA:BB:CC:DD:EE:FF"
281-
digest = hashlib.md5(f"GUEST:{fritzbox_mac}".encode()).digest()
282-
expected = "02:" + ":".join(f"{b:02x}" for b in digest[:5])
277+
fritzbox_mac = normalize_mac("AA:BB:CC:DD:EE:FF")
278+
expected = string_to_fake_mac(fritzbox_mac)
283279

284280
device = fritzbox.create_guest_wifi_device(self._fc_with_mac("AA:BB:CC:DD:EE:FF"))
285281
assert device["mac_address"] == expected
@@ -296,10 +292,8 @@ def test_successful_connection(self):
296292
fc_instance.modelname = "FRITZ!Box 7590"
297293
fc_instance.system_version = "7.57"
298294
fc_class = MagicMock(return_value=fc_instance)
299-
fc_module = MagicMock()
300-
fc_module.FritzConnection = fc_class
301295

302-
with patch.dict("sys.modules", {"fritzconnection": fc_module}):
296+
with patch("fritzbox.FritzConnection", fc_class):
303297
result = fritzbox.get_fritzbox_connection("fritz.box", 49443, "admin", "pass", True)
304298

305299
assert result is fc_instance
@@ -308,16 +302,13 @@ def test_successful_connection(self):
308302
)
309303

310304
def test_import_error_returns_none(self):
311-
with patch.dict("sys.modules", {"fritzconnection": None}):
305+
with patch("fritzbox.FritzConnection", side_effect=ImportError("fritzconnection not found")):
312306
result = fritzbox.get_fritzbox_connection("fritz.box", 49443, "admin", "pass", True)
313307

314308
assert result is None
315309

316310
def test_connection_exception_returns_none(self):
317-
fc_module = MagicMock()
318-
fc_module.FritzConnection.side_effect = Exception("Connection refused")
319-
320-
with patch.dict("sys.modules", {"fritzconnection": fc_module}):
311+
with patch("fritzbox.FritzConnection", side_effect=Exception("Connection refused")):
321312
result = fritzbox.get_fritzbox_connection("fritz.box", 49443, "admin", "pass", True)
322313

323314
assert result is None

test/scan/test_down_sleep_events.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,3 +444,122 @@ def test_online_history_down_count_excludes_sleeping(self):
444444
assert count == 1, (
445445
f"Expected 1 down device (sleeping device must not be counted), got {count}"
446446
)
447+
448+
449+
# ---------------------------------------------------------------------------
450+
# Layer 1c: insert_events() — forced-online device suppression
451+
#
452+
# Devices with devForceStatus='online' are always considered present by the
453+
# operator. Generating 'Device Down' or 'Disconnected' events for them causes
454+
# spurious flapping detection (devFlapping counts these events in DevicesView).
455+
#
456+
# Affected queries in insert_events():
457+
# 1a Device Down (non-sleeping) — DevicesView query
458+
# 1b Device Down (sleep-expired) — DevicesView query
459+
# 3 Disconnected — Devices table query
460+
# ---------------------------------------------------------------------------
461+
462+
class TestInsertEventsForceOnline:
463+
"""
464+
Regression tests: forced-online devices must never generate
465+
'Device Down' or 'Disconnected' events.
466+
"""
467+
468+
def test_forced_online_no_device_down_event(self):
469+
"""
470+
devForceStatus='online', devAlertDown=1, absent from CurrentScan.
471+
Must NOT produce a 'Device Down' event (regression: used to fire and
472+
cause devFlapping=1 after the threshold was reached).
473+
"""
474+
conn = _make_db()
475+
cur = conn.cursor()
476+
_insert_device(cur, "ff:00:00:00:00:01", alert_down=1, present_last_scan=1,
477+
force_status="online")
478+
conn.commit()
479+
480+
insert_events(DummyDB(conn))
481+
482+
assert "ff:00:00:00:00:01" not in _down_event_macs(cur), (
483+
"forced-online device must never generate a 'Device Down' event"
484+
)
485+
486+
def test_forced_online_sleep_expired_no_device_down_event(self):
487+
"""
488+
devForceStatus='online', devCanSleep=1, sleep window expired.
489+
Must NOT produce a 'Device Down' event via the sleep-expired path.
490+
"""
491+
conn = _make_db(sleep_minutes=30)
492+
cur = conn.cursor()
493+
_insert_device(cur, "ff:00:00:00:00:02", alert_down=1, present_last_scan=0,
494+
can_sleep=1, last_connection=_minutes_ago(45),
495+
force_status="online")
496+
conn.commit()
497+
498+
insert_events(DummyDB(conn))
499+
500+
assert "ff:00:00:00:00:02" not in _down_event_macs(cur), (
501+
"forced-online sleeping device must not get 'Device Down' after sleep expires"
502+
)
503+
504+
def test_forced_online_no_disconnected_event(self):
505+
"""
506+
devForceStatus='online', devAlertDown=0 (Disconnected path), absent.
507+
Must NOT produce a 'Disconnected' event.
508+
"""
509+
conn = _make_db()
510+
cur = conn.cursor()
511+
_insert_device(cur, "ff:00:00:00:00:03", alert_down=0, present_last_scan=1,
512+
force_status="online")
513+
conn.commit()
514+
515+
insert_events(DummyDB(conn))
516+
517+
cur.execute(
518+
"SELECT COUNT(*) AS cnt FROM Events "
519+
"WHERE eveMac = 'ff:00:00:00:00:03' AND eveEventType = 'Disconnected'"
520+
)
521+
assert cur.fetchone()["cnt"] == 0, (
522+
"forced-online device must never generate a 'Disconnected' event"
523+
)
524+
525+
def test_forced_online_uppercase_no_device_down_event(self):
526+
"""devForceStatus='ONLINE' (uppercase) must also be suppressed."""
527+
conn = _make_db()
528+
cur = conn.cursor()
529+
_insert_device(cur, "ff:00:00:00:00:04", alert_down=1, present_last_scan=1,
530+
force_status="ONLINE")
531+
conn.commit()
532+
533+
insert_events(DummyDB(conn))
534+
535+
assert "ff:00:00:00:00:04" not in _down_event_macs(cur), (
536+
"forced-online device (uppercase) must never generate a 'Device Down' event"
537+
)
538+
539+
def test_dont_force_still_fires_device_down(self):
540+
"""devForceStatus='dont_force' must behave normally — event fires."""
541+
conn = _make_db()
542+
cur = conn.cursor()
543+
_insert_device(cur, "ff:00:00:00:00:05", alert_down=1, present_last_scan=1,
544+
force_status="dont_force")
545+
conn.commit()
546+
547+
insert_events(DummyDB(conn))
548+
549+
assert "ff:00:00:00:00:05" in _down_event_macs(cur), (
550+
"dont_force device must still generate 'Device Down' when absent"
551+
)
552+
553+
def test_forced_offline_still_fires_device_down(self):
554+
"""devForceStatus='offline' suppresses nothing — event fires."""
555+
conn = _make_db()
556+
cur = conn.cursor()
557+
_insert_device(cur, "ff:00:00:00:00:06", alert_down=1, present_last_scan=1,
558+
force_status="offline")
559+
conn.commit()
560+
561+
insert_events(DummyDB(conn))
562+
563+
assert "ff:00:00:00:00:06" in _down_event_macs(cur), (
564+
"forced-offline device must still generate 'Device Down' when absent"
565+
)

0 commit comments

Comments
 (0)