Files
netbox-librenms-plugin/netbox_librenms_plugin/tests/test_interface_vlan_sync.py
Vlastislav Svatek 673e67106e
Some checks failed
ci / deploy (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
first commit
2026-06-05 10:39:05 +02:00

564 lines
21 KiB
Python

"""
Tests for interface VLAN sync functionality (Phase 2).
Tests cover:
- VlanAssignmentMixin methods
- Port VLAN enrichment
- VLAN sync action
"""
from unittest.mock import MagicMock, patch
# Import the autouse fixture from helpers
pytest_plugins = ["netbox_librenms_plugin.tests.test_librenms_api_helpers"]
class TestVlanAssignmentMixin:
"""Tests for VlanAssignmentMixin methods."""
def test_get_vlan_groups_for_device_includes_site_scoped(self, mock_librenms_config):
"""Test that VLAN groups scoped to device's site are included."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
# Create mock device with site
mock_device = MagicMock()
mock_device.site = MagicMock()
mock_device.site.pk = 1
mock_device.site.region = None
mock_device.site.group = None
mock_device.location = None
mock_device.rack = None
# Mock the VLAN group query
mock_site_group = MagicMock()
mock_site_group.name = "Site VLANs"
mock_site_group.pk = 10
with patch.object(mixin, "_get_vlan_groups_for_scope") as mock_get_scope:
mock_get_scope.return_value = [mock_site_group]
with patch("ipam.models.VLANGroup") as mock_vlan_group_class:
mock_vlan_group_class.objects.filter.return_value = []
mixin.get_vlan_groups_for_device(mock_device)
# Verify site scope was queried
assert mock_get_scope.called
def test_get_vlan_groups_for_device_includes_global(self, mock_librenms_config):
"""Test that global VLAN groups (no scope) are included."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
# Create mock device with no location context
mock_device = MagicMock()
mock_device.site = None
mock_device.location = None
mock_device.rack = None
with patch.object(mixin, "_get_vlan_groups_for_scope") as mock_get_scope:
mock_get_scope.return_value = []
with patch("ipam.models.VLANGroup") as mock_vlan_group_class:
mock_global_group = MagicMock()
mock_global_group.name = "Global VLANs"
mock_global_group.pk = 20
mock_vlan_group_class.objects.filter.return_value = [mock_global_group]
mixin.get_vlan_groups_for_device(mock_device)
# Verify global scope was queried
mock_vlan_group_class.objects.filter.assert_called_with(scope_type__isnull=True)
def test_select_most_specific_group_prefers_rack(self, mock_librenms_config):
"""Test that rack-scoped groups are preferred over site-scoped."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
# Create mock device with rack
mock_device = MagicMock()
mock_device.rack = MagicMock()
mock_device.rack.pk = 1
mock_device.site = MagicMock()
mock_device.site.pk = 2
mock_device.site.region = None
mock_device.site.group = None
mock_device.location = None
# Create mock groups with different scopes
mock_rack_group = MagicMock()
mock_rack_group.scope_type = MagicMock()
mock_rack_group.scope_type.pk = 100 # Rack content type
mock_rack_group.scope_id = 1
mock_site_group = MagicMock()
mock_site_group.scope_type = MagicMock()
mock_site_group.scope_type.pk = 101 # Site content type
mock_site_group.scope_id = 2
with patch("django.contrib.contenttypes.models.ContentType") as mock_ct:
# Mock ContentType lookups
mock_ct.objects.get_for_model.side_effect = lambda model: MagicMock(pk=100 if "Rack" in str(model) else 101)
result = mixin._select_most_specific_group([mock_rack_group, mock_site_group], mock_device)
# Rack-scoped should be preferred
assert result == mock_rack_group
def test_select_most_specific_group_returns_none_for_ambiguous(self, mock_librenms_config):
"""Test that None is returned when multiple groups have same priority."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
# Create mock device
mock_device = MagicMock()
mock_device.site = MagicMock()
mock_device.site.pk = 1
mock_device.site.region = None
mock_device.site.group = None
mock_device.rack = None
mock_device.location = None
# Create two groups with same scope (both site-scoped to same site)
mock_group1 = MagicMock()
mock_group1.scope_type = MagicMock()
mock_group1.scope_type.pk = 101
mock_group1.scope_id = 1
mock_group2 = MagicMock()
mock_group2.scope_type = MagicMock()
mock_group2.scope_type.pk = 101
mock_group2.scope_id = 1
with patch("django.contrib.contenttypes.models.ContentType") as mock_ct:
mock_ct.objects.get_for_model.return_value = MagicMock(pk=101)
result = mixin._select_most_specific_group([mock_group1, mock_group2], mock_device)
# Ambiguous - should return None
assert result is None
def test_get_ancestors_returns_hierarchy(self, mock_librenms_config):
"""Test that _get_ancestors returns full parent chain."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
# Create mock location hierarchy
mock_grandparent = MagicMock()
mock_grandparent.parent = None
mock_parent = MagicMock()
mock_parent.parent = mock_grandparent
mock_location = MagicMock()
mock_location.parent = mock_parent
ancestors = mixin._get_ancestors(mock_location)
assert len(ancestors) == 3
assert ancestors[0] == mock_location
assert ancestors[1] == mock_parent
assert ancestors[2] == mock_grandparent
def test_find_vlan_in_group_prefers_specified_group(self, mock_librenms_config):
"""Test that _find_vlan_in_group prefers the specified group."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
mock_vlan_in_group = MagicMock()
mock_vlan_global = MagicMock()
lookup_maps = {
"vid_group_to_vlan": {
(100, 5): mock_vlan_in_group,
(100, None): mock_vlan_global,
},
"vid_to_vlans": {
100: [mock_vlan_in_group, mock_vlan_global],
},
}
result = mixin._find_vlan_in_group(100, 5, lookup_maps)
assert result == mock_vlan_in_group
def test_find_vlan_in_group_falls_back_to_global(self, mock_librenms_config):
"""Test that _find_vlan_in_group falls back to global VLAN."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
mock_vlan_global = MagicMock()
lookup_maps = {
"vid_group_to_vlan": {
(100, None): mock_vlan_global,
},
"vid_to_vlans": {
100: [mock_vlan_global],
},
}
# Request group 5 which doesn't have VLAN 100
result = mixin._find_vlan_in_group(100, 5, lookup_maps)
assert result == mock_vlan_global
def test_find_vlan_in_group_returns_none_if_not_found(self, mock_librenms_config):
"""Test that _find_vlan_in_group returns None if VLAN not found."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
lookup_maps = {
"vid_group_to_vlan": {},
"vid_to_vlans": {},
}
result = mixin._find_vlan_in_group(999, None, lookup_maps)
assert result is None
class TestPortVlanEnrichment:
"""Tests for port VLAN data enrichment."""
pytest_plugins = ["tests.test_librenms_api_helpers"]
@patch("requests.get")
def test_parse_port_vlan_data_access_port(self, mock_get, mock_librenms_config):
"""Test parsing access port VLAN data."""
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
api = LibreNMSAPI(server_key="default")
port_data = {
"port_id": 1234,
"ifName": "Gi1/0/1",
"ifDescr": "GigabitEthernet1/0/1",
"ifVlan": "100",
"ifTrunk": None,
}
result = api.parse_port_vlan_data(port_data, "ifName")
assert result["port_id"] == 1234
assert result["interface_name"] == "Gi1/0/1"
assert result["mode"] == "access"
assert result["untagged_vlan"] == 100
assert result["tagged_vlans"] == []
@patch("requests.get")
def test_parse_port_vlan_data_trunk_port(self, mock_get, mock_librenms_config):
"""Test parsing trunk port VLAN data."""
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
api = LibreNMSAPI(server_key="default")
port_data = {
"port_id": 5678,
"ifName": "Te1/1/1",
"ifDescr": "TenGigabitEthernet1/1/1",
"ifVlan": "90",
"ifTrunk": "dot1Q",
"vlans": [
{"vlan": 90, "untagged": 1, "state": "unknown"},
{"vlan": 50, "untagged": 0, "state": "forwarding"},
{"vlan": 60, "untagged": 0, "state": "forwarding"},
],
}
result = api.parse_port_vlan_data(port_data, "ifName")
assert result["port_id"] == 5678
assert result["interface_name"] == "Te1/1/1"
assert result["mode"] == "tagged"
assert result["untagged_vlan"] == 90
assert sorted(result["tagged_vlans"]) == [50, 60]
@patch("requests.get")
def test_parse_port_vlan_data_uses_interface_name_field(self, mock_get, mock_librenms_config):
"""Test that parse_port_vlan_data respects interface_name_field parameter."""
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
api = LibreNMSAPI(server_key="default")
port_data = {
"port_id": 1234,
"ifName": "Gi1/0/1",
"ifDescr": "GigabitEthernet1/0/1",
"ifVlan": "100",
"ifTrunk": None,
}
result = api.parse_port_vlan_data(port_data, "ifDescr")
assert result["interface_name"] == "GigabitEthernet1/0/1"
class TestInterfaceVlanSync:
"""Tests for interface VLAN sync action."""
pytest_plugins = ["tests.test_librenms_api_helpers"]
def test_update_interface_vlan_assignment_access_mode(self, mock_librenms_config):
"""Test that access mode is set correctly for untagged-only ports."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
mock_interface = MagicMock()
mock_interface.tagged_vlans = MagicMock()
mock_vlan = MagicMock()
mock_vlan.vid = 100
lookup_maps = {
"vid_group_to_vlan": {(100, None): mock_vlan},
"vid_to_vlans": {100: [mock_vlan]},
}
vlan_data = {
"untagged_vlan": 100,
"tagged_vlans": [],
}
mixin._update_interface_vlan_assignment(mock_interface, vlan_data, None, lookup_maps)
assert mock_interface.mode == "access"
assert mock_interface.untagged_vlan == mock_vlan
mock_interface.tagged_vlans.clear.assert_called_once()
def test_update_interface_vlan_assignment_tagged_mode(self, mock_librenms_config):
"""Test that tagged mode is set for trunk ports."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
mock_interface = MagicMock()
mock_interface.tagged_vlans = MagicMock()
mock_vlan_100 = MagicMock()
mock_vlan_100.vid = 100
mock_vlan_200 = MagicMock()
mock_vlan_200.vid = 200
mock_vlan_300 = MagicMock()
mock_vlan_300.vid = 300
lookup_maps = {
"vid_group_to_vlan": {
(100, None): mock_vlan_100,
(200, None): mock_vlan_200,
(300, None): mock_vlan_300,
},
"vid_to_vlans": {
100: [mock_vlan_100],
200: [mock_vlan_200],
300: [mock_vlan_300],
},
}
vlan_data = {
"untagged_vlan": 100,
"tagged_vlans": [200, 300],
}
mixin._update_interface_vlan_assignment(mock_interface, vlan_data, None, lookup_maps)
assert mock_interface.mode == "tagged"
assert mock_interface.untagged_vlan == mock_vlan_100
mock_interface.tagged_vlans.set.assert_called_once_with([mock_vlan_200, mock_vlan_300])
def test_update_interface_vlan_assignment_missing_vlans(self, mock_librenms_config):
"""Test that missing VLANs are tracked in result."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
mock_interface = MagicMock()
mock_interface.tagged_vlans = MagicMock()
# Empty lookup maps - no VLANs exist in NetBox
lookup_maps = {
"vid_group_to_vlan": {},
"vid_to_vlans": {},
}
vlan_data = {
"untagged_vlan": 100,
"tagged_vlans": [200, 300],
}
result = mixin._update_interface_vlan_assignment(mock_interface, vlan_data, None, lookup_maps)
assert result["missing_vlans"] == [100, 200, 300]
assert mock_interface.untagged_vlan is None
mock_interface.tagged_vlans.set.assert_called_once_with([])
def test_update_interface_vlan_assignment_respects_group_selection(self, mock_librenms_config):
"""Test that VLAN group selection is respected."""
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
mixin = VlanAssignmentMixin()
mock_interface = MagicMock()
mock_interface.tagged_vlans = MagicMock()
mock_vlan_group1 = MagicMock()
mock_vlan_group1.vid = 100
mock_vlan_global = MagicMock()
mock_vlan_global.vid = 100
lookup_maps = {
"vid_group_to_vlan": {
(100, 5): mock_vlan_group1,
(100, None): mock_vlan_global,
},
"vid_to_vlans": {
100: [mock_vlan_group1, mock_vlan_global],
},
}
vlan_data = {
"untagged_vlan": 100,
"tagged_vlans": [],
}
# Request VLAN from group 5
mixin._update_interface_vlan_assignment(mock_interface, vlan_data, 5, lookup_maps)
# Should use group-specific VLAN
assert mock_interface.untagged_vlan == mock_vlan_group1
class TestInterfaceCssClassGroupMatching:
"""
Tests for group-aware VLAN CSS class functions in utils.py.
Verifies that VLAN group mismatch (same VID but different group) produces
orange (text-warning) instead of green (text-success).
"""
# -- get_untagged_vlan_css_class --
def test_untagged_vid_match_group_match_returns_green(self, mock_librenms_config):
from netbox_librenms_plugin.utils import get_untagged_vlan_css_class
assert get_untagged_vlan_css_class(60, 60, True, [], group_matches=True) == "text-success"
def test_untagged_vid_match_group_mismatch_returns_orange(self, mock_librenms_config):
from netbox_librenms_plugin.utils import get_untagged_vlan_css_class
assert get_untagged_vlan_css_class(60, 60, True, [], group_matches=False) == "text-warning"
def test_untagged_vid_differs_group_irrelevant(self, mock_librenms_config):
"""Different VIDs -> text-warning regardless of group_matches."""
from netbox_librenms_plugin.utils import get_untagged_vlan_css_class
assert get_untagged_vlan_css_class(60, 100, True, [], group_matches=True) == "text-warning"
def test_untagged_not_in_netbox_ignores_group(self, mock_librenms_config):
from netbox_librenms_plugin.utils import get_untagged_vlan_css_class
assert get_untagged_vlan_css_class(60, 60, False, [], group_matches=True) == "text-danger"
def test_untagged_missing_vlan_ignores_group(self, mock_librenms_config):
from netbox_librenms_plugin.utils import get_untagged_vlan_css_class
assert get_untagged_vlan_css_class(60, 60, True, [60], group_matches=True) == "text-danger"
def test_untagged_no_netbox_vlan_returns_red(self, mock_librenms_config):
from netbox_librenms_plugin.utils import get_untagged_vlan_css_class
assert get_untagged_vlan_css_class(60, None, True, [], group_matches=True) == "text-danger"
def test_untagged_default_group_matches_is_true(self, mock_librenms_config):
"""Without group_matches param, defaults to True (backward compat)."""
from netbox_librenms_plugin.utils import get_untagged_vlan_css_class
assert get_untagged_vlan_css_class(60, 60, True, []) == "text-success"
# -- get_tagged_vlan_css_class --
def test_tagged_vid_present_group_match_returns_green(self, mock_librenms_config):
from netbox_librenms_plugin.utils import get_tagged_vlan_css_class
assert get_tagged_vlan_css_class(60, {60, 100}, True, [], group_matches=True) == "text-success"
def test_tagged_vid_present_group_mismatch_returns_orange(self, mock_librenms_config):
from netbox_librenms_plugin.utils import get_tagged_vlan_css_class
assert get_tagged_vlan_css_class(60, {60, 100}, True, [], group_matches=False) == "text-warning"
def test_tagged_vid_absent_group_irrelevant(self, mock_librenms_config):
from netbox_librenms_plugin.utils import get_tagged_vlan_css_class
assert get_tagged_vlan_css_class(60, {100}, True, [], group_matches=True) == "text-danger"
def test_tagged_not_in_netbox_ignores_group(self, mock_librenms_config):
from netbox_librenms_plugin.utils import get_tagged_vlan_css_class
assert get_tagged_vlan_css_class(60, {60}, False, [], group_matches=True) == "text-danger"
def test_tagged_missing_vlan_ignores_group(self, mock_librenms_config):
from netbox_librenms_plugin.utils import get_tagged_vlan_css_class
assert get_tagged_vlan_css_class(60, {60}, True, [60], group_matches=True) == "text-danger"
def test_tagged_default_group_matches_is_true(self, mock_librenms_config):
"""Without group_matches param, defaults to True (backward compat)."""
from netbox_librenms_plugin.utils import get_tagged_vlan_css_class
assert get_tagged_vlan_css_class(60, {60}, True, []) == "text-success"
# -- check_vlan_group_matches --
def test_check_group_matches_untagged_same_group(self, mock_librenms_config):
from netbox_librenms_plugin.utils import check_vlan_group_matches
assert check_vlan_group_matches("U", 60, 5, 5, {}, 60, set()) is True
def test_check_group_matches_untagged_different_group(self, mock_librenms_config):
from netbox_librenms_plugin.utils import check_vlan_group_matches
assert check_vlan_group_matches("U", 60, 10, 5, {}, 60, set()) is False
def test_check_group_matches_untagged_vid_differs(self, mock_librenms_config):
"""When VIDs don't match, group comparison is irrelevant -> True."""
from netbox_librenms_plugin.utils import check_vlan_group_matches
assert check_vlan_group_matches("U", 60, 10, 5, {}, 100, set()) is True
def test_check_group_matches_tagged_same_group(self, mock_librenms_config):
from netbox_librenms_plugin.utils import check_vlan_group_matches
assert check_vlan_group_matches("T", 60, 5, None, {60: 5}, None, {60}) is True
def test_check_group_matches_tagged_different_group(self, mock_librenms_config):
from netbox_librenms_plugin.utils import check_vlan_group_matches
assert check_vlan_group_matches("T", 60, 10, None, {60: 5}, None, {60}) is False
def test_check_group_matches_tagged_vid_absent(self, mock_librenms_config):
"""When VID is not tagged in NetBox, group comparison irrelevant -> True."""
from netbox_librenms_plugin.utils import check_vlan_group_matches
assert check_vlan_group_matches("T", 60, 10, None, {}, None, set()) is True
def test_check_group_matches_global_to_global(self, mock_librenms_config):
"""Both NetBox VLAN and selected have no group (global) -> match."""
from netbox_librenms_plugin.utils import check_vlan_group_matches
assert check_vlan_group_matches("U", 60, None, None, {}, 60, set()) is True
def test_check_group_matches_global_vs_group(self, mock_librenms_config):
"""NetBox VLAN is global, selected is a specific group -> mismatch."""
from netbox_librenms_plugin.utils import check_vlan_group_matches
assert check_vlan_group_matches("U", 60, 5, None, {}, 60, set()) is False