FIX: lowercase MAC normalization across project v0.2

Signed-off-by: jokob-sk <jokob.sk@gmail.com>
This commit is contained in:
jokob-sk
2026-02-07 14:02:54 +11:00
parent 946ad00253
commit 827b5d2ad3
11 changed files with 42 additions and 40 deletions

View File

@@ -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 = `
<table class="table table-bordered table-striped node-leafs-table " id="table_leafs_${id}" data-node-mac="${normalized_mac}">
</table>`;
loadDeviceTable({

View File

@@ -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):

View File

@@ -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"]}
)

View File

@@ -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)
)

View File

@@ -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

View File

@@ -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')

View File

@@ -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"],
}

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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():