mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-10-06 04:56:29 -04:00
Merge branch 'main' into feat/adguard-export-plugin
This commit is contained in:
216 files changed
+15022
-4127
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -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"] == ""
|
||||
@@ -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"
|
||||
@@ -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.:
|
||||
<img src=x onerror="...">
|
||||
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 <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
|
||||
('<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,
|
||||
)
|
||||
Reference in new issue
Block a user