From 478b018fa5e20b7cf4c968b356c9923bb9e31f04 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Wed, 21 Jan 2026 01:58:52 +0000 Subject: [PATCH] feat: Enhance plugin configurations and improve MAC normalization --- front/plugins/__template/config.json | 62 +++++ front/plugins/adguard_import/config.json | 62 +++++ front/plugins/arp_scan/config.json | 4 +- front/plugins/asuswrt_import/config.json | 4 +- front/plugins/avahi_scan/config.json | 4 +- front/plugins/dhcp_leases/config.json | 4 +- front/plugins/dig_scan/config.json | 4 +- front/plugins/freebox/config.json | 62 +++++ front/plugins/icmp_scan/config.json | 62 +++++ front/plugins/internet_ip/config.json | 62 +++++ front/plugins/ipneigh/config.json | 4 +- front/plugins/luci_import/config.json | 4 +- front/plugins/mikrotik_scan/config.json | 62 +++++ front/plugins/nbtscan_scan/config.json | 4 +- front/plugins/nmap_dev_scan/config.json | 62 +++++ front/plugins/nslookup_scan/config.json | 4 +- front/plugins/omada_sdn_imp/config.json | 62 +++++ front/plugins/omada_sdn_openapi/config.json | 62 +++++ front/plugins/pihole_api_scan/config.json | 4 +- front/plugins/pihole_scan/config.json | 4 +- front/plugins/plugin_helper.py | 7 +- front/plugins/snmp_discovery/config.json | 4 +- front/plugins/sync/config.json | 62 +++++ front/plugins/unifi_api_import/config.json | 4 +- front/plugins/unifi_import/config.json | 4 +- front/plugins/vendor_update/config.json | 4 +- server/api_server/api_server_start.py | 43 ++-- server/db/authoritative_handler.py | 28 ++- server/models/device_instance.py | 4 +- server/scan/device_handling.py | 11 +- .../test_device_field_lock.py | 24 ++ .../test_ip_format_and_locking.py | 146 +++++++++++ test/test_device_atomicity.py | 231 ++++++++++++++++++ test/test_plugin_helper.py | 6 + 34 files changed, 1117 insertions(+), 63 deletions(-) create mode 100644 test/test_device_atomicity.py diff --git a/front/plugins/__template/config.json b/front/plugins/__template/config.json index 80b2c64c..dde5a747 100755 --- a/front/plugins/__template/config.json +++ b/front/plugins/__template/config.json @@ -333,6 +333,68 @@ "string": "Maximum time in seconds to wait for the script to finish. If this time is exceeded the script is aborted." } ] + }, + { + "function": "SET_ALWAYS", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], + "transformers": [] + } + ] + }, + "default_value": ["devMac", "devLastIP"], + "options": [ + "devMac", + "devLastIP" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Set always columns" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "These columns are always overwritten by this plugin, unless the user locks or overwrites the value." + } + ] + }, + { + "function": "SET_EMPTY", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], + "transformers": [] + } + ] + }, + "default_value": [], + "options": [ + "devMac", + "devLastIP" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Set empty columns" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "These columns are only overwritten if they are empty or set to NEWDEV." + } + ] } ], "database_column_definitions": [ diff --git a/front/plugins/adguard_import/config.json b/front/plugins/adguard_import/config.json index 8a9e8530..a9f60663 100644 --- a/front/plugins/adguard_import/config.json +++ b/front/plugins/adguard_import/config.json @@ -307,6 +307,68 @@ "string": "Some devices don't have a MAC assigned. Enabling the FAKE_MAC setting generates a fake MAC address from the IP address to track devices, but it may cause inconsistencies if IPs change or devices are re-discovered with a different MAC. Static IPs are recommended. Device type and icon might not be detected correctly and some plugins might fail if they depend on a valid MAC address. When unchecked, devices with empty MAC addresses are skipped." } ] + }, + { + "function": "SET_ALWAYS", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], + "transformers": [] + } + ] + }, + "default_value": ["devMac", "devLastIP"], + "options": [ + "devMac", + "devLastIP" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Set always columns" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "These columns are always overwritten by this plugin, unless the user locks or overwrites the value." + } + ] + }, + { + "function": "SET_EMPTY", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], + "transformers": [] + } + ] + }, + "default_value": [], + "options": [ + "devMac", + "devLastIP" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Set empty columns" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "These columns are only overwritten if they are empty or set to NEWDEV." + } + ] } ], "database_column_definitions": [ diff --git a/front/plugins/arp_scan/config.json b/front/plugins/arp_scan/config.json index 842069c9..f0f8e4d2 100755 --- a/front/plugins/arp_scan/config.json +++ b/front/plugins/arp_scan/config.json @@ -274,7 +274,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "elementOptions": [{ "multiple": "true", "ordeable": "true"}], "transformers": [] } ] @@ -305,7 +305,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], "transformers": [] } ] diff --git a/front/plugins/asuswrt_import/config.json b/front/plugins/asuswrt_import/config.json index a4678ceb..c4d54a83 100755 --- a/front/plugins/asuswrt_import/config.json +++ b/front/plugins/asuswrt_import/config.json @@ -135,7 +135,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true"}], + "elementOptions": [{ "multiple": "true", "ordeable": "true"}], "transformers": [] } ] @@ -166,7 +166,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true"}], + "elementOptions": [{ "multiple": "true", "ordeable": "true"}], "transformers": [] } ] diff --git a/front/plugins/avahi_scan/config.json b/front/plugins/avahi_scan/config.json index e65e6c9a..5f170004 100755 --- a/front/plugins/avahi_scan/config.json +++ b/front/plugins/avahi_scan/config.json @@ -89,7 +89,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true"}], + "elementOptions": [{ "multiple": "true", "ordeable": "true"}], "transformers": [] } ] @@ -119,7 +119,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true"}], + "elementOptions": [{ "multiple": "true", "ordeable": "true"}], "transformers": [] } ] diff --git a/front/plugins/dhcp_leases/config.json b/front/plugins/dhcp_leases/config.json index ad94cd5d..8fe9bd86 100755 --- a/front/plugins/dhcp_leases/config.json +++ b/front/plugins/dhcp_leases/config.json @@ -689,7 +689,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "elementOptions": [{ "multiple": "true", "ordeable": "true"}], "transformers": [] } ] @@ -720,7 +720,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], "transformers": [] } ] diff --git a/front/plugins/dig_scan/config.json b/front/plugins/dig_scan/config.json index 981d9d34..b97f3602 100755 --- a/front/plugins/dig_scan/config.json +++ b/front/plugins/dig_scan/config.json @@ -90,7 +90,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "elementOptions": [{ "multiple": "true", "ordeable": "true"}], "transformers": [] } ] @@ -121,7 +121,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], "transformers": [] } ] diff --git a/front/plugins/freebox/config.json b/front/plugins/freebox/config.json index 0558b8ac..2252fcfe 100755 --- a/front/plugins/freebox/config.json +++ b/front/plugins/freebox/config.json @@ -303,6 +303,68 @@ "string": "Maximum time in seconds to wait for the script to finish. If this time is exceeded the script is aborted." } ] + }, + { + "function": "SET_ALWAYS", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], + "transformers": [] + } + ] + }, + "default_value": ["devMac", "devLastIP"], + "options": [ + "devMac", + "devLastIP" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Set always columns" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "These columns are always overwritten by this plugin, unless the user locks or overwrites the value." + } + ] + }, + { + "function": "SET_EMPTY", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], + "transformers": [] + } + ] + }, + "default_value": [], + "options": [ + "devMac", + "devLastIP" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Set empty columns" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "These columns are only overwritten if they are empty or set to NEWDEV." + } + ] } ], "database_column_definitions": [ diff --git a/front/plugins/icmp_scan/config.json b/front/plugins/icmp_scan/config.json index 43b0c892..fd400adc 100755 --- a/front/plugins/icmp_scan/config.json +++ b/front/plugins/icmp_scan/config.json @@ -296,6 +296,68 @@ "string": "Maximum time in seconds to wait for the script to finish. If this time is exceeded the script is aborted." } ] + }, + { + "function": "SET_ALWAYS", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], + "transformers": [] + } + ] + }, + "default_value": ["devMac", "devLastIP"], + "options": [ + "devMac", + "devLastIP" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Set always columns" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "These columns are always overwritten by this plugin, unless the user locks or overwrites the value." + } + ] + }, + { + "function": "SET_EMPTY", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], + "transformers": [] + } + ] + }, + "default_value": [], + "options": [ + "devMac", + "devLastIP" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Set empty columns" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "These columns are only overwritten if they are empty or set to NEWDEV." + } + ] } ], "database_column_definitions": [ diff --git a/front/plugins/internet_ip/config.json b/front/plugins/internet_ip/config.json index f55eae79..dc2a988d 100755 --- a/front/plugins/internet_ip/config.json +++ b/front/plugins/internet_ip/config.json @@ -410,6 +410,68 @@ "string": "Benachrichtige nur bei diesen Status. new bedeutet ein neues eindeutiges (einzigartige Kombination aus PrimaryId und SecondaryId) Objekt wurde erkennt. watched-changed bedeutet eine ausgewählte Watched_ValueN-Spalte hat sich geändert." } ] + }, + { + "function": "SET_ALWAYS", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], + "transformers": [] + } + ] + }, + "default_value": ["devMac", "devLastIP"], + "options": [ + "devMac", + "devLastIP" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Set always columns" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "These columns are always overwritten by this plugin, unless the user locks or overwrites the value." + } + ] + }, + { + "function": "SET_EMPTY", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], + "transformers": [] + } + ] + }, + "default_value": [], + "options": [ + "devMac", + "devLastIP" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Set empty columns" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "These columns are only overwritten if they are empty or set to NEWDEV." + } + ] } ], "database_column_definitions": [ diff --git a/front/plugins/ipneigh/config.json b/front/plugins/ipneigh/config.json index 12a53563..c80046c4 100755 --- a/front/plugins/ipneigh/config.json +++ b/front/plugins/ipneigh/config.json @@ -138,7 +138,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], "transformers": [] } ] @@ -169,7 +169,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], "transformers": [] } ] diff --git a/front/plugins/luci_import/config.json b/front/plugins/luci_import/config.json index f70be1a6..8bb19489 100755 --- a/front/plugins/luci_import/config.json +++ b/front/plugins/luci_import/config.json @@ -391,7 +391,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], "transformers": [] } ] @@ -422,7 +422,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], "transformers": [] } ] diff --git a/front/plugins/mikrotik_scan/config.json b/front/plugins/mikrotik_scan/config.json index a57c280d..c6964077 100755 --- a/front/plugins/mikrotik_scan/config.json +++ b/front/plugins/mikrotik_scan/config.json @@ -267,6 +267,68 @@ "string": "Password for Mikrotik Router" } ] + }, + { + "function": "SET_ALWAYS", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], + "transformers": [] + } + ] + }, + "default_value": ["devMac", "devLastIP"], + "options": [ + "devMac", + "devLastIP" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Set always columns" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "These columns are always overwritten by this plugin, unless the user locks or overwrites the value." + } + ] + }, + { + "function": "SET_EMPTY", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], + "transformers": [] + } + ] + }, + "default_value": [], + "options": [ + "devMac", + "devLastIP" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Set empty columns" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "These columns are only overwritten if they are empty or set to NEWDEV." + } + ] } ], "database_column_definitions": [ diff --git a/front/plugins/nbtscan_scan/config.json b/front/plugins/nbtscan_scan/config.json index f1e56117..d380bba5 100755 --- a/front/plugins/nbtscan_scan/config.json +++ b/front/plugins/nbtscan_scan/config.json @@ -89,7 +89,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], "transformers": [] } ] @@ -119,7 +119,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], "transformers": [] } ] diff --git a/front/plugins/nmap_dev_scan/config.json b/front/plugins/nmap_dev_scan/config.json index db5b4aca..2f6c69f3 100755 --- a/front/plugins/nmap_dev_scan/config.json +++ b/front/plugins/nmap_dev_scan/config.json @@ -451,6 +451,68 @@ "string": "When scanning remote networks, NMAP can only retrieve the IP address, not the MAC address. Enabling the FAKE_MAC setting generates a fake MAC address from the IP address to track devices, but it may cause inconsistencies if IPs change or devices are re-discovered with a different MAC. Static IPs are recommended. Device type and icon might not be detected correctly and some plugins might fail if they depend on a valid MAC address. When unchecked, devices with empty MAC addresses are skipped." } ] + }, + { + "function": "SET_ALWAYS", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], + "transformers": [] + } + ] + }, + "default_value": ["devMac", "devLastIP"], + "options": [ + "devMac", + "devLastIP" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Set always columns" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "These columns are always overwritten by this plugin, unless the user locks or overwrites the value." + } + ] + }, + { + "function": "SET_EMPTY", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], + "transformers": [] + } + ] + }, + "default_value": [], + "options": [ + "devMac", + "devLastIP" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Set empty columns" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "These columns are only overwritten if they are empty or set to NEWDEV." + } + ] } ], "database_column_definitions": [ diff --git a/front/plugins/nslookup_scan/config.json b/front/plugins/nslookup_scan/config.json index 3de69f80..e57a4899 100755 --- a/front/plugins/nslookup_scan/config.json +++ b/front/plugins/nslookup_scan/config.json @@ -89,7 +89,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true"}], + "elementOptions": [{ "multiple": "true", "ordeable": "true"}], "transformers": [] } ] @@ -120,7 +120,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true"}], + "elementOptions": [{ "multiple": "true", "ordeable": "true"}], "transformers": [] } ] diff --git a/front/plugins/omada_sdn_imp/config.json b/front/plugins/omada_sdn_imp/config.json index 89a26266..845cd4ed 100755 --- a/front/plugins/omada_sdn_imp/config.json +++ b/front/plugins/omada_sdn_imp/config.json @@ -467,6 +467,68 @@ } ] } + }, + { + "function": "SET_ALWAYS", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], + "transformers": [] + } + ] + }, + "default_value": ["devMac", "devLastIP"], + "options": [ + "devMac", + "devLastIP" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Set always columns" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "These columns are always overwritten by this plugin, unless the user locks or overwrites the value." + } + ] + }, + { + "function": "SET_EMPTY", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], + "transformers": [] + } + ] + }, + "default_value": [], + "options": [ + "devMac", + "devLastIP" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Set empty columns" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "These columns are only overwritten if they are empty or set to NEWDEV." + } + ] } ], "database_column_definitions": [ diff --git a/front/plugins/omada_sdn_openapi/config.json b/front/plugins/omada_sdn_openapi/config.json index dfd23285..5dd193d5 100755 --- a/front/plugins/omada_sdn_openapi/config.json +++ b/front/plugins/omada_sdn_openapi/config.json @@ -440,6 +440,68 @@ } ] } + }, + { + "function": "SET_ALWAYS", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], + "transformers": [] + } + ] + }, + "default_value": ["devMac", "devLastIP"], + "options": [ + "devMac", + "devLastIP" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Set always columns" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "These columns are always overwritten by this plugin, unless the user locks or overwrites the value." + } + ] + }, + { + "function": "SET_EMPTY", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], + "transformers": [] + } + ] + }, + "default_value": [], + "options": [ + "devMac", + "devLastIP" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Set empty columns" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "These columns are only overwritten if they are empty or set to NEWDEV." + } + ] } ], "database_column_definitions": [ diff --git a/front/plugins/pihole_api_scan/config.json b/front/plugins/pihole_api_scan/config.json index 16283444..f80c2f55 100644 --- a/front/plugins/pihole_api_scan/config.json +++ b/front/plugins/pihole_api_scan/config.json @@ -121,7 +121,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], "transformers": [] } ] @@ -154,7 +154,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], "transformers": [] } ] diff --git a/front/plugins/pihole_scan/config.json b/front/plugins/pihole_scan/config.json index 7f98770f..a72f7113 100755 --- a/front/plugins/pihole_scan/config.json +++ b/front/plugins/pihole_scan/config.json @@ -223,7 +223,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true"}], + "elementOptions": [{ "multiple": "true", "ordeable": "true"}], "transformers": [] } ] @@ -253,7 +253,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true"}], + "elementOptions": [{ "multiple": "true", "ordeable": "true"}], "transformers": [] } ] diff --git a/front/plugins/plugin_helper.py b/front/plugins/plugin_helper.py index 972af95e..6e1f99a0 100755 --- a/front/plugins/plugin_helper.py +++ b/front/plugins/plugin_helper.py @@ -184,7 +184,12 @@ def normalize_mac(mac): :param mac: The MAC address to normalize. :return: The normalized MAC address. """ - s = str(mac).upper().strip() + s = str(mac).strip() + + if s.lower() == "internet": + return "Internet" + + s = s.upper() # Determine separator if present, prefer colon, then hyphen if ':' in s: diff --git a/front/plugins/snmp_discovery/config.json b/front/plugins/snmp_discovery/config.json index fc194a3e..f3f5ea84 100755 --- a/front/plugins/snmp_discovery/config.json +++ b/front/plugins/snmp_discovery/config.json @@ -593,7 +593,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], "transformers": [] } ] @@ -624,7 +624,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], "transformers": [] } ] diff --git a/front/plugins/sync/config.json b/front/plugins/sync/config.json index 913ab556..bc32e307 100755 --- a/front/plugins/sync/config.json +++ b/front/plugins/sync/config.json @@ -859,6 +859,68 @@ "string": "Status" } ] + }, + { + "function": "SET_ALWAYS", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], + "transformers": [] + } + ] + }, + "default_value": ["devMac", "devLastIP"], + "options": [ + "devMac", + "devLastIP" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Set always columns" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "These columns are always overwritten by this plugin, unless the user locks or overwrites the value." + } + ] + }, + { + "function": "SET_EMPTY", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], + "transformers": [] + } + ] + }, + "default_value": [], + "options": [ + "devMac", + "devLastIP" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Set empty columns" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "These columns are only overwritten if they are empty or set to NEWDEV." + } + ] } ] } \ No newline at end of file diff --git a/front/plugins/unifi_api_import/config.json b/front/plugins/unifi_api_import/config.json index 1aedc822..9381d60f 100755 --- a/front/plugins/unifi_api_import/config.json +++ b/front/plugins/unifi_api_import/config.json @@ -505,7 +505,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], "transformers": [] } ] @@ -538,7 +538,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], "transformers": [] } ] diff --git a/front/plugins/unifi_import/config.json b/front/plugins/unifi_import/config.json index d82019d2..897a2c33 100755 --- a/front/plugins/unifi_import/config.json +++ b/front/plugins/unifi_import/config.json @@ -923,7 +923,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], "transformers": [] } ] @@ -959,7 +959,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], "transformers": [] } ] diff --git a/front/plugins/vendor_update/config.json b/front/plugins/vendor_update/config.json index 93eef6d6..eb156f5e 100755 --- a/front/plugins/vendor_update/config.json +++ b/front/plugins/vendor_update/config.json @@ -233,7 +233,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], "transformers": [] } ] @@ -265,7 +265,7 @@ "elements": [ { "elementType": "select", - "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "elementOptions": [{ "multiple": "true", "ordeable": "true" }], "transformers": [] } ] diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index 3187596c..e509ca28 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -44,7 +44,7 @@ from models.user_events_queue_instance import UserEventsQueueInstance # noqa: E from models.event_instance import EventInstance # noqa: E402 [flake8 lint suppression] # Import tool logic from the MCP/tools module to reuse behavior (no blueprints) -from plugin_helper import is_mac # noqa: E402 [flake8 lint suppression] +from plugin_helper import is_mac, normalize_mac # noqa: E402 [flake8 lint suppression] # is_mac is provided in mcp_endpoint and used by those handlers # mcp_endpoint contains helper functions; routes moved into this module to keep a single place for routes from messaging.in_app import ( # noqa: E402 [flake8 lint suppression] @@ -469,33 +469,28 @@ def api_device_field_lock(mac, payload=None): if not field_name: return jsonify({"success": False, "error": "fieldName is required"}), 400 - # Validate that the field can be locked - source_field = field_name + "Source" - allowed_tracked_fields = { - "devMac", "devName", "devLastIP", "devVendor", "devFQDN", - "devSSID", "devParentMAC", "devParentPort", "devParentRelType", "devVlan" - } - if field_name not in allowed_tracked_fields: - return jsonify({"success": False, "error": f"Field '{field_name}' cannot be locked"}), 400 - device_handler = DeviceInstance() + normalized_mac = normalize_mac(mac) try: - # When locking: set source to LOCKED - # When unlocking: check current value and let plugins take over - new_source = "LOCKED" if should_lock else "NEWDEV" - result = device_handler.updateDeviceColumn(mac, source_field, new_source) - - if result.get("success"): - action = "locked" if should_lock else "unlocked" - return jsonify({ - "success": True, - "message": f"Field {field_name} {action}", - "fieldName": field_name, - "locked": should_lock - }) + if should_lock: + result = device_handler.lockDeviceField(normalized_mac, field_name) + action = "locked" else: - return jsonify(result), 400 + result = device_handler.unlockDeviceField(normalized_mac, field_name) + action = "unlocked" + + response = dict(result) + response["fieldName"] = field_name + response["locked"] = should_lock + + if response.get("success"): + response.setdefault("message", f"Field {field_name} {action}") + return jsonify(response) + + if "does not support" in response.get("error", ""): + response["error"] = f"Field '{field_name}' cannot be {action}" + return jsonify(response), 400 except Exception as e: mylog("none", f"Error locking field {field_name} for {mac}: {str(e)}") return jsonify({"success": False, "error": str(e)}), 500 diff --git a/server/db/authoritative_handler.py b/server/db/authoritative_handler.py index 39f3e498..63aaed83 100644 --- a/server/db/authoritative_handler.py +++ b/server/db/authoritative_handler.py @@ -152,12 +152,20 @@ def enforce_source_on_user_update(devMac, updates_dict, conn): cur = conn.cursor() # Check if field has a corresponding source and should be updated + cur = conn.cursor() + try: + cur.execute("PRAGMA table_info(Devices)") + device_columns = {row["name"] for row in cur.fetchall()} + except Exception: + device_columns = set() + updates_to_apply = {} for field_name, new_value in updates_dict.items(): if field_name in FIELD_SOURCE_MAP: source_field = FIELD_SOURCE_MAP[field_name] # User is updating this field, so mark it as USER - updates_to_apply[source_field] = "USER" + if not device_columns or source_field in device_columns: + updates_to_apply[source_field] = "USER" if not updates_to_apply: return @@ -198,6 +206,15 @@ def lock_field(devMac, field_name, conn): source_field = FIELD_SOURCE_MAP[field_name] cur = conn.cursor() + try: + cur.execute("PRAGMA table_info(Devices)") + device_columns = {row["name"] for row in cur.fetchall()} + except Exception: + device_columns = set() + + if device_columns and source_field not in device_columns: + mylog("debug", [f"[lock_field] Source column {source_field} missing for {field_name}"]) + return sql = f"UPDATE Devices SET {source_field}='LOCKED' WHERE devMac = ?" @@ -227,6 +244,15 @@ def unlock_field(devMac, field_name, conn): source_field = FIELD_SOURCE_MAP[field_name] cur = conn.cursor() + try: + cur.execute("PRAGMA table_info(Devices)") + device_columns = {row["name"] for row in cur.fetchall()} + except Exception: + device_columns = set() + + if device_columns and source_field not in device_columns: + mylog("debug", [f"[unlock_field] Source column {source_field} missing for {field_name}"]) + return # Unlock by resetting to empty (allows overwrite) sql = f"UPDATE Devices SET {source_field}='' WHERE devMac = ?" diff --git a/server/models/device_instance.py b/server/models/device_instance.py index 90933e14..4ebb88be 100755 --- a/server/models/device_instance.py +++ b/server/models/device_instance.py @@ -593,7 +593,6 @@ class DeviceInstance: conn = get_temp_db_connection() cur = conn.cursor() cur.execute(sql, values) - conn.commit() if data.get("createNew", False): # Initialize source-tracking fields on device creation. @@ -617,7 +616,6 @@ class DeviceInstance: source_values.append(normalized_mac) source_sql = f"UPDATE Devices SET {set_clause} WHERE devMac = ?" cur.execute(source_sql, source_values) - conn.commit() # Enforce source tracking on user updates # User-updated fields should have their *Source set to "USER" @@ -631,6 +629,8 @@ class DeviceInstance: conn.close() return {"success": False, "error": f"Source tracking failed: {e}"} + # Commit all changes atomically after all operations succeed + conn.commit() conn.close() mylog("debug", f"[DeviceInstance] setDeviceData SQL: {sql.strip()}") diff --git a/server/scan/device_handling.py b/server/scan/device_handling.py index f7bf4155..1e444816 100755 --- a/server/scan/device_handling.py +++ b/server/scan/device_handling.py @@ -545,12 +545,13 @@ def create_new_devices(db): # Derive primary IP family values cur_IP = str(cur_IP).strip() if cur_IP else "" cur_IP_normalized = check_IP_format(cur_IP) if ":" not in cur_IP else cur_IP - - # Validate IPv6 addresses using format_ip_long for consistency - if ":" in cur_IP_normalized: + + # Validate IPv6 addresses using format_ip_long for consistency (do not store integer result) + if cur_IP_normalized and ":" in cur_IP_normalized: validated_ipv6 = format_ip_long(cur_IP_normalized) - cur_IP_normalized = validated_ipv6 if validated_ipv6 else "" - + if validated_ipv6 is None or validated_ipv6 < 0: + cur_IP_normalized = "" + primary_ipv4 = cur_IP_normalized if cur_IP_normalized and ":" not in cur_IP_normalized else "" primary_ipv6 = cur_IP_normalized if cur_IP_normalized and ":" in cur_IP_normalized else "" diff --git a/test/authoritative_fields/test_device_field_lock.py b/test/authoritative_fields/test_device_field_lock.py index b1361fcc..cd24cbfd 100644 --- a/test/authoritative_fields/test_device_field_lock.py +++ b/test/authoritative_fields/test_device_field_lock.py @@ -115,6 +115,30 @@ class TestDeviceFieldLock: assert resp.status_code == 400 assert "cannot be locked" in resp.json.get("error", "") + def test_lock_field_normalizes_mac(self, client, test_mac, auth_headers): + """Lock endpoint should normalize MACs before applying locks.""" + # Create device with normalized MAC + self.test_create_test_device(client, test_mac, auth_headers) + + mac_variant = "aa-bb-cc-dd-ee-ff" + payload = { + "fieldName": "devName", + "lock": True + } + resp = client.post( + f"/device/{mac_variant}/field/lock", + json=payload, + headers=auth_headers + ) + assert resp.status_code == 200, f"Failed to lock via normalized MAC: {resp.json}" + assert resp.json.get("locked") is True + + # Verify source is LOCKED on normalized MAC + resp = client.get(f"/device/{test_mac}", headers=auth_headers) + assert resp.status_code == 200 + device_data = resp.json + assert device_data.get("devNameSource") == "LOCKED" + def test_lock_all_tracked_fields(self, client, test_mac, auth_headers): """Lock each tracked field individually.""" # First create device diff --git a/test/authoritative_fields/test_ip_format_and_locking.py b/test/authoritative_fields/test_ip_format_and_locking.py index 3141a538..ff3d26a3 100644 --- a/test/authoritative_fields/test_ip_format_and_locking.py +++ b/test/authoritative_fields/test_ip_format_and_locking.py @@ -72,6 +72,103 @@ def ip_test_db(): conn.close() +@pytest.fixture +def new_device_db(): + """Create an in-memory SQLite database for create_new_devices tests.""" + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + cur.execute( + """ + CREATE TABLE Devices ( + devMac TEXT PRIMARY KEY, + devName TEXT, + devVendor TEXT, + devLastIP TEXT, + devPrimaryIPv4 TEXT, + devPrimaryIPv6 TEXT, + devFirstConnection TEXT, + devLastConnection TEXT, + devSyncHubNode TEXT, + devGUID TEXT, + devParentMAC TEXT, + devParentPort TEXT, + devSite TEXT, + devSSID TEXT, + devType TEXT, + devSourcePlugin TEXT, + devAlertEvents INTEGER, + devAlertDown INTEGER, + devPresentLastScan INTEGER, + devIsArchived INTEGER, + devIsNew INTEGER, + devSkipRepeated INTEGER, + devScan INTEGER, + devOwner TEXT, + devFavorite INTEGER, + devGroup TEXT, + devComments TEXT, + devLogEvents INTEGER, + devLocation TEXT, + devCustomProps TEXT, + devParentRelType TEXT, + devReqNicsOnline INTEGER + ) + """ + ) + + cur.execute( + """ + CREATE TABLE CurrentScan ( + cur_MAC TEXT, + cur_Name TEXT, + cur_Vendor TEXT, + cur_ScanMethod TEXT, + cur_IP TEXT, + cur_SyncHubNodeName TEXT, + cur_NetworkNodeMAC TEXT, + cur_PORT TEXT, + cur_NetworkSite TEXT, + cur_SSID TEXT, + cur_Type TEXT + ) + """ + ) + + cur.execute( + """ + CREATE TABLE Events ( + eve_MAC TEXT, + eve_IP TEXT, + eve_DateTime TEXT, + eve_EventType TEXT, + eve_AdditionalInfo TEXT, + eve_PendingAlertEmail INTEGER + ) + """ + ) + + 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 + ) + """ + ) + + conn.commit() + yield conn + conn.close() + + @pytest.fixture def mock_ip_handlers(): """Mock device_handling helper functions.""" @@ -311,6 +408,55 @@ def test_invalid_ip_values_rejected(ip_test_db, mock_ip_handlers): ), f"Invalid IP '{invalid_ip}' should not overwrite valid IPv4" +def test_invalid_ipv6_rejected_on_create_new_devices(new_device_db): + """Invalid IPv6 values should not be persisted when creating new devices.""" + cur = new_device_db.cursor() + + cur.execute( + """ + INSERT INTO CurrentScan ( + cur_MAC, cur_Name, cur_Vendor, cur_ScanMethod, cur_IP, + cur_SyncHubNodeName, cur_NetworkNodeMAC, cur_PORT, + cur_NetworkSite, cur_SSID, cur_Type + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "AA:BB:CC:DD:EE:10", + "", + "Vendor", + "ARPSCAN", + "fe80::zz", + "", + "", + "", + "", + "", + "", + ), + ) + new_device_db.commit() + + db = Mock() + db.sql_connection = new_device_db + db.sql = cur + db.commitDB = Mock(side_effect=new_device_db.commit) + + with patch("helper.get_setting_value", return_value=""), patch.object( + device_handling, "get_setting_value", return_value="" + ): + device_handling.create_new_devices(db) + + row = cur.execute( + "SELECT devLastIP, devPrimaryIPv4, devPrimaryIPv6 FROM Devices WHERE devMac = ?", + ("AA:BB:CC:DD:EE:10",), + ).fetchone() + + assert row is not None, "Device should be created" + assert row["devLastIP"] == "", "Invalid IPv6 should not set devLastIP" + assert row["devPrimaryIPv4"] == "", "Invalid IPv6 should not set devPrimaryIPv4" + assert row["devPrimaryIPv6"] == "", "Invalid IPv6 should not set devPrimaryIPv6" + + def test_ipv4_ipv6_mixed_in_multiple_scans(ip_test_db, mock_ip_handlers): """Multiple scans with different IP types should set both primary fields correctly.""" cur = ip_test_db.cursor() diff --git a/test/test_device_atomicity.py b/test/test_device_atomicity.py new file mode 100644 index 00000000..39cbf2cd --- /dev/null +++ b/test/test_device_atomicity.py @@ -0,0 +1,231 @@ +""" +Test for atomicity of device updates with source-tracking. + +Verifies that: +1. If source-tracking fails, the device row is rolled back. +2. If source-tracking succeeds, device row and sources are both committed. +3. Database remains consistent in both scenarios. +""" + +import sys +import os +import sqlite3 +import tempfile +import unittest +from unittest.mock import patch + + +# Add server and plugins to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'server')) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'front', 'plugins')) + +from models.device_instance import DeviceInstance # noqa: E402 [flake8 lint suppression] +from plugin_helper import normalize_mac # noqa: E402 [flake8 lint suppression] + + +class TestDeviceAtomicity(unittest.TestCase): + """Test atomic transactions for device updates with source-tracking.""" + + def setUp(self): + """Create an in-memory SQLite DB for testing.""" + self.test_db = tempfile.NamedTemporaryFile(delete=False, suffix='.db') + self.test_db_path = self.test_db.name + self.test_db.close() + + # Create minimal schema + conn = sqlite3.connect(self.test_db_path) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + # Create Devices table with source-tracking columns + cur.execute(""" + CREATE TABLE Devices ( + devMac TEXT PRIMARY KEY, + devName TEXT, + devOwner TEXT, + devType TEXT, + devVendor TEXT, + devIcon TEXT, + devFavorite INTEGER DEFAULT 0, + devGroup TEXT, + devLocation TEXT, + devComments TEXT, + devParentMAC TEXT, + devParentPort TEXT, + devSSID TEXT, + devSite TEXT, + devStaticIP INTEGER DEFAULT 0, + devScan INTEGER DEFAULT 0, + devAlertEvents INTEGER DEFAULT 0, + devAlertDown INTEGER DEFAULT 0, + devParentRelType TEXT DEFAULT 'default', + devReqNicsOnline INTEGER DEFAULT 0, + devSkipRepeated INTEGER DEFAULT 0, + devIsNew INTEGER DEFAULT 0, + devIsArchived INTEGER DEFAULT 0, + devLastConnection TEXT, + devFirstConnection TEXT, + devLastIP TEXT, + devGUID TEXT, + devCustomProps TEXT, + devSourcePlugin TEXT, + devNameSource TEXT, + devTypeSource TEXT, + devVendorSource TEXT, + devIconSource TEXT, + devGroupSource TEXT, + devLocationSource TEXT, + devCommentsSource TEXT, + devMacSource TEXT + ) + """) + conn.commit() + conn.close() + + def tearDown(self): + """Clean up test database.""" + if os.path.exists(self.test_db_path): + os.unlink(self.test_db_path) + + def _get_test_db_connection(self): + """Override database connection for testing.""" + conn = sqlite3.connect(self.test_db_path) + conn.row_factory = sqlite3.Row + return conn + + def test_create_new_device_atomicity(self): + """ + Test that device creation and source-tracking are atomic. + If source tracking fails, the device should not be created. + """ + device_instance = DeviceInstance() + test_mac = normalize_mac("aa:bb:cc:dd:ee:ff") + + # Patch at module level where it's used + with patch('models.device_instance.get_temp_db_connection', self._get_test_db_connection): + # Create a new device + data = { + "createNew": True, + "devMac": test_mac, + "devName": "Test Device", + "devOwner": "John Doe", + "devType": "Laptop", + } + + result = device_instance.setDeviceData(test_mac, data) + + # Verify success + self.assertTrue(result["success"], f"Device creation failed: {result}") + + # Verify device exists + conn = self._get_test_db_connection() + cur = conn.cursor() + cur.execute("SELECT * FROM Devices WHERE devMac = ?", (test_mac,)) + device = cur.fetchone() + conn.close() + + self.assertIsNotNone(device, "Device was not created") + self.assertEqual(device["devName"], "Test Device") + + # Verify source tracking was set + self.assertEqual(device["devMacSource"], "NEWDEV") + self.assertEqual(device["devNameSource"], "NEWDEV") + + def test_update_device_with_source_tracking_atomicity(self): + """ + Test that device update and source-tracking are atomic. + If source tracking fails, the device update should be rolled back. + """ + device_instance = DeviceInstance() + test_mac = normalize_mac("aa:bb:cc:dd:ee:ff") + + # Create initial device + conn = self._get_test_db_connection() + cur = conn.cursor() + cur.execute(""" + INSERT INTO Devices ( + devMac, devName, devOwner, devType, + devNameSource, devTypeSource + ) VALUES (?, ?, ?, ?, ?, ?) + """, (test_mac, "Old Name", "Old Owner", "Desktop", "PLUGIN", "PLUGIN")) + conn.commit() + conn.close() + + # Patch database connection + with patch('models.device_instance.get_temp_db_connection', self._get_test_db_connection): + with patch('models.device_instance.enforce_source_on_user_update') as mock_enforce: + mock_enforce.return_value = None + data = { + "createNew": False, + "devMac": test_mac, + "devName": "New Name", + "devOwner": "New Owner", + } + + result = device_instance.setDeviceData(test_mac, data) + + # Verify success + self.assertTrue(result["success"], f"Device update failed: {result}") + + # Verify device was updated + conn = self._get_test_db_connection() + cur = conn.cursor() + cur.execute("SELECT * FROM Devices WHERE devMac = ?", (test_mac,)) + device = cur.fetchone() + conn.close() + + self.assertEqual(device["devName"], "New Name") + self.assertEqual(device["devOwner"], "New Owner") + + def test_source_tracking_failure_rolls_back_device(self): + """ + Test that if enforce_source_on_user_update fails, the entire + transaction is rolled back (device and sources). + """ + device_instance = DeviceInstance() + test_mac = normalize_mac("aa:bb:cc:dd:ee:ff") + + # Create initial device + conn = self._get_test_db_connection() + cur = conn.cursor() + cur.execute(""" + INSERT INTO Devices ( + devMac, devName, devOwner, devType, + devNameSource, devTypeSource + ) VALUES (?, ?, ?, ?, ?, ?) + """, (test_mac, "Original Name", "Original Owner", "Desktop", "PLUGIN", "PLUGIN")) + conn.commit() + conn.close() + + # Patch database connection and mock source enforcement failure + with patch('models.device_instance.get_temp_db_connection', self._get_test_db_connection): + with patch('models.device_instance.enforce_source_on_user_update') as mock_enforce: + # Simulate source tracking failure + mock_enforce.side_effect = Exception("Source tracking error") + + data = { + "createNew": False, + "devMac": test_mac, + "devName": "Failed Update", + "devOwner": "Failed Owner", + } + + result = device_instance.setDeviceData(test_mac, data) + + # Verify error response + self.assertFalse(result["success"]) + self.assertIn("Source tracking failed", result["error"]) + + # Verify device was NOT updated (rollback successful) + conn = self._get_test_db_connection() + cur = conn.cursor() + cur.execute("SELECT * FROM Devices WHERE devMac = ?", (test_mac,)) + device = cur.fetchone() + conn.close() + + self.assertEqual(device["devName"], "Original Name", "Device should not have been updated on source tracking failure") + self.assertEqual(device["devOwner"], "Original Owner", "Device should not have been updated on source tracking failure") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_plugin_helper.py b/test/test_plugin_helper.py index 1d712c21..9a88f39b 100644 --- a/test/test_plugin_helper.py +++ b/test/test_plugin_helper.py @@ -16,3 +16,9 @@ def test_normalize_mac_preserves_wildcard(): 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" + + +def test_normalize_mac_preserves_internet_root(): + assert normalize_mac("internet") == "Internet" + assert normalize_mac("Internet") == "Internet" + assert normalize_mac("INTERNET") == "Internet"