From 827b5d2ad3569d641c03708c2a77939506c438a8 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Sat, 7 Feb 2026 14:02:54 +1100 Subject: [PATCH] FIX: lowercase MAC normalization across project v0.2 Signed-off-by: jokob-sk --- front/network.php | 22 +++++++++------- front/plugins/plugin_helper.py | 4 +-- server/api_server/openapi/schemas.py | 4 +-- test/api_endpoints/test_device_endpoints.py | 4 +-- test/api_endpoints/test_devices_endpoints.py | 2 +- .../test_mcp_extended_endpoints.py | 8 +++--- .../api_endpoints/test_mcp_tools_endpoints.py | 26 +++++++++---------- test/scan/test_device_field_lock.py | 2 +- test/scan/test_ip_update_logic.py | 6 ++--- test/server/test_graphql_endpoints.py | 2 +- test/test_plugin_helper.py | 2 +- 11 files changed, 42 insertions(+), 40 deletions(-) diff --git a/front/network.php b/front/network.php index c634c703..dbf328e9 100755 --- a/front/network.php +++ b/front/network.php @@ -96,12 +96,14 @@ COUNT(child.devMac) AS node_ports_count FROM Devices AS parent LEFT JOIN Devices AS child - ON child.devParentMAC = parent.devMac - WHERE parent.devType IN ( - ${networkDeviceTypes}) + /* CRITICAL FIX: COLLATE NOCASE ensures the join works + even if devParentMAC is uppercase and devMac is lowercase + */ + ON child.devParentMAC = parent.devMac COLLATE NOCASE + WHERE parent.devType IN (${networkDeviceTypes}) AND parent.devIsArchived = 0 GROUP BY parent.devMac, parent.devName, parent.devPresentLastScan, - parent.devType, parent.devParentMAC, parent.devIcon, parent.devAlertDown + parent.devType, parent.devParentMAC, parent.devIcon, parent.devAlertDown ORDER BY parent.devName; `; @@ -381,9 +383,8 @@ } // ---------------------------------------------------- - function loadConnectedDevices(node_mac) { - - // 1. Force to lowercase to match the new DB standard + function loadConnectedDevices(node_mac) { + // Standardize the input just in case const normalized_mac = node_mac.toLowerCase(); const sql = ` @@ -397,13 +398,14 @@ ELSE 'Unknown status' END AS devStatus FROM Devices - WHERE devParentMac = '${normalized_mac}'`; + /* Using COLLATE NOCASE here solves the 'TEXT' vs 'NOCASE' mismatch */ + WHERE devParentMac = '${normalized_mac}' COLLATE NOCASE`; - const id = node_mac.replace(/:/g, '_'); + // Keep the ID generation consistent + const id = normalized_mac.replace(/:/g, '_'); const wrapperHtml = ` -
`; loadDeviceTable({ diff --git a/front/plugins/plugin_helper.py b/front/plugins/plugin_helper.py index b1c0399a..b8c120ad 100755 --- a/front/plugins/plugin_helper.py +++ b/front/plugins/plugin_helper.py @@ -91,11 +91,11 @@ def is_typical_router_ip(ip_address): def is_mac(input): input_str = str(input).lower().strip() # Convert to string and lowercase so non-string values won't raise errors - # Full MAC (6 octets) e.g. AA:BB:CC:DD:EE:FF + # Full MAC (6 octets) e.g. aa:bb:cc:dd:ee:ff full_mac_re = re.compile(r"^[0-9a-f]{2}([-:]?)[0-9a-f]{2}(\1[0-9a-f]{2}){4}$") # Wildcard prefix format: exactly 3 octets followed by a trailing '*' component - # Examples: AA:BB:CC:* + # Examples: aa:bb:cc:* wildcard_re = re.compile(r"^[0-9a-f]{2}[-:]?[0-9a-f]{2}[-:]?[0-9a-f]{2}[-:]?\*$") if full_mac_re.match(input_str) or wildcard_re.match(input_str): diff --git a/server/api_server/openapi/schemas.py b/server/api_server/openapi/schemas.py index 409ca176..84561a7c 100644 --- a/server/api_server/openapi/schemas.py +++ b/server/api_server/openapi/schemas.py @@ -458,7 +458,7 @@ class DeleteDevicesRequest(BaseModel): { "summary": "Delete specific devices", "value": { - "macs": ["AA:BB:CC:DD:EE:FF", "AA:BB:CC:DD:*"], + "macs": ["aa:bb:cc:dd:ee:ff", "aa:bb:cc:dd:*"], "confirm_delete_all": False } } @@ -570,7 +570,7 @@ class WakeOnLanResponse(BaseResponse): output: Optional[str] = Field( None, description="Command output", - json_schema_extra={"examples": ["Sent magic packet to AA:BB:CC:DD:EE:FF"]} + json_schema_extra={"examples": ["Sent magic packet to aa:bb:cc:dd:ee:ff"]} ) diff --git a/test/api_endpoints/test_device_endpoints.py b/test/api_endpoints/test_device_endpoints.py index 85284f73..3d903fe7 100644 --- a/test/api_endpoints/test_device_endpoints.py +++ b/test/api_endpoints/test_device_endpoints.py @@ -28,7 +28,7 @@ def client(): @pytest.fixture def test_mac(): # Generate a unique MAC for each test run - return "AA:BB:CC:" + ":".join(f"{random.randint(0, 255):02X}" for _ in range(3)) + return "aa:bb:cc:" + ":".join(f"{random.randint(0, 255):02X}" for _ in range(3)) def auth_headers(token): @@ -100,7 +100,7 @@ def test_copy_device(client, api_token, test_mac): assert resp.status_code == 200 # Step 2: Generate a target MAC - target_mac = "AA:BB:CC:" + ":".join( + target_mac = "aa:bb:cc:" + ":".join( f"{random.randint(0, 255):02X}" for _ in range(3) ) diff --git a/test/api_endpoints/test_devices_endpoints.py b/test/api_endpoints/test_devices_endpoints.py index 593c874d..c5e99032 100644 --- a/test/api_endpoints/test_devices_endpoints.py +++ b/test/api_endpoints/test_devices_endpoints.py @@ -196,6 +196,6 @@ def test_devices_by_status(client, api_token, test_mac): def test_delete_test_devices(client, api_token): # Delete by MAC - resp = client.delete("/devices", json={"macs": ["AA:BB:CC:*"]}, headers=auth_headers(api_token)) + resp = client.delete("/devices", json={"macs": ["aa:bb:cc:*"]}, headers=auth_headers(api_token)) assert resp.status_code == 200 assert resp.json.get("success") is True diff --git a/test/api_endpoints/test_mcp_extended_endpoints.py b/test/api_endpoints/test_mcp_extended_endpoints.py index a4b5d7e3..6b242ec9 100644 --- a/test/api_endpoints/test_mcp_extended_endpoints.py +++ b/test/api_endpoints/test_mcp_extended_endpoints.py @@ -78,7 +78,7 @@ def test_reset_device_props(mock_reset, client, api_token): def test_copy_device(mock_copy, client, api_token): """Test POST /device/copy.""" mock_copy.return_value = {"success": True} - payload = {"macFrom": "00:11:22:33:44:55", "macTo": "AA:BB:CC:DD:EE:FF"} + payload = {"macFrom": "00:11:22:33:44:55", "macTo": "aa:bb:cc:dd:ee:ff"} response = client.post('/device/copy', json=payload, @@ -86,21 +86,21 @@ def test_copy_device(mock_copy, client, api_token): assert response.status_code == 200 assert response.get_json() == {"success": True} - mock_copy.assert_called_with("00:11:22:33:44:55", "AA:BB:CC:DD:EE:FF") + mock_copy.assert_called_with("00:11:22:33:44:55", "aa:bb:cc:dd:ee:ff") @patch('models.device_instance.DeviceInstance.deleteDevices') def test_delete_devices_bulk(mock_delete, client, api_token): """Test DELETE /devices.""" mock_delete.return_value = {"success": True} - payload = {"macs": ["00:11:22:33:44:55", "AA:BB:CC:DD:EE:FF"]} + payload = {"macs": ["00:11:22:33:44:55", "aa:bb:cc:dd:ee:ff"]} response = client.delete('/devices', json=payload, headers=auth_headers(api_token)) assert response.status_code == 200 - mock_delete.assert_called_with(["00:11:22:33:44:55", "AA:BB:CC:DD:EE:FF"]) + mock_delete.assert_called_with(["00:11:22:33:44:55", "aa:bb:cc:dd:ee:ff"]) @patch('models.device_instance.DeviceInstance.deleteAllWithEmptyMacs') diff --git a/test/api_endpoints/test_mcp_tools_endpoints.py b/test/api_endpoints/test_mcp_tools_endpoints.py index c18c0195..77d7561a 100644 --- a/test/api_endpoints/test_mcp_tools_endpoints.py +++ b/test/api_endpoints/test_mcp_tools_endpoints.py @@ -30,7 +30,7 @@ def test_get_device_info_ip_partial(mock_db_conn, client, api_token): # Mock database connection - DeviceInstance._fetchall calls conn.execute().fetchall() mock_conn = MagicMock() mock_execute_result = MagicMock() - mock_execute_result.fetchall.return_value = [{"devName": "Test Device", "devMac": "AA:BB:CC:DD:EE:FF", "devLastIP": "192.168.1.50"}] + mock_execute_result.fetchall.return_value = [{"devName": "Test Device", "devMac": "aa:bb:cc:dd:ee:ff", "devLastIP": "192.168.1.50"}] mock_conn.execute.return_value = mock_execute_result mock_db_conn.return_value = mock_conn @@ -92,7 +92,7 @@ def test_get_open_ports_ip(mock_device_db_conn, mock_plugin_db_conn, client, api # 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 for DeviceInstance.getByIP (returns device with MAC) - mock_execute_result.fetchone.return_value = {"devMac": "AA:BB:CC:DD:EE:FF"} + mock_execute_result.fetchone.return_value = {"devMac": "aa:bb:cc:dd:ee:ff"} mock_conn.execute.return_value = mock_execute_result mock_plugin_db_conn.return_value = mock_conn @@ -119,7 +119,7 @@ def test_get_open_ports_mac_resolve(mock_plugin_db_conn, client, api_token): mock_conn.execute.return_value = mock_execute_result mock_plugin_db_conn.return_value = mock_conn - payload = {"target": "AA:BB:CC:DD:EE:FF"} + payload = {"target": "aa:bb:cc:dd:ee:ff"} response = client.post("/device/open_ports", json=payload, headers=auth_headers(api_token)) assert response.status_code == 200 @@ -163,7 +163,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 = [{"eve_DateTime": now, "eve_EventType": "New Device", "eve_MAC": "aa:bb:cc:dd:ee:ff"}] mock_conn.execute.return_value = mock_execute_result mock_db_conn.return_value = mock_conn @@ -186,12 +186,12 @@ def test_set_device_alias(mock_update_col, client, api_token): mock_update_col.return_value = {"success": True, "message": "Device alias updated"} payload = {"alias": "New Device Name"} - response = client.post("/device/AA:BB:CC:DD:EE:FF/set-alias", json=payload, headers=auth_headers(api_token)) + response = client.post("/device/aa:bb:cc:dd:ee:ff/set-alias", json=payload, headers=auth_headers(api_token)) assert response.status_code == 200 data = response.get_json() assert data["success"] is True - mock_update_col.assert_called_once_with("AA:BB:CC:DD:EE:FF", "devName", "New Device Name") + mock_update_col.assert_called_once_with("aa:bb:cc:dd:ee:ff", "devName", "New Device Name") @patch("models.device_instance.DeviceInstance.updateDeviceColumn") @@ -214,15 +214,15 @@ def test_set_device_alias_not_found(mock_update_col, client, api_token): @patch("api_server.api_server_start.wakeonlan") def test_wol_wake_device(mock_wakeonlan, client, api_token): """Test wol_wake_device.""" - mock_wakeonlan.return_value = {"success": True, "message": "WOL packet sent to AA:BB:CC:DD:EE:FF"} + mock_wakeonlan.return_value = {"success": True, "message": "WOL packet sent to aa:bb:cc:dd:ee:ff"} - payload = {"devMac": "AA:BB:CC:DD:EE:FF"} + payload = {"devMac": "aa:bb:cc:dd:ee:ff"} response = client.post("/nettools/wakeonlan", json=payload, headers=auth_headers(api_token)) assert response.status_code == 200 data = response.get_json() assert data["success"] is True - assert "AA:BB:CC:DD:EE:FF" in data["message"] + assert "aa:bb:cc:dd:ee:ff" in data["message"] def test_wol_wake_device_invalid_mac(client, api_token): @@ -249,7 +249,7 @@ def test_get_latest_device(mock_db_conn, client, api_token): mock_execute_result = MagicMock() mock_execute_result.fetchone.return_value = { "devName": "Latest Device", - "devMac": "AA:BB:CC:DD:EE:FF", + "devMac": "aa:bb:cc:dd:ee:ff", "devLastIP": "192.168.1.100", "devFirstConnection": "2025-12-07 10:30:00", } @@ -262,7 +262,7 @@ def test_get_latest_device(mock_db_conn, client, api_token): data = response.get_json() assert len(data) >= 1, "Expected at least one device in response" assert data[0]["devName"] == "Latest Device" - assert data[0]["devMac"] == "AA:BB:CC:DD:EE:FF" + assert data[0]["devMac"] == "aa:bb:cc:dd:ee:ff" def test_openapi_spec(client, api_token): @@ -293,7 +293,7 @@ def test_mcp_devices_export_csv(mock_db_conn, client, api_token): """Test MCP devices export in CSV format.""" mock_conn = MagicMock() mock_execute_result = MagicMock() - mock_execute_result.fetchall.return_value = [{"devMac": "AA:BB:CC:DD:EE:FF", "devName": "Test Device", "devLastIP": "192.168.1.1"}] + mock_execute_result.fetchall.return_value = [{"devMac": "aa:bb:cc:dd:ee:ff", "devName": "Test Device", "devLastIP": "192.168.1.1"}] mock_conn.execute.return_value = mock_execute_result mock_db_conn.return_value = mock_conn @@ -310,7 +310,7 @@ def test_mcp_devices_export_json(mock_export, client, api_token): """Test MCP devices export in JSON format.""" mock_export.return_value = { "format": "json", - "data": [{"devMac": "AA:BB:CC:DD:EE:FF", "devName": "Test Device", "devLastIP": "192.168.1.1"}], + "data": [{"devMac": "aa:bb:cc:dd:ee:ff", "devName": "Test Device", "devLastIP": "192.168.1.1"}], "columns": ["devMac", "devName", "devLastIP"], } diff --git a/test/scan/test_device_field_lock.py b/test/scan/test_device_field_lock.py index 7703daf1..1818bacd 100644 --- a/test/scan/test_device_field_lock.py +++ b/test/scan/test_device_field_lock.py @@ -31,7 +31,7 @@ def client(): @pytest.fixture def test_mac(): """Generate a test MAC address.""" - return "AA:BB:CC:DD:EE:FF" + return "aa:bb:cc:dd:ee:ff" @pytest.fixture diff --git a/test/scan/test_ip_update_logic.py b/test/scan/test_ip_update_logic.py index 6cdb47f9..9c995a5d 100644 --- a/test/scan/test_ip_update_logic.py +++ b/test/scan/test_ip_update_logic.py @@ -38,7 +38,7 @@ def test_ipv6_update_preserves_ipv4(scan_db, mock_device_handling): # 1️⃣ Create device with IPv4 cur.execute( "INSERT INTO Devices (devMac, devLastIP, devPrimaryIPv4, devName) VALUES (?, ?, ?, ?)", - ("AA:BB:CC:DD:EE:FF", "192.168.1.10", "192.168.1.10", "Device") + ("aa:bb:cc:dd:ee:ff", "192.168.1.10", "192.168.1.10", "Device") ) # 2️⃣ Insert a scan reporting IPv6 @@ -47,7 +47,7 @@ def test_ipv6_update_preserves_ipv4(scan_db, mock_device_handling): INSERT INTO CurrentScan (scanMac, scanLastIP, scanSourcePlugin, scanLastConnection) VALUES (?, ?, ?, ?) """, - ("AA:BB:CC:DD:EE:FF", "2001:db8::1", "TEST_PLUGIN", "2025-01-01 01:00:00") + ("aa:bb:cc:dd:ee:ff", "2001:db8::1", "TEST_PLUGIN", "2025-01-01 01:00:00") ) scan_db.commit() @@ -61,7 +61,7 @@ def test_ipv6_update_preserves_ipv4(scan_db, mock_device_handling): # 4️⃣ Verify the device fields row = cur.execute( "SELECT devLastIP, devPrimaryIPv4, devPrimaryIPv6 FROM Devices WHERE devMac = ?", - ("AA:BB:CC:DD:EE:FF",), + ("aa:bb:cc:dd:ee:ff",), ).fetchone() assert row["devLastIP"] == "2001:db8::1" # Latest IP is now IPv6 diff --git a/test/server/test_graphql_endpoints.py b/test/server/test_graphql_endpoints.py index 15078194..d5a44e55 100755 --- a/test/server/test_graphql_endpoints.py +++ b/test/server/test_graphql_endpoints.py @@ -24,7 +24,7 @@ def client(): @pytest.fixture def test_mac(): # Generate a unique MAC for each test run - return "AA:BB:CC:" + ":".join(f"{random.randint(0, 255):02X}" for _ in range(3)) + return "aa:bb:cc:" + ":".join(f"{random.randint(0, 255):02X}" for _ in range(3)) def auth_headers(token): diff --git a/test/test_plugin_helper.py b/test/test_plugin_helper.py index 59e11597..9e4610a7 100644 --- a/test/test_plugin_helper.py +++ b/test/test_plugin_helper.py @@ -15,7 +15,7 @@ def test_normalize_mac_preserves_wildcard(): # Call once and assert deterministic result result = normalize_mac("aabbcc*") assert result == "AA:BB:CC:*", f"Expected 'AA:BB:CC:*' but got '{result}'" - assert normalize_mac("aa:bb:cc:dd:ee:ff") == "AA:BB:CC:DD:EE:FF" + assert normalize_mac("aa:bb:cc:dd:ee:ff") == "aa:bb:cc:dd:ee:ff" def test_normalize_mac_preserves_internet_root():