mirror of
https://github.com/TheNetworkGuy/netbox-zabbix-sync.git
synced 2026-03-21 20:18:38 -06:00
Added VM tests and tag tests
This commit is contained in:
+378
-11
@@ -1,6 +1,7 @@
|
|||||||
"""Tests for the core sync module."""
|
"""Tests for the core sync module."""
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from typing import ClassVar
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from requests.exceptions import ConnectionError as RequestsConnectionError
|
from requests.exceptions import ConnectionError as RequestsConnectionError
|
||||||
@@ -81,7 +82,8 @@ class MockNetboxDevice:
|
|||||||
# Setup device type with proper structure
|
# Setup device type with proper structure
|
||||||
if device_type is None:
|
if device_type is None:
|
||||||
self.device_type = MagicMock()
|
self.device_type = MagicMock()
|
||||||
self.device_type.custom_fields = {"zabbix_template": "TestTemplate"}
|
self.device_type.custom_fields = {
|
||||||
|
"zabbix_template": "TestTemplate"}
|
||||||
self.device_type.manufacturer = MagicMock()
|
self.device_type.manufacturer = MagicMock()
|
||||||
self.device_type.manufacturer.name = "TestManufacturer"
|
self.device_type.manufacturer.name = "TestManufacturer"
|
||||||
self.device_type.display = "Test Device Type"
|
self.device_type.display = "Test Device Type"
|
||||||
@@ -108,7 +110,11 @@ class MockNetboxDevice:
|
|||||||
|
|
||||||
|
|
||||||
class MockNetboxVM:
|
class MockNetboxVM:
|
||||||
"""Mock NetBox virtual machine object."""
|
"""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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -119,15 +125,77 @@ class MockNetboxVM:
|
|||||||
config_context=None,
|
config_context=None,
|
||||||
site=None,
|
site=None,
|
||||||
primary_ip=None,
|
primary_ip=None,
|
||||||
|
role=None,
|
||||||
|
cluster=None,
|
||||||
|
tenant=None,
|
||||||
|
platform=None,
|
||||||
|
tags=None,
|
||||||
):
|
):
|
||||||
self.id = vm_id
|
self.id = vm_id
|
||||||
self.name = name
|
self.name = name
|
||||||
self.status = MagicMock()
|
self.status = MagicMock()
|
||||||
self.status.label = status_label
|
self.status.label = status_label
|
||||||
|
self.status.value = status_label.lower()
|
||||||
self.custom_fields = {"zabbix_hostid": zabbix_hostid}
|
self.custom_fields = {"zabbix_hostid": zabbix_hostid}
|
||||||
self.config_context = config_context or {}
|
# Default config_context includes a template so the VM is not skipped
|
||||||
self.site = site
|
self.config_context = (
|
||||||
self.primary_ip = primary_ip
|
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 TestSyncNetboxConnection(unittest.TestCase):
|
class TestSyncNetboxConnection(unittest.TestCase):
|
||||||
@@ -305,7 +373,8 @@ class TestSyncDeviceProcessing(unittest.TestCase):
|
|||||||
mock_zabbix = MagicMock()
|
mock_zabbix = MagicMock()
|
||||||
mock_zabbix_api.return_value = mock_zabbix
|
mock_zabbix_api.return_value = mock_zabbix
|
||||||
mock_zabbix.version = version
|
mock_zabbix.version = version
|
||||||
mock_zabbix.hostgroup.get.return_value = [{"groupid": "1", "name": "TestGroup"}]
|
mock_zabbix.hostgroup.get.return_value = [
|
||||||
|
{"groupid": "1", "name": "TestGroup"}]
|
||||||
mock_zabbix.template.get.return_value = [
|
mock_zabbix.template.get.return_value = [
|
||||||
{"templateid": "1", "name": "TestTemplate"}
|
{"templateid": "1", "name": "TestTemplate"}
|
||||||
]
|
]
|
||||||
@@ -431,7 +500,8 @@ class TestSyncZabbixVersionHandling(unittest.TestCase):
|
|||||||
mock_zabbix.version = "6.0"
|
mock_zabbix.version = "6.0"
|
||||||
mock_zabbix.hostgroup.get.return_value = []
|
mock_zabbix.hostgroup.get.return_value = []
|
||||||
mock_zabbix.template.get.return_value = []
|
mock_zabbix.template.get.return_value = []
|
||||||
mock_zabbix.proxy.get.return_value = [{"proxyid": "1", "host": "proxy1"}]
|
mock_zabbix.proxy.get.return_value = [
|
||||||
|
{"proxyid": "1", "host": "proxy1"}]
|
||||||
|
|
||||||
syncer = Sync()
|
syncer = Sync()
|
||||||
syncer.connect(
|
syncer.connect(
|
||||||
@@ -458,7 +528,8 @@ class TestSyncZabbixVersionHandling(unittest.TestCase):
|
|||||||
mock_zabbix.version = "7.0"
|
mock_zabbix.version = "7.0"
|
||||||
mock_zabbix.hostgroup.get.return_value = []
|
mock_zabbix.hostgroup.get.return_value = []
|
||||||
mock_zabbix.template.get.return_value = []
|
mock_zabbix.template.get.return_value = []
|
||||||
mock_zabbix.proxy.get.return_value = [{"proxyid": "1", "name": "proxy1"}]
|
mock_zabbix.proxy.get.return_value = [
|
||||||
|
{"proxyid": "1", "name": "proxy1"}]
|
||||||
mock_zabbix.proxygroup.get.return_value = []
|
mock_zabbix.proxygroup.get.return_value = []
|
||||||
|
|
||||||
syncer = Sync()
|
syncer = Sync()
|
||||||
@@ -653,7 +724,8 @@ class TestDeviceHandeling(unittest.TestCase):
|
|||||||
mock_zabbix = MagicMock()
|
mock_zabbix = MagicMock()
|
||||||
mock_zabbix_api.return_value = mock_zabbix
|
mock_zabbix_api.return_value = mock_zabbix
|
||||||
mock_zabbix.version = version
|
mock_zabbix.version = version
|
||||||
mock_zabbix.hostgroup.get.return_value = [{"groupid": "1", "name": "TestGroup"}]
|
mock_zabbix.hostgroup.get.return_value = [
|
||||||
|
{"groupid": "1", "name": "TestGroup"}]
|
||||||
mock_zabbix.template.get.return_value = [
|
mock_zabbix.template.get.return_value = [
|
||||||
{"templateid": "1", "name": "TestTemplate"}
|
{"templateid": "1", "name": "TestTemplate"}
|
||||||
]
|
]
|
||||||
@@ -754,7 +826,8 @@ class TestDeviceHandeling(unittest.TestCase):
|
|||||||
# Verify host was created with the config context template, not the custom field one
|
# Verify host was created with the config context template, not the custom field one
|
||||||
mock_zabbix.host.create.assert_called_once()
|
mock_zabbix.host.create.assert_called_once()
|
||||||
create_call_kwargs = mock_zabbix.host.create.call_args.kwargs
|
create_call_kwargs = mock_zabbix.host.create.call_args.kwargs
|
||||||
self.assertEqual(create_call_kwargs["templates"], [{"templateid": "2"}])
|
self.assertEqual(create_call_kwargs["templates"], [
|
||||||
|
{"templateid": "2"}])
|
||||||
|
|
||||||
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
|
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
|
||||||
@patch("netbox_zabbix_sync.modules.core.nbapi")
|
@patch("netbox_zabbix_sync.modules.core.nbapi")
|
||||||
@@ -802,7 +875,8 @@ class TestDeviceHandeling(unittest.TestCase):
|
|||||||
# Config context overrides the custom field - only "ContextTemplate" should be used
|
# Config context overrides the custom field - only "ContextTemplate" should be used
|
||||||
mock_zabbix.host.create.assert_called_once()
|
mock_zabbix.host.create.assert_called_once()
|
||||||
create_call_kwargs = mock_zabbix.host.create.call_args.kwargs
|
create_call_kwargs = mock_zabbix.host.create.call_args.kwargs
|
||||||
self.assertEqual(create_call_kwargs["templates"], [{"templateid": "2"}])
|
self.assertEqual(create_call_kwargs["templates"], [
|
||||||
|
{"templateid": "2"}])
|
||||||
# Verify the custom field template was NOT used
|
# Verify the custom field template was NOT used
|
||||||
self.assertNotIn({"templateid": "1"}, create_call_kwargs["templates"])
|
self.assertNotIn({"templateid": "1"}, create_call_kwargs["templates"])
|
||||||
|
|
||||||
@@ -1122,3 +1196,296 @@ class TestDeviceStatusHandling(unittest.TestCase):
|
|||||||
syncer.start()
|
syncer.start()
|
||||||
|
|
||||||
mock_zabbix.host.update.assert_called_once_with(hostid=42, status="1")
|
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.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-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")
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"""Tests for the ZabbixTags class in the tags module."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from netbox_zabbix_sync.modules.tags import ZabbixTags
|
||||||
|
|
||||||
|
|
||||||
|
class DummyNBForTags:
|
||||||
|
"""Minimal NetBox object that supports field_mapper's dict-style access."""
|
||||||
|
|
||||||
|
def __init__(self, name="test-host", config_context=None, tags=None, site=None):
|
||||||
|
self.name = name
|
||||||
|
self.config_context = config_context or {}
|
||||||
|
self.tags = tags or []
|
||||||
|
# Stored as a plain dict so field_mapper can traverse "site/name"
|
||||||
|
self.site = site if site is not None else {"name": "TestSite"}
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
return getattr(self, key)
|
||||||
|
|
||||||
|
|
||||||
|
class TestZabbixTagsInit(unittest.TestCase):
|
||||||
|
"""Tests for ZabbixTags initialisation."""
|
||||||
|
|
||||||
|
def test_sync_true_when_tag_sync_enabled(self):
|
||||||
|
"""sync flag should be True when tag_sync=True."""
|
||||||
|
nb = DummyNBForTags()
|
||||||
|
tags = ZabbixTags(nb, tag_map={}, tag_sync=True, logger=MagicMock())
|
||||||
|
self.assertTrue(tags.sync)
|
||||||
|
|
||||||
|
def test_sync_false_when_tag_sync_disabled(self):
|
||||||
|
"""sync flag should be False when tag_sync=False (default)."""
|
||||||
|
nb = DummyNBForTags()
|
||||||
|
tags = ZabbixTags(nb, tag_map={}, logger=MagicMock())
|
||||||
|
self.assertFalse(tags.sync)
|
||||||
|
|
||||||
|
def test_repr_and_str_return_host_name(self):
|
||||||
|
nb = DummyNBForTags(name="my-host")
|
||||||
|
tags = ZabbixTags(nb, tag_map={}, host="my-host", logger=MagicMock())
|
||||||
|
self.assertEqual(repr(tags), "my-host")
|
||||||
|
self.assertEqual(str(tags), "my-host")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderTag(unittest.TestCase):
|
||||||
|
"""Tests for ZabbixTags.render_tag()."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
nb = DummyNBForTags()
|
||||||
|
self.logger = MagicMock()
|
||||||
|
self.tags = ZabbixTags(
|
||||||
|
nb, tag_map={}, tag_sync=True, tag_lower=True, logger=self.logger
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_valid_tag_lowercased(self):
|
||||||
|
"""Valid name+value with tag_lower=True should produce lowercase keys."""
|
||||||
|
result = self.tags.render_tag("Site", "Production")
|
||||||
|
self.assertEqual(result, {"tag": "site", "value": "production"})
|
||||||
|
|
||||||
|
def test_valid_tag_not_lowercased(self):
|
||||||
|
"""tag_lower=False should preserve original case."""
|
||||||
|
nb = DummyNBForTags()
|
||||||
|
tags = ZabbixTags(
|
||||||
|
nb, tag_map={}, tag_sync=True, tag_lower=False, logger=self.logger
|
||||||
|
)
|
||||||
|
result = tags.render_tag("Site", "Production")
|
||||||
|
self.assertEqual(result, {"tag": "Site", "value": "Production"})
|
||||||
|
|
||||||
|
def test_invalid_name_none_returns_false(self):
|
||||||
|
"""None as tag name should return False."""
|
||||||
|
result = self.tags.render_tag(None, "somevalue")
|
||||||
|
self.assertFalse(result)
|
||||||
|
|
||||||
|
def test_invalid_name_too_long_returns_false(self):
|
||||||
|
"""Name exceeding 256 characters should return False."""
|
||||||
|
long_name = "x" * 257
|
||||||
|
result = self.tags.render_tag(long_name, "somevalue")
|
||||||
|
self.assertFalse(result)
|
||||||
|
|
||||||
|
def test_invalid_value_none_returns_false(self):
|
||||||
|
"""None as tag value should return False."""
|
||||||
|
result = self.tags.render_tag("site", None)
|
||||||
|
self.assertFalse(result)
|
||||||
|
|
||||||
|
def test_invalid_value_empty_string_returns_false(self):
|
||||||
|
"""Empty string as tag value should return False."""
|
||||||
|
result = self.tags.render_tag("site", "")
|
||||||
|
self.assertFalse(result)
|
||||||
|
|
||||||
|
def test_invalid_value_too_long_returns_false(self):
|
||||||
|
"""Value exceeding 256 characters should return False."""
|
||||||
|
long_value = "x" * 257
|
||||||
|
result = self.tags.render_tag("site", long_value)
|
||||||
|
self.assertFalse(result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateFromTagMap(unittest.TestCase):
|
||||||
|
"""Tests for the field_mapper-driven tag generation path."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.logger = MagicMock()
|
||||||
|
|
||||||
|
def test_generate_tag_from_field_map(self):
|
||||||
|
"""Tags derived from tag_map fields are lowercased and returned correctly."""
|
||||||
|
nb = DummyNBForTags(name="router01")
|
||||||
|
# "site/name" → nb["site"]["name"] → "TestSite", mapped to tag name "site"
|
||||||
|
tag_map = {"site/name": "site"}
|
||||||
|
tags = ZabbixTags(
|
||||||
|
nb,
|
||||||
|
tag_map=tag_map,
|
||||||
|
tag_sync=True,
|
||||||
|
tag_lower=True,
|
||||||
|
logger=self.logger,
|
||||||
|
)
|
||||||
|
result = tags.generate()
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
self.assertEqual(result[0]["tag"], "site")
|
||||||
|
self.assertEqual(result[0]["value"], "testsite")
|
||||||
|
|
||||||
|
def test_generate_empty_field_map_produces_no_tags(self):
|
||||||
|
"""An empty tag_map with no context or NB tags should return an empty list."""
|
||||||
|
nb = DummyNBForTags()
|
||||||
|
tags = ZabbixTags(nb, tag_map={}, tag_sync=True, logger=self.logger)
|
||||||
|
result = tags.generate()
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
|
||||||
|
def test_generate_deduplicates_tags(self):
|
||||||
|
"""Duplicate tags produced by the map should be deduplicated."""
|
||||||
|
# Two map entries that resolve to the same tag/value pair
|
||||||
|
nb = DummyNBForTags(name="router01")
|
||||||
|
tag_map = {"site/name": "site", "site/name": "site"} # noqa: F601
|
||||||
|
tags = ZabbixTags(
|
||||||
|
nb,
|
||||||
|
tag_map=tag_map,
|
||||||
|
tag_sync=True,
|
||||||
|
tag_lower=True,
|
||||||
|
logger=self.logger,
|
||||||
|
)
|
||||||
|
result = tags.generate()
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateFromConfigContext(unittest.TestCase):
|
||||||
|
"""Tests for the config_context-driven tag generation path."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.logger = MagicMock()
|
||||||
|
|
||||||
|
def test_generates_tags_from_config_context(self):
|
||||||
|
"""Tags listed in config_context['zabbix']['tags'] are added correctly."""
|
||||||
|
nb = DummyNBForTags(
|
||||||
|
config_context={
|
||||||
|
"zabbix": {
|
||||||
|
"tags": [
|
||||||
|
{"environment": "production"},
|
||||||
|
{"location": "DC1"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
tags = ZabbixTags(
|
||||||
|
nb, tag_map={}, tag_sync=True, tag_lower=True, logger=self.logger
|
||||||
|
)
|
||||||
|
result = tags.generate()
|
||||||
|
self.assertEqual(len(result), 2)
|
||||||
|
tag_names = [t["tag"] for t in result]
|
||||||
|
self.assertIn("environment", tag_names)
|
||||||
|
self.assertIn("location", tag_names)
|
||||||
|
|
||||||
|
def test_skips_config_context_tags_with_invalid_values(self):
|
||||||
|
"""Config context tags with None value should be silently dropped."""
|
||||||
|
nb = DummyNBForTags(
|
||||||
|
config_context={
|
||||||
|
"zabbix": {
|
||||||
|
"tags": [
|
||||||
|
{"environment": None}, # invalid value
|
||||||
|
{"location": "DC1"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
tags = ZabbixTags(
|
||||||
|
nb, tag_map={}, tag_sync=True, tag_lower=True, logger=self.logger
|
||||||
|
)
|
||||||
|
result = tags.generate()
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
self.assertEqual(result[0]["tag"], "location")
|
||||||
|
|
||||||
|
def test_ignores_zabbix_tags_key_missing(self):
|
||||||
|
"""Missing 'tags' key inside config_context['zabbix'] produces no tags."""
|
||||||
|
nb = DummyNBForTags(config_context={"zabbix": {"templates": ["T1"]}})
|
||||||
|
tags = ZabbixTags(nb, tag_map={}, tag_sync=True, logger=self.logger)
|
||||||
|
result = tags.generate()
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
|
||||||
|
def test_ignores_config_context_tags_not_a_list(self):
|
||||||
|
"""Non-list value for config_context['zabbix']['tags'] produces no tags."""
|
||||||
|
nb = DummyNBForTags(config_context={"zabbix": {"tags": "not-a-list"}})
|
||||||
|
tags = ZabbixTags(nb, tag_map={}, tag_sync=True, logger=self.logger)
|
||||||
|
result = tags.generate()
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateFromNetboxTags(unittest.TestCase):
|
||||||
|
"""Tests for the NetBox device tags forwarding path."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.logger = MagicMock()
|
||||||
|
# Simulate a list of NetBox tag objects (as dicts, matching real API shape)
|
||||||
|
self.nb_tags = [
|
||||||
|
{"name": "ping", "slug": "ping", "display": "ping"},
|
||||||
|
{"name": "snmp", "slug": "snmp", "display": "snmp"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_generates_tags_from_netbox_tags_using_name(self):
|
||||||
|
"""NetBox device tags are forwarded using tag_name label and tag_value='name'."""
|
||||||
|
nb = DummyNBForTags(tags=self.nb_tags)
|
||||||
|
tags = ZabbixTags(
|
||||||
|
nb,
|
||||||
|
tag_map={},
|
||||||
|
tag_sync=True,
|
||||||
|
tag_lower=True,
|
||||||
|
tag_name="NetBox",
|
||||||
|
tag_value="name",
|
||||||
|
logger=self.logger,
|
||||||
|
)
|
||||||
|
result = tags.generate()
|
||||||
|
self.assertEqual(len(result), 2)
|
||||||
|
for t in result:
|
||||||
|
self.assertEqual(t["tag"], "netbox")
|
||||||
|
values = {t["value"] for t in result}
|
||||||
|
self.assertIn("ping", values)
|
||||||
|
self.assertIn("snmp", values)
|
||||||
|
|
||||||
|
def test_generates_tags_from_netbox_tags_using_slug(self):
|
||||||
|
"""tag_value='slug' should use the slug field from each NetBox tag."""
|
||||||
|
nb = DummyNBForTags(tags=self.nb_tags)
|
||||||
|
tags = ZabbixTags(
|
||||||
|
nb,
|
||||||
|
tag_map={},
|
||||||
|
tag_sync=True,
|
||||||
|
tag_lower=False,
|
||||||
|
tag_name="NetBox",
|
||||||
|
tag_value="slug",
|
||||||
|
logger=self.logger,
|
||||||
|
)
|
||||||
|
result = tags.generate()
|
||||||
|
values = {t["value"] for t in result}
|
||||||
|
self.assertIn("ping", values)
|
||||||
|
self.assertIn("snmp", values)
|
||||||
|
|
||||||
|
def test_generates_tags_from_netbox_tags_default_value_field(self):
|
||||||
|
"""When tag_value is not a recognised field name, falls back to 'name'."""
|
||||||
|
nb = DummyNBForTags(tags=self.nb_tags)
|
||||||
|
tags = ZabbixTags(
|
||||||
|
nb,
|
||||||
|
tag_map={},
|
||||||
|
tag_sync=True,
|
||||||
|
tag_lower=True,
|
||||||
|
tag_name="NetBox",
|
||||||
|
tag_value="invalid_field", # not display/name/slug → fall back to "name"
|
||||||
|
logger=self.logger,
|
||||||
|
)
|
||||||
|
result = tags.generate()
|
||||||
|
values = {t["value"] for t in result}
|
||||||
|
self.assertIn("ping", values)
|
||||||
|
|
||||||
|
def test_skips_netbox_tags_when_tag_name_not_set(self):
|
||||||
|
"""NetBox tag forwarding is skipped when tag_name is not configured."""
|
||||||
|
nb = DummyNBForTags(tags=self.nb_tags)
|
||||||
|
tags = ZabbixTags(
|
||||||
|
nb,
|
||||||
|
tag_map={},
|
||||||
|
tag_sync=True,
|
||||||
|
tag_lower=True,
|
||||||
|
tag_name=None,
|
||||||
|
logger=self.logger,
|
||||||
|
)
|
||||||
|
result = tags.generate()
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user