Files
netbox-zabbix-sync/tests/test_core.py

1125 lines
42 KiB
Python

"""Tests for the core sync module."""
import unittest
from unittest.mock import MagicMock, patch
from requests.exceptions import ConnectionError as RequestsConnectionError
from zabbix_utils import APIRequestError
from netbox_zabbix_sync.modules.core import Sync
class MockNetboxDevice:
"""Mock NetBox device object."""
def __init__(
self,
device_id=1,
name="test-device",
status_label="Active",
zabbix_hostid=None,
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.status.value = status_label.lower()
self.custom_fields = {
"zabbix_hostid": zabbix_hostid,
"zabbix_template": "TestTemplate",
}
self.config_context = config_context or {}
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
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."""
class MockNetboxVM:
"""Mock NetBox virtual machine object."""
def __init__(
self,
vm_id=1,
name="test-vm",
status_label="Active",
zabbix_hostid=None,
config_context=None,
site=None,
primary_ip=None,
):
self.id = vm_id
self.name = name
self.status = MagicMock()
self.status.label = status_label
self.custom_fields = {"zabbix_hostid": zabbix_hostid}
self.config_context = config_context or {}
self.site = site
self.primary_ip = primary_ip
class TestSyncNetboxConnection(unittest.TestCase):
"""Test NetBox connection handling in sync function."""
@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
type(mock_netbox).version = property(
lambda self: (_ for _ in ()).throw(RequestsConnectionError())
)
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,
)
self.assertFalse(result)
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):
"""Test Zabbix connection handling in sync function."""
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"
return mock_netbox
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@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"
)
# Start syncer and set connection details
syncer = Sync()
result = syncer.connect(
"http://netbox.local",
"token",
"http://zabbix.local",
"user",
"pass",
None,
)
# Validate that result is False due to Zabbix API error
self.assertFalse(result)
class TestSyncZabbixAuthentication(unittest.TestCase):
"""Test Zabbix authentication methods."""
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 = []
mock_zabbix.template.get.return_value = []
mock_zabbix.proxy.get.return_value = []
mock_zabbix.proxygroup.get.return_value = []
return mock_zabbix
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@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)
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 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.ZabbixAPI")
@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)
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 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")
self.assertNotIn("user", call_kwargs)
self.assertNotIn("password", call_kwargs)
class TestSyncDeviceProcessing(unittest.TestCase):
"""Test device processing in sync function."""
def _setup_netbox_mock(self, mock_api, devices=None, vms=None):
"""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 = devices or []
mock_netbox.virtualization.virtual_machines.filter.return_value = vms or []
mock_netbox.dcim.site_groups.all.return_value = []
mock_netbox.dcim.regions.all.return_value = []
mock_netbox.extras.journal_entries = MagicMock()
return mock_netbox
def _setup_zabbix_mock(self, mock_zabbix_api, version="6.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 = []
return mock_zabbix
@patch("netbox_zabbix_sync.modules.core.PhysicalDevice")
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_sync_processes_devices_from_netbox(
self, mock_api, mock_zabbix_api, mock_physical_device
):
"""Test that sync creates PhysicalDevice instances for NetBox devices."""
device1 = MockNetboxDevice(device_id=1, name="device1")
device2 = MockNetboxDevice(device_id=2, name="device2")
self._setup_netbox_mock(mock_api, devices=[device1, device2])
self._setup_zabbix_mock(mock_zabbix_api)
# Mock PhysicalDevice to have no template (skip further processing)
mock_device_instance = MagicMock()
mock_device_instance.zbx_template_names = []
mock_physical_device.return_value = mock_device_instance
syncer = Sync()
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"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.VirtualMachine")
@patch("netbox_zabbix_sync.modules.core.PhysicalDevice")
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@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
):
"""Test that sync processes VMs when sync_vms is enabled."""
vm1 = MockNetboxVM(vm_id=1, name="vm1")
vm2 = MockNetboxVM(vm_id=2, name="vm2")
self._setup_netbox_mock(mock_api, vms=[vm1, vm2])
self._setup_zabbix_mock(mock_zabbix_api)
# Mock VM to have no template (skip further processing)
mock_vm_instance = MagicMock()
mock_vm_instance.zbx_template_names = []
mock_virtual_machine.return_value = mock_vm_instance
syncer = Sync({"sync_vms": True})
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"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.VirtualMachine")
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_sync_skips_vms_when_disabled(
self, mock_api, mock_zabbix_api, mock_virtual_machine
):
"""Test that sync does NOT process VMs when sync_vms is disabled."""
vm1 = MockNetboxVM(vm_id=1, name="vm1")
self._setup_netbox_mock(mock_api, vms=[vm1])
self._setup_zabbix_mock(mock_zabbix_api)
syncer = Sync()
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start()
# Verify VirtualMachine was never called
mock_virtual_machine.assert_not_called()
class TestSyncZabbixVersionHandling(unittest.TestCase):
"""Test Zabbix version-specific handling."""
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
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@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)
mock_zabbix = MagicMock()
mock_zabbix_api.return_value = mock_zabbix
mock_zabbix.version = "6.0"
mock_zabbix.hostgroup.get.return_value = []
mock_zabbix.template.get.return_value = []
mock_zabbix.proxy.get.return_value = [{"proxyid": "1", "host": "proxy1"}]
syncer = Sync()
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"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.ZabbixAPI")
@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)
mock_zabbix = MagicMock()
mock_zabbix_api.return_value = mock_zabbix
mock_zabbix.version = "7.0"
mock_zabbix.hostgroup.get.return_value = []
mock_zabbix.template.get.return_value = []
mock_zabbix.proxy.get.return_value = [{"proxyid": "1", "name": "proxy1"}]
mock_zabbix.proxygroup.get.return_value = []
syncer = Sync()
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"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.ZabbixAPI")
@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)
mock_zabbix = MagicMock()
mock_zabbix_api.return_value = mock_zabbix
mock_zabbix.version = "7.0"
mock_zabbix.hostgroup.get.return_value = []
mock_zabbix.template.get.return_value = []
mock_zabbix.proxy.get.return_value = []
mock_zabbix.proxygroup.get.return_value = []
syncer = Sync()
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"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.ZabbixAPI")
@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)
mock_zabbix = MagicMock()
mock_zabbix_api.return_value = mock_zabbix
mock_zabbix.version = "6.0"
mock_zabbix.hostgroup.get.return_value = []
mock_zabbix.template.get.return_value = []
mock_zabbix.proxy.get.return_value = []
syncer = Sync()
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start()
# Verify proxygroup.get was NOT called for Zabbix 6
mock_zabbix.proxygroup.get.assert_not_called()
class TestSyncLogout(unittest.TestCase):
"""Test that sync properly logs out from Zabbix."""
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
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@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)
mock_zabbix = MagicMock()
mock_zabbix_api.return_value = mock_zabbix
mock_zabbix.version = "6.0"
mock_zabbix.hostgroup.get.return_value = []
mock_zabbix.template.get.return_value = []
mock_zabbix.proxy.get.return_value = []
syncer = Sync()
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start()
# Verify logout was called
mock_zabbix.logout.assert_called_once()
class TestSyncProxyNameSanitization(unittest.TestCase):
"""Test proxy name field sanitization for Zabbix 6."""
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
@patch("netbox_zabbix_sync.modules.core.proxy_prepper")
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@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
):
"""Test that for Zabbix 6, proxy 'host' field is renamed to 'name'."""
self._setup_netbox_mock(mock_api)
mock_zabbix = MagicMock()
mock_zabbix_api.return_value = mock_zabbix
mock_zabbix.version = "6.0"
mock_zabbix.hostgroup.get.return_value = []
mock_zabbix.template.get.return_value = []
# Zabbix 6 returns 'host' field
mock_zabbix.proxy.get.return_value = [
{"proxyid": "1", "host": "proxy1"},
{"proxyid": "2", "host": "proxy2"},
]
mock_proxy_prepper.return_value = []
syncer = Sync()
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start()
# Verify proxy_prepper was called with sanitized proxy list
call_args = mock_proxy_prepper.call_args[0]
proxies = call_args[0]
# Check that 'host' was renamed to 'name'
for proxy in proxies:
self.assertIn("name", proxy)
self.assertNotIn("host", proxy)
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
# Setup virtual chassis mock
vc_master = MagicMock()
vc_master.id = 1 # Same as device ID - device is primary
virtual_chassis = MagicMock()
virtual_chassis.master = vc_master
virtual_chassis.name = "SW01"
device = MockNetboxDevice(
device_id=1,
name="SW01N0",
virtual_chassis=virtual_chassis,
)
# 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")
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_templates_from_config_context(self, mock_api, mock_zabbix_api):
"""Test that templates_config_context=True uses the config context template."""
device = MockNetboxDevice(
device_id=1,
name="Router01",
config_context={
"zabbix": {
"templates": ["ContextTemplate"],
}
},
)
mock_netbox = self._setup_netbox_mock(mock_api)
mock_netbox.dcim.devices.filter.return_value = [device]
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
# Both templates exist in Zabbix
mock_zabbix.template.get.return_value = [
{"templateid": "1", "name": "TestTemplate"},
{"templateid": "2", "name": "ContextTemplate"},
]
syncer = Sync({"templates_config_context": True})
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start()
# Verify host was created with the config context template, not the custom field one
mock_zabbix.host.create.assert_called_once()
create_call_kwargs = mock_zabbix.host.create.call_args.kwargs
self.assertEqual(create_call_kwargs["templates"], [{"templateid": "2"}])
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_templates_config_context_overrule(self, mock_api, mock_zabbix_api):
"""Test that templates_config_context_overrule=True prefers config context over custom field.
The device has:
- Custom field template (device type): "TestTemplate"
- Config context template (device): "ContextTemplate"
With overrule enabled the config context should win and the host should
be created with "ContextTemplate" only.
"""
device = MockNetboxDevice(
device_id=1,
name="Router01",
config_context={
"zabbix": {
"templates": ["ContextTemplate"],
}
},
)
mock_netbox = self._setup_netbox_mock(mock_api)
mock_netbox.dcim.devices.filter.return_value = [device]
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
# Both templates exist in Zabbix
mock_zabbix.template.get.return_value = [
{"templateid": "1", "name": "TestTemplate"},
{"templateid": "2", "name": "ContextTemplate"},
]
syncer = Sync({"templates_config_context_overrule": True})
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start()
# Config context overrides the custom field - only "ContextTemplate" should be used
mock_zabbix.host.create.assert_called_once()
create_call_kwargs = mock_zabbix.host.create.call_args.kwargs
self.assertEqual(create_call_kwargs["templates"], [{"templateid": "2"}])
# Verify the custom field template was NOT used
self.assertNotIn({"templateid": "1"}, create_call_kwargs["templates"])
class TestDeviceStatusHandling(unittest.TestCase):
"""
Tests device status handling during NetBox to Zabbix synchronization.
Validates the correct sync behavior for various combinations of NetBox device
status, Zabbix host state, and the 'zabbix_device_removal' / 'zabbix_device_disable'
configuration settings.
Scenarios:
1. Active, not in Zabbix → created enabled
2. Active, already in Zabbix → consistency check passes, no update
3. Staged, not in Zabbix → created disabled
4. Staged, already in Zabbix → consistency check passes, no update
5. Decommissioning, not in Zabbix → skipped entirely
6. Decommissioning, in Zabbix → host deleted from Zabbix (cleanup)
7. Active, in Zabbix but disabled → host re-enabled via consistency check
8. Failed, in Zabbix but enabled → host disabled via consistency check
"""
# Hostgroup produced by the default "site/manufacturer/role" format
# for the default MockNetboxDevice attributes.
EXPECTED_HOSTGROUP = "TestSite/TestManufacturer/Switch"
def _setup_netbox_mock(self, mock_api, devices=None):
"""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 = devices or []
mock_netbox.virtualization.virtual_machines.filter.return_value = []
mock_netbox.dcim.site_groups.all.return_value = []
mock_netbox.dcim.regions.all.return_value = []
mock_netbox.extras.journal_entries = MagicMock()
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": self.EXPECTED_HOSTGROUP}
]
mock_zabbix.hostgroup.create.return_value = {"groupids": ["2"]}
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_zabbix.host.get.return_value = []
mock_zabbix.host.create.return_value = {"hostids": ["1"]}
mock_zabbix.host.update.return_value = {"hostids": ["42"]}
mock_zabbix.host.delete.return_value = [42]
return mock_zabbix
def _make_zabbix_host(self, hostname="test-device", status="0"):
"""Build a minimal but complete Zabbix host response for consistency_check."""
return [
{
"hostid": "42",
"host": hostname,
"name": hostname,
"parentTemplates": [{"templateid": "1"}],
"hostgroups": [{"groupid": "1"}],
"groups": [{"groupid": "1"}],
"status": status,
# Single empty-dict interface: len==1 avoids SyncInventoryError,
# empty keys prevent any spurious interface-update calls.
"interfaces": [{}],
"inventory_mode": "-1",
"inventory": {},
"macros": [],
"tags": [],
"proxy_hostid": "0",
"proxyid": "0",
"proxy_groupid": "0",
}
]
# ------------------------------------------------------------------
# Scenario 1: Active device, not yet in Zabbix → created enabled (status=0)
# ------------------------------------------------------------------
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_active_device_not_in_zabbix_is_created(self, mock_api, mock_zabbix_api):
"""Active device not yet synced to Zabbix should be created with status enabled (0)."""
device = MockNetboxDevice(
name="test-device", status_label="Active", zabbix_hostid=None
)
self._setup_netbox_mock(mock_api, devices=[device])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
syncer = Sync()
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start()
mock_zabbix.host.create.assert_called_once()
create_kwargs = mock_zabbix.host.create.call_args.kwargs
self.assertEqual(create_kwargs["host"], "test-device")
self.assertEqual(create_kwargs["status"], 0)
# ------------------------------------------------------------------
# Scenario 2: Active device, already in Zabbix → consistency check,
# Zabbix status matches → no updates
# ------------------------------------------------------------------
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_active_device_in_zabbix_is_consistent(self, mock_api, mock_zabbix_api):
"""Active device already in Zabbix with matching status should require no updates."""
device = MockNetboxDevice(
name="test-device", status_label="Active", zabbix_hostid=42
)
self._setup_netbox_mock(mock_api, devices=[device])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
mock_zabbix.host.get.return_value = self._make_zabbix_host(status="0")
syncer = Sync()
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start()
mock_zabbix.host.create.assert_not_called()
mock_zabbix.host.update.assert_not_called()
# ------------------------------------------------------------------
# Scenario 3: Staged device, not yet in Zabbix → created disabled (status=1)
# ------------------------------------------------------------------
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_staged_device_not_in_zabbix_is_created_disabled(
self, mock_api, mock_zabbix_api
):
"""Staged device not yet in Zabbix should be created with status disabled (1)."""
device = MockNetboxDevice(
name="test-device", status_label="Staged", zabbix_hostid=None
)
self._setup_netbox_mock(mock_api, devices=[device])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
syncer = Sync()
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start()
mock_zabbix.host.create.assert_called_once()
create_kwargs = mock_zabbix.host.create.call_args.kwargs
self.assertEqual(create_kwargs["status"], 1)
# ------------------------------------------------------------------
# Scenario 4: Staged device, already in Zabbix as disabled → no update needed
# ------------------------------------------------------------------
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_staged_device_in_zabbix_is_consistent(self, mock_api, mock_zabbix_api):
"""Staged device already in Zabbix as disabled should pass consistency check with no updates."""
device = MockNetboxDevice(
name="test-device", status_label="Staged", zabbix_hostid=42
)
self._setup_netbox_mock(mock_api, devices=[device])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
mock_zabbix.host.get.return_value = self._make_zabbix_host(status="1")
syncer = Sync()
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start()
mock_zabbix.host.create.assert_not_called()
mock_zabbix.host.update.assert_not_called()
# ------------------------------------------------------------------
# Scenario 5: Decommissioning device, not in Zabbix → skipped (no create, no delete)
# ------------------------------------------------------------------
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_decommissioning_device_not_in_zabbix_is_skipped(
self, mock_api, mock_zabbix_api
):
"""Decommissioning device with no Zabbix ID should be skipped entirely."""
device = MockNetboxDevice(
name="test-device", status_label="Decommissioning", zabbix_hostid=None
)
self._setup_netbox_mock(mock_api, devices=[device])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
syncer = Sync()
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start()
mock_zabbix.host.create.assert_not_called()
mock_zabbix.host.delete.assert_not_called()
# ------------------------------------------------------------------
# Scenario 6: Decommissioning device, already in Zabbix → cleanup (host deleted)
# ------------------------------------------------------------------
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_decommissioning_device_in_zabbix_is_deleted(
self, mock_api, mock_zabbix_api
):
"""Decommissioning device with a Zabbix ID should be deleted from Zabbix."""
device = MockNetboxDevice(
name="test-device", status_label="Decommissioning", zabbix_hostid=42
)
self._setup_netbox_mock(mock_api, devices=[device])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
# Zabbix still has the host → it should be deleted
mock_zabbix.host.get.return_value = [{"hostid": "42"}]
syncer = Sync()
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start()
mock_zabbix.host.delete.assert_called_once_with(42)
# ------------------------------------------------------------------
# Scenario 7: Active device, Zabbix host is disabled → re-enable via consistency check
# ------------------------------------------------------------------
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_active_device_disabled_in_zabbix_is_enabled(
self, mock_api, mock_zabbix_api
):
"""Active device whose Zabbix host is disabled should be re-enabled by consistency check."""
device = MockNetboxDevice(
name="test-device", status_label="Active", zabbix_hostid=42
)
self._setup_netbox_mock(mock_api, devices=[device])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
# Zabbix host currently disabled; device is Active → status out-of-sync
mock_zabbix.host.get.return_value = self._make_zabbix_host(status="1")
syncer = Sync()
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start()
mock_zabbix.host.update.assert_called_once_with(hostid=42, status="0")
# ------------------------------------------------------------------
# Scenario 8: Failed device, Zabbix host is enabled → disable via consistency check
# ------------------------------------------------------------------
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_failed_device_enabled_in_zabbix_is_disabled(
self, mock_api, mock_zabbix_api
):
"""Failed device whose Zabbix host is enabled should be disabled by consistency check."""
device = MockNetboxDevice(
name="test-device", status_label="Failed", zabbix_hostid=42
)
self._setup_netbox_mock(mock_api, devices=[device])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
# Zabbix host currently enabled; device is Failed → status out-of-sync
mock_zabbix.host.get.return_value = self._make_zabbix_host(status="0")
syncer = Sync()
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start()
mock_zabbix.host.update.assert_called_once_with(hostid=42, status="1")