From dfba6f4714d914eb688ad09ca852afe5e5864302 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Wed, 18 Feb 2026 13:57:37 +0000 Subject: [PATCH] Renamed NB API import, removed unused sys import, added error when ZBX token and password are both used, revamped the core testing file and added useful tests such as device clustering and a base for future device testing. --- netbox_zabbix_sync/modules/core.py | 19 +- tests/test_core.py | 417 ++++++++++++++++++----------- 2 files changed, 280 insertions(+), 156 deletions(-) diff --git a/netbox_zabbix_sync/modules/core.py b/netbox_zabbix_sync/modules/core.py index d53c305..83538ed 100644 --- a/netbox_zabbix_sync/modules/core.py +++ b/netbox_zabbix_sync/modules/core.py @@ -1,10 +1,9 @@ """Core component of the sync process""" import ssl -import sys from os import environ -from pynetbox import api +from pynetbox import api as nbapi from requests.exceptions import ConnectionError as RequestsConnectionError from zabbix_utils import APIRequestError, ProcessingError, ZabbixAPI @@ -83,7 +82,7 @@ class Sync: :param zbx_token: Description """ # Initialize Netbox API connection - netbox = api(nb_host, token=nb_token, threading=True) + netbox = nbapi(nb_host, token=nb_token, threading=True) try: # Get NetBox version nb_version = netbox.version @@ -95,25 +94,35 @@ class Sync: "Unable to connect to NetBox with URL %s. Please check the URL and status of NetBox.", nb_host, ) + return False # Set Zabbix API + if (zbx_pass or zbx_user) and zbx_token: + e = ( + "Both ZABBIX_PASS, ZABBIX_USER and ZABBIX_TOKEN environment variables are set. " + "Please choose between token or password based authentication." + ) + logger.error(e) + return False try: ssl_ctx = ssl.create_default_context() # If a custom CA bundle is set for pynetbox (requests), also use it for the Zabbix API if environ.get("REQUESTS_CA_BUNDLE", None): ssl_ctx.load_verify_locations(environ["REQUESTS_CA_BUNDLE"]) - if not zbx_token: + logger.debug("Using user/password authentication for Zabbix API.") self.zabbix = ZabbixAPI( zbx_host, user=zbx_user, password=zbx_pass, ssl_context=ssl_ctx ) else: + logger.debug("Using token authentication for Zabbix API.") self.zabbix = ZabbixAPI(zbx_host, token=zbx_token, ssl_context=ssl_ctx) self.zabbix.check_auth() except (APIRequestError, ProcessingError) as zbx_error: e = f"Zabbix returned the following error: {zbx_error}." logger.error(e) - sys.exit(1) + return False + return True def start(self): """ diff --git a/tests/test_core.py b/tests/test_core.py index e45c2dd..f5dde70 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -3,30 +3,11 @@ import unittest from unittest.mock import MagicMock, patch -from pynetbox.core.query import RequestError as NBRequestError from requests.exceptions import ConnectionError as RequestsConnectionError -from zabbix_utils import APIRequestError, ProcessingError +from zabbix_utils import APIRequestError from netbox_zabbix_sync.modules.core import Sync -# Minimal config for testing - includes all keys used by sync() -TEST_CONFIG = { - "hostgroup_format": "site", - "vm_hostgroup_format": "site", - "sync_vms": False, - "nb_device_filter": {}, - "nb_vm_filter": {}, - "create_journal": False, - "templates_config_context": False, - "templates_config_context_overrule": False, - "create_hostgroups": False, - "clustering": False, - "zabbix_device_removal": ["Decommissioning", "Inventory"], - "zabbix_device_disable": ["Offline", "Planned", "Staged", "Failed"], - "full_proxy_sync": False, - "extended_site_properties": False, -} - class MockNetboxDevice: """Mock NetBox device object.""" @@ -40,15 +21,93 @@ class MockNetboxDevice: config_context=None, site=None, primary_ip=None, + virtual_chassis=None, + device_type=None, + tenant=None, + device_role=None, + role=None, + platform=None, + serial="", + tags=None, ): self.id = device_id self.name = name self.status = MagicMock() self.status.label = status_label - self.custom_fields = {"zabbix_hostid": zabbix_hostid} + self.status.value = status_label.lower() + self.custom_fields = { + "zabbix_hostid": zabbix_hostid, + "zabbix_template": "TestTemplate", + } self.config_context = config_context or {} - self.site = site - self.primary_ip = primary_ip + self.tenant = tenant + self.platform = platform + self.serial = serial + self.asset_tag = None + self.location = None + self.rack = None + self.position = None + self.face = None + self.latitude = None + self.longitude = None + self.parent_device = None + self.airflow = None + self.cluster = None + self.vc_position = None + self.vc_priority = None + self.description = "" + self.comments = "" + self.tags = tags or [] + self.oob_ip = None + + # Setup site with proper structure + if site is None: + self.site = MagicMock() + self.site.name = "TestSite" + self.site.slug = "testsite" + else: + self.site = site + + # Setup primary IP with proper structure + if primary_ip is None: + self.primary_ip = MagicMock() + self.primary_ip.address = "192.168.1.1/24" + else: + self.primary_ip = primary_ip + + self.primary_ip4 = self.primary_ip + self.primary_ip6 = None + + # Setup device type with proper structure + if device_type is None: + self.device_type = MagicMock() + self.device_type.custom_fields = { + "zabbix_template": "TestTemplate"} + self.device_type.manufacturer = MagicMock() + self.device_type.manufacturer.name = "TestManufacturer" + self.device_type.display = "Test Device Type" + self.device_type.model = "Test Model" + self.device_type.slug = "test-model" + else: + self.device_type = device_type + + # Setup device role (NetBox 2/3 compatibility) and role (NetBox 4+) + if device_role is None and role is None: + # Create default role + mock_role = MagicMock() + mock_role.name = "Switch" + mock_role.slug = "switch" + self.device_role = mock_role # NetBox 2/3 + self.role = mock_role # NetBox 4+ + else: + self.device_role = device_role or role + self.role = role or device_role + + self.virtual_chassis = virtual_chassis + + def save(self): + """Mock save method for NetBox device.""" + pass class MockNetboxVM: @@ -77,9 +136,9 @@ class MockNetboxVM: class TestSyncNetboxConnection(unittest.TestCase): """Test NetBox connection handling in sync function.""" - @patch("netbox_zabbix_sync.modules.core.api") - def test_sync_exits_on_netbox_connection_error(self, mock_api): - """Test that sync exits when NetBox connection fails.""" + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_sync_error_on_netbox_connection_error(self, mock_api): + """Test that sync returns False when NetBox connection fails.""" mock_netbox = MagicMock() mock_api.return_value = mock_netbox # Simulate connection error when accessing version @@ -87,41 +146,40 @@ class TestSyncNetboxConnection(unittest.TestCase): lambda self: (_ for _ in ()).throw(RequestsConnectionError()) ) - with self.assertRaises(SystemExit) as context: - syncer = Sync() - syncer.connect( - nb_host="http://netbox.local", - nb_token="token", - zbx_host="http://zabbix.local", - zbx_user="user", - zbx_pass="pass", - zbx_token=None, - ) - syncer.start() - - self.assertEqual(context.exception.code, 1) - - @patch("netbox_zabbix_sync.modules.core.api") - def test_sync_exits_on_netbox_request_error(self, mock_api): - """Test that sync exits when NetBox returns a request error.""" - mock_netbox = MagicMock() - mock_api.return_value = mock_netbox - # Simulate NetBox request error - type(mock_netbox).version = property( - lambda self: (_ for _ in ()).throw(NBRequestError(MagicMock())) + syncer = Sync() + result = syncer.connect( + nb_host="http://netbox.local", + nb_token="token", + zbx_host="http://zabbix.local", + zbx_user="user", + zbx_pass="pass", + zbx_token=None, ) - with self.assertRaises(SystemExit) as context: - Sync( - "http://netbox.local", - "token", - "http://zabbix.local", - "user", - "pass", - None, - ) + self.assertFalse(result) - self.assertEqual(context.exception.code, 1) + +class TestZabbixUserTokenConflict(unittest.TestCase): + """Test that sync returns False when both ZABBIX_USER/PASS and ZABBIX_TOKEN are set.""" + + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_sync_error_on_user_token_conflict(self, mock_api): + """Test that sync returns False when both user/pass and token are provided.""" + mock_netbox = MagicMock() + mock_api.return_value = mock_netbox + mock_netbox.version = "3.5" + + syncer = Sync() + result = syncer.connect( + nb_host="http://netbox.local", + nb_token="token", + zbx_host="http://zabbix.local", + zbx_user="user", + zbx_pass="pass", + zbx_token="token", # Both token and user/pass provided + ) + + self.assertFalse(result) class TestSyncZabbixConnection(unittest.TestCase): @@ -132,59 +190,30 @@ class TestSyncZabbixConnection(unittest.TestCase): mock_netbox = MagicMock() mock_api.return_value = mock_netbox mock_netbox.version = "3.5" - mock_netbox.extras.custom_fields.filter.return_value = [] - mock_netbox.dcim.devices.filter.return_value = [] - mock_netbox.virtualization.virtual_machines.filter.return_value = [] - mock_netbox.dcim.site_groups.all.return_value = [] - mock_netbox.dcim.regions.all.return_value = [] return mock_netbox - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_exits_on_zabbix_api_error(self, mock_api, mock_zabbix_api): """Test that sync exits when Zabbix API authentication fails.""" + # Simulate Netbox API self._setup_netbox_mock(mock_api) - # Simulate Zabbix API error mock_zabbix_api.return_value.check_auth.side_effect = APIRequestError( "Invalid credentials" ) - - with self.assertRaises(SystemExit) as context: - Sync( - "http://netbox.local", - "token", - "http://zabbix.local", - "user", - "pass", - None, - ) - - self.assertEqual(context.exception.code, 1) - - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) - @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") - def test_sync_exits_on_zabbix_processing_error(self, mock_api, mock_zabbix_api): - """Test that sync exits when Zabbix has processing error.""" - self._setup_netbox_mock(mock_api) - - mock_zabbix_api.return_value.check_auth.side_effect = ProcessingError( - "Processing failed" + # Start syncer and set connection details + syncer = Sync() + result = syncer.connect( + "http://netbox.local", + "token", + "http://zabbix.local", + "user", + "pass", + None, ) - - with self.assertRaises(SystemExit) as context: - Sync( - "http://netbox.local", - "token", - "http://zabbix.local", - "user", - "pass", - None, - ) - - self.assertEqual(context.exception.code, 1) + # Validate that result is False due to Zabbix API error + self.assertFalse(result) class TestSyncZabbixAuthentication(unittest.TestCase): @@ -202,7 +231,7 @@ class TestSyncZabbixAuthentication(unittest.TestCase): mock_netbox.dcim.regions.all.return_value = [] return mock_netbox - def _setup_zabbix_mock(self, mock_zabbix_api, version="6.0"): + def _setup_zabbix_mock(self, mock_zabbix_api, version="7.0"): """Helper to setup a working Zabbix mock.""" mock_zabbix = MagicMock() mock_zabbix_api.return_value = mock_zabbix @@ -213,48 +242,44 @@ class TestSyncZabbixAuthentication(unittest.TestCase): mock_zabbix.proxygroup.get.return_value = [] return mock_zabbix - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_uses_user_password_when_no_token(self, mock_api, mock_zabbix_api): """Test that sync uses user/password auth when no token is provided.""" self._setup_netbox_mock(mock_api) - self._setup_zabbix_mock(mock_zabbix_api) - Sync( - "http://netbox.local", - "nb_token", - "http://zabbix.local", - "zbx_user", - "zbx_pass", - None, # No token + syncer = Sync() + syncer.connect( + nb_host="http://netbox.local", + nb_token="nb_token", + zbx_host="http://zabbix.local", + zbx_user="zbx_user", + zbx_pass="zbx_pass", ) - # Verify ZabbixAPI was called with user/password + # Verify ZabbixAPI was called with user/password and without token mock_zabbix_api.assert_called_once() call_kwargs = mock_zabbix_api.call_args.kwargs self.assertEqual(call_kwargs["user"], "zbx_user") self.assertEqual(call_kwargs["password"], "zbx_pass") self.assertNotIn("token", call_kwargs) - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_uses_token_when_provided(self, mock_api, mock_zabbix_api): """Test that sync uses token auth when token is provided.""" self._setup_netbox_mock(mock_api) self._setup_zabbix_mock(mock_zabbix_api) - Sync( - "http://netbox.local", - "nb_token", - "http://zabbix.local", - "zbx_user", - "zbx_pass", - "zbx_token", # Token provided + syncer = Sync() + syncer.connect( + nb_host="http://netbox.local", + nb_token="nb_token", + zbx_host="http://zabbix.local", + zbx_token="zbx_token", ) - # Verify ZabbixAPI was called with token + # Verify ZabbixAPI was called with token and without user/password mock_zabbix_api.assert_called_once() call_kwargs = mock_zabbix_api.call_args.kwargs self.assertEqual(call_kwargs["token"], "zbx_token") @@ -292,10 +317,9 @@ class TestSyncDeviceProcessing(unittest.TestCase): mock_zabbix.proxygroup.get.return_value = [] return mock_zabbix - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) @patch("netbox_zabbix_sync.modules.core.PhysicalDevice") @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_processes_devices_from_netbox( self, mock_api, mock_zabbix_api, mock_physical_device ): @@ -311,7 +335,8 @@ class TestSyncDeviceProcessing(unittest.TestCase): mock_device_instance.zbx_template_names = [] mock_physical_device.return_value = mock_device_instance - Sync( + syncer = Sync() + syncer.connect( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -319,15 +344,15 @@ class TestSyncDeviceProcessing(unittest.TestCase): "pass", None, ) + syncer.start() # Verify PhysicalDevice was instantiated for each device self.assertEqual(mock_physical_device.call_count, 2) - @patch("netbox_zabbix_sync.modules.core.config", {**TEST_CONFIG, "sync_vms": True}) @patch("netbox_zabbix_sync.modules.core.VirtualMachine") @patch("netbox_zabbix_sync.modules.core.PhysicalDevice") @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_processes_vms_when_enabled( self, mock_api, mock_zabbix_api, mock_physical_device, mock_virtual_machine ): @@ -343,7 +368,8 @@ class TestSyncDeviceProcessing(unittest.TestCase): mock_vm_instance.zbx_template_names = [] mock_virtual_machine.return_value = mock_vm_instance - Sync( + syncer = Sync({"sync_vms": True}) + syncer.connect( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -351,14 +377,14 @@ class TestSyncDeviceProcessing(unittest.TestCase): "pass", None, ) + syncer.start() # Verify VirtualMachine was instantiated for each VM self.assertEqual(mock_virtual_machine.call_count, 2) - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) @patch("netbox_zabbix_sync.modules.core.VirtualMachine") @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_skips_vms_when_disabled( self, mock_api, mock_zabbix_api, mock_virtual_machine ): @@ -368,7 +394,8 @@ class TestSyncDeviceProcessing(unittest.TestCase): self._setup_netbox_mock(mock_api, vms=[vm1]) self._setup_zabbix_mock(mock_zabbix_api) - Sync( + syncer = Sync() + syncer.connect( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -376,6 +403,7 @@ class TestSyncDeviceProcessing(unittest.TestCase): "pass", None, ) + syncer.start() # Verify VirtualMachine was never called mock_virtual_machine.assert_not_called() @@ -396,9 +424,8 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): mock_netbox.dcim.regions.all.return_value = [] return mock_netbox - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_uses_host_proxy_name_for_zabbix_6(self, mock_api, mock_zabbix_api): """Test that sync uses 'host' as proxy name field for Zabbix 6.""" self._setup_netbox_mock(mock_api) @@ -411,7 +438,8 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): mock_zabbix.proxy.get.return_value = [ {"proxyid": "1", "host": "proxy1"}] - Sync( + syncer = Sync() + syncer.connect( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -419,13 +447,13 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): "pass", None, ) + syncer.start() # Verify proxy.get was called with 'host' field mock_zabbix.proxy.get.assert_called_with(output=["proxyid", "host"]) - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_uses_name_proxy_field_for_zabbix_7(self, mock_api, mock_zabbix_api): """Test that sync uses 'name' as proxy name field for Zabbix 7.""" self._setup_netbox_mock(mock_api) @@ -439,7 +467,8 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): {"proxyid": "1", "name": "proxy1"}] mock_zabbix.proxygroup.get.return_value = [] - Sync( + syncer = Sync() + syncer.connect( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -447,13 +476,13 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): "pass", None, ) + syncer.start() # Verify proxy.get was called with 'name' field mock_zabbix.proxy.get.assert_called_with(output=["proxyid", "name"]) - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_fetches_proxygroups_for_zabbix_7(self, mock_api, mock_zabbix_api): """Test that sync fetches proxy groups for Zabbix 7.""" self._setup_netbox_mock(mock_api) @@ -466,7 +495,8 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): mock_zabbix.proxy.get.return_value = [] mock_zabbix.proxygroup.get.return_value = [] - Sync( + syncer = Sync() + syncer.connect( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -474,13 +504,13 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): "pass", None, ) + syncer.start() # Verify proxygroup.get was called for Zabbix 7 mock_zabbix.proxygroup.get.assert_called_once() - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_skips_proxygroups_for_zabbix_6(self, mock_api, mock_zabbix_api): """Test that sync does NOT fetch proxy groups for Zabbix 6.""" self._setup_netbox_mock(mock_api) @@ -492,7 +522,8 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): mock_zabbix.template.get.return_value = [] mock_zabbix.proxy.get.return_value = [] - Sync( + syncer = Sync() + syncer.connect( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -500,6 +531,7 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): "pass", None, ) + syncer.start() # Verify proxygroup.get was NOT called for Zabbix 6 mock_zabbix.proxygroup.get.assert_not_called() @@ -520,9 +552,8 @@ class TestSyncLogout(unittest.TestCase): mock_netbox.dcim.regions.all.return_value = [] return mock_netbox - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_logs_out_from_zabbix(self, mock_api, mock_zabbix_api): """Test that sync calls logout on Zabbix API after completion.""" self._setup_netbox_mock(mock_api) @@ -534,7 +565,8 @@ class TestSyncLogout(unittest.TestCase): mock_zabbix.template.get.return_value = [] mock_zabbix.proxy.get.return_value = [] - Sync( + syncer = Sync() + syncer.connect( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -542,6 +574,7 @@ class TestSyncLogout(unittest.TestCase): "pass", None, ) + syncer.start() # Verify logout was called mock_zabbix.logout.assert_called_once() @@ -563,9 +596,8 @@ class TestSyncProxyNameSanitization(unittest.TestCase): return mock_netbox @patch("netbox_zabbix_sync.modules.core.proxy_prepper") - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_renames_host_to_name_for_zabbix_6_proxies( self, mock_api, mock_zabbix_api, mock_proxy_prepper ): @@ -584,7 +616,8 @@ class TestSyncProxyNameSanitization(unittest.TestCase): ] mock_proxy_prepper.return_value = [] - Sync( + syncer = Sync() + syncer.connect( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -592,6 +625,7 @@ class TestSyncProxyNameSanitization(unittest.TestCase): "pass", None, ) + syncer.start() # Verify proxy_prepper was called with sanitized proxy list call_args = mock_proxy_prepper.call_args[0] @@ -602,5 +636,86 @@ class TestSyncProxyNameSanitization(unittest.TestCase): self.assertNotIn("host", proxy) -if __name__ == "__main__": - unittest.main() +class TestDeviceHandeling(unittest.TestCase): + """ + Tests several devices which can be synced to Zabbix. + This class contains a lot of data in order to validate proper handling of different device types and configurations. + """ + + def _setup_netbox_mock(self, mock_api): + """Helper to setup a working NetBox mock.""" + mock_netbox = MagicMock() + mock_api.return_value = mock_netbox + mock_netbox.version = "3.5" + mock_netbox.extras.custom_fields.filter.return_value = [] + mock_netbox.dcim.devices.filter.return_value = [] + mock_netbox.virtualization.virtual_machines.filter.return_value = [] + mock_netbox.dcim.site_groups.all.return_value = [] + mock_netbox.dcim.regions.all.return_value = [] + return mock_netbox + + def _setup_zabbix_mock(self, mock_zabbix_api, version=7.0): + """Helper to setup a working Zabbix mock.""" + mock_zabbix = MagicMock() + mock_zabbix_api.return_value = mock_zabbix + mock_zabbix.version = version + mock_zabbix.hostgroup.get.return_value = [ + {"groupid": "1", "name": "TestGroup"}] + mock_zabbix.template.get.return_value = [ + {"templateid": "1", "name": "TestTemplate"} + ] + mock_zabbix.proxy.get.return_value = [] + mock_zabbix.proxygroup.get.return_value = [] + mock_zabbix.logout = MagicMock() + # Mock host.get to return empty (host doesn't exist yet) + mock_zabbix.host.get.return_value = [] + # Mock host.create to return success + mock_zabbix.host.create.return_value = {"hostids": ["1"]} + return mock_zabbix + + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_sync_cluster_where_device_is_primary( + self, mock_api, mock_zabbix_api + ): + """Test that sync properly handles a device that is the primary in a virtual chassis.""" + # Create a device that is part of a virtual chassis and is the primary + device = MockNetboxDevice( + device_id=1, + name="SW01N0", + virtual_chassis=MagicMock(), + ) + device.virtual_chassis.master = MagicMock() + device.virtual_chassis.master.id = 1 # Same as device ID - device is primary + device.virtual_chassis.name = "SW01" + + # Setup NetBox mock with a site for hostgroup + mock_netbox = self._setup_netbox_mock(mock_api) + mock_netbox.dcim.devices.filter.return_value = [device] + + # Create a mock site for hostgroup generation + mock_site = MagicMock() + mock_site.name = "TestSite" + device.site = mock_site + + # Setup Zabbix mock + mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) + + # Run the sync with clustering enabled + syncer = Sync({"clustering": True}) + syncer.connect( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + syncer.start() + + # Verify that host.create was called with the cluster name "SW01", not "SW01N0" + mock_zabbix.host.create.assert_called_once() + create_call_kwargs = mock_zabbix.host.create.call_args.kwargs + + # The host should be created with the virtual chassis name, not the device name + self.assertEqual(create_call_kwargs["host"], "SW01")