Files
netbox-zabbix-sync/tests/test_core.py
Wouter de Bruijn 0b456bfc18 Added mock record class
2026-03-13 14:09:50 +01:00

1869 lines
70 KiB
Python

"""Tests for the core sync module."""
import unittest
from typing import ClassVar
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 MockListObject:
def __repr__(self):
return str(self.object)
def serialize(self):
ret = {k: getattr(self, k) for k in ["object_id", "object_type"]}
return ret
def __getattr__(self, k):
return getattr(self.object, k)
def __iter__(self):
for i in ["object_id", "object_type", "object"]:
cur_attr = getattr(self, i)
if isinstance(cur_attr, MockRecord):
cur_attr = dict(cur_attr)
yield i, cur_attr
class MockRecord:
def __init__(self, values, api, endpoint):
self.has_details = False
self._full_cache = []
self._init_cache = []
self.api = api
self.default_ret = MockRecord
def __iter__(self):
for i in dict(self._init_cache):
cur_attr = getattr(self, i)
if isinstance(cur_attr, MockRecord):
yield i, dict(cur_attr)
elif isinstance(cur_attr, list) and all(
isinstance(i, (MockRecord, MockListObject)) for i in cur_attr
):
yield i, [dict(x) for x in cur_attr]
else:
yield i, cur_attr
def __getitem__(self, k):
return dict(self)[k]
def __str__(self):
return (
getattr(self, "name", None)
or getattr(self, "label", None)
or getattr(self, "display", None)
or ""
)
def __repr__(self):
return str(self)
def __getstate__(self):
return self.__dict__
def __setstate__(self, d):
self.__dict__.update(d)
def __key__(self):
if hasattr(self, "id"):
return ("mock", self.id)
else:
return "mock"
def __hash__(self):
return hash(self.__key__())
def __eq__(self, other):
if isinstance(other, MockRecord):
return self.__key__() == other.__key__()
return NotImplemented
class MockNetboxDevice(MockRecord):
"""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,
):
super().__init__(values={}, api=None, endpoint=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(MockRecord):
"""Mock NetBox virtual machine object.
Mirrors the real NetBox API response structure so the full VirtualMachine
pipeline runs without mocking the class itself.
"""
def __init__(
self,
vm_id=1,
name="test-vm",
status_label="Active",
zabbix_hostid=None,
config_context=None,
site=None,
primary_ip=None,
role=None,
cluster=None,
tenant=None,
platform=None,
tags=None,
):
super().__init__(values={}, api=None, endpoint=None)
self.id = vm_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}
# Default config_context includes a template so the VM is not skipped
self.config_context = (
config_context
if config_context is not None
else {"zabbix": {"templates": ["TestTemplate"]}}
)
self.tenant = tenant
self.platform = platform
self.serial = ""
self.description = ""
self.comments = ""
self.vcpus = None
self.memory = None
self.disk = None
self.virtual_chassis = None
self.tags = tags or []
self.oob_ip = None
# Setup site
if site is None:
self.site = MagicMock()
self.site.name = "TestSite"
self.site.slug = "testsite"
self.site.region = None
self.site.group = None
else:
self.site = site
# Setup primary IP
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 role
if role is None:
mock_role = MagicMock()
mock_role.name = "Switch"
mock_role.slug = "switch"
self.role = mock_role
else:
self.role = role
# Setup cluster
if cluster is None:
mock_cluster = MagicMock()
mock_cluster.name = "TestCluster"
mock_cluster_type = MagicMock()
mock_cluster_type.name = "TestClusterType"
mock_cluster.type = mock_cluster_type
self.cluster = mock_cluster
else:
self.cluster = cluster
def save(self):
"""Mock save method."""
class TestNetboxTokenHandling(unittest.TestCase):
"""Test that sync properly handles NetBox token authentication."""
def test_v1_token_with_netbox_45(self):
"""Test that v1 token with NetBox 4.5+ logs warning but returns True."""
syncer = Sync()
with self.assertLogs("NetBox-Zabbix-sync", level="WARNING") as log_context:
result = syncer._validate_netbox_token("token123", "4.5")
self.assertTrue(result)
self.assertTrue(
any("v1 token format" in record.message for record in log_context.records)
)
def test_v2_token_with_netbox_35(self):
"""Test that v2 token with NetBox < 4.5 logs error and returns False."""
syncer = Sync()
with self.assertLogs("NetBox-Zabbix-sync", level="ERROR") as log_context:
result = syncer._validate_netbox_token("nbt_key123.token123", "3.5")
self.assertFalse(result)
self.assertTrue(
any(
"v2 token format with Netbox version lower than 4.5" in record.message
for record in log_context.records
)
)
def test_v2_token_with_netbox_45(self):
"""Test that v2 token with NetBox 4.5+ logs debug and returns True."""
syncer = Sync()
with self.assertLogs("NetBox-Zabbix-sync", level="DEBUG") as log_context:
result = syncer._validate_netbox_token("nbt_key123.token123", "4.5")
self.assertTrue(result)
self.assertTrue(
any("v2 token format" in record.message for record in log_context.records)
)
def test_v1_token_with_netbox_35(self):
"""Test that v1 token with NetBox < 4.5 logs debug and returns True."""
syncer = Sync()
with self.assertLogs("NetBox-Zabbix-sync", level="DEBUG") as log_context:
result = syncer._validate_netbox_token("token123", "3.5")
self.assertTrue(result)
self.assertTrue(
any("v1 token format" in record.message for record in log_context.records)
)
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(
nb_host="http://netbox.local",
nb_token="token",
zbx_host="http://zabbix.local",
zbx_user="user",
zbx_pass="pass",
zbx_token=None,
)
# Should return False due to Zabbix API error
self.assertFalse(result)
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 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 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_sync_cluster_where_device_is_not_primary(self, mock_api, mock_zabbix_api):
"""Test that a non-primary cluster member is skipped and not created in Zabbix."""
# vc_master.id (2) differs from device.id (1) → device is secondary
vc_master = MagicMock()
vc_master.id = 2 # Different from device ID → device is NOT primary
virtual_chassis = MagicMock()
virtual_chassis.master = vc_master
virtual_chassis.name = "SW01"
device = MockNetboxDevice(
device_id=1,
name="SW01N1",
virtual_chassis=virtual_chassis,
)
mock_netbox = self._setup_netbox_mock(mock_api)
mock_netbox.dcim.devices.filter.return_value = [device]
mock_site = MagicMock()
mock_site.name = "TestSite"
device.site = mock_site
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
syncer = Sync({"clustering": True})
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start()
# Secondary cluster member must be skipped — no host should be created
mock_zabbix.host.create.assert_not_called()
@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.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")
class TestVMStatusHandling(unittest.TestCase):
"""
Mirrors TestDeviceStatusHandling for VirtualMachine objects.
Validates the VM sync loop in core.py using real VirtualMachine instances
(not mocked) for the same 8 status scenarios.
"""
# Hostgroup produced by vm_hostgroup_format "site/role" with default MockNetboxVM values.
EXPECTED_HOSTGROUP = "TestSite/Switch"
def _setup_netbox_mock(self, mock_api, 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 = []
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=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.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-vm", status="0"):
"""Build a minimal 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 mean no spurious interface-update calls.
"interfaces": [{}],
"inventory_mode": "-1",
"inventory": {},
"macros": [],
"tags": [],
"proxy_hostid": "0",
"proxyid": "0",
"proxy_groupid": "0",
}
]
# Simple Sync config that enables VM sync with a flat hostgroup format
_SYNC_CFG: ClassVar[dict] = {"sync_vms": True, "vm_hostgroup_format": "site/role"}
# ------------------------------------------------------------------
# Scenario 1: Active VM, 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_vm_not_in_zabbix_is_created(self, mock_api, mock_zabbix_api):
"""Active VM not yet synced to Zabbix should be created with status enabled (0)."""
vm = MockNetboxVM(name="test-vm", status_label="Active", zabbix_hostid=None)
self._setup_netbox_mock(mock_api, vms=[vm])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
syncer = Sync(self._SYNC_CFG)
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-vm")
self.assertEqual(create_kwargs["status"], 0)
# ------------------------------------------------------------------
# Scenario 2: Active VM, already in Zabbix → consistency check, no update
# ------------------------------------------------------------------
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_active_vm_in_zabbix_is_consistent(self, mock_api, mock_zabbix_api):
"""Active VM already in Zabbix with matching status should require no updates."""
vm = MockNetboxVM(name="test-vm", status_label="Active", zabbix_hostid=42)
self._setup_netbox_mock(mock_api, vms=[vm])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
mock_zabbix.host.get.return_value = self._make_zabbix_host(status="0")
syncer = Sync(self._SYNC_CFG)
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 VM, 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_vm_not_in_zabbix_is_created_disabled(
self, mock_api, mock_zabbix_api
):
"""Staged VM not yet in Zabbix should be created with status disabled (1)."""
vm = MockNetboxVM(name="test-vm", status_label="Staged", zabbix_hostid=None)
self._setup_netbox_mock(mock_api, vms=[vm])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
syncer = Sync(self._SYNC_CFG)
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 VM, 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_vm_in_zabbix_is_consistent(self, mock_api, mock_zabbix_api):
"""Staged VM already in Zabbix as disabled should pass consistency check with no updates."""
vm = MockNetboxVM(name="test-vm", status_label="Staged", zabbix_hostid=42)
self._setup_netbox_mock(mock_api, vms=[vm])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
mock_zabbix.host.get.return_value = self._make_zabbix_host(status="1")
syncer = Sync(self._SYNC_CFG)
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 VM, 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_vm_not_in_zabbix_is_skipped(
self, mock_api, mock_zabbix_api
):
"""Decommissioning VM with no Zabbix ID should be skipped entirely."""
vm = MockNetboxVM(
name="test-vm", status_label="Decommissioning", zabbix_hostid=None
)
self._setup_netbox_mock(mock_api, vms=[vm])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
syncer = Sync(self._SYNC_CFG)
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 VM, already in Zabbix → cleanup (host deleted)
# ------------------------------------------------------------------
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_decommissioning_vm_in_zabbix_is_deleted(self, mock_api, mock_zabbix_api):
"""Decommissioning VM with a Zabbix ID should be deleted from Zabbix."""
vm = MockNetboxVM(
name="test-vm", status_label="Decommissioning", zabbix_hostid=42
)
self._setup_netbox_mock(mock_api, vms=[vm])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
mock_zabbix.host.get.return_value = [{"hostid": "42"}]
syncer = Sync(self._SYNC_CFG)
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 VM, 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_vm_disabled_in_zabbix_is_enabled(self, mock_api, mock_zabbix_api):
"""Active VM whose Zabbix host is disabled should be re-enabled by consistency check."""
vm = MockNetboxVM(name="test-vm", status_label="Active", zabbix_hostid=42)
self._setup_netbox_mock(mock_api, vms=[vm])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
mock_zabbix.host.get.return_value = self._make_zabbix_host(status="1")
syncer = Sync(self._SYNC_CFG)
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 VM, 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_vm_enabled_in_zabbix_is_disabled(self, mock_api, mock_zabbix_api):
"""Failed VM whose Zabbix host is enabled should be disabled by consistency check."""
vm = MockNetboxVM(name="test-vm", status_label="Failed", zabbix_hostid=42)
self._setup_netbox_mock(mock_api, vms=[vm])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
mock_zabbix.host.get.return_value = self._make_zabbix_host(status="0")
syncer = Sync(self._SYNC_CFG)
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")
class TestCombineFilters(unittest.TestCase):
"""Test the _combine_filters method and filter override behavior in start()."""
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="7.0"):
"""Helper to setup a working Zabbix mock."""
mock_zabbix = MagicMock()
mock_zabbix_api.return_value = mock_zabbix
# Set version as float to match expected type in device.py comparisons
mock_zabbix.version = float(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.host.get.return_value = []
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_filter_override_with_name_parameter(self, mock_api, mock_zabbix_api):
"""Test that method filter parameter overrides config filter for name.
Scenario:
- Config has nb_device_filter with name="SW01N0"
- start() is called with device_filter {"name": "Testdev02"}
- Only the device matching "Testdev02" should be processed
"""
# Create two mock devices
device_matching_method_filter = MockNetboxDevice(
device_id=1, name="Testdev02", status_label="Active"
)
device_matching_config_filter = MockNetboxDevice(
device_id=2, name="SW01N0", status_label="Active"
)
# Setup mocks - the filter should be called with the combined/overridden filter
self._setup_netbox_mock(
mock_api,
devices=[
device_matching_method_filter,
device_matching_config_filter,
],
)
self._setup_zabbix_mock(mock_zabbix_api)
# Create sync with config filter specifying one name
syncer = Sync({"nb_device_filter": {"name": "SW01N0"}})
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
# Call start with method filter specifying a different name
# The method filter should override the config filter
syncer.start(device_filter={"name": "Testdev02"})
# Verify that nbapi.dcim.devices.filter was called with the override filter
mock_netbox = mock_api.return_value
filter_call_kwargs = mock_netbox.dcim.devices.filter.call_args[1]
self.assertEqual(filter_call_kwargs.get("name"), "Testdev02")
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_filter_override_site_parameter(self, mock_api, mock_zabbix_api):
"""Test that site filter override works correctly.
Scenario:
- Config has no device filter set
- start() is called with device_filter {"site": "fra01"}
- Only devices in fra01 site should be processed
"""
# Create mock sites
site_fra01 = MagicMock()
site_fra01.name = "fra01"
site_fra01.slug = "fra01"
site_ams01 = MagicMock()
site_ams01.name = "ams01"
site_ams01.slug = "ams01"
# Create devices in different sites
device_fra01 = MockNetboxDevice(
device_id=1, name="device-fra01", status_label="Active", site=site_fra01
)
device_ams01 = MockNetboxDevice(
device_id=2, name="device-ams01", status_label="Active", site=site_ams01
)
self._setup_netbox_mock(mock_api, devices=[device_fra01, device_ams01])
self._setup_zabbix_mock(mock_zabbix_api)
syncer = Sync()
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
# Call start with site filter for fra01
syncer.start(device_filter={"site": "fra01"})
# Verify that nbapi.dcim.devices.filter was called with the site filter
mock_netbox = mock_api.return_value
filter_call_kwargs = mock_netbox.dcim.devices.filter.call_args[1]
self.assertEqual(filter_call_kwargs.get("site"), "fra01")
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_config_filter_overridden_by_start_parameter(
self, mock_api, mock_zabbix_api
):
"""Test that start() method filter overrides config filter.
Scenario:
- Config specifies nb_device_filter with {"name": "SW01N0", "site": "ams01"}
- start() is called with {"name": "Testdev02"} (only overriding name)
- The final filter should be {"name": "Testdev02", "site": "ams01"}
- Both name and site filters should be applied with the override
"""
device_matching_all = MockNetboxDevice(
device_id=1, name="Testdev02", status_label="Active"
)
self._setup_netbox_mock(mock_api, devices=[device_matching_all])
self._setup_zabbix_mock(mock_zabbix_api)
# Create sync with config filter having multiple parameters
syncer = Sync({"nb_device_filter": {"name": "SW01N0", "site": "ams01"}})
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
# Call start with method filter that overrides only the name
syncer.start(device_filter={"name": "Testdev02"})
# Verify that nbapi.dcim.devices.filter was called with combined filter
# (site from config + name from method parameter)
mock_netbox = mock_api.return_value
filter_call_kwargs = mock_netbox.dcim.devices.filter.call_args[1]
self.assertEqual(filter_call_kwargs.get("name"), "Testdev02")
self.assertEqual(filter_call_kwargs.get("site"), "ams01")
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_vm_filter_override_with_method_parameter(self, mock_api, mock_zabbix_api):
"""Test that VM filter override works correctly.
Scenario:
- Config enables VM sync with nb_vm_filter {"name": "vm-prod"}
- start() is called with vm_filter {"name": "vm-test"}
- Only VMs matching "vm-test" should be processed
"""
# Create two mock VMs
vm_matching_method_filter = MockNetboxVM(
vm_id=1, name="vm-test", status_label="Active"
)
vm_matching_config_filter = MockNetboxVM(
vm_id=2, name="vm-prod", status_label="Active"
)
self._setup_netbox_mock(
mock_api,
vms=[vm_matching_method_filter, vm_matching_config_filter],
)
self._setup_zabbix_mock(mock_zabbix_api)
# Create sync with config filter for VMs
syncer = Sync(
{
"sync_vms": True,
"nb_vm_filter": {"name": "vm-prod"},
}
)
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
# Call start with method filter that overrides the VM name filter
syncer.start(vm_filter={"name": "vm-test"})
# Verify that nbapi.virtualization.virtual_machines.filter was called with override
mock_netbox = mock_api.return_value
filter_call_kwargs = (
mock_netbox.virtualization.virtual_machines.filter.call_args[1]
)
self.assertEqual(filter_call_kwargs.get("name"), "vm-test")
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_multiple_filter_parameters_combined(self, mock_api, mock_zabbix_api):
"""Test that multiple filter parameters are correctly combined.
Scenario:
- Config has nb_device_filter with {"site": "fra01", "status": "active"}
- start() is called with {"name": "router*"}
- The final filter should have all three parameters
"""
device = MockNetboxDevice(device_id=1, name="router01", status_label="Active")
self._setup_netbox_mock(mock_api, devices=[device])
self._setup_zabbix_mock(mock_zabbix_api)
syncer = Sync({"nb_device_filter": {"site": "fra01", "status": "active"}})
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start(device_filter={"name": "router*"})
mock_netbox = mock_api.return_value
filter_call_kwargs = mock_netbox.dcim.devices.filter.call_args[1]
# All three parameters should be present
self.assertEqual(filter_call_kwargs.get("site"), "fra01")
self.assertEqual(filter_call_kwargs.get("status"), "active")
self.assertEqual(filter_call_kwargs.get("name"), "router*")