Merge branch 'main' into feat/adguard-export-plugin

This commit is contained in:
Jokob @NetAlertX authored and GitHub committed 2026-05-30 08:53:04 +10:00
commit 727fe0e3e2
216 files changed
+15022 -4127

No files matched your search

+8 -11
View File
@@ -8,7 +8,6 @@ import pytest
from helper import get_setting_value
from api_server.api_server_start import app
from db.db_helper import get_device_conditions
@pytest.fixture(scope="session")
@@ -163,17 +162,15 @@ def test_devices_totals(client, api_token, test_mac):
data = resp.json
assert isinstance(data, list)
# 3. Dynamically get expected length
conditions = get_device_conditions()
expected_length = len(conditions)
assert len(data) == expected_length
# 3. Verify the response has exactly 6 elements in documented order:
# [all, connected, favorites, new, down, archived]
expected_length = 6
assert len(data) == expected_length, (
f"Expected 6 totals (all, connected, favorites, new, down, archived), got {len(data)}"
)
# 4. Check that at least 1 device exists when there are any conditions
if expected_length > 0:
assert data[0] >= 1 # 'devices' count includes the dummy device
else:
# no conditions defined; data should be an empty list
assert data == []
# 4. Check that at least 1 device exists (all count includes the dummy device)
assert data[0] >= 1 # index 0 = 'all'
finally:
delete_dummy(client, api_token, test_mac)
+5 -5
View File
@@ -61,7 +61,7 @@ def test_create_event(client, api_token, test_mac):
resp = list_events(client, api_token, test_mac)
assert resp.status_code == 200
events = resp.get_json().get("events", [])
assert any(ev.get("eve_MAC") == test_mac for ev in events)
assert any(ev.get("eveMac") == test_mac for ev in events)
def test_delete_events_for_mac(client, api_token, test_mac):
@@ -73,7 +73,7 @@ def test_delete_events_for_mac(client, api_token, test_mac):
resp = list_events(client, api_token, test_mac)
assert resp.status_code == 200
events = resp.json.get("events", [])
assert any(ev["eve_MAC"] == test_mac for ev in events)
assert any(ev["eveMac"] == test_mac for ev in events)
# delete
resp = client.delete(f"/events/{test_mac}", headers=auth_headers(api_token))
@@ -143,10 +143,10 @@ def test_delete_events_dynamic_days(client, api_token, test_mac):
thirty_days_ago = timeNowUTC(as_string=False) - timedelta(days=30)
initial_younger_count = 0
for ev in initial_events:
if ev.get("eve_MAC") == test_mac and ev.get("eve_DateTime"):
if ev.get("eveMac") == test_mac and ev.get("eveDateTime"):
try:
# Parse event datetime (handle ISO format)
ev_time_str = ev["eve_DateTime"]
ev_time_str = ev["eveDateTime"]
# Try parsing with timezone info
try:
ev_time = datetime.fromisoformat(ev_time_str.replace("Z", "+00:00"))
@@ -176,6 +176,6 @@ def test_delete_events_dynamic_days(client, api_token, test_mac):
# confirm only recent events remain (pre-existing younger + newly created 5-day-old)
resp = list_events(client, api_token, test_mac)
events = resp.get_json().get("events", [])
mac_events = [ev for ev in events if ev.get("eve_MAC") == test_mac]
mac_events = [ev for ev in events if ev.get("eveMac") == test_mac]
expected_remaining = initial_younger_count + 1 # 1 for the 5-day-old event we created
assert len(mac_events) == expected_remaining
+239
View File
@@ -192,3 +192,242 @@ def test_graphql_langstrings_excludes_languages_json(client, api_token):
f"languages.json leaked into langStrings as {len(polluted)} entries; "
"graphql_endpoint.py must exclude it from the directory scan"
)
# --- PLUGINS_OBJECTS TESTS ---
def test_graphql_plugins_objects_no_options(client, api_token):
"""pluginsObjects without options returns valid schema (entries list + count fields)"""
query = {
"query": """
{
pluginsObjects {
dbCount
count
entries {
index
plugin
objectPrimaryId
status
}
}
}
"""
}
resp = client.post("/graphql", json=query, headers=auth_headers(api_token))
assert resp.status_code == 200
body = resp.get_json()
assert "errors" not in body
result = body["data"]["pluginsObjects"]
assert isinstance(result["entries"], list)
assert isinstance(result["dbCount"], int)
assert isinstance(result["count"], int)
assert result["dbCount"] >= result["count"]
def test_graphql_plugins_objects_pagination(client, api_token):
"""pluginsObjects with limit=5 returns at most 5 entries and count reflects filter total"""
query = {
"query": """
query PluginsObjectsPaged($options: PluginQueryOptionsInput) {
pluginsObjects(options: $options) {
dbCount
count
entries { index plugin }
}
}
""",
"variables": {"options": {"page": 1, "limit": 5}}
}
resp = client.post("/graphql", json=query, headers=auth_headers(api_token))
assert resp.status_code == 200
body = resp.get_json()
assert "errors" not in body
result = body["data"]["pluginsObjects"]
assert len(result["entries"]) <= 5
assert result["count"] >= len(result["entries"])
def test_graphql_plugins_events_no_options(client, api_token):
"""pluginsEvents without options returns valid schema"""
query = {
"query": """
{
pluginsEvents {
dbCount
count
entries { index plugin objectPrimaryId dateTimeCreated }
}
}
"""
}
resp = client.post("/graphql", json=query, headers=auth_headers(api_token))
assert resp.status_code == 200
body = resp.get_json()
assert "errors" not in body
result = body["data"]["pluginsEvents"]
assert isinstance(result["entries"], list)
assert isinstance(result["count"], int)
def test_graphql_plugins_history_no_options(client, api_token):
"""pluginsHistory without options returns valid schema"""
query = {
"query": """
{
pluginsHistory {
dbCount
count
entries { index plugin watchedValue1 }
}
}
"""
}
resp = client.post("/graphql", json=query, headers=auth_headers(api_token))
assert resp.status_code == 200
body = resp.get_json()
assert "errors" not in body
result = body["data"]["pluginsHistory"]
assert isinstance(result["entries"], list)
assert isinstance(result["count"], int)
def test_graphql_plugins_hard_cap(client, api_token):
"""limit=99999 is clamped server-side to at most 1000 entries"""
query = {
"query": """
query PluginsHardCap($options: PluginQueryOptionsInput) {
pluginsObjects(options: $options) {
count
entries { index }
}
}
""",
"variables": {"options": {"page": 1, "limit": 99999}}
}
resp = client.post("/graphql", json=query, headers=auth_headers(api_token))
assert resp.status_code == 200
body = resp.get_json()
assert "errors" not in body
entries = body["data"]["pluginsObjects"]["entries"]
assert len(entries) <= 1000, f"Hard cap violated: got {len(entries)} entries"
# --- EVENTS TESTS ---
def test_graphql_events_no_options(client, api_token):
"""events without options returns valid schema (entries list + count fields)"""
query = {
"query": """
{
events {
dbCount
count
entries {
eveMac
eveIp
eveDateTime
eveEventType
eveAdditionalInfo
}
}
}
"""
}
resp = client.post("/graphql", json=query, headers=auth_headers(api_token))
assert resp.status_code == 200
body = resp.get_json()
assert "errors" not in body
result = body["data"]["events"]
assert isinstance(result["entries"], list)
assert isinstance(result["count"], int)
assert isinstance(result["dbCount"], int)
def test_graphql_events_filter_by_mac(client, api_token):
"""events filtered by eveMac='00:00:00:00:00:00' returns only that MAC (or empty)"""
query = {
"query": """
query EventsByMac($options: EventQueryOptionsInput) {
events(options: $options) {
count
entries { eveMac eveEventType eveDateTime }
}
}
""",
"variables": {"options": {"eveMac": "00:00:00:00:00:00", "limit": 50}}
}
resp = client.post("/graphql", json=query, headers=auth_headers(api_token))
assert resp.status_code == 200
body = resp.get_json()
assert "errors" not in body
result = body["data"]["events"]
for entry in result["entries"]:
assert entry["eveMac"].upper() == "00:00:00:00:00:00", (
f"MAC filter leaked a non-matching row: {entry['eveMac']}"
)
# --- PLUGIN FILTER SCOPING TESTS ---
def test_graphql_plugins_objects_dbcount_scoped_to_plugin(client, api_token):
"""dbCount should reflect only the rows for the requested plugin, not the entire table."""
# First, get the unscoped total
query_all = {
"query": "{ pluginsObjects { dbCount count } }"
}
resp_all = client.post("/graphql", json=query_all, headers=auth_headers(api_token))
assert resp_all.status_code == 200
total_all = resp_all.get_json()["data"]["pluginsObjects"]["dbCount"]
# Now request a non-existent plugin — dbCount must be 0
query_fake = {
"query": """
query Scoped($options: PluginQueryOptionsInput) {
pluginsObjects(options: $options) { dbCount count entries { plugin } }
}
""",
"variables": {"options": {"plugin": "NONEXISTENT_PLUGIN_XYZ"}}
}
resp_fake = client.post("/graphql", json=query_fake, headers=auth_headers(api_token))
assert resp_fake.status_code == 200
body_fake = resp_fake.get_json()
assert "errors" not in body_fake
result_fake = body_fake["data"]["pluginsObjects"]
assert result_fake["dbCount"] == 0, (
f"dbCount should be 0 for non-existent plugin, got {result_fake['dbCount']}"
)
assert result_fake["count"] == 0
assert result_fake["entries"] == []
def test_graphql_plugins_objects_scoped_entries_match_plugin(client, api_token):
"""When filtering by plugin, all returned entries must belong to that plugin."""
# Get first available plugin prefix from the unscoped query
query_sample = {
"query": "{ pluginsObjects(options: {page: 1, limit: 1}) { entries { plugin } } }"
}
resp = client.post("/graphql", json=query_sample, headers=auth_headers(api_token))
assert resp.status_code == 200
entries = resp.get_json()["data"]["pluginsObjects"]["entries"]
if not entries:
pytest.skip("No plugin objects in database")
target = entries[0]["plugin"]
# Query scoped to that plugin
query_scoped = {
"query": """
query Scoped($options: PluginQueryOptionsInput) {
pluginsObjects(options: $options) { dbCount count entries { plugin } }
}
""",
"variables": {"options": {"plugin": target, "page": 1, "limit": 100}}
}
resp2 = client.post("/graphql", json=query_scoped, headers=auth_headers(api_token))
assert resp2.status_code == 200
result = resp2.get_json()["data"]["pluginsObjects"]
assert result["dbCount"] > 0
for entry in result["entries"]:
assert entry["plugin"].upper() == target.upper(), (
f"Plugin filter leaked: expected {target}, got {entry['plugin']}"
)
@@ -440,7 +440,7 @@ def test_sync_get(mock_handle, client, api_token):
def test_sync_post(mock_handle, client, api_token):
"""Test POST /sync."""
mock_handle.return_value = ({"success": True}, 200)
payload = {"data": {}, "node_name": "node1", "plugin": "test"}
payload = {"data": "encrypted_payload_string", "node_name": "node1", "plugin": "test"}
response = client.post('/sync',
json=payload,
headers=auth_headers(api_token))
@@ -116,7 +116,7 @@ def test_get_open_ports_ip(mock_device_db_conn, mock_plugin_db_conn, client, api
mock_execute_result = MagicMock()
# Mock for PluginObjectInstance.getByField (returns port data)
mock_execute_result.fetchall.return_value = [{"Object_SecondaryID": "22", "Watched_Value2": "ssh"}, {"Object_SecondaryID": "80", "Watched_Value2": "http"}]
mock_execute_result.fetchall.return_value = [{"objectSecondaryId": "22", "watchedValue2": "ssh"}, {"objectSecondaryId": "80", "watchedValue2": "http"}]
# Mock for DeviceInstance.getByIP (returns device with MAC)
mock_execute_result.fetchone.return_value = {"devMac": "aa:bb:cc:dd:ee:ff"}
@@ -141,7 +141,7 @@ def test_get_open_ports_mac_resolve(mock_plugin_db_conn, client, api_token):
# Mock database connection for MAC-based open ports query
mock_conn = MagicMock()
mock_execute_result = MagicMock()
mock_execute_result.fetchall.return_value = [{"Object_SecondaryID": "80", "Watched_Value2": "http"}]
mock_execute_result.fetchall.return_value = [{"objectSecondaryId": "80", "watchedValue2": "http"}]
mock_conn.execute.return_value = mock_execute_result
mock_plugin_db_conn.return_value = mock_conn
@@ -189,7 +189,7 @@ def test_get_recent_alerts(mock_db_conn, client, api_token):
mock_conn = MagicMock()
mock_execute_result = MagicMock()
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
mock_execute_result.fetchall.return_value = [{"eve_DateTime": now, "eve_EventType": "New Device", "eve_MAC": "aa:bb:cc:dd:ee:ff"}]
mock_execute_result.fetchall.return_value = [{"eveDateTime": now, "eveEventType": "New Device", "eveMac": "aa:bb:cc:dd:ee:ff"}]
mock_conn.execute.return_value = mock_execute_result
mock_db_conn.return_value = mock_conn
@@ -0,0 +1,72 @@
"""Tests for /plugins/stats endpoint."""
import sys
import os
import pytest
INSTALL_PATH = os.getenv("NETALERTX_APP", "/app")
sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
from helper import get_setting_value # noqa: E402
from api_server.api_server_start import app # noqa: E402
@pytest.fixture(scope="session")
def api_token():
return get_setting_value("API_TOKEN")
@pytest.fixture
def client():
with app.test_client() as client:
yield client
def auth_headers(token):
return {"Authorization": f"Bearer {token}"}
def test_plugin_stats_unauthorized(client):
"""Missing token should be forbidden."""
resp = client.get("/plugins/stats")
assert resp.status_code == 403
assert resp.get_json().get("success") is False
def test_plugin_stats_success(client, api_token):
"""Valid token returns success with data array."""
resp = client.get("/plugins/stats", headers=auth_headers(api_token))
assert resp.status_code == 200
data = resp.get_json()
assert data.get("success") is True
assert isinstance(data.get("data"), list)
def test_plugin_stats_entry_structure(client, api_token):
"""Each entry has tableName, plugin, cnt fields."""
resp = client.get("/plugins/stats", headers=auth_headers(api_token))
data = resp.get_json()
for entry in data["data"]:
assert "tableName" in entry
assert "plugin" in entry
assert "cnt" in entry
assert entry["tableName"] in ("objects", "events", "history")
assert isinstance(entry["cnt"], int)
assert entry["cnt"] >= 0
def test_plugin_stats_with_foreignkey(client, api_token):
"""foreignKey param filters results and returns valid structure."""
resp = client.get(
"/plugins/stats?foreignKey=00:00:00:00:00:00",
headers=auth_headers(api_token),
)
assert resp.status_code == 200
data = resp.get_json()
assert data.get("success") is True
assert isinstance(data.get("data"), list)
# With a non-existent MAC, data should be empty
assert len(data["data"]) == 0
+40 -3
View File
@@ -74,7 +74,7 @@ def test_list_sessions(client, api_token, test_mac):
assert resp.status_code == 200
assert resp.json.get("success") is True
sessions = resp.json.get("sessions")
assert any(ses["ses_MAC"] == test_mac for ses in sessions)
assert any(ses["sesMac"] == test_mac for ses in sessions)
def test_device_sessions_by_period(client, api_token, test_mac):
@@ -105,7 +105,7 @@ def test_device_sessions_by_period(client, api_token, test_mac):
print(test_mac)
assert isinstance(sessions, list)
assert any(s["ses_MAC"] == test_mac for s in sessions)
assert any(s["sesMac"] == test_mac for s in sessions)
def test_device_session_events(client, api_token, test_mac):
@@ -160,6 +160,43 @@ def test_device_session_events(client, api_token, test_mac):
assert isinstance(sessions, list)
def test_session_events_pagination(client, api_token):
"""session-events supports page, limit, and returns total/recordsFiltered."""
resp = client.get(
"/sessions/session-events?type=all&period=1 year&page=1&limit=5",
headers=auth_headers(api_token),
)
assert resp.status_code == 200
body = resp.json
assert "data" in body
assert "total" in body
assert "recordsFiltered" in body
assert isinstance(body["total"], int)
assert len(body["data"]) <= 5
def test_session_events_sorting(client, api_token):
"""session-events supports sortCol and sortDir without errors."""
resp_desc = client.get(
"/sessions/session-events?type=all&period=1 year&page=1&limit=10&sortCol=0&sortDir=desc",
headers=auth_headers(api_token),
)
assert resp_desc.status_code == 200
desc_data = resp_desc.json["data"]
resp_asc = client.get(
"/sessions/session-events?type=all&period=1 year&page=1&limit=10&sortCol=0&sortDir=asc",
headers=auth_headers(api_token),
)
assert resp_asc.status_code == 200
asc_data = resp_asc.json["data"]
# If there are at least 2 rows, order should differ (or be identical if all same)
if len(desc_data) >= 2 and len(asc_data) >= 2:
# First row of desc should >= first row of asc (column 0 is the order column)
assert desc_data[0][0] >= asc_data[0][0] or desc_data == asc_data
# -----------------------------
def test_delete_session(client, api_token, test_mac):
# First create session
@@ -178,7 +215,7 @@ def test_delete_session(client, api_token, test_mac):
# Confirm deletion
resp = client.get(f"/sessions/list?mac={test_mac}", headers=auth_headers(api_token))
sessions = resp.json.get("sessions")
assert not any(ses["ses_MAC"] == test_mac for ses in sessions)
assert not any(ses["sesMac"] == test_mac for ses in sessions)
def test_get_sessions_calendar(client, api_token, test_mac):
+121
View File
@@ -0,0 +1,121 @@
"""Tests for the /sync POST and GET endpoints.
Covers:
- Authentication enforcement (403 on missing/invalid token)
- Content-type enforcement on POST (regression for data= vs json= bug)
- Happy-path POST returns 200
- GET auth enforcement
"""
import os
import sys
import pytest
INSTALL_PATH = os.getenv("NETALERTX_APP", "/app")
sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
from helper import get_setting_value # noqa: E402
from api_server.api_server_start import app # noqa: E402
@pytest.fixture(scope="session")
def api_token():
"""Load API token from system settings."""
return get_setting_value("API_TOKEN")
@pytest.fixture
def client():
"""Flask test client."""
with app.test_client() as client:
yield client
def auth_headers(token):
"""Helper to construct Authorization header."""
return {"Authorization": f"Bearer {token}"}
# ========================================================================
# POST /sync - authentication
# ========================================================================
def test_sync_post_no_token_is_forbidden(client):
resp = client.post("/sync")
assert resp.status_code == 403
def test_sync_post_invalid_token_is_forbidden(client):
resp = client.post("/sync", headers=auth_headers("INVALID-TOKEN"))
assert resp.status_code == 403
# ========================================================================
# POST /sync - content-type enforcement
# Regression: node used to send data= (form-encoded); validation rejects it.
# ========================================================================
def test_sync_post_form_encoded_returns_415(client, api_token):
"""Form-encoded body must be rejected with 415 Unsupported Media Type.
Regression test: before the fix sync.py used ``requests.post(data=…)``
which sends application/x-www-form-urlencoded. The validate_request
middleware requires application/json — this test ensures that contract
is enforced so the node can never silently regress to form encoding.
"""
resp = client.post(
"/sync",
headers=auth_headers(api_token),
data={"data": "payload", "plugin": "ARPSCAN", "node_name": "Node1"},
content_type="application/x-www-form-urlencoded",
)
assert resp.status_code == 415
def test_sync_post_json_body_is_accepted(client, api_token, tmp_path, monkeypatch):
"""JSON body must pass validation and return 200."""
plugins_dir = tmp_path / "log" / "plugins"
plugins_dir.mkdir(parents=True)
monkeypatch.setenv("NETALERTX_PLUGINS_LOG", str(plugins_dir))
resp = client.post(
"/sync",
headers=auth_headers(api_token),
json={"data": "test_payload", "plugin": "TESTPLUGIN", "node_name": "TestNode"},
)
assert resp.status_code == 200
data = resp.get_json()
assert data is not None
assert "message" in data
def test_sync_post_json_body_writes_encoded_file(client, api_token, tmp_path, monkeypatch):
"""A successful POST must persist an encoded file in the plugins log dir."""
plugins_dir = tmp_path / "log" / "plugins"
plugins_dir.mkdir(parents=True)
monkeypatch.setenv("NETALERTX_PLUGINS_LOG", str(plugins_dir))
client.post(
"/sync",
headers=auth_headers(api_token),
json={"data": "encrypted_blob", "plugin": "ARPSCAN", "node_name": "Node1"},
)
written = list(plugins_dir.glob("last_result.ARPSCAN.encoded.Node1.*.log"))
assert len(written) == 1
assert written[0].read_text() == "encrypted_blob"
# ========================================================================
# GET /sync - authentication
# ========================================================================
def test_sync_get_no_token_is_forbidden(client):
resp = client.get("/sync")
assert resp.status_code == 403
def test_sync_get_invalid_token_is_forbidden(client):
resp = client.get("/sync", headers=auth_headers("INVALID-TOKEN"))
assert resp.status_code == 403
+15 -15
View File
@@ -20,10 +20,10 @@ class SafeConditionBuilder:
# Whitelist of allowed column names for filtering
ALLOWED_COLUMNS = {
"eve_MAC",
"eve_DateTime",
"eve_IP",
"eve_EventType",
"eveMac",
"eveDateTime",
"eveIp",
"eveEventType",
"devName",
"devComments",
"devLastIP",
@@ -34,15 +34,15 @@ class SafeConditionBuilder:
"devPresentLastScan",
"devFavorite",
"devIsNew",
"Plugin",
"Object_PrimaryId",
"Object_SecondaryId",
"DateTimeChanged",
"Watched_Value1",
"Watched_Value2",
"Watched_Value3",
"Watched_Value4",
"Status",
"plugin",
"objectPrimaryId",
"objectSecondaryId",
"dateTimeChanged",
"watchedValue1",
"watchedValue2",
"watchedValue3",
"watchedValue4",
"status",
}
# Whitelist of allowed comparison operators
@@ -403,7 +403,7 @@ class SafeConditionBuilder:
This method handles basic patterns like:
- devName = 'value' (with optional AND/OR prefix)
- devComments LIKE '%value%'
- eve_EventType IN ('type1', 'type2')
- eveEventType IN ('type1', 'type2')
Args:
condition: Single condition string to parse
@@ -633,7 +633,7 @@ class SafeConditionBuilder:
self.parameters[param_name] = event_type
param_names.append(f":{param_name}")
sql_snippet = f"AND eve_EventType IN ({', '.join(param_names)})"
sql_snippet = f"AND eveEventType IN ({', '.join(param_names)})"
return sql_snippet, self.parameters
def get_safe_condition_legacy(
+1 -1
View File
@@ -174,7 +174,7 @@ def test_compound_with_like_patterns(builder):
def test_compound_with_inequality_operators(builder):
"""Test compound conditions with various inequality operators."""
condition = "AND eve_DateTime > '2024-01-01' AND eve_DateTime < '2024-12-31'"
condition = "AND eveDateTime > '2024-01-01' AND eveDateTime < '2024-12-31'"
sql, params = builder.build_safe_condition(condition)
+64 -31
View File
@@ -31,24 +31,26 @@ def _make_json(section, devices, column_names, title="Test Section"):
SAMPLE_NEW_DEVICES = [
{
"MAC": "AA:BB:CC:DD:EE:FF",
"Datetime": "2025-01-15 10:30:00",
"IP": "192.168.1.42",
"Event Type": "New Device",
"Device name": "MyPhone",
"Comments": "",
"devName": "MyPhone",
"eveMac": "aa:bb:cc:dd:ee:ff",
"devVendor": "",
"eveIp": "192.168.1.42",
"eveDateTime": "2025-01-15 10:30:00",
"eveEventType": "New Device",
"devComments": "",
},
{
"MAC": "11:22:33:44:55:66",
"Datetime": "2025-01-15 11:00:00",
"IP": "192.168.1.99",
"Event Type": "New Device",
"Device name": "Laptop",
"Comments": "Office",
"devName": "Laptop",
"eveMac": "11:22:33:44:55:66",
"devVendor": "Dell",
"eveIp": "192.168.1.99",
"eveDateTime": "2025-01-15 11:00:00",
"eveEventType": "New Device",
"devComments": "Office",
},
]
NEW_DEVICE_COLUMNS = ["MAC", "Datetime", "IP", "Event Type", "Device name", "Comments"]
NEW_DEVICE_COLUMNS = ["devName", "eveMac", "devVendor", "eveIp", "eveDateTime", "eveEventType", "devComments"]
class TestConstructNotificationsTemplates(unittest.TestCase):
@@ -98,9 +100,9 @@ class TestConstructNotificationsTemplates(unittest.TestCase):
self.assertIn("---------", text)
# Legacy format: each header appears as "Header: \tValue"
self.assertIn("MAC:", text)
self.assertIn("AA:BB:CC:DD:EE:FF", text)
self.assertIn("Device name:", text)
self.assertIn("eveMac:", text)
self.assertIn("aa:bb:cc:dd:ee:ff", text)
self.assertIn("devName:", text)
self.assertIn("MyPhone", text)
# HTML must still be generated
@@ -115,7 +117,7 @@ class TestConstructNotificationsTemplates(unittest.TestCase):
mock_setting.side_effect = self._setting_factory({
"NTFPRCS_TEXT_SECTION_HEADERS": True,
"NTFPRCS_TEXT_TEMPLATE_new_devices": "{Device name} ({MAC}) - {IP}",
"NTFPRCS_TEXT_TEMPLATE_new_devices": "{devName} ({eveMac}) - {eveIp}",
})
json_data = _make_json(
@@ -123,7 +125,7 @@ class TestConstructNotificationsTemplates(unittest.TestCase):
)
_, text = construct_notifications(json_data, "new_devices")
self.assertIn("MyPhone (AA:BB:CC:DD:EE:FF) - 192.168.1.42", text)
self.assertIn("MyPhone (aa:bb:cc:dd:ee:ff) - 192.168.1.42", text)
self.assertIn("Laptop (11:22:33:44:55:66) - 192.168.1.99", text)
# -----------------------------------------------------------------
@@ -135,7 +137,7 @@ class TestConstructNotificationsTemplates(unittest.TestCase):
mock_setting.side_effect = self._setting_factory({
"NTFPRCS_TEXT_SECTION_HEADERS": True,
"NTFPRCS_TEXT_TEMPLATE_new_devices": "{Device name} - {NonExistent}",
"NTFPRCS_TEXT_TEMPLATE_new_devices": "{devName} - {NonExistent}",
})
json_data = _make_json(
@@ -155,7 +157,7 @@ class TestConstructNotificationsTemplates(unittest.TestCase):
mock_setting.side_effect = self._setting_factory({
"NTFPRCS_TEXT_SECTION_HEADERS": False,
"NTFPRCS_TEXT_TEMPLATE_new_devices": "{Device name} ({MAC})",
"NTFPRCS_TEXT_TEMPLATE_new_devices": "{devName} ({eveMac})",
})
json_data = _make_json(
@@ -166,7 +168,7 @@ class TestConstructNotificationsTemplates(unittest.TestCase):
self.assertNotIn("🆕 New devices", text)
self.assertNotIn("---------", text)
# Template output still present
self.assertIn("MyPhone (AA:BB:CC:DD:EE:FF)", text)
self.assertIn("MyPhone (aa:bb:cc:dd:ee:ff)", text)
# -----------------------------------------------------------------
# Section headers enabled (default when setting absent/empty)
@@ -196,7 +198,7 @@ class TestConstructNotificationsTemplates(unittest.TestCase):
mock_setting.side_effect = self._setting_factory({
"NTFPRCS_TEXT_SECTION_HEADERS": True,
"NTFPRCS_TEXT_TEMPLATE_new_devices": "{Device name} ({BadField}) - {IP}",
"NTFPRCS_TEXT_TEMPLATE_new_devices": "{devName} ({BadField}) - {eveIp}",
})
json_data = _make_json(
@@ -207,7 +209,7 @@ class TestConstructNotificationsTemplates(unittest.TestCase):
self.assertIn("MyPhone ({BadField}) - 192.168.1.42", text)
# -----------------------------------------------------------------
# Down devices section uses different column names
# Down devices section uses same column names as all other sections
# -----------------------------------------------------------------
@patch("models.notification_instance.get_setting_value")
def test_down_devices_template(self, mock_setting):
@@ -215,25 +217,56 @@ class TestConstructNotificationsTemplates(unittest.TestCase):
mock_setting.side_effect = self._setting_factory({
"NTFPRCS_TEXT_SECTION_HEADERS": True,
"NTFPRCS_TEXT_TEMPLATE_down_devices": "{devName} ({eve_MAC}) down since {eve_DateTime}",
"NTFPRCS_TEXT_TEMPLATE_down_devices": "{devName} ({eveMac}) down since {eveDateTime}",
})
down_devices = [
{
"devName": "Router",
"eve_MAC": "FF:EE:DD:CC:BB:AA",
"eveMac": "ff:ee:dd:cc:bb:aa",
"devVendor": "Cisco",
"eve_IP": "10.0.0.1",
"eve_DateTime": "2025-01-15 08:00:00",
"eve_EventType": "Device Down",
"eveIp": "10.0.0.1",
"eveDateTime": "2025-01-15 08:00:00",
"eveEventType": "Device Down",
"devComments": "",
}
]
columns = ["devName", "eve_MAC", "devVendor", "eve_IP", "eve_DateTime", "eve_EventType"]
columns = ["devName", "eveMac", "devVendor", "eveIp", "eveDateTime", "eveEventType", "devComments"]
json_data = _make_json("down_devices", down_devices, columns, "🔴 Down devices")
_, text = construct_notifications(json_data, "down_devices")
self.assertIn("Router (FF:EE:DD:CC:BB:AA) down since 2025-01-15 08:00:00", text)
self.assertIn("Router (ff:ee:dd:cc:bb:aa) down since 2025-01-15 08:00:00", text)
# -----------------------------------------------------------------
# Down reconnected section uses same unified column names
# -----------------------------------------------------------------
@patch("models.notification_instance.get_setting_value")
def test_down_reconnected_template(self, mock_setting):
from models.notification_instance import construct_notifications
mock_setting.side_effect = self._setting_factory({
"NTFPRCS_TEXT_SECTION_HEADERS": True,
"NTFPRCS_TEXT_TEMPLATE_down_reconnected": "{devName} ({eveMac}) reconnected at {eveDateTime}",
})
reconnected = [
{
"devName": "Switch",
"eveMac": "aa:11:bb:22:cc:33",
"devVendor": "Netgear",
"eveIp": "10.0.0.2",
"eveDateTime": "2025-01-15 09:30:00",
"eveEventType": "Down Reconnected",
"devComments": "",
}
]
columns = ["devName", "eveMac", "devVendor", "eveIp", "eveDateTime", "eveEventType", "devComments"]
json_data = _make_json("down_reconnected", reconnected, columns, "🔁 Reconnected down devices")
_, text = construct_notifications(json_data, "down_reconnected")
self.assertIn("Switch (aa:11:bb:22:cc:33) reconnected at 2025-01-15 09:30:00", text)
# -----------------------------------------------------------------
# HTML output is unchanged regardless of template config
@@ -255,7 +288,7 @@ class TestConstructNotificationsTemplates(unittest.TestCase):
# Get HTML with template
mock_setting.side_effect = self._setting_factory({
"NTFPRCS_TEXT_SECTION_HEADERS": True,
"NTFPRCS_TEXT_TEMPLATE_new_devices": "{Device name} ({MAC})",
"NTFPRCS_TEXT_TEMPLATE_new_devices": "{devName} ({eveMac})",
})
html_with, _ = construct_notifications(json_data, "new_devices")
+1 -1
View File
@@ -15,7 +15,7 @@ sys.modules['logger'] = Mock()
class SafeConditionBuilderForTesting:
"""Minimal SafeConditionBuilder implementation for tests."""
ALLOWED_COLUMNS = {'devName', 'eve_MAC', 'eve_EventType'}
ALLOWED_COLUMNS = {'devName', 'eveMac', 'eveEventType'}
ALLOWED_OPERATORS = {'=', '!=', '<', '>', '<=', '>=', 'LIKE', 'NOT LIKE'}
ALLOWED_LOGICAL_OPERATORS = {'AND', 'OR'}
@@ -174,13 +174,13 @@ def test_null_byte_injection(builder):
def test_build_condition_with_allowed_values(builder):
"""Test building condition with specific allowed values."""
conditions = [
{"column": "eve_EventType", "operator": "=", "value": "Connected"},
{"column": "eveEventType", "operator": "=", "value": "Connected"},
{"column": "devName", "operator": "LIKE", "value": "%test%"}
]
condition, params = builder.build_condition(conditions, "AND")
# Should create valid parameterized condition
assert "eve_EventType = :" in condition
assert "eveEventType = :" in condition
assert "devName LIKE :" in condition
assert len(params) == 2
+7 -7
View File
@@ -58,9 +58,9 @@ class TestSafeConditionBuilder(unittest.TestCase):
def test_validate_column_name(self):
"""Test column name validation against whitelist."""
# Valid columns
self.assertTrue(self.builder._validate_column_name('eve_MAC'))
self.assertTrue(self.builder._validate_column_name('eveMac'))
self.assertTrue(self.builder._validate_column_name('devName'))
self.assertTrue(self.builder._validate_column_name('eve_EventType'))
self.assertTrue(self.builder._validate_column_name('eveEventType'))
# Invalid columns
self.assertFalse(self.builder._validate_column_name('malicious_column'))
@@ -103,9 +103,9 @@ class TestSafeConditionBuilder(unittest.TestCase):
def test_build_in_condition_valid(self):
"""Test building valid IN conditions."""
sql, params = self.builder._build_in_condition('AND', 'eve_EventType', 'IN', "'Connected', 'Disconnected'")
sql, params = self.builder._build_in_condition('AND', 'eveEventType', 'IN', "'Connected', 'Disconnected'")
self.assertIn('AND eve_EventType IN', sql)
self.assertIn('AND eveEventType IN', sql)
self.assertEqual(len(params), 2)
self.assertIn('Connected', params.values())
self.assertIn('Disconnected', params.values())
@@ -162,7 +162,7 @@ class TestSafeConditionBuilder(unittest.TestCase):
event_types = ['Connected', 'Disconnected']
sql, params = self.builder.build_event_type_filter(event_types)
self.assertIn('AND eve_EventType IN', sql)
self.assertIn('AND eveEventType IN', sql)
self.assertEqual(len(params), 2)
self.assertIn('Connected', params.values())
self.assertIn('Disconnected', params.values())
@@ -354,9 +354,9 @@ class TestSecurityBenchmarks(unittest.TestCase):
"""Test coverage of condition patterns."""
patterns_tested = [
"AND devName = 'value'",
"OR eve_EventType LIKE '%test%'",
"OR eveEventType LIKE '%test%'",
"AND devComments IS NULL",
"AND eve_EventType IN ('Connected', 'Disconnected')",
"AND eveEventType IN ('Connected', 'Disconnected')",
]
for pattern in patterns_tested:
+307
View File
@@ -0,0 +1,307 @@
"""
Unit tests for migrate_to_camelcase() in db_upgrade.
Covers:
- Already-migrated schema (eveMac present) → skip, return True
- Unrecognised schema (neither eveMac nor eve_MAC) → skip, return True
- Legacy Events columns renamed to camelCase equivalents
- Legacy Sessions columns renamed to camelCase equivalents
- Legacy Online_History columns renamed to camelCase equivalents
- Legacy Plugins_Objects columns renamed to camelCase equivalents
- Legacy Plugins_Language_Strings columns renamed to camelCase equivalents
- Missing tables are silently skipped without error
- Existing row data is preserved through the column rename
- Views referencing old column names are dropped before ALTER TABLE runs
- Migration is idempotent (second call detects eveMac and returns early)
"""
import sys
import os
import sqlite3
INSTALL_PATH = os.getenv('NETALERTX_APP', '/app')
sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
from db.db_upgrade import migrate_to_camelcase # noqa: E402
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_cursor():
"""Return an in-memory SQLite cursor and its parent connection."""
conn = sqlite3.connect(":memory:")
return conn.cursor(), conn
def _col_names(cursor, table):
"""Return the set of column names for a given table."""
cursor.execute(f'PRAGMA table_info("{table}")')
return {row[1] for row in cursor.fetchall()}
# ---------------------------------------------------------------------------
# Legacy DDL fixtures (pre-migration schema with old column names)
# ---------------------------------------------------------------------------
_LEGACY_EVENTS_DDL = """
CREATE TABLE Events (
eve_MAC TEXT NOT NULL,
eve_IP TEXT NOT NULL,
eve_DateTime DATETIME NOT NULL,
eve_EventType TEXT NOT NULL,
eve_AdditionalInfo TEXT DEFAULT '',
eve_PendingAlertEmail INTEGER NOT NULL DEFAULT 1,
eve_PairEventRowid INTEGER
)
"""
_LEGACY_SESSIONS_DDL = """
CREATE TABLE Sessions (
ses_MAC TEXT,
ses_IP TEXT,
ses_EventTypeConnection TEXT,
ses_DateTimeConnection DATETIME,
ses_EventTypeDisconnection TEXT,
ses_DateTimeDisconnection DATETIME,
ses_StillConnected INTEGER,
ses_AdditionalInfo TEXT
)
"""
_LEGACY_ONLINE_HISTORY_DDL = """
CREATE TABLE Online_History (
"Index" INTEGER PRIMARY KEY AUTOINCREMENT,
"Scan_Date" TEXT,
"Online_Devices" INTEGER,
"Down_Devices" INTEGER,
"All_Devices" INTEGER,
"Archived_Devices" INTEGER,
"Offline_Devices" INTEGER
)
"""
_LEGACY_PLUGINS_OBJECTS_DDL = """
CREATE TABLE Plugins_Objects (
"Index" INTEGER PRIMARY KEY AUTOINCREMENT,
Plugin TEXT NOT NULL,
Object_PrimaryID TEXT NOT NULL,
Object_SecondaryID TEXT NOT NULL,
DateTimeCreated TEXT NOT NULL,
DateTimeChanged TEXT NOT NULL,
Watched_Value1 TEXT NOT NULL,
Watched_Value2 TEXT NOT NULL,
Watched_Value3 TEXT NOT NULL,
Watched_Value4 TEXT NOT NULL,
Status TEXT NOT NULL,
Extra TEXT NOT NULL,
UserData TEXT NOT NULL,
ForeignKey TEXT NOT NULL,
SyncHubNodeName TEXT,
HelpVal1 TEXT,
HelpVal2 TEXT,
HelpVal3 TEXT,
HelpVal4 TEXT,
ObjectGUID TEXT
)
"""
_LEGACY_PLUGINS_LANG_DDL = """
CREATE TABLE Plugins_Language_Strings (
"Index" INTEGER PRIMARY KEY AUTOINCREMENT,
Language_Code TEXT NOT NULL,
String_Key TEXT NOT NULL,
String_Value TEXT NOT NULL,
Extra TEXT NOT NULL
)
"""
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestMigrateToCamelCase:
def test_returns_true_if_already_camelcase(self):
"""DB already on camelCase schema → skip silently, return True."""
cur, conn = _make_cursor()
cur.execute("""
CREATE TABLE Events (
eveMac TEXT NOT NULL, eveIp TEXT NOT NULL,
eveDateTime DATETIME NOT NULL, eveEventType TEXT NOT NULL,
eveAdditionalInfo TEXT, evePendingAlertEmail INTEGER,
evePairEventRowid INTEGER
)
""")
conn.commit()
result = migrate_to_camelcase(cur)
assert result is True
assert "eveMac" in _col_names(cur, "Events")
def test_returns_true_if_unknown_schema(self):
"""Events exists but has neither eve_MAC nor eveMac → skip, return True."""
cur, conn = _make_cursor()
cur.execute("CREATE TABLE Events (someOtherCol TEXT)")
conn.commit()
result = migrate_to_camelcase(cur)
assert result is True
def test_events_legacy_columns_renamed(self):
"""All legacy eve_* columns are renamed to their camelCase equivalents."""
cur, conn = _make_cursor()
cur.execute(_LEGACY_EVENTS_DDL)
conn.commit()
result = migrate_to_camelcase(cur)
assert result is True
cols = _col_names(cur, "Events")
expected_new = {
"eveMac", "eveIp", "eveDateTime", "eveEventType",
"eveAdditionalInfo", "evePendingAlertEmail", "evePairEventRowid",
}
old_names = {
"eve_MAC", "eve_IP", "eve_DateTime", "eve_EventType",
"eve_AdditionalInfo", "eve_PendingAlertEmail", "eve_PairEventRowid",
}
assert expected_new.issubset(cols), f"Missing new columns: {expected_new - cols}"
assert not old_names & cols, f"Old columns still present: {old_names & cols}"
def test_sessions_legacy_columns_renamed(self):
"""All legacy ses_* columns are renamed to their camelCase equivalents."""
cur, conn = _make_cursor()
cur.execute(_LEGACY_EVENTS_DDL)
cur.execute(_LEGACY_SESSIONS_DDL)
conn.commit()
result = migrate_to_camelcase(cur)
assert result is True
cols = _col_names(cur, "Sessions")
assert {
"sesMac", "sesIp", "sesEventTypeConnection", "sesDateTimeConnection",
"sesEventTypeDisconnection", "sesDateTimeDisconnection",
"sesStillConnected", "sesAdditionalInfo",
}.issubset(cols)
assert not {"ses_MAC", "ses_IP", "ses_DateTimeConnection"} & cols
def test_online_history_legacy_columns_renamed(self):
"""Quoted legacy Online_History column names are renamed to camelCase."""
cur, conn = _make_cursor()
cur.execute(_LEGACY_EVENTS_DDL)
cur.execute(_LEGACY_ONLINE_HISTORY_DDL)
conn.commit()
migrate_to_camelcase(cur)
cols = _col_names(cur, "Online_History")
assert {
"scanDate", "onlineDevices", "downDevices",
"allDevices", "archivedDevices", "offlineDevices",
}.issubset(cols)
assert not {"Scan_Date", "Online_Devices", "Down_Devices"} & cols
def test_plugins_objects_legacy_columns_renamed(self):
"""All renamed Plugins_Objects columns receive their camelCase names."""
cur, conn = _make_cursor()
cur.execute(_LEGACY_EVENTS_DDL)
cur.execute(_LEGACY_PLUGINS_OBJECTS_DDL)
conn.commit()
migrate_to_camelcase(cur)
cols = _col_names(cur, "Plugins_Objects")
assert {
"plugin", "objectPrimaryId", "objectSecondaryId",
"dateTimeCreated", "dateTimeChanged",
"watchedValue1", "watchedValue2", "watchedValue3", "watchedValue4",
"status", "extra", "userData", "foreignKey", "syncHubNodeName",
"helpVal1", "helpVal2", "helpVal3", "helpVal4", "objectGuid",
}.issubset(cols)
assert not {
"Object_PrimaryID", "Watched_Value1", "ObjectGUID",
"ForeignKey", "UserData", "Plugin",
} & cols
def test_plugins_language_strings_renamed(self):
"""Plugins_Language_Strings legacy column names are renamed to camelCase."""
cur, conn = _make_cursor()
cur.execute(_LEGACY_EVENTS_DDL)
cur.execute(_LEGACY_PLUGINS_LANG_DDL)
conn.commit()
migrate_to_camelcase(cur)
cols = _col_names(cur, "Plugins_Language_Strings")
assert {"languageCode", "stringKey", "stringValue", "extra"}.issubset(cols)
assert not {"Language_Code", "String_Key", "String_Value"} & cols
def test_missing_table_silently_skipped(self):
"""Tables in the migration map that don't exist are skipped without error."""
cur, conn = _make_cursor()
# Only Events (legacy) exists — all other mapped tables are absent
cur.execute(_LEGACY_EVENTS_DDL)
conn.commit()
result = migrate_to_camelcase(cur)
assert result is True
assert "eveMac" in _col_names(cur, "Events")
def test_data_preserved_after_rename(self):
"""Existing rows remain accessible under the new camelCase column names."""
cur, conn = _make_cursor()
cur.execute(_LEGACY_EVENTS_DDL)
cur.execute(
"INSERT INTO Events (eve_MAC, eve_IP, eve_DateTime, eve_EventType) "
"VALUES ('aa:bb:cc:dd:ee:ff', '192.168.1.1', '2025-01-01 12:00:00', 'Connected')"
)
conn.commit()
migrate_to_camelcase(cur)
cur.execute(
"SELECT eveMac, eveIp, eveEventType FROM Events WHERE eveMac = 'aa:bb:cc:dd:ee:ff'"
)
row = cur.fetchone()
assert row is not None, "Row missing after camelCase migration"
assert row[0] == "aa:bb:cc:dd:ee:ff"
assert row[1] == "192.168.1.1"
assert row[2] == "Connected"
def test_views_dropped_before_migration(self):
"""Views referencing old column names do not block ALTER TABLE RENAME COLUMN."""
cur, conn = _make_cursor()
cur.execute(_LEGACY_EVENTS_DDL)
# A view that references old column names would normally block the rename
cur.execute("CREATE VIEW Events_Devices AS SELECT eve_MAC, eve_IP FROM Events")
conn.commit()
result = migrate_to_camelcase(cur)
assert result is True
assert "eveMac" in _col_names(cur, "Events")
# View is dropped (ensure_views() is responsible for recreation separately)
cur.execute("SELECT name FROM sqlite_master WHERE type='view' AND name='Events_Devices'")
assert cur.fetchone() is None
def test_idempotent_second_run(self):
"""Running migration twice is safe — second call detects eveMac and exits early."""
cur, conn = _make_cursor()
cur.execute(_LEGACY_EVENTS_DDL)
conn.commit()
first = migrate_to_camelcase(cur)
second = migrate_to_camelcase(cur)
assert first is True
assert second is True
cols = _col_names(cur, "Events")
assert "eveMac" in cols
assert "eve_MAC" not in cols
+26 -26
View File
@@ -25,26 +25,26 @@ def _make_db():
cur.execute("""
CREATE TABLE Events (
eve_MAC TEXT NOT NULL,
eve_IP TEXT NOT NULL,
eve_DateTime DATETIME NOT NULL,
eve_EventType TEXT NOT NULL,
eve_AdditionalInfo TEXT DEFAULT '',
eve_PendingAlertEmail INTEGER NOT NULL DEFAULT 1,
eve_PairEventRowid INTEGER
eveMac TEXT NOT NULL,
eveIp TEXT NOT NULL,
eveDateTime DATETIME NOT NULL,
eveEventType TEXT NOT NULL,
eveAdditionalInfo TEXT DEFAULT '',
evePendingAlertEmail INTEGER NOT NULL DEFAULT 1,
evePairEventRowid INTEGER
)
""")
cur.execute("""
CREATE TABLE Sessions (
ses_MAC TEXT,
ses_IP TEXT,
ses_EventTypeConnection TEXT,
ses_DateTimeConnection DATETIME,
ses_EventTypeDisconnection TEXT,
ses_DateTimeDisconnection DATETIME,
ses_StillConnected INTEGER,
ses_AdditionalInfo TEXT
sesMac TEXT,
sesIp TEXT,
sesEventTypeConnection TEXT,
sesDateTimeConnection DATETIME,
sesEventTypeDisconnection TEXT,
sesDateTimeDisconnection DATETIME,
sesStillConnected INTEGER,
sesAdditionalInfo TEXT
)
""")
@@ -59,13 +59,13 @@ def _seed_sessions(cur, old_count: int, recent_count: int, days: int):
"""
for i in range(old_count):
cur.execute(
"INSERT INTO Sessions (ses_MAC, ses_DateTimeConnection) "
"INSERT INTO Sessions (sesMac, sesDateTimeConnection) "
"VALUES (?, date('now', ?))",
(f"AA:BB:CC:DD:EE:{i:02X}", f"-{days + 1} day"),
)
for i in range(recent_count):
cur.execute(
"INSERT INTO Sessions (ses_MAC, ses_DateTimeConnection) "
"INSERT INTO Sessions (sesMac, sesDateTimeConnection) "
"VALUES (?, date('now'))",
(f"11:22:33:44:55:{i:02X}",),
)
@@ -75,7 +75,7 @@ def _run_sessions_trim(cur, days: int) -> int:
"""Execute the exact DELETE used by db_cleanup and return rowcount."""
cur.execute(
f"DELETE FROM Sessions "
f"WHERE ses_DateTimeConnection <= date('now', '-{days} day')"
f"WHERE sesDateTimeConnection <= date('now', '-{days} day')"
)
return cur.rowcount
@@ -126,20 +126,20 @@ class TestSessionsTrim:
cur = conn.cursor()
# Row exactly AT the boundary (date = 'now' - days exactly)
cur.execute(
"INSERT INTO Sessions (ses_MAC, ses_DateTimeConnection) "
"INSERT INTO Sessions (sesMac, sesDateTimeConnection) "
"VALUES (?, date('now', ?))",
("AA:BB:CC:00:00:01", "-30 day"),
)
# Row just inside the window
cur.execute(
"INSERT INTO Sessions (ses_MAC, ses_DateTimeConnection) "
"INSERT INTO Sessions (sesMac, sesDateTimeConnection) "
"VALUES (?, date('now', '-29 day'))",
("AA:BB:CC:00:00:02",),
)
_run_sessions_trim(cur, days=30)
cur.execute("SELECT ses_MAC FROM Sessions")
cur.execute("SELECT sesMac FROM Sessions")
remaining_macs = {row[0] for row in cur.fetchall()}
# Boundary row (== threshold) is deleted; inside row survives
assert "AA:BB:CC:00:00:02" in remaining_macs, "Row inside window was wrongly deleted"
@@ -157,8 +157,8 @@ class TestSessionsTrim:
with open(script_path) as fh:
source = fh.read()
events_expr = "DELETE FROM Events WHERE eve_DateTime <= date('now', '-{str(DAYS_TO_KEEP_EVENTS)} day')"
sessions_expr = "DELETE FROM Sessions WHERE ses_DateTimeConnection <= date('now', '-{str(DAYS_TO_KEEP_EVENTS)} day')"
events_expr = "DELETE FROM Events WHERE eveDateTime <= date('now', '-{str(DAYS_TO_KEEP_EVENTS)} day')"
sessions_expr = "DELETE FROM Sessions WHERE sesDateTimeConnection <= date('now', '-{str(DAYS_TO_KEEP_EVENTS)} day')"
assert events_expr in source, "Events DELETE expression changed unexpectedly"
assert sessions_expr in source, "Sessions DELETE is not aligned with Events DELETE"
@@ -181,7 +181,7 @@ class TestAnalyze:
# Seed some rows so ANALYZE has something to measure
for i in range(20):
cur.execute(
"INSERT INTO Events (eve_MAC, eve_IP, eve_DateTime, eve_EventType) "
"INSERT INTO Events (eveMac, eveIp, eveDateTime, eveEventType) "
"VALUES (?, '1.2.3.4', date('now'), 'Connected')",
(f"AA:BB:CC:DD:EE:{i:02X}",),
)
@@ -238,7 +238,7 @@ class TestPragmaOptimize:
for i in range(50):
cur.execute(
"INSERT INTO Sessions (ses_MAC, ses_DateTimeConnection) "
"INSERT INTO Sessions (sesMac, sesDateTimeConnection) "
"VALUES (?, date('now', '-60 day'))",
(f"AA:BB:CC:DD:EE:{i:02X}",),
)
@@ -247,7 +247,7 @@ class TestPragmaOptimize:
# Mirror the tail sequence from cleanup_database.
# WAL checkpoints are omitted: they require no open transaction and are
# not supported on :memory: databases (SQLite raises OperationalError).
cur.execute("DELETE FROM Sessions WHERE ses_DateTimeConnection <= date('now', '-30 day')")
cur.execute("DELETE FROM Sessions WHERE sesDateTimeConnection <= date('now', '-30 day')")
conn.commit()
cur.execute("ANALYZE;")
conn.execute("VACUUM;")
+267 -23
View File
@@ -6,6 +6,7 @@ Import from any test subdirectory with:
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from db_test_helpers import make_db, insert_device, minutes_ago, DummyDB, down_event_macs, make_device_dict, sync_insert_devices
from db_test_helpers import make_plugin_db, make_plugin_dict, make_plugin_event_row, seed_plugin_object, plugin_history_rows, plugin_objects_rows, PluginFakeDB
"""
import sqlite3
@@ -76,16 +77,16 @@ CREATE_DEVICES = """
)
"""
# Includes eve_PairEventRowid — required by insert_events().
# Includes evePairEventRowid — required by insert_events().
CREATE_EVENTS = """
CREATE TABLE IF NOT EXISTS Events (
eve_MAC TEXT,
eve_IP TEXT,
eve_DateTime TEXT,
eve_EventType TEXT,
eve_AdditionalInfo TEXT,
eve_PendingAlertEmail INTEGER,
eve_PairEventRowid INTEGER
eveMac TEXT,
eveIp TEXT,
eveDateTime TEXT,
eveEventType TEXT,
eveAdditionalInfo TEXT,
evePendingAlertEmail INTEGER,
evePairEventRowid INTEGER
)
"""
@@ -171,6 +172,7 @@ def insert_device(
can_sleep: int = 0,
last_connection: str | None = None,
last_ip: str = "192.168.1.1",
force_status: str | None = None,
) -> None:
"""
Insert a minimal Devices row.
@@ -189,16 +191,19 @@ def insert_device(
ISO-8601 UTC string; defaults to 60 minutes ago when omitted.
last_ip:
Value stored in devLastIP.
force_status:
Value for devForceStatus (``'online'``, ``'offline'``, or ``None``/
``'dont_force'``).
"""
cur.execute(
"""
INSERT INTO Devices
(devMac, devAlertDown, devPresentLastScan, devCanSleep,
devLastConnection, devLastIP, devIsArchived, devIsNew)
VALUES (?, ?, ?, ?, ?, ?, 0, 0)
devLastConnection, devLastIP, devIsArchived, devIsNew, devForceStatus)
VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?)
""",
(mac, alert_down, present_last_scan, can_sleep,
last_connection or minutes_ago(60), last_ip),
last_connection or minutes_ago(60), last_ip, force_status),
)
@@ -278,43 +283,84 @@ def sync_insert_devices(
conn: sqlite3.Connection,
device_data: list,
existing_macs: set | None = None,
behavior: str = "copy-new",
) -> int:
"""
Schema-aware device INSERT mirroring sync.py's Mode-3 insert block.
Schema-aware device write mirroring sync.py's Mode-3 SYNC_BEHAVIOR block.
Parameters
----------
conn:
In-memory (or real) SQLite connection with a Devices table.
In-memory (or real) SQLite connection with Devices and Events tables.
device_data:
List of device dicts as received from table_devices.json or a node log.
existing_macs:
Set of MAC addresses already present in Devices. Rows whose devMac is
in this set are skipped. Pass ``None`` (default) to insert everything.
Set of MAC addresses already present in Devices. Used to compute
genuinely new MACs for the Events INSERT and (for ``copy-new``) to
filter write candidates. Pass ``None`` to treat every device as new.
behavior:
One of ``"copy-new"`` (default), ``"carbon-copy"``, or
``"hub-defaults"``.
Returns the number of rows actually inserted.
``copy-new`` — INSERT OR IGNORE for new MACs only (current default).
``carbon-copy`` — UPSERT (INSERT … ON CONFLICT DO UPDATE) for all MACs.
``hub-defaults``— skip write entirely; hub pipeline handles new devices
and their Events rows.
Returns the number of device rows written (0 for ``hub-defaults``).
Side-effect: inserts an Events row with eveEventType='New Device' for each
genuinely new MAC when behavior is ``copy-new`` or ``carbon-copy``.
"""
if not device_data:
if not device_data or behavior == "hub-defaults":
return 0
cursor = conn.cursor()
candidates = (
# Genuinely new MACs — drives the Events INSERT for both non-hub-defaults modes.
new_devices = (
[d for d in device_data if d["devMac"] not in existing_macs]
if existing_macs is not None
else list(device_data)
)
# Fire "New Device" events before the Devices INSERT pre-seeds the table.
if new_devices:
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
cursor.executemany(
"""INSERT OR IGNORE INTO Events
(eveMac, eveIp, eveDateTime, eveEventType, eveAdditionalInfo, evePendingAlertEmail)
VALUES (?, ?, ?, 'New Device', ?, 1)""",
[(d["devMac"], d.get("devLastIP", ""), now, d.get("devVendor", ""))
for d in new_devices],
)
if behavior == "copy-new":
candidates = new_devices
else: # carbon-copy — process all devices
candidates = list(device_data)
if not candidates:
conn.commit()
return 0
cursor.execute("PRAGMA table_info(Devices)")
db_columns = {row[1] for row in cursor.fetchall()}
insert_cols = [k for k in candidates[0].keys() if k in db_columns]
columns = ", ".join(insert_cols)
insert_cols = [k for k in candidates[0].keys() if k in db_columns]
columns = ", ".join(insert_cols)
placeholders = ", ".join("?" for _ in insert_cols)
sql = f"INSERT INTO Devices ({columns}) VALUES ({placeholders})"
if behavior == "carbon-copy":
_CARBON_COPY_SKIP = {"devMac", "devPresentLastScan"}
update_cols = [col for col in insert_cols if col not in _CARBON_COPY_SKIP]
update_clause = ", ".join(f"{col}=excluded.{col}" for col in update_cols)
sql = (
f"INSERT INTO Devices ({columns}) VALUES ({placeholders}) "
f"ON CONFLICT(devMac) DO UPDATE SET {update_clause}"
)
else:
sql = f"INSERT OR IGNORE INTO Devices ({columns}) VALUES ({placeholders})"
values = [tuple(d.get(col) for col in insert_cols) for d in candidates]
cursor.executemany(sql, values)
conn.commit()
@@ -327,8 +373,8 @@ def sync_insert_devices(
def down_event_macs(cur) -> set:
"""Return the set of MACs that have a 'Device Down' event row (lowercased)."""
cur.execute("SELECT eve_MAC FROM Events WHERE eve_EventType = 'Device Down'")
return {r["eve_MAC"].lower() for r in cur.fetchall()}
cur.execute("SELECT eveMac FROM Events WHERE eveEventType = 'Device Down'")
return {r["eveMac"].lower() for r in cur.fetchall()}
# ---------------------------------------------------------------------------
@@ -347,3 +393,201 @@ class DummyDB:
def commitDB(self) -> None:
self._conn.commit()
# ---------------------------------------------------------------------------
# Plugin tables DDL & helpers (used by test/server/test_plugin_history_filtering.py)
# ---------------------------------------------------------------------------
CREATE_PLUGINS_OBJECTS = """
CREATE TABLE IF NOT EXISTS Plugins_Objects(
"index" INTEGER PRIMARY KEY AUTOINCREMENT,
plugin TEXT NOT NULL,
objectPrimaryId TEXT NOT NULL,
objectSecondaryId TEXT NOT NULL,
dateTimeCreated TEXT NOT NULL,
dateTimeChanged TEXT NOT NULL,
watchedValue1 TEXT NOT NULL,
watchedValue2 TEXT NOT NULL,
watchedValue3 TEXT NOT NULL,
watchedValue4 TEXT NOT NULL,
"status" TEXT NOT NULL,
extra TEXT NOT NULL,
userData TEXT NOT NULL,
foreignKey TEXT NOT NULL,
syncHubNodeName TEXT,
helpVal1 TEXT,
helpVal2 TEXT,
helpVal3 TEXT,
helpVal4 TEXT,
objectGuid TEXT
);
"""
CREATE_PLUGINS_EVENTS = """
CREATE TABLE IF NOT EXISTS Plugins_Events(
"index" INTEGER PRIMARY KEY AUTOINCREMENT,
plugin TEXT NOT NULL,
objectPrimaryId TEXT NOT NULL,
objectSecondaryId TEXT NOT NULL,
dateTimeCreated TEXT NOT NULL,
dateTimeChanged TEXT NOT NULL,
watchedValue1 TEXT NOT NULL,
watchedValue2 TEXT NOT NULL,
watchedValue3 TEXT NOT NULL,
watchedValue4 TEXT NOT NULL,
"status" TEXT NOT NULL,
extra TEXT NOT NULL,
userData TEXT NOT NULL,
foreignKey TEXT NOT NULL,
syncHubNodeName TEXT,
helpVal1 TEXT,
helpVal2 TEXT,
helpVal3 TEXT,
helpVal4 TEXT,
objectGuid TEXT
);
"""
CREATE_PLUGINS_HISTORY = """
CREATE TABLE IF NOT EXISTS Plugins_History(
"index" INTEGER PRIMARY KEY AUTOINCREMENT,
plugin TEXT NOT NULL,
objectPrimaryId TEXT NOT NULL,
objectSecondaryId TEXT NOT NULL,
dateTimeCreated TEXT NOT NULL,
dateTimeChanged TEXT NOT NULL,
watchedValue1 TEXT NOT NULL,
watchedValue2 TEXT NOT NULL,
watchedValue3 TEXT NOT NULL,
watchedValue4 TEXT NOT NULL,
"status" TEXT NOT NULL,
extra TEXT NOT NULL,
userData TEXT NOT NULL,
foreignKey TEXT NOT NULL,
syncHubNodeName TEXT,
helpVal1 TEXT,
helpVal2 TEXT,
helpVal3 TEXT,
helpVal4 TEXT,
objectGuid TEXT
);
"""
class PluginFakeSQL:
"""Wraps a sqlite3.Cursor to provide the interface plugin.py expects."""
def __init__(self, cursor):
self._cursor = cursor
def execute(self, sql, params=None):
if params:
return self._cursor.execute(sql, params)
return self._cursor.execute(sql)
def executemany(self, sql, params_list):
return self._cursor.executemany(sql, params_list)
class PluginFakeDB:
"""Minimal DB facade expected by process_plugin_events."""
def __init__(self, conn):
self.sql_connection = conn
self.sql = PluginFakeSQL(conn.cursor())
def get_sql_array(self, query):
cur = self.sql_connection.cursor()
cur.execute(query)
return cur.fetchall()
def commitDB(self):
self.sql_connection.commit()
def make_plugin_db() -> tuple:
"""
Return a (PluginFakeDB, connection) backed by an in-memory SQLite
database with all three plugin tables created.
"""
conn = sqlite3.connect(":memory:")
conn.executescript(
CREATE_PLUGINS_OBJECTS + CREATE_PLUGINS_EVENTS + CREATE_PLUGINS_HISTORY
)
conn.commit()
db = PluginFakeDB(conn)
return db, conn
def make_plugin_dict(prefix: str, watched_columns=None) -> dict:
"""Return a minimal plugin dict compatible with process_plugin_events."""
return {
"unique_prefix": prefix,
"settings": [
{
"function": "WATCH",
"value": watched_columns or ["watchedValue1"],
},
],
}
def make_plugin_event_row(prefix: str, primary_id: str, secondary_id="sec",
watched1="val1", watched2="", watched3="",
watched4="", changed="2026-01-01 00:00:00",
extra="", user_data="", foreign_key="",
status="not-processed"):
"""Build a tuple mimicking a raw plugin output row (19 columns + index)."""
return (
0, # index (placeholder, not used for events)
prefix, # plugin
primary_id,
secondary_id,
changed, # dateTimeCreated
changed, # dateTimeChanged
watched1,
watched2,
watched3,
watched4,
status,
extra,
user_data,
foreign_key,
None, # syncHubNodeName
None, # helpVal1
None, # helpVal2
None, # helpVal3
None, # helpVal4
)
def seed_plugin_object(cur, prefix: str, primary_id: str,
secondary_id="sec", watched1="val1",
status="watched-not-changed",
changed="2026-01-01 00:00:00"):
"""Insert a row into Plugins_Objects to simulate a pre-existing object."""
cur.execute(
"""INSERT INTO Plugins_Objects
(plugin, objectPrimaryId, objectSecondaryId, dateTimeCreated,
dateTimeChanged, watchedValue1, watchedValue2, watchedValue3,
watchedValue4, status, extra, userData, foreignKey)
VALUES (?, ?, ?, ?, ?, ?, '', '', '', ?, '', '', '')""",
(prefix, primary_id, secondary_id, changed, changed, watched1, status),
)
def plugin_history_rows(conn, prefix: str):
"""Return all Plugins_History rows for a given plugin prefix."""
cur = conn.cursor()
cur.execute(
"SELECT * FROM Plugins_History WHERE plugin = ?", (prefix,)
)
return cur.fetchall()
def plugin_objects_rows(conn, prefix: str):
"""Return all Plugins_Objects rows for a given plugin prefix."""
cur = conn.cursor()
cur.execute(
"SELECT * FROM Plugins_Objects WHERE plugin = ?", (prefix,)
)
return cur.fetchall()
+33 -31
View File
@@ -39,13 +39,15 @@ def test_db(test_db_path):
# Minimal schema for integration testing
cur.execute('''
CREATE TABLE IF NOT EXISTS Events_Devices (
eve_MAC TEXT,
eve_DateTime TEXT,
eveMac TEXT,
eveDateTime TEXT,
devLastIP TEXT,
eve_EventType TEXT,
eveIp TEXT,
eveEventType TEXT,
devName TEXT,
devVendor TEXT,
devComments TEXT,
eve_PendingAlertEmail INTEGER
evePendingAlertEmail INTEGER
)
''')
@@ -61,36 +63,36 @@ def test_db(test_db_path):
cur.execute('''
CREATE TABLE IF NOT EXISTS Events (
eve_MAC TEXT,
eve_DateTime TEXT,
eve_EventType TEXT,
eve_PendingAlertEmail INTEGER
eveMac TEXT,
eveDateTime TEXT,
eveEventType TEXT,
evePendingAlertEmail INTEGER
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS Plugins_Events (
Plugin TEXT,
Object_PrimaryId TEXT,
Object_SecondaryId TEXT,
DateTimeChanged TEXT,
Watched_Value1 TEXT,
Watched_Value2 TEXT,
Watched_Value3 TEXT,
Watched_Value4 TEXT,
Status TEXT
plugin TEXT,
objectPrimaryId TEXT,
objectSecondaryId TEXT,
dateTimeChanged TEXT,
watchedValue1 TEXT,
watchedValue2 TEXT,
watchedValue3 TEXT,
watchedValue4 TEXT,
"status" TEXT
)
''')
# Insert test data
test_data = [
('aa:bb:cc:dd:ee:ff', '2024-01-01 12:00:00', '192.168.1.100', 'New Device', 'Test Device', 'Test Comment', 1),
('11:22:33:44:55:66', '2024-01-01 12:01:00', '192.168.1.101', 'Connected', 'Test Device 2', 'Another Comment', 1),
('77:88:99:aa:bb:cc', '2024-01-01 12:02:00', '192.168.1.102', 'Disconnected', 'Test Device 3', 'Third Comment', 1),
('aa:bb:cc:dd:ee:ff', '2024-01-01 12:00:00', '192.168.1.100', '192.168.1.100', 'New Device', 'Test Device', 'Apple', 'Test Comment', 1),
('11:22:33:44:55:66', '2024-01-01 12:01:00', '192.168.1.101', '192.168.1.101', 'Connected', 'Test Device 2', 'Dell', 'Another Comment', 1),
('77:88:99:aa:bb:cc', '2024-01-01 12:02:00', '192.168.1.102', '192.168.1.102', 'Disconnected', 'Test Device 3', 'Cisco', 'Third Comment', 1),
]
cur.executemany('''
INSERT INTO Events_Devices (eve_MAC, eve_DateTime, devLastIP, eve_EventType, devName, devComments, eve_PendingAlertEmail)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO Events_Devices (eveMac, eveDateTime, devLastIP, eveIp, eveEventType, devName, devVendor, devComments, evePendingAlertEmail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', test_data)
conn.commit()
@@ -115,7 +117,7 @@ def test_fresh_install_compatibility(builder):
def test_existing_db_compatibility():
mock_db = Mock()
mock_result = Mock()
mock_result.columnNames = ['MAC', 'Datetime', 'IP', 'Event Type', 'Device name', 'Comments']
mock_result.columnNames = ['devName', 'eveMac', 'devVendor', 'eveIp', 'eveDateTime', 'eveEventType', 'devComments']
mock_result.json = {'data': []}
mock_db.get_table_as_json.return_value = mock_result
@@ -143,9 +145,9 @@ def test_notification_system_integration(builder):
assert "devName = :" in condition
assert 'EmailTestDevice' in params.values()
apprise_condition = "AND eve_EventType = 'Connected'"
apprise_condition = "AND eveEventType = 'Connected'"
condition, params = builder.get_safe_condition_legacy(apprise_condition)
assert "eve_EventType = :" in condition
assert "eveEventType = :" in condition
assert 'Connected' in params.values()
webhook_condition = "AND devComments LIKE '%webhook%'"
@@ -153,9 +155,9 @@ def test_notification_system_integration(builder):
assert "devComments LIKE :" in condition
assert '%webhook%' in params.values()
mqtt_condition = "AND eve_MAC = 'aa:bb:cc:dd:ee:ff'"
mqtt_condition = "AND eveMac = 'aa:bb:cc:dd:ee:ff'"
condition, params = builder.get_safe_condition_legacy(mqtt_condition)
assert "eve_MAC = :" in condition
assert "eveMac = :" in condition
assert 'aa:bb:cc:dd:ee:ff' in params.values()
@@ -163,7 +165,7 @@ def test_settings_persistence(builder):
test_settings = [
"AND devName = 'Persistent Device'",
"AND devComments = {s-quote}Legacy Quote{s-quote}",
"AND eve_EventType IN ('Connected', 'Disconnected')",
"AND eveEventType IN ('Connected', 'Disconnected')",
"AND devLastIP = '192.168.1.1'",
""
]
@@ -188,9 +190,9 @@ def test_device_operations(builder):
def test_plugin_functionality(builder):
plugin_conditions = [
"AND Plugin = 'TestPlugin'",
"AND Object_PrimaryId = 'primary123'",
"AND Status = 'Active'"
"AND plugin = 'TestPlugin'",
"AND objectPrimaryId = 'primary123'",
"AND status = 'Active'"
]
for cond in plugin_conditions:
safe_condition, params = builder.get_safe_condition_legacy(cond)
+414
View File
@@ -0,0 +1,414 @@
"""
Tests for Fritz!Box plugin (fritzbox.py).
fritzbox.py is imported directly. Its module-level side effects
(get_setting_value, Logger, Plugin_Objects) are patched out before the
first import so no live config reads, log files, or result files are
created during tests.
"""
import sys
import os
from unittest.mock import patch, MagicMock
from utils.crypto_utils import string_to_fake_mac
import pytest
# ---------------------------------------------------------------------------
# Path setup
# ---------------------------------------------------------------------------
_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
_SERVER = os.path.join(_ROOT, "server")
_PLUGIN_DIR = os.path.join(_ROOT, "front", "plugins", "fritzbox")
for _p in [_ROOT, _SERVER, _PLUGIN_DIR]:
if _p not in sys.path:
sys.path.insert(0, _p)
# ---------------------------------------------------------------------------
# Import fritzbox with module-level side effects patched
# ---------------------------------------------------------------------------
# fritzbox.py calls get_setting_value(), Logger(), and Plugin_Objects() at
# module level. Patching these before the first import prevents live config
# reads, log-file creation, and result-file creation during tests.
with patch("helper.get_setting_value", return_value="UTC"), \
patch("logger.Logger"), \
patch("plugin_helper.Plugin_Objects"):
import fritzbox # noqa: E402
from plugin_helper import normalize_mac # noqa: E402
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def _make_host_entry(mac="AA:BB:CC:DD:EE:FF", ip="192.168.1.10",
hostname="testdevice", active=1, interface="Ethernet"):
return {
"NewMACAddress": mac,
"NewIPAddress": ip,
"NewHostName": hostname,
"NewActive": active,
"NewInterfaceType": interface,
}
@pytest.fixture
def mock_fritz_hosts():
"""
Patches fritzbox.FritzHosts so that get_connected_devices() uses a
controllable mock. Yields the FritzHosts *instance* (what FritzHosts(fc)
returns).
"""
hosts_instance = MagicMock()
with patch("fritzbox.FritzHosts", return_value=hosts_instance):
yield hosts_instance
# ===========================================================================
# get_connected_devices
# ===========================================================================
class TestGetConnectedDevices:
def test_returns_active_device(self, mock_fritz_hosts):
mock_fritz_hosts.host_numbers = 1
mock_fritz_hosts.get_generic_host_entry.return_value = _make_host_entry(active=1)
devices = fritzbox.get_connected_devices(MagicMock(), active_only=True)
assert len(devices) == 1
assert devices[0]["active_status"] == "Active"
def test_active_only_filters_inactive_device(self, mock_fritz_hosts):
mock_fritz_hosts.host_numbers = 2
mock_fritz_hosts.get_generic_host_entry.side_effect = [
_make_host_entry(mac="AA:BB:CC:DD:EE:01", active=1),
_make_host_entry(mac="AA:BB:CC:DD:EE:02", active=0),
]
devices = fritzbox.get_connected_devices(MagicMock(), active_only=True)
assert len(devices) == 1
assert devices[0]["mac_address"] == "aa:bb:cc:dd:ee:01"
def test_active_only_false_includes_inactive_device(self, mock_fritz_hosts):
mock_fritz_hosts.host_numbers = 2
mock_fritz_hosts.get_generic_host_entry.side_effect = [
_make_host_entry(mac="AA:BB:CC:DD:EE:01", active=1),
_make_host_entry(mac="AA:BB:CC:DD:EE:02", active=0),
]
devices = fritzbox.get_connected_devices(MagicMock(), active_only=False)
assert len(devices) == 2
assert devices[1]["active_status"] == "Inactive"
def test_device_without_mac_is_skipped(self, mock_fritz_hosts):
mock_fritz_hosts.host_numbers = 2
mock_fritz_hosts.get_generic_host_entry.side_effect = [
_make_host_entry(mac=""),
_make_host_entry(mac="AA:BB:CC:DD:EE:01"),
]
devices = fritzbox.get_connected_devices(MagicMock(), active_only=False)
assert len(devices) == 1
assert devices[0]["mac_address"] == "aa:bb:cc:dd:ee:01"
def test_ethernet_interface_maps_to_lan(self, mock_fritz_hosts):
mock_fritz_hosts.host_numbers = 1
mock_fritz_hosts.get_generic_host_entry.return_value = _make_host_entry(interface="Ethernet")
devices = fritzbox.get_connected_devices(MagicMock(), active_only=False)
assert devices[0]["interface_type"] == "LAN"
def test_wifi_interface_maps_to_wifi(self, mock_fritz_hosts):
mock_fritz_hosts.host_numbers = 1
mock_fritz_hosts.get_generic_host_entry.return_value = _make_host_entry(interface="802.11")
devices = fritzbox.get_connected_devices(MagicMock(), active_only=False)
assert devices[0]["interface_type"] == "WiFi"
def test_unknown_interface_is_preserved(self, mock_fritz_hosts):
mock_fritz_hosts.host_numbers = 1
mock_fritz_hosts.get_generic_host_entry.return_value = _make_host_entry(interface="SomeOtherType")
devices = fritzbox.get_connected_devices(MagicMock(), active_only=False)
assert devices[0]["interface_type"] == "SomeOtherType"
def test_mac_address_is_normalized_to_lowercase(self, mock_fritz_hosts):
mock_fritz_hosts.host_numbers = 1
mock_fritz_hosts.get_generic_host_entry.return_value = _make_host_entry(mac="AA:BB:CC:DD:EE:FF")
devices = fritzbox.get_connected_devices(MagicMock(), active_only=False)
assert devices[0]["mac_address"] == "aa:bb:cc:dd:ee:ff"
def test_missing_hostname_defaults_to_unknown(self, mock_fritz_hosts):
mock_fritz_hosts.host_numbers = 1
mock_fritz_hosts.get_generic_host_entry.return_value = _make_host_entry(hostname="")
devices = fritzbox.get_connected_devices(MagicMock(), active_only=False)
assert devices[0]["hostname"] == "Unknown"
def test_failed_host_entry_does_not_abort_remaining(self, mock_fritz_hosts):
mock_fritz_hosts.host_numbers = 3
mock_fritz_hosts.get_generic_host_entry.side_effect = [
_make_host_entry(mac="AA:BB:CC:DD:EE:01"),
Exception("TR-064 timeout"),
_make_host_entry(mac="AA:BB:CC:DD:EE:03"),
]
devices = fritzbox.get_connected_devices(MagicMock(), active_only=False)
assert len(devices) == 2
def test_empty_host_list_returns_empty(self, mock_fritz_hosts):
mock_fritz_hosts.host_numbers = 0
devices = fritzbox.get_connected_devices(MagicMock(), active_only=False)
assert devices == []
# ===========================================================================
# check_guest_wifi_status
# ===========================================================================
class TestCheckGuestWifiStatus:
def test_disabled_service_returns_inactive(self):
fc = MagicMock()
fc.call_action.return_value = {"NewEnable": False, "NewSSID": ""}
result = fritzbox.check_guest_wifi_status(fc, guest_service_num=3)
assert result["active"] is False
def test_enabled_service_returns_active(self):
fc = MagicMock()
fc.call_action.return_value = {"NewEnable": True, "NewSSID": "MyGuestWiFi"}
result = fritzbox.check_guest_wifi_status(fc, guest_service_num=3)
assert result["active"] is True
assert result["ssid"] == "MyGuestWiFi"
def test_queries_correct_service_number(self):
fc = MagicMock()
fc.call_action.return_value = {"NewEnable": True, "NewSSID": "Guest"}
fritzbox.check_guest_wifi_status(fc, guest_service_num=2)
fc.call_action.assert_called_once_with("WLANConfiguration2", "GetInfo")
def test_service_exception_returns_inactive(self):
fc = MagicMock()
fc.call_action.side_effect = Exception("Service unavailable")
result = fritzbox.check_guest_wifi_status(fc, guest_service_num=3)
assert result["active"] is False
def test_empty_ssid_uses_default_label(self):
fc = MagicMock()
fc.call_action.return_value = {"NewEnable": True, "NewSSID": ""}
result = fritzbox.check_guest_wifi_status(fc, guest_service_num=3)
assert result["active"] is True
assert result["ssid"] == "Guest WiFi"
def test_service1_can_be_guest(self):
fc = MagicMock()
fc.call_action.return_value = {"NewEnable": True, "NewSSID": "Gast"}
result = fritzbox.check_guest_wifi_status(fc, guest_service_num=1)
assert result["active"] is True
fc.call_action.assert_called_once_with("WLANConfiguration1", "GetInfo")
# ===========================================================================
# create_guest_wifi_device
# ===========================================================================
class TestCreateGuestWifiDevice:
def _fc_with_mac(self, mac):
fc = MagicMock()
fc.call_action.return_value = {"NewMACAddress": mac}
return fc
def test_returns_device_dict(self):
device = fritzbox.create_guest_wifi_device(self._fc_with_mac("AA:BB:CC:DD:EE:FF"))
assert device is not None
assert "mac_address" in device
assert device["hostname"] == "Guest WiFi Network"
assert device["active_status"] == "Active"
assert device["interface_type"] == "Access Point"
assert device["ip_address"] == ""
# MAC must match string_to_fake_mac output (fa:ce: prefix)
assert device["mac_address"].startswith("fa:ce:")
def test_guest_mac_has_locally_administered_bit(self):
"""The locally-administered bit (0x02) must be set in the first byte.
string_to_fake_mac uses the 'fa:ce:' prefix; 0xFA & 0x02 == 0x02."""
device = fritzbox.create_guest_wifi_device(self._fc_with_mac("AA:BB:CC:DD:EE:FF"))
first_byte = int(device["mac_address"].split(":")[0], 16)
assert first_byte & 0x02 != 0
def test_guest_mac_format_is_valid(self):
"""MAC must be 6 colon-separated lowercase hex pairs."""
device = fritzbox.create_guest_wifi_device(self._fc_with_mac("AA:BB:CC:DD:EE:FF"))
parts = device["mac_address"].split(":")
assert len(parts) == 6
for part in parts:
assert len(part) == 2
int(part, 16) # raises ValueError if not valid hex
def test_guest_mac_is_deterministic(self):
"""Same Fritz!Box MAC must always produce the same guest MAC."""
fc = self._fc_with_mac("AA:BB:CC:DD:EE:FF")
mac1 = fritzbox.create_guest_wifi_device(fc)["mac_address"]
mac2 = fritzbox.create_guest_wifi_device(fc)["mac_address"]
assert mac1 == mac2
def test_different_fritzbox_macs_produce_different_guest_macs(self):
mac_a = fritzbox.create_guest_wifi_device(self._fc_with_mac("AA:BB:CC:DD:EE:01"))["mac_address"]
mac_b = fritzbox.create_guest_wifi_device(self._fc_with_mac("AA:BB:CC:DD:EE:02"))["mac_address"]
assert mac_a != mac_b
def test_no_fritzbox_mac_uses_fallback(self):
"""When DeviceInfo returns no MAC, fall back to a sentinel-derived MAC."""
fc = MagicMock()
fc.call_action.return_value = {"NewMACAddress": ""}
device = fritzbox.create_guest_wifi_device(fc)
assert device["mac_address"] == string_to_fake_mac("FRITZBOX_GUEST")
def test_device_info_exception_returns_none(self):
"""If DeviceInfo call raises, create_guest_wifi_device must return None."""
fc = MagicMock()
fc.call_action.side_effect = Exception("Connection refused")
device = fritzbox.create_guest_wifi_device(fc)
assert device is None
def test_known_mac_produces_known_guest_mac(self):
"""
Regression anchor: for a fixed Fritz!Box MAC, the expected guest MAC
is derived via string_to_fake_mac(normalize_mac(...)). If the hashing
logic in fritzbox.py or string_to_fake_mac changes, this test fails.
"""
fritzbox_mac = normalize_mac("AA:BB:CC:DD:EE:FF")
expected = string_to_fake_mac(fritzbox_mac)
device = fritzbox.create_guest_wifi_device(self._fc_with_mac("AA:BB:CC:DD:EE:FF"))
assert device["mac_address"] == expected
# ===========================================================================
# get_fritzbox_connection
# ===========================================================================
class TestGetFritzboxConnection:
def test_successful_connection(self):
fc_instance = MagicMock()
fc_instance.modelname = "FRITZ!Box 7590"
fc_instance.system_version = "7.57"
fc_class = MagicMock(return_value=fc_instance)
with patch("fritzbox.FritzConnection", fc_class):
result = fritzbox.get_fritzbox_connection("fritz.box", 49443, "admin", "pass", True)
assert result is fc_instance
fc_class.assert_called_once_with(
address="fritz.box", port=49443, user="admin", password="pass", use_tls=True, timeout=10,
)
def test_import_error_returns_none(self):
with patch("fritzbox.FritzConnection", side_effect=ImportError("fritzconnection not found")):
result = fritzbox.get_fritzbox_connection("fritz.box", 49443, "admin", "pass", True)
assert result is None
def test_connection_exception_returns_none(self):
with patch("fritzbox.FritzConnection", side_effect=Exception("Connection refused")):
result = fritzbox.get_fritzbox_connection("fritz.box", 49443, "admin", "pass", True)
assert result is None
# ===========================================================================
# main
# ===========================================================================
class TestMain:
_SETTINGS = {
"FRITZBOX_HOST": "fritz.box",
"FRITZBOX_PORT": 49443,
"FRITZBOX_USER": "admin",
"FRITZBOX_PASS": "secret",
"FRITZBOX_USE_TLS": True,
"FRITZBOX_REPORT_GUEST": False,
"FRITZBOX_GUEST_SERVICE": 3,
"FRITZBOX_ACTIVE_ONLY": True,
}
def _patch_settings(self):
return patch.object(
fritzbox, "get_setting_value",
side_effect=lambda key: self._SETTINGS[key],
)
def test_connection_failure_returns_1(self):
mock_po = MagicMock()
with self._patch_settings(), \
patch.object(fritzbox, "get_fritzbox_connection", return_value=None), \
patch.object(fritzbox, "plugin_objects", mock_po):
result = fritzbox.main()
assert result == 1
mock_po.write_result_file.assert_called_once()
mock_po.add_object.assert_not_called()
def test_scan_processes_devices(self):
devices = [
{"mac_address": "aa:bb:cc:dd:ee:01", "ip_address": "192.168.1.10",
"hostname": "device1", "active_status": "Active", "interface_type": "LAN"},
{"mac_address": "aa:bb:cc:dd:ee:02", "ip_address": "192.168.1.11",
"hostname": "device2", "active_status": "Active", "interface_type": "WiFi"},
]
mock_po = MagicMock()
with self._patch_settings(), \
patch.object(fritzbox, "get_fritzbox_connection", return_value=MagicMock()), \
patch.object(fritzbox, "get_connected_devices", return_value=devices), \
patch.object(fritzbox, "plugin_objects", mock_po):
result = fritzbox.main()
assert result == 0
assert mock_po.add_object.call_count == 2
mock_po.write_result_file.assert_called_once()
def test_guest_wifi_device_appended_when_active(self):
devices = [
{"mac_address": "aa:bb:cc:dd:ee:01", "ip_address": "192.168.1.10",
"hostname": "device1", "active_status": "Active", "interface_type": "LAN"},
]
guest_device = {
"mac_address": "02:a1:b2:c3:d4:e5", "ip_address": "",
"hostname": "Guest WiFi Network", "active_status": "Active",
"interface_type": "Access Point",
}
settings = {**self._SETTINGS, "FRITZBOX_REPORT_GUEST": True}
mock_po = MagicMock()
with patch.object(fritzbox, "get_setting_value", side_effect=lambda k: settings[k]), \
patch.object(fritzbox, "get_fritzbox_connection", return_value=MagicMock()), \
patch.object(fritzbox, "get_connected_devices", return_value=devices), \
patch.object(fritzbox, "check_guest_wifi_status", return_value={"active": True, "ssid": "Guest"}), \
patch.object(fritzbox, "create_guest_wifi_device", return_value=guest_device), \
patch.object(fritzbox, "plugin_objects", mock_po):
result = fritzbox.main()
assert result == 0
assert mock_po.add_object.call_count == 2 # 1 device + 1 guest
# Verify the guest device was passed correctly
guest_call = mock_po.add_object.call_args_list[1]
assert guest_call.kwargs["primaryId"] == "02:a1:b2:c3:d4:e5"
assert guest_call.kwargs["watched3"] == "Access Point"
def test_guest_wifi_not_appended_when_inactive(self):
devices = [
{"mac_address": "aa:bb:cc:dd:ee:01", "ip_address": "192.168.1.10",
"hostname": "device1", "active_status": "Active", "interface_type": "LAN"},
]
settings = {**self._SETTINGS, "FRITZBOX_REPORT_GUEST": True}
mock_po = MagicMock()
with patch.object(fritzbox, "get_setting_value", side_effect=lambda k: settings[k]), \
patch.object(fritzbox, "get_fritzbox_connection", return_value=MagicMock()), \
patch.object(fritzbox, "get_connected_devices", return_value=devices), \
patch.object(fritzbox, "check_guest_wifi_status", return_value={"active": False, "ssid": ""}), \
patch.object(fritzbox, "plugin_objects", mock_po):
result = fritzbox.main()
assert result == 0
assert mock_po.add_object.call_count == 1 # only the real device
+417 -17
View File
@@ -46,7 +46,7 @@ def _send_data(api_token, file_content, encryption_key, file_path, node_name, pr
}
headers = {"Authorization": f"Bearer {api_token}"}
try:
response = requests.post(hub_url + API_ENDPOINT, data=data, headers=headers, timeout=5)
response = requests.post(hub_url + API_ENDPOINT, json=data, headers=headers, timeout=5)
return response.status_code == 200
except requests.RequestException:
return False
@@ -68,9 +68,33 @@ def _get_data(api_token, node_url):
def _node_name_from_filename(file_name: str) -> str:
"""Mirror of the node-name extraction in sync.main()."""
parts = file_name.split(".")
return parts[2] if ("decoded" in file_name or "encoded" in file_name) else parts[1]
"""Mirror of the node-name extraction in sync.main() (Mode 3).
PUSH shape: last_result.PLUGIN.(decoded|encoded).NodeName.N.log
— marker present AND the second-to-last segment (before .log) is a digit
PULL shape: last_result.NodeName.log
— no marker, or marker present but no digit counter
(e.g. node name is 'office.encoded.lab')
Both forms handle dots anywhere in PLUGIN or NodeName.
"""
marker_present = '.decoded.' in file_name or '.encoded.' in file_name
is_push = marker_present and file_name.rsplit('.', 2)[1].isdigit()
if is_push:
marker = '.decoded.' if '.decoded.' in file_name else '.encoded.'
_, after = file_name.split(marker, 1)
return after.rsplit('.', 2)[0]
return file_name[len('last_result.'):-len('.log')]
def _should_delete_after_process(filename: str) -> bool:
"""Mirror of the delete-after-process condition in execute_plugin() (server/plugin.py).
Only node-sync intermediary files (.encoded. / .decoded.) are removed after
processing. Local plugin result files (last_result.ARPSCAN.log etc.) must
survive so SYNC Mode 1 can read and forward them to the hub.
"""
return ".encoded." in filename or ".decoded." in filename
def _determine_mode(hub_url: str, send_devices: bool, plugins_to_sync: list, pull_nodes: list):
@@ -205,7 +229,7 @@ class TestSendData:
with patch("requests.post", return_value=resp) as mock_post:
_send_data(API_TOKEN, '{"data":[]}', ENCRYPTION_KEY,
"/tmp/file.log", "node1", "SYNC", HUB_URL)
payload = mock_post.call_args[1]["data"]
payload = mock_post.call_args[1]["json"]
assert "data" in payload # encrypted blob
assert payload["file_path"] == "/tmp/file.log"
assert payload["plugin"] == "SYNC"
@@ -219,7 +243,7 @@ class TestSendData:
with patch("requests.post", return_value=resp) as mock_post:
_send_data(API_TOKEN, plaintext, ENCRYPTION_KEY,
"/tmp/file.log", "node1", "SYNC", HUB_URL)
transmitted = mock_post.call_args[1]["data"]["data"]
transmitted = mock_post.call_args[1]["json"]["data"]
assert transmitted != plaintext
# Verify it round-trips correctly
assert decrypt_data(transmitted, ENCRYPTION_KEY) == plaintext
@@ -296,23 +320,59 @@ class TestGetData:
class TestNodeNameExtraction:
def test_simple_filename(self):
# last_result.MyNode.log → "MyNode"
def test_pull_mode_filename(self):
# PULL mode: last_result.MyNode.log → "MyNode"
assert _node_name_from_filename("last_result.MyNode.log") == "MyNode"
def test_decoded_filename(self):
# last_result.decoded.MyNode.1.log → "MyNode"
assert _node_name_from_filename("last_result.decoded.MyNode.1.log") == "MyNode"
def test_push_decoded_filename(self):
# PUSH mode (post-decode): last_result.ARPSCAN.decoded.MyNode.1.log → "MyNode"
assert _node_name_from_filename("last_result.ARPSCAN.decoded.MyNode.1.log") == "MyNode"
def test_encoded_filename(self):
# last_result.encoded.MyNode.1.log → "MyNode"
assert _node_name_from_filename("last_result.encoded.MyNode.1.log") == "MyNode"
def test_push_encoded_filename(self):
# PUSH mode (pre-decode): last_result.ARPSCAN.encoded.MyNode.1.log → "MyNode"
assert _node_name_from_filename("last_result.ARPSCAN.encoded.MyNode.1.log") == "MyNode"
def test_node_name_with_underscores(self):
def test_pull_node_name_with_underscores(self):
assert _node_name_from_filename("last_result.Wladek_Site.log") == "Wladek_Site"
def test_decoded_node_name_with_underscores(self):
assert _node_name_from_filename("last_result.decoded.Wladek_Site.1.log") == "Wladek_Site"
def test_push_decoded_node_name_with_underscores(self):
assert _node_name_from_filename("last_result.ARPSCAN.decoded.Wladek_Site.1.log") == "Wladek_Site"
def test_push_decoded_node_name_with_counter_gt_1(self):
# Counter increments when multiple pushes arrive before SYNC runs
assert _node_name_from_filename("last_result.ARPSCAN.decoded.Node_Vlan01.3.log") == "Node_Vlan01"
def test_push_decoded_different_plugins(self):
for plugin in ("NMAP", "PIHOLE", "DHCPLEASES"):
fname = f"last_result.{plugin}.decoded.HubNode.1.log"
assert _node_name_from_filename(fname) == "HubNode", \
f"Expected 'HubNode' from {fname}"
# --- dot-in-identifier regression (fragile parts[3] fix) ---
def test_pull_node_name_with_dots(self):
# PULL mode: node name set to e.g. "node.home" or an IP like "192.168.1.82"
assert _node_name_from_filename("last_result.node.home.log") == "node.home"
assert _node_name_from_filename("last_result.192.168.1.82.log") == "192.168.1.82"
def test_push_decoded_node_name_with_dots(self):
# Node name "Node.Vlan01" must survive the filename round-trip intact
assert _node_name_from_filename("last_result.ARPSCAN.decoded.Node.Vlan01.1.log") == "Node.Vlan01"
def test_push_decoded_plugin_name_with_dots(self):
# Hypothetical plugin with a dot in its name must not shift the node index
assert _node_name_from_filename("last_result.MY.PLUGIN.decoded.NodeA.1.log") == "NodeA"
def test_push_both_identifiers_with_dots(self):
assert _node_name_from_filename(
"last_result.A.B.decoded.x.y.z.1.log"
) == "x.y.z"
def test_pull_with_encoded_in_node_name(self):
# Regression: PULL file whose node name contains '.encoded.' must NOT
# be mis-classified as a PUSH artifact (no digit counter → PULL branch).
assert _node_name_from_filename("last_result.office.encoded.lab.log") == "office.encoded.lab"
assert _node_name_from_filename("last_result.site.decoded.backup.log") == "site.decoded.backup"
# ===========================================================================
@@ -409,5 +469,345 @@ class TestReceiveInsert:
inserted = sync_insert_devices(conn, [device], existing_macs=set())
assert inserted == 1
# ===========================================================================
# Plugin result file retention (regression for execute_plugin delete bug)
# ===========================================================================
class TestPluginFileRetention:
"""Regression for the execute_plugin() delete-condition bug (server/plugin.py).
Before the fix the condition was ``filename != "last_result.log"``. No
plugin ever writes to that literal name — all write ``last_result.ARPSCAN.log``
etc. — so every local result file was deleted immediately after processing,
before SYNC Mode 1 had a chance to read and forward it to the hub.
The corrected condition deletes ONLY ``.encoded.`` / ``.decoded.``
node-sync intermediary files. Local plugin result files must survive.
"""
def test_local_result_file_not_flagged_for_deletion(self):
assert _should_delete_after_process("last_result.ARPSCAN.log") is False
def test_local_result_files_for_common_plugins_not_flagged(self):
for plugin in ("NMAP", "PIHOLE", "SYNC", "DHCPLEASES", "ARPSCAN"):
fname = f"last_result.{plugin}.log"
assert _should_delete_after_process(fname) is False, \
f"{fname} must NOT be deleted — SYNC Mode 1 still needs it"
def test_encoded_node_sync_file_flagged_for_deletion(self):
assert _should_delete_after_process("last_result.ARPSCAN.encoded.Node1.1.log") is True
def test_decoded_node_sync_file_flagged_for_deletion(self):
assert _should_delete_after_process("last_result.ARPSCAN.decoded.Node1.1.log") is True
def test_encoded_files_with_various_node_names_flagged(self):
for node in ("Node1", "Home_Hub", "Site_B", "OfficeNode"):
fname = f"last_result.ARPSCAN.encoded.{node}.1.log"
assert _should_delete_after_process(fname) is True, \
f"{fname} should be deleted after processing"
def test_decoded_files_with_various_node_names_flagged(self):
for node in ("Node1", "Home_Hub", "Site_B"):
fname = f"last_result.ARPSCAN.decoded.{node}.2.log"
assert _should_delete_after_process(fname) is True, \
f"{fname} should be deleted after processing"
def test_empty_device_list_returns_zero(self, conn):
assert sync_insert_devices(conn, [], existing_macs=set()) == 0
# ===========================================================================
# Mode 3 JSON-skip behaviour
# Regression: local plugin result files (pipe-delimited) must not crash Mode 3.
# ===========================================================================
def _parse_sync_payload(file_path: str) -> list:
"""Mirror of the json.load + data['data'] block in sync.main() Mode 3.
Returns the list of device dicts on success, or raises nothing on invalid
input — callers should catch JSONDecodeError / KeyError and skip the file.
"""
with open(file_path, "r") as f:
data = json.load(f)
return data["data"]
class TestMode3JsonSkip:
"""Regression for the crash when Mode 3 encountered pipe-delimited plugin files.
Before the fix, sync.py called json.load() on every last_result.*.log file
returned by decode_and_rename_files(), including local plugin result files
(e.g. last_result.DIGSCAN.log) which are pipe-delimited and not JSON. The
fix wraps the load in try/except(JSONDecodeError, KeyError) and continues.
"""
def test_valid_sync_payload_is_parsed(self, tmp_path):
payload = {"data": [{"devMac": "aa:bb:cc:dd:ee:01", "devName": "TestDevice"}]}
f = tmp_path / "last_result.ARPSCAN.decoded.Node1.1.log"
f.write_text(json.dumps(payload))
result = _parse_sync_payload(str(f))
assert len(result) == 1
assert result[0]["devMac"] == "aa:bb:cc:dd:ee:01"
def test_pipe_delimited_file_raises_json_error(self, tmp_path):
"""Pipe-delimited plugin file must raise JSONDecodeError so callers can skip it."""
f = tmp_path / "last_result.DIGSCAN.log"
f.write_text("aa:bb:cc:dd:ee:01|192.168.1.1|2026-01-01 00:00:00|hostname||subnet||DIGSCAN|||||\n")
with pytest.raises(json.JSONDecodeError):
_parse_sync_payload(str(f))
def test_json_without_data_key_raises_key_error(self, tmp_path):
"""JSON that lacks the 'data' key must raise KeyError so callers can skip it."""
f = tmp_path / "last_result.UNKNOWN.log"
f.write_text(json.dumps({"result": []}))
with pytest.raises(KeyError):
_parse_sync_payload(str(f))
def test_empty_file_raises_json_error(self, tmp_path):
f = tmp_path / "last_result.EMPTY.log"
f.write_text("")
with pytest.raises(json.JSONDecodeError):
_parse_sync_payload(str(f))
# ===========================================================================
# SYNC_BEHAVIOR - three hub device-write modes (Mode 3 - RECEIVE)
# ===========================================================================
class TestSyncBehavior:
"""Covers the three SYNC_BEHAVIOR modes for hub-side device writes.
copy-new (default) — INSERT new MACs only, skip existing.
carbon-copy — UPSERT all MACs; node values overwrite hub values.
hub-defaults — skip direct write; let hub pipeline handle it.
"""
# ------------------------------------------------------------------
# copy-new (default – backward compatible)
# ------------------------------------------------------------------
def test_copy_new_inserts_new_device(self, conn):
device = make_device_dict(mac="aa:bb:cc:dd:ee:01")
written = sync_insert_devices(conn, [device], existing_macs=set(), behavior="copy-new")
assert written == 1
cur = conn.cursor()
cur.execute("SELECT devMac FROM Devices WHERE devMac = ?", ("aa:bb:cc:dd:ee:01",))
assert cur.fetchone() is not None
def test_copy_new_skips_existing_device(self, conn):
cur = conn.cursor()
cur.execute("INSERT INTO Devices (devMac, devName) VALUES (?, ?)", ("aa:bb:cc:dd:ee:01", "Original"))
conn.commit()
device = make_device_dict(mac="aa:bb:cc:dd:ee:01", devName="Updated")
written = sync_insert_devices(conn, [device], existing_macs={"aa:bb:cc:dd:ee:01"}, behavior="copy-new")
assert written == 0
cur.execute("SELECT devName FROM Devices WHERE devMac = ?", ("aa:bb:cc:dd:ee:01",))
assert cur.fetchone()["devName"] == "Original"
def test_copy_new_only_new_in_mixed_batch(self, conn):
cur = conn.cursor()
cur.execute("INSERT INTO Devices (devMac, devName) VALUES (?, ?)", ("aa:bb:cc:dd:ee:existing", "Existing"))
conn.commit()
devices = [
make_device_dict(mac="aa:bb:cc:dd:ee:existing"),
make_device_dict(mac="aa:bb:cc:dd:ee:new1"),
make_device_dict(mac="aa:bb:cc:dd:ee:new2"),
]
written = sync_insert_devices(conn, devices, existing_macs={"aa:bb:cc:dd:ee:existing"}, behavior="copy-new")
assert written == 2
# ------------------------------------------------------------------
# carbon-copy — UPSERT, node is authoritative
# ------------------------------------------------------------------
def test_carbon_copy_inserts_new_device(self, conn):
device = make_device_dict(mac="aa:bb:cc:dd:ee:01")
written = sync_insert_devices(conn, [device], behavior="carbon-copy")
assert written == 1
cur = conn.cursor()
cur.execute("SELECT devMac FROM Devices WHERE devMac = ?", ("aa:bb:cc:dd:ee:01",))
assert cur.fetchone() is not None
def test_carbon_copy_overwrites_existing_device(self, conn):
cur = conn.cursor()
cur.execute("INSERT INTO Devices (devMac, devName) VALUES (?, ?)", ("aa:bb:cc:dd:ee:01", "OldName"))
conn.commit()
device = make_device_dict(mac="aa:bb:cc:dd:ee:01", devName="NewName")
written = sync_insert_devices(conn, [device], behavior="carbon-copy")
assert written == 1
cur.execute("SELECT devName FROM Devices WHERE devMac = ?", ("aa:bb:cc:dd:ee:01",))
assert cur.fetchone()["devName"] == "NewName"
def test_carbon_copy_processes_all_devices_in_batch(self, conn):
cur = conn.cursor()
cur.execute("INSERT INTO Devices (devMac, devName) VALUES (?, ?)", ("aa:bb:cc:dd:ee:01", "OldName"))
conn.commit()
devices = [
make_device_dict(mac="aa:bb:cc:dd:ee:01", devName="UpdatedName"),
make_device_dict(mac="aa:bb:cc:dd:ee:02"),
]
written = sync_insert_devices(conn, devices, behavior="carbon-copy")
assert written == 2
cur.execute("SELECT devName FROM Devices WHERE devMac = ?", ("aa:bb:cc:dd:ee:01",))
assert cur.fetchone()["devName"] == "UpdatedName"
def test_carbon_copy_does_not_duplicate_existing_device(self, conn):
cur = conn.cursor()
cur.execute("INSERT INTO Devices (devMac, devName) VALUES (?, ?)", ("aa:bb:cc:dd:ee:01", "Original"))
conn.commit()
device = make_device_dict(mac="aa:bb:cc:dd:ee:01", devName="Updated")
sync_insert_devices(conn, [device], behavior="carbon-copy")
cur.execute("SELECT COUNT(*) AS cnt FROM Devices WHERE devMac = ?", ("aa:bb:cc:dd:ee:01",))
assert cur.fetchone()["cnt"] == 1
def test_carbon_copy_does_not_overwrite_devPresentLastScan(self, conn):
"""Regression: carbon-copy must NOT clobber devPresentLastScan.
Scenario: device is online on the hub (devPresentLastScan=1) but the
node reports it as offline (devPresentLastScan=0). Without the fix the
UPSERT would flip presence to 0, triggering a Device Down event on the
next scan cycle and a Connected event on the scan after that, causing
the device to accumulate enough churn events to be flagged as Flapping.
"""
cur = conn.cursor()
# Hub already knows this device and currently sees it as online.
cur.execute(
"INSERT INTO Devices (devMac, devName, devPresentLastScan) VALUES (?, ?, ?)",
("aa:bb:cc:dd:ee:01", "HubDevice", 1),
)
conn.commit()
# Node reports same MAC as offline.
device = make_device_dict(mac="aa:bb:cc:dd:ee:01", devPresentLastScan=0)
sync_insert_devices(conn, [device], behavior="carbon-copy")
cur.execute(
"SELECT devPresentLastScan FROM Devices WHERE devMac = ?",
("aa:bb:cc:dd:ee:01",),
)
row = cur.fetchone()
assert row["devPresentLastScan"] == 1, (
"carbon-copy must not overwrite devPresentLastScan with a node's offline value"
)
# ------------------------------------------------------------------
# hub-defaults — no direct write, hub pipeline handles it
# ------------------------------------------------------------------
def test_hub_defaults_writes_nothing(self, conn):
device = make_device_dict(mac="aa:bb:cc:dd:ee:01")
written = sync_insert_devices(conn, [device], behavior="hub-defaults")
assert written == 0
def test_hub_defaults_leaves_db_empty(self, conn):
devices = [make_device_dict(mac=f"aa:bb:cc:dd:ee:0{i}") for i in range(3)]
sync_insert_devices(conn, devices, behavior="hub-defaults")
cur = conn.cursor()
cur.execute("SELECT COUNT(*) AS cnt FROM Devices")
assert cur.fetchone()["cnt"] == 0
def test_hub_defaults_returns_zero_for_empty_input(self, conn):
assert sync_insert_devices(conn, [], behavior="hub-defaults") == 0
# ------------------------------------------------------------------
# "New Device" events — copy-new and carbon-copy must fire; hub-defaults must not
# ------------------------------------------------------------------
def test_copy_new_fires_new_device_event(self, conn):
device = make_device_dict(mac="aa:bb:cc:dd:ee:01")
sync_insert_devices(conn, [device], existing_macs=set(), behavior="copy-new")
cur = conn.cursor()
cur.execute("SELECT COUNT(*) AS cnt FROM Events WHERE eveEventType='New Device' AND eveMac=?", ("aa:bb:cc:dd:ee:01",))
assert cur.fetchone()["cnt"] == 1
def test_copy_new_does_not_fire_event_for_existing_device(self, conn):
cur = conn.cursor()
cur.execute("INSERT INTO Devices (devMac) VALUES (?)", ("aa:bb:cc:dd:ee:01",))
conn.commit()
device = make_device_dict(mac="aa:bb:cc:dd:ee:01")
sync_insert_devices(conn, [device], existing_macs={"aa:bb:cc:dd:ee:01"}, behavior="copy-new")
cur.execute("SELECT COUNT(*) AS cnt FROM Events WHERE eveEventType='New Device'")
assert cur.fetchone()["cnt"] == 0
def test_carbon_copy_fires_new_device_event_for_new_mac(self, conn):
device = make_device_dict(mac="aa:bb:cc:dd:ee:01")
sync_insert_devices(conn, [device], existing_macs=set(), behavior="carbon-copy")
cur = conn.cursor()
cur.execute("SELECT COUNT(*) AS cnt FROM Events WHERE eveEventType='New Device' AND eveMac=?", ("aa:bb:cc:dd:ee:01",))
assert cur.fetchone()["cnt"] == 1
def test_carbon_copy_does_not_fire_event_for_existing_mac(self, conn):
cur = conn.cursor()
cur.execute("INSERT INTO Devices (devMac) VALUES (?)", ("aa:bb:cc:dd:ee:01",))
conn.commit()
device = make_device_dict(mac="aa:bb:cc:dd:ee:01", devName="Updated")
sync_insert_devices(conn, [device], existing_macs={"aa:bb:cc:dd:ee:01"}, behavior="carbon-copy")
cur.execute("SELECT COUNT(*) AS cnt FROM Events WHERE eveEventType='New Device'")
assert cur.fetchone()["cnt"] == 0
def test_hub_defaults_fires_no_events_directly(self, conn):
devices = [make_device_dict(mac=f"aa:bb:cc:dd:ee:0{i}") for i in range(3)]
sync_insert_devices(conn, devices, existing_macs=set(), behavior="hub-defaults")
cur = conn.cursor()
cur.execute("SELECT COUNT(*) AS cnt FROM Events WHERE eveEventType='New Device'")
assert cur.fetchone()["cnt"] == 0
def test_copy_new_fires_events_only_for_new_macs_in_mixed_batch(self, conn):
cur = conn.cursor()
cur.execute("INSERT INTO Devices (devMac) VALUES (?)", ("aa:bb:cc:dd:ee:existing",))
conn.commit()
devices = [
make_device_dict(mac="aa:bb:cc:dd:ee:existing"),
make_device_dict(mac="aa:bb:cc:dd:ee:new1"),
make_device_dict(mac="aa:bb:cc:dd:ee:new2"),
]
sync_insert_devices(conn, devices, existing_macs={"aa:bb:cc:dd:ee:existing"}, behavior="copy-new")
cur.execute("SELECT eveMac FROM Events WHERE eveEventType='New Device'")
event_macs = {r["eveMac"] for r in cur.fetchall()}
assert event_macs == {"aa:bb:cc:dd:ee:new1", "aa:bb:cc:dd:ee:new2"}
def test_carbon_copy_fires_events_only_for_new_macs_in_mixed_batch(self, conn):
cur = conn.cursor()
cur.execute("INSERT INTO Devices (devMac, devName) VALUES (?, ?)", ("aa:bb:cc:dd:ee:existing", "Old"))
conn.commit()
devices = [
make_device_dict(mac="aa:bb:cc:dd:ee:existing", devName="Updated"),
make_device_dict(mac="aa:bb:cc:dd:ee:new1"),
]
sync_insert_devices(conn, devices, existing_macs={"aa:bb:cc:dd:ee:existing"}, behavior="carbon-copy")
cur.execute("SELECT eveMac FROM Events WHERE eveEventType='New Device'")
event_macs = {r["eveMac"] for r in cur.fetchall()}
assert event_macs == {"aa:bb:cc:dd:ee:new1"}
def test_new_device_event_fields_are_correct(self, conn):
device = make_device_dict(mac="aa:bb:cc:dd:ee:01", devLastIP="10.0.0.1", devVendor="Acme")
sync_insert_devices(conn, [device], existing_macs=set(), behavior="copy-new")
cur = conn.cursor()
cur.execute(
"SELECT * FROM Events WHERE eveEventType='New Device' AND eveMac=?",
("aa:bb:cc:dd:ee:01",),
)
row = cur.fetchone()
assert row is not None
assert row["eveMac"] == "aa:bb:cc:dd:ee:01"
assert row["eveIp"] == "10.0.0.1"
assert row["eveAdditionalInfo"] == "Acme"
assert row["evePendingAlertEmail"] == 1
# Confirm exactly one event was inserted (no duplicates).
cur.execute(
"SELECT COUNT(*) AS cnt FROM Events WHERE eveEventType='New Device' AND eveMac=?",
("aa:bb:cc:dd:ee:01",),
)
assert cur.fetchone()["cnt"] == 1
+122 -3
View File
@@ -258,8 +258,8 @@ class TestInsertEventsSleepSuppression:
can_sleep=1, last_connection=last_conn)
# Simulate: a Device Down event already exists for this absence
cur.execute(
"INSERT INTO Events (eve_MAC, eve_IP, eve_DateTime, eve_EventType, "
"eve_AdditionalInfo, eve_PendingAlertEmail) "
"INSERT INTO Events (eveMac, eveIp, eveDateTime, eveEventType, "
"eveAdditionalInfo, evePendingAlertEmail) "
"VALUES (?, '192.168.1.1', ?, 'Device Down', '', 1)",
("bb:00:00:00:00:04", _minutes_ago(15)),
)
@@ -269,7 +269,7 @@ class TestInsertEventsSleepSuppression:
cur.execute(
"SELECT COUNT(*) as cnt FROM Events "
"WHERE eve_MAC = 'bb:00:00:00:00:04' AND eve_EventType = 'Device Down'"
"WHERE eveMac = 'bb:00:00:00:00:04' AND eveEventType = 'Device Down'"
)
count = cur.fetchone()["cnt"]
assert count == 1, (
@@ -444,3 +444,122 @@ class TestDownCountSleepingSuppression:
assert count == 1, (
f"Expected 1 down device (sleeping device must not be counted), got {count}"
)
# ---------------------------------------------------------------------------
# Layer 1c: insert_events() — forced-online device suppression
#
# Devices with devForceStatus='online' are always considered present by the
# operator. Generating 'Device Down' or 'Disconnected' events for them causes
# spurious flapping detection (devFlapping counts these events in DevicesView).
#
# Affected queries in insert_events():
# 1a Device Down (non-sleeping) — DevicesView query
# 1b Device Down (sleep-expired) — DevicesView query
# 3 Disconnected — Devices table query
# ---------------------------------------------------------------------------
class TestInsertEventsForceOnline:
"""
Regression tests: forced-online devices must never generate
'Device Down' or 'Disconnected' events.
"""
def test_forced_online_no_device_down_event(self):
"""
devForceStatus='online', devAlertDown=1, absent from CurrentScan.
Must NOT produce a 'Device Down' event (regression: used to fire and
cause devFlapping=1 after the threshold was reached).
"""
conn = _make_db()
cur = conn.cursor()
_insert_device(cur, "ff:00:00:00:00:01", alert_down=1, present_last_scan=1,
force_status="online")
conn.commit()
insert_events(DummyDB(conn))
assert "ff:00:00:00:00:01" not in _down_event_macs(cur), (
"forced-online device must never generate a 'Device Down' event"
)
def test_forced_online_sleep_expired_no_device_down_event(self):
"""
devForceStatus='online', devCanSleep=1, sleep window expired.
Must NOT produce a 'Device Down' event via the sleep-expired path.
"""
conn = _make_db(sleep_minutes=30)
cur = conn.cursor()
_insert_device(cur, "ff:00:00:00:00:02", alert_down=1, present_last_scan=0,
can_sleep=1, last_connection=_minutes_ago(45),
force_status="online")
conn.commit()
insert_events(DummyDB(conn))
assert "ff:00:00:00:00:02" not in _down_event_macs(cur), (
"forced-online sleeping device must not get 'Device Down' after sleep expires"
)
def test_forced_online_no_disconnected_event(self):
"""
devForceStatus='online', devAlertDown=0 (Disconnected path), absent.
Must NOT produce a 'Disconnected' event.
"""
conn = _make_db()
cur = conn.cursor()
_insert_device(cur, "ff:00:00:00:00:03", alert_down=0, present_last_scan=1,
force_status="online")
conn.commit()
insert_events(DummyDB(conn))
cur.execute(
"SELECT COUNT(*) AS cnt FROM Events "
"WHERE eveMac = 'ff:00:00:00:00:03' AND eveEventType = 'Disconnected'"
)
assert cur.fetchone()["cnt"] == 0, (
"forced-online device must never generate a 'Disconnected' event"
)
def test_forced_online_uppercase_no_device_down_event(self):
"""devForceStatus='ONLINE' (uppercase) must also be suppressed."""
conn = _make_db()
cur = conn.cursor()
_insert_device(cur, "ff:00:00:00:00:04", alert_down=1, present_last_scan=1,
force_status="ONLINE")
conn.commit()
insert_events(DummyDB(conn))
assert "ff:00:00:00:00:04" not in _down_event_macs(cur), (
"forced-online device (uppercase) must never generate a 'Device Down' event"
)
def test_dont_force_still_fires_device_down(self):
"""devForceStatus='dont_force' must behave normally — event fires."""
conn = _make_db()
cur = conn.cursor()
_insert_device(cur, "ff:00:00:00:00:05", alert_down=1, present_last_scan=1,
force_status="dont_force")
conn.commit()
insert_events(DummyDB(conn))
assert "ff:00:00:00:00:05" in _down_event_macs(cur), (
"dont_force device must still generate 'Device Down' when absent"
)
def test_forced_offline_still_fires_device_down(self):
"""devForceStatus='offline' suppresses nothing — event fires."""
conn = _make_db()
cur = conn.cursor()
_insert_device(cur, "ff:00:00:00:00:06", alert_down=1, present_last_scan=1,
force_status="offline")
conn.commit()
insert_events(DummyDB(conn))
assert "ff:00:00:00:00:06" in _down_event_macs(cur), (
"forced-offline device must still generate 'Device Down' when absent"
)
+14 -14
View File
@@ -117,12 +117,12 @@ def scan_db_for_new_devices():
cur.execute(
"""
CREATE TABLE Events (
eve_MAC TEXT,
eve_IP TEXT,
eve_DateTime TEXT,
eve_EventType TEXT,
eve_AdditionalInfo TEXT,
eve_PendingAlertEmail INTEGER
eveMac TEXT,
eveIp TEXT,
eveDateTime TEXT,
eveEventType TEXT,
eveAdditionalInfo TEXT,
evePendingAlertEmail INTEGER
)
"""
)
@@ -130,14 +130,14 @@ def scan_db_for_new_devices():
cur.execute(
"""
CREATE TABLE Sessions (
ses_MAC TEXT,
ses_IP TEXT,
ses_EventTypeConnection TEXT,
ses_DateTimeConnection TEXT,
ses_EventTypeDisconnection TEXT,
ses_DateTimeDisconnection TEXT,
ses_StillConnected INTEGER,
ses_AdditionalInfo TEXT
sesMac TEXT,
sesIp TEXT,
sesEventTypeConnection TEXT,
sesDateTimeConnection TEXT,
sesEventTypeDisconnection TEXT,
sesDateTimeDisconnection TEXT,
sesStillConnected INTEGER,
sesAdditionalInfo TEXT
)
"""
)
+90
View File
@@ -0,0 +1,90 @@
"""Tests for update_sync_hub_node backfill."""
import sys
import os
from unittest.mock import patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from db_test_helpers import make_db, DummyDB # noqa: E402
from server.scan import device_handling
def _make_db(devices):
"""Create an in-memory DB with full schema and seed rows."""
conn = make_db()
cur = conn.cursor()
cur.executemany(
"INSERT INTO Devices (devMac, devSyncHubNode) VALUES (?, ?)",
devices,
)
conn.commit()
return conn
def _read_nodes(conn):
"""Return a dict of devMac -> devSyncHubNode."""
return {
row["devMac"]: row["devSyncHubNode"]
for row in conn.execute("SELECT devMac, devSyncHubNode FROM Devices")
}
@patch.object(device_handling, "get_setting_value", return_value="MyNode")
def test_backfill_empty_values(mock_setting):
"""Empty and null devSyncHubNode should be backfilled with SYNC_node_name."""
conn = _make_db([
("AA:AA:AA:AA:AA:01", ""),
("AA:AA:AA:AA:AA:02", None),
("AA:AA:AA:AA:AA:03", "null"),
])
device_handling.update_sync_hub_node(DummyDB(conn))
nodes = _read_nodes(conn)
assert nodes["AA:AA:AA:AA:AA:01"] == "MyNode"
assert nodes["AA:AA:AA:AA:AA:02"] == "MyNode"
assert nodes["AA:AA:AA:AA:AA:03"] == "MyNode"
@patch.object(device_handling, "get_setting_value", return_value="MyNode")
def test_no_overwrite_existing(mock_setting):
"""Devices with a real devSyncHubNode should not be overwritten."""
conn = _make_db([
("AA:AA:AA:AA:AA:01", "RemoteNode"),
("AA:AA:AA:AA:AA:02", ""),
])
device_handling.update_sync_hub_node(DummyDB(conn))
nodes = _read_nodes(conn)
assert nodes["AA:AA:AA:AA:AA:01"] == "RemoteNode"
assert nodes["AA:AA:AA:AA:AA:02"] == "MyNode"
@patch.object(device_handling, "get_setting_value", return_value="")
def test_noop_when_setting_empty(mock_setting):
"""No updates when SYNC_node_name is empty."""
conn = _make_db([
("AA:AA:AA:AA:AA:01", ""),
("AA:AA:AA:AA:AA:02", None),
])
device_handling.update_sync_hub_node(DummyDB(conn))
nodes = _read_nodes(conn)
assert nodes["AA:AA:AA:AA:AA:01"] == ""
assert nodes["AA:AA:AA:AA:AA:02"] is None
@patch.object(device_handling, "get_setting_value", return_value=None)
def test_noop_when_setting_none(mock_setting):
"""No updates when SYNC_node_name is None."""
conn = _make_db([
("AA:AA:AA:AA:AA:01", ""),
])
device_handling.update_sync_hub_node(DummyDB(conn))
nodes = _read_nodes(conn)
assert nodes["AA:AA:AA:AA:AA:01"] == ""
+50 -1
View File
@@ -15,7 +15,7 @@ import pytest
INSTALL_PATH = os.getenv('NETALERTX_APP', '/app')
sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
from utils.datetime_utils import timeNowUTC, DATETIME_PATTERN # noqa: E402
from utils.datetime_utils import timeNowUTC, format_date_iso, DATETIME_PATTERN # noqa: E402
class TestTimeNowUTC:
@@ -104,3 +104,52 @@ class TestTimeNowUTC:
t2 = datetime.datetime.strptime(t2_str, DATETIME_PATTERN)
assert t2 >= t1
class TestFormatDateIso:
"""
Regression tests for format_date_iso().
Root cause being guarded: DB timestamps are stored as naive UTC strings
(e.g. '2026-04-04 08:54:00'). The old prepTimeStamp() called
conf.tz.localize() which LABELS the naive value with the local TZ offset
instead of CONVERTING it. This made '08:54 UTC' become '08:54+02:00',
telling Home Assistant the event happened at 06:54 UTC — 2 hours too early.
format_date_iso() correctly replaces(tzinfo=UTC) first, then converts.
"""
def test_naive_utc_string_gets_utc_tzinfo(self):
"""A naive DB timestamp must be interpreted as UTC, not local time."""
result = format_date_iso("2026-04-04 08:54:00")
assert result is not None
# Must contain a TZ offset ('+' or 'Z'), not be naive
assert "+" in result or result.endswith("Z"), \
f"Expected timezone in ISO output, got: {result}"
def test_naive_utc_string_offset_reflects_utc_source(self):
"""
The UTC instant must be preserved. Whatever the local offset, the
calendar moment encoded in the ISO string must equal 08:54 UTC.
"""
result = format_date_iso("2026-04-04 08:54:00")
parsed = datetime.datetime.fromisoformat(result)
# Normalise to UTC for the assertion
utc_parsed = parsed.astimezone(datetime.UTC)
assert utc_parsed.hour == 8
assert utc_parsed.minute == 54
def test_empty_string_returns_none(self):
"""format_date_iso('') must return None, not raise."""
assert format_date_iso("") is None
def test_none_returns_none(self):
"""format_date_iso(None) must return None, not raise."""
assert format_date_iso(None) is None
def test_output_is_valid_iso8601(self):
"""Output must be parseable by datetime.fromisoformat()."""
result = format_date_iso("2026-01-15 12:00:00")
assert result is not None
# Should not raise
datetime.datetime.fromisoformat(result)
@@ -0,0 +1,236 @@
"""
Tests for the Plugins_History selective recording introduced to prevent
unbounded table growth.
Verifies that process_plugin_events() only writes history rows for objects
whose state actually changed in the current cycle:
- new objects
- watched-changed objects
- missing-in-last-scan (first transition only)
Objects that are watched-not-changed or already missing should NOT generate
history entries.
"""
import sys
import os
import pytest
# ---------------------------------------------------------------------------
# Path setup
# ---------------------------------------------------------------------------
INSTALL_PATH = os.getenv("NETALERTX_APP", "/app")
sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
# Test helpers (shared DDL, fake DB, factories)
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from db_test_helpers import ( # noqa: E402
make_plugin_db,
make_plugin_dict,
make_plugin_event_row,
seed_plugin_object,
plugin_history_rows,
plugin_objects_rows,
)
from plugin import process_plugin_events # noqa: E402
PREFIX = "TESTPLG"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def plugin_db():
"""Yield a (PluginFakeDB, connection) backed by an in-memory SQLite database."""
db, conn = make_plugin_db()
yield db, conn
conn.close()
def _no_report_on(key):
"""Monkeypatch target: return empty REPORT_ON so no events are generated."""
return [] if key.endswith("_REPORT_ON") else ""
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestHistoryOnlyRecordsChanges:
"""Core assertion: unchanged objects must NOT appear in Plugins_History."""
def test_new_object_recorded_in_history(self, plugin_db, monkeypatch):
"""A brand-new object should produce exactly one history row."""
db, conn = plugin_db
monkeypatch.setattr("plugin.get_setting_value", _no_report_on)
plugin = make_plugin_dict(PREFIX)
events = [make_plugin_event_row(PREFIX, "device_A")]
process_plugin_events(db, plugin, events)
rows = plugin_history_rows(conn, PREFIX)
assert len(rows) == 1
assert rows[0][2] == "device_A" # objectPrimaryId
def test_unchanged_object_not_recorded(self, plugin_db, monkeypatch):
"""An object with watched-not-changed should NOT appear in history."""
db, conn = plugin_db
monkeypatch.setattr("plugin.get_setting_value", _no_report_on)
cur = conn.cursor()
seed_plugin_object(cur, PREFIX, "device_A", watched1="val1",
status="watched-not-changed")
conn.commit()
plugin = make_plugin_dict(PREFIX)
events = [make_plugin_event_row(PREFIX, "device_A", watched1="val1")]
process_plugin_events(db, plugin, events)
rows = plugin_history_rows(conn, PREFIX)
assert len(rows) == 0, (
"watched-not-changed objects should not generate history rows"
)
def test_watched_changed_recorded(self, plugin_db, monkeypatch):
"""An object whose watched column changed should appear in history."""
db, conn = plugin_db
monkeypatch.setattr("plugin.get_setting_value", _no_report_on)
cur = conn.cursor()
seed_plugin_object(cur, PREFIX, "device_A", watched1="old_value",
status="watched-not-changed")
conn.commit()
plugin = make_plugin_dict(PREFIX)
events = [make_plugin_event_row(PREFIX, "device_A", watched1="new_value")]
process_plugin_events(db, plugin, events)
rows = plugin_history_rows(conn, PREFIX)
assert len(rows) == 1
assert rows[0][2] == "device_A"
def test_missing_first_time_recorded(self, plugin_db, monkeypatch):
"""An object going missing for the first time should appear in history."""
db, conn = plugin_db
monkeypatch.setattr("plugin.get_setting_value", _no_report_on)
cur = conn.cursor()
seed_plugin_object(cur, PREFIX, "device_A", status="watched-not-changed")
conn.commit()
plugin = make_plugin_dict(PREFIX)
events = [] # No events reported — device_A is now missing
process_plugin_events(db, plugin, events)
rows = plugin_history_rows(conn, PREFIX)
assert len(rows) == 1
assert rows[0][2] == "device_A"
def test_already_missing_not_re_recorded(self, plugin_db, monkeypatch):
"""An object already marked missing-in-last-scan should NOT produce
another history row on subsequent runs."""
db, conn = plugin_db
monkeypatch.setattr("plugin.get_setting_value", _no_report_on)
cur = conn.cursor()
seed_plugin_object(cur, PREFIX, "device_A",
status="missing-in-last-scan")
conn.commit()
plugin = make_plugin_dict(PREFIX)
events = [] # still missing
process_plugin_events(db, plugin, events)
rows = plugin_history_rows(conn, PREFIX)
assert len(rows) == 0, (
"already-missing objects should not generate additional history rows"
)
def test_mixed_scenario(self, plugin_db, monkeypatch):
"""Simulate a realistic mixed run: new + unchanged + changed + missing."""
db, conn = plugin_db
monkeypatch.setattr("plugin.get_setting_value", _no_report_on)
cur = conn.cursor()
seed_plugin_object(cur, PREFIX, "unchanged", watched1="same",
status="watched-not-changed")
seed_plugin_object(cur, PREFIX, "will_change", watched1="old",
status="watched-not-changed")
seed_plugin_object(cur, PREFIX, "will_vanish",
status="watched-not-changed")
seed_plugin_object(cur, PREFIX, "already_gone",
status="missing-in-last-scan")
conn.commit()
plugin = make_plugin_dict(PREFIX)
events = [
make_plugin_event_row(PREFIX, "brand_new"), # new
make_plugin_event_row(PREFIX, "unchanged", watched1="same"), # no change
make_plugin_event_row(PREFIX, "will_change", watched1="new"), # changed
# will_vanish not reported → first-time missing
# already_gone not reported → still missing (no history)
]
process_plugin_events(db, plugin, events)
rows = plugin_history_rows(conn, PREFIX)
recorded_ids = {r[2] for r in rows} # objectPrimaryId
assert "brand_new" in recorded_ids, "new object should be in history"
assert "will_change" in recorded_ids, "changed object should be in history"
assert "will_vanish" in recorded_ids, "first-time missing should be in history"
assert "unchanged" not in recorded_ids, "unchanged should NOT be in history"
assert "already_gone" not in recorded_ids, "already-missing should NOT be in history"
assert len(rows) == 3
def test_objects_table_still_updated_for_unchanged(self, plugin_db, monkeypatch):
"""Even though history is skipped, Plugins_Objects must still be updated
for unchanged objects (no regression)."""
db, conn = plugin_db
monkeypatch.setattr("plugin.get_setting_value", _no_report_on)
cur = conn.cursor()
seed_plugin_object(cur, PREFIX, "device_A", watched1="val1",
status="watched-not-changed")
conn.commit()
plugin = make_plugin_dict(PREFIX)
events = [make_plugin_event_row(PREFIX, "device_A", watched1="val1")]
process_plugin_events(db, plugin, events)
objs = plugin_objects_rows(conn, PREFIX)
assert len(objs) == 1, "Plugins_Objects should still have the object"
def test_recovery_from_missing_recorded(self, plugin_db, monkeypatch):
"""An object that was missing-in-last-scan and reappears (even with
unchanged watched values) should produce a history row."""
db, conn = plugin_db
monkeypatch.setattr("plugin.get_setting_value", _no_report_on)
cur = conn.cursor()
seed_plugin_object(cur, PREFIX, "device_A", watched1="val1",
status="missing-in-last-scan")
conn.commit()
plugin = make_plugin_dict(PREFIX)
# device_A reappears with the same watched value
events = [make_plugin_event_row(PREFIX, "device_A", watched1="val1")]
process_plugin_events(db, plugin, events)
rows = plugin_history_rows(conn, PREFIX)
assert len(rows) == 1, (
"recovery from missing-in-last-scan should generate a history row"
)
assert rows[0][2] == "device_A"
+335
View File
@@ -0,0 +1,335 @@
#!/usr/bin/env python3
"""
Stored-XSS regression tests for devName / devFQDN rendering.
Scenario
--------
A LAN-controlled scanner (e.g. DHCPLSS / nmap) can supply arbitrary hostnames
that end up stored as `devName` or `devFQDN` in the database. If these values
are injected into jQuery `.html()` calls or template-literal HTML strings
without HTML-entity escaping, a stored XSS payload executes in the
authenticated operator's browser.
These tests verify that no page listed in the "affected surfaces" table renders
the raw payload as executable HTML.
Canary mechanism
----------------
The XSS payload sets `window.__xss_canary = true` if executed:
<img src=x onerror="window.__xss_canary=true">
Before each page navigation we reset the canary to `false`. After the page
fully loads (and after any async device-list fetches have had time to run) we
assert that the canary is still `false`.
Note: these tests require a running NetAlertX backend and frontend (use the
devcontainer startup tasks or the Docker compose stack). They are Selenium-
based (headless Chromium) and are skipped when a browser is unavailable.
"""
import time
import requests
import pytest
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from selenium.webdriver.common.by import By # noqa: E402
from .test_helpers import ( # noqa: E402
BASE_URL, API_BASE_URL,
get_driver, get_api_token,
wait_for_page_load,
)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
# XSS payload: sets the window canary if the string is parsed as HTML.
# We keep the payload simple and deterministic.
XSS_PAYLOAD = '<img src=x onerror="window.__xss_canary=true">'
# A second, attribute-break variant that escapes unquoted attribute contexts
XSS_ATTR_BREAK = '" onmouseover="window.__xss_canary=true" data-x="'
# The fake MAC used for our synthetic test device (FA:CE prefix = recognised as
# "fake" by isFakeMac() in ui_components.js, so it won't affect real scanning).
XSS_TEST_MAC = "fa:ce:00:00:00:01"
# Seconds to wait after page load for async device-list fetches to complete.
ASYNC_WAIT_S = 4
# Pages to exercise (relative URLs; all are authenticated but devcontainer
# skips auth by default).
PAGES_UNDER_TEST = [
("/devices.php", "Device list table (devName / devFQDN columns)"),
("/network.php", "Network tabs and tree"),
(f"/deviceDetails.php?mac={XSS_TEST_MAC}", "Device detail page title"),
("/presence.php", "FullCalendar presence view"),
("/multiEditCore.php", "Multi-edit device selector"),
("/events.php", "Events table device name column"),
]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _auth_headers(token):
return {"Authorization": f"Bearer {token}"}
def _create_xss_device(token: str):
"""Create a synthetic device whose devName is the XSS payload."""
payload = {
"createNew": True,
"devName": XSS_PAYLOAD,
"devFQDN": XSS_PAYLOAD,
"devOwner": "XSS-test",
"devType": "Other",
"devVendor": "XSS-test",
"devLastIP": "192.168.99.99",
}
resp = requests.post(
f"{API_BASE_URL}/device/{XSS_TEST_MAC}",
json=payload,
headers=_auth_headers(token),
timeout=10,
)
return resp
def _create_xss_event(token: str):
"""Create an event for the XSS test device so events.php renders its name."""
requests.post(
f"{API_BASE_URL}/events/create/{XSS_TEST_MAC}",
json={"event_type": "Device Down", "ip": "192.168.99.99", "additional_info": "xss-test"},
headers=_auth_headers(token),
timeout=10,
)
def _delete_xss_device(token: str):
"""Delete the synthetic XSS test device and its events."""
requests.delete(
f"{API_BASE_URL}/events/{XSS_TEST_MAC}",
headers=_auth_headers(token),
timeout=10,
)
requests.delete(
f"{API_BASE_URL}/devices",
json={"macs": [XSS_TEST_MAC]},
headers=_auth_headers(token),
timeout=10,
)
def _read_canary(driver) -> bool:
"""Return True if the XSS canary was tripped."""
return bool(driver.execute_script("return window.__xss_canary || false;"))
def _navigate_with_canary(driver, url, timeout=15):
"""Navigate to *url* with window.__xss_canary pre-initialised to false.
Uses CDP Page.addScriptToEvaluateOnNewDocument so the canary is set
*before* any page script runs. If we reset it after driver.get() instead,
a payload that fires during page load would set the canary and we would
immediately clear the evidence.
"""
result = driver.execute_cdp_cmd(
"Page.addScriptToEvaluateOnNewDocument",
{"source": "window.__xss_canary = false;"},
)
script_id = result.get("identifier")
try:
driver.get(url)
wait_for_page_load(driver, timeout=timeout)
finally:
if script_id:
driver.execute_cdp_cmd(
"Page.removeScriptToEvaluateOnNewDocument",
{"identifier": script_id},
)
def _force_cache_refresh(driver):
"""Clear localStorage to force a fresh device-list fetch on next page load."""
driver.execute_script("if(window.localStorage){ localStorage.clear(); }")
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def xss_token():
"""Retrieve the API token; skip the module if unavailable."""
token = get_api_token()
if not token:
pytest.skip("API token not available – is the backend running?")
return token
@pytest.fixture(scope="module")
def xss_device(xss_token):
"""Create the XSS test device (and one event) before the module; clean up after."""
resp = _create_xss_device(xss_token)
if resp.status_code not in (200, 201):
pytest.skip(f"Could not create XSS test device (HTTP {resp.status_code}). "
"Is the backend running?")
# Create an event so events.php actually renders this device's name.
_create_xss_event(xss_token)
yield XSS_TEST_MAC
_delete_xss_device(xss_token)
@pytest.fixture(scope="module")
def xss_driver(xss_device): # depend on xss_device so device exists before browser starts
"""Single headless browser instance shared across all XSS tests in this module."""
driver = get_driver()
if not driver:
pytest.skip("Headless browser (Chromium) not available")
# Warm the localStorage device cache so later pages can render the device.
driver.get(f"{BASE_URL}/devices.php")
wait_for_page_load(driver, timeout=15)
time.sleep(ASYNC_WAIT_S) # let async API fetch populate localStorage
yield driver
driver.quit()
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("path,description", PAGES_UNDER_TEST)
def test_devname_xss_not_executed(xss_driver, path, description):
"""
Verify that the XSS canary is NOT tripped when a page renders the XSS
device name.
Pass criterion: window.__xss_canary remains false after the page loads
and the async device list fetch has had time to complete.
"""
driver = xss_driver
# Pre-initialise canary via CDP so it is false before any page JS runs.
# Resetting after driver.get() would erase evidence of early-firing payloads.
_navigate_with_canary(driver, f"{BASE_URL}{path}")
# Give async JS (DataTables, FullCalendar, network tree) time to render
time.sleep(ASYNC_WAIT_S)
# Final canary check
fired = _read_canary(driver)
assert not fired, (
f"XSS canary was tripped on {description} ({path}). "
f"The raw payload '{XSS_PAYLOAD}' was executed as HTML. "
"Check that encodeSpecialChars() is applied at all render sites."
)
@pytest.mark.parametrize("path,description", PAGES_UNDER_TEST)
def test_devname_raw_payload_not_in_html_source(xss_driver, path, description):
"""
Verify that the XSS payload was NOT injected as a real HTML element.
A properly escaped devName ends up as text content in the DOM, e.g.:
&lt;img src=x onerror="..."&gt;
The browser's DOM serialiser encodes < and > but NOT ", so page_source
still contains the literal string onerror="..." — that is expected and
safe. What must NOT appear is an actual unescaped opening tag:
<img src=x
which would indicate the payload was inserted as real HTML.
"""
driver = xss_driver
driver.get(f"{BASE_URL}{path}")
wait_for_page_load(driver, timeout=15)
time.sleep(ASYNC_WAIT_S)
page_source = driver.page_source
# An actual injected <img src=x tag would appear as-is in the serialised DOM.
# Escaped text content would appear as &lt;img src=x — no literal "<img src=x".
assert "<img src=x" not in page_source, (
f"XSS payload injected as real HTML on {description} ({path}): "
"'<img src=x' found literally in page source. "
"Ensure encodeSpecialChars() wraps all devName/devFQDN render sites."
)
def test_devname_xss_device_shows_as_escaped_text(xss_driver):
"""
On devices.php, confirm the XSS payload is displayed as visible escaped text
('&lt;img' or just '<img' as textContent) rather than as an injected element.
We check the page text (what the user reads) includes part of the payload as
literal characters, and that NO <img> element with src=x was injected.
"""
driver = xss_driver
driver.get(f"{BASE_URL}/devices.php")
wait_for_page_load(driver, timeout=15)
time.sleep(ASYNC_WAIT_S)
# The body text should contain the literal payload characters as plain text
body_text = driver.find_element(By.TAG_NAME, "body").text
if "onerror" in body_text:
# Payload is visible as literal text — confirm it was not also executed
assert not driver.execute_script("return window.__xss_canary || false"), (
"XSS canary fired even though payload appeared as visible text on devices.php"
)
# No <img> with src=x should have been injected by the XSS payload
injected_imgs = driver.find_elements(By.CSS_SELECTOR, "img[src='x']")
assert len(injected_imgs) == 0, (
f"XSS payload created an <img src=x> element — payload was not escaped. "
f"Found {len(injected_imgs)} injected image(s)."
)
def test_devname_attribute_break_xss_not_executed(xss_driver, xss_token):
"""
Verify that the attribute-break variant of XSS payload is also handled.
This checks that encodeSpecialChars() encodes double-quotes in devName,
preventing an attacker from breaking out of an HTML attribute context.
"""
attr_break_mac = "fa:ce:00:00:00:02"
attr_payload = {
"createNew": True,
"devName": XSS_ATTR_BREAK,
"devOwner": "XSS-attr-test",
"devLastIP": "192.168.99.100",
}
try:
resp = requests.post(
f"{API_BASE_URL}/device/{attr_break_mac}",
json=attr_payload,
headers=_auth_headers(xss_token),
timeout=10,
)
if resp.status_code not in (200, 201):
pytest.skip(f"Could not create attribute-break XSS device (HTTP {resp.status_code})")
# Pre-set canary via CDP, then navigate — same pattern as main tests.
_navigate_with_canary(xss_driver, f"{BASE_URL}/devices.php")
time.sleep(ASYNC_WAIT_S)
fired = _read_canary(xss_driver)
assert not fired, (
f"Attribute-break XSS canary was tripped on devices.php. "
f"Payload: {XSS_ATTR_BREAK!r}. "
"Ensure double-quotes are encoded by encodeSpecialChars()."
)
finally:
requests.delete(
f"{API_BASE_URL}/devices",
json={"macs": [attr_break_mac]},
headers=_auth_headers(xss_token),
timeout=10,
)