""" Step 1 smoke tests — verify view class wiring (mixins, MRO, key attributes). These tests never touch the database or network; they only inspect class hierarchies and attribute presence. """ import os from pathlib import Path from unittest.mock import MagicMock, patch import pytest class TestLibreNMSAPIMixinWiring: """Views that need LibreNMSAPIMixin must have it in their MRO.""" def _assert_has_api_mixin(self, view_class): from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin assert LibreNMSAPIMixin in view_class.__mro__, f"{view_class.__name__} is missing LibreNMSAPIMixin in its MRO" def test_sync_site_location_has_librenms_api_mixin(self): from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView self._assert_has_api_mixin(SyncSiteLocationView) def test_add_device_has_librenms_api_mixin(self): from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView self._assert_has_api_mixin(AddDeviceToLibreNMSView) def test_update_location_has_librenms_api_mixin(self): from netbox_librenms_plugin.views.sync.devices import UpdateDeviceLocationView self._assert_has_api_mixin(UpdateDeviceLocationView) def test_update_device_name_has_librenms_api_mixin(self): from netbox_librenms_plugin.views.sync.device_fields import UpdateDeviceNameView self._assert_has_api_mixin(UpdateDeviceNameView) def test_update_device_serial_has_librenms_api_mixin(self): from netbox_librenms_plugin.views.sync.device_fields import UpdateDeviceSerialView self._assert_has_api_mixin(UpdateDeviceSerialView) def test_update_device_type_has_librenms_api_mixin(self): from netbox_librenms_plugin.views.sync.device_fields import UpdateDeviceTypeView self._assert_has_api_mixin(UpdateDeviceTypeView) def test_update_device_platform_has_librenms_api_mixin(self): from netbox_librenms_plugin.views.sync.device_fields import UpdateDevicePlatformView self._assert_has_api_mixin(UpdateDevicePlatformView) def test_create_assign_platform_has_librenms_api_mixin(self): from netbox_librenms_plugin.views.sync.device_fields import CreateAndAssignPlatformView self._assert_has_api_mixin(CreateAndAssignPlatformView) def test_assign_vc_serial_has_librenms_api_mixin(self): from netbox_librenms_plugin.views.sync.device_fields import AssignVCSerialView self._assert_has_api_mixin(AssignVCSerialView) class TestCacheMixinWiring: """Views that cache LibreNMS data must have CacheMixin and expose get_cache_key.""" def _assert_has_cache_mixin(self, view_class): from netbox_librenms_plugin.views.mixins import CacheMixin assert CacheMixin in view_class.__mro__, f"{view_class.__name__} is missing CacheMixin" assert hasattr(view_class, "get_cache_key"), f"{view_class.__name__} missing get_cache_key method" def test_sync_interfaces_has_cache_mixin(self): from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView self._assert_has_cache_mixin(SyncInterfacesView) def test_sync_cables_has_cache_mixin(self): from netbox_librenms_plugin.views.sync.cables import SyncCablesView self._assert_has_cache_mixin(SyncCablesView) def test_sync_ip_addresses_has_cache_mixin(self): from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView self._assert_has_cache_mixin(SyncIPAddressesView) def test_sync_vlans_has_cache_mixin(self): from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView self._assert_has_cache_mixin(SyncVLANsView) def test_delete_interfaces_has_cache_mixin(self): from netbox_librenms_plugin.views.sync.interfaces import DeleteNetBoxInterfacesView self._assert_has_cache_mixin(DeleteNetBoxInterfacesView) class TestPermissionMixinWiring: """All action views must have LibreNMSPermissionMixin.""" def _assert_has_permission_mixin(self, view_class): from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin assert LibreNMSPermissionMixin in view_class.__mro__, ( f"{view_class.__name__} is missing LibreNMSPermissionMixin" ) def test_sync_interfaces_has_permission_mixin(self): from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView self._assert_has_permission_mixin(SyncInterfacesView) def test_sync_cables_has_permission_mixin(self): from netbox_librenms_plugin.views.sync.cables import SyncCablesView self._assert_has_permission_mixin(SyncCablesView) def test_add_device_has_permission_mixin(self): from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView self._assert_has_permission_mixin(AddDeviceToLibreNMSView) class TestRequiredObjectPermissionsWiring: """ POST-only sync views that modify NetBox objects must declare required_object_permissions and include the NetBoxObjectPermissionMixin (and LibreNMSPermissionMixin) in their MRO.""" def _assert_has_mixins(self, view_class): """ Assert that *view_class* includes both permission mixins in its MRO. Checking the MRO (not just runtime behaviour) guarantees that the permission enforcement is wired at the class level — a missing mixin would silently skip all permission checks even if the tests otherwise pass. """ from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin, NetBoxObjectPermissionMixin assert NetBoxObjectPermissionMixin in view_class.__mro__, ( f"{view_class.__name__} is missing NetBoxObjectPermissionMixin" ) assert LibreNMSPermissionMixin in view_class.__mro__, ( f"{view_class.__name__} is missing LibreNMSPermissionMixin" ) def test_sync_interfaces_has_required_object_permissions(self): from dcim.models import Interface from virtualization.models import VMInterface from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView self._assert_has_mixins(SyncInterfacesView) view = object.__new__(SyncInterfacesView) # Dynamic views compute permissions per-request; verify the resolver works perms_device = view.get_required_permissions_for_object_type("device") perms_vm = view.get_required_permissions_for_object_type("virtualmachine") assert ("add", Interface) in perms_device assert ("change", Interface) in perms_device assert ("add", VMInterface) in perms_vm assert ("change", VMInterface) in perms_vm def test_sync_cables_has_required_object_permissions(self): from netbox_librenms_plugin.views.sync.cables import SyncCablesView self._assert_has_mixins(SyncCablesView) assert "POST" in SyncCablesView.required_object_permissions def test_sync_vlans_has_required_object_permissions(self): from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView self._assert_has_mixins(SyncVLANsView) assert "POST" in SyncVLANsView.required_object_permissions def test_sync_ip_addresses_has_required_object_permissions(self): from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView self._assert_has_mixins(SyncIPAddressesView) assert "POST" in SyncIPAddressesView.required_object_permissions def test_update_device_name_has_required_object_permissions(self): from netbox_librenms_plugin.views.sync.device_fields import UpdateDeviceNameView self._assert_has_mixins(UpdateDeviceNameView) assert "POST" in UpdateDeviceNameView.required_object_permissions def test_update_device_serial_has_required_object_permissions(self): from netbox_librenms_plugin.views.sync.device_fields import UpdateDeviceSerialView self._assert_has_mixins(UpdateDeviceSerialView) assert "POST" in UpdateDeviceSerialView.required_object_permissions def test_delete_interfaces_has_required_object_permissions(self): from dcim.models import Interface from virtualization.models import VMInterface from netbox_librenms_plugin.views.sync.interfaces import DeleteNetBoxInterfacesView self._assert_has_mixins(DeleteNetBoxInterfacesView) view = object.__new__(DeleteNetBoxInterfacesView) # Dynamic views compute permissions per-request; verify the resolver works perms_device = view.get_required_permissions_for_object_type("device") perms_vm = view.get_required_permissions_for_object_type("virtualmachine") assert ("delete", Interface) in perms_device assert ("delete", VMInterface) in perms_vm class TestViewPropertyLazyInit: """ Verify that _librenms_api starts as None (lazy, not eager-init) and that the librenms_api property descriptor exists on the class.""" def test_librenms_api_mixin_property_is_defined_on_class(self): from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin assert isinstance(LibreNMSAPIMixin.__dict__.get("librenms_api"), property), ( "librenms_api must be a property descriptor on LibreNMSAPIMixin" ) def test_librenms_api_starts_as_none_after_mixin_init(self): from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin class DummyView(LibreNMSAPIMixin): pass dummy = DummyView() # After init, the backing attribute must be None (lazy, not eager) assert dummy._librenms_api is None def test_sync_interfaces_has_librenms_api_property_via_class(self): """SyncInterfacesView must expose librenms_api through its MRO.""" from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView assert any("librenms_api" in vars(cls) for cls in SyncInterfacesView.__mro__) # ── Template syntax smoke tests ────────────────────────────────────────────── _TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "templates" / "netbox_librenms_plugin" _TEMPLATE_FILES = sorted(_TEMPLATE_DIR.rglob("*.html")) class TestTemplateSyntax: """Compile every plugin template to catch syntax errors early.""" @pytest.fixture(autouse=True, scope="class") def _django_engine(self): """Ensure Django is set up once and expose the template engine.""" os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings") import django django.setup() from django.template import engines self.__class__._engine = engines["django"] @pytest.mark.parametrize( "template_path", _TEMPLATE_FILES, ids=[str(p.relative_to(_TEMPLATE_DIR)) for p in _TEMPLATE_FILES], ) def test_template_compiles(self, template_path): """Each template must parse without TemplateSyntaxError.""" source = template_path.read_text() # Compile the template — raises TemplateSyntaxError on bad tags self._engine.from_string(source) class TestRenderDeviceSelectionEscape: """VCCableTable.render_device_selection must HTML-escape member.name.""" def test_member_name_is_escaped(self): from unittest.mock import MagicMock, patch from netbox_librenms_plugin.tables.cables import VCCableTable device = MagicMock() device.id = 1 vc = MagicMock() member = MagicMock() member.id = 1 member.name = '' vc.members.all.return_value = [member] device.virtual_chassis = vc table = VCCableTable([], device=device) record = {"local_port": "eth0", "local_port_id": "42"} with patch( "netbox_librenms_plugin.tables.cables.get_virtual_chassis_member", return_value=member, ): html = str(table.render_device_selection(None, record)) assert "