from unittest.mock import MagicMock, patch class TestLibreNMSPermissionMixin: """Tests for permission mixin functionality.""" def test_has_write_permission_granted(self): """User with change permission has write access.""" from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin mixin = LibreNMSPermissionMixin() mixin.request = MagicMock() mixin.request.user.has_perm.return_value = True assert mixin.has_write_permission() is True def test_has_write_permission_denied(self): """User without change permission lacks write access.""" from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin mixin = LibreNMSPermissionMixin() mixin.request = MagicMock() mixin.request.user.has_perm.return_value = False assert mixin.has_write_permission() is False def test_require_write_permission_allowed(self): """User with write permission gets None (allowed to proceed).""" from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin mixin = LibreNMSPermissionMixin() mixin.request = MagicMock() mixin.request.user.has_perm.return_value = True result = mixin.require_write_permission() assert result is None def test_require_write_permission_denied(self): """User without write permission gets redirect response to referrer.""" from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin mixin = LibreNMSPermissionMixin() mixin.request = MagicMock() mixin.request.user.has_perm.return_value = False mixin.request.path = "/some/path/" mixin.request.META = {"HTTP_REFERER": "/original/page/"} mixin.request.get_host.return_value = "testserver" mixin.request.is_secure.return_value = False mixin.request.headers = {} # Not an HTMX request with patch("netbox_librenms_plugin.views.mixins.redirect") as mock_redirect: with patch("netbox_librenms_plugin.views.mixins.messages"): result = mixin.require_write_permission() mock_redirect.assert_called_once_with("/original/page/") assert result is not None def test_require_write_permission_denied_htmx(self): """HTMX request without write permission gets HX-Redirect response.""" from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin mixin = LibreNMSPermissionMixin() mixin.request = MagicMock() mixin.request.user.has_perm.return_value = False mixin.request.path = "/some/path/" mixin.request.META = {"HTTP_REFERER": "/original/page/"} mixin.request.get_host.return_value = "testserver" mixin.request.is_secure.return_value = False mixin.request.headers = {"HX-Request": "true"} with patch("netbox_librenms_plugin.views.mixins.messages"): result = mixin.require_write_permission() # Should return HttpResponse with HX-Redirect header assert result is not None assert result["HX-Redirect"] == "/original/page/" def test_require_write_permission_json_allowed(self): """User with write permission gets None (allowed to proceed).""" from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin mixin = LibreNMSPermissionMixin() mixin.request = MagicMock() mixin.request.user.has_perm.return_value = True result = mixin.require_write_permission_json() assert result is None def test_require_write_permission_json_denied(self): """User without write permission gets JsonResponse with 403.""" import json from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin mixin = LibreNMSPermissionMixin() mixin.request = MagicMock() mixin.request.user.has_perm.return_value = False result = mixin.require_write_permission_json() assert result is not None assert result.status_code == 403 content = json.loads(result.content) assert content["error"] == "You do not have permission to perform this action." def test_require_write_permission_json_custom_message(self): """Custom error message is returned in JsonResponse.""" import json from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin mixin = LibreNMSPermissionMixin() mixin.request = MagicMock() mixin.request.user.has_perm.return_value = False result = mixin.require_write_permission_json(error_message="Custom denied message") assert result is not None assert result.status_code == 403 content = json.loads(result.content) assert content["error"] == "Custom denied message" class TestAPIPermissions: """Tests for API permission class.""" def test_get_requires_view_permission(self): """GET requests require view permission.""" from netbox_librenms_plugin.api.views import LibreNMSPluginPermission from netbox_librenms_plugin.constants import PERM_VIEW_PLUGIN permission = LibreNMSPluginPermission() request = MagicMock() request.method = "GET" request.user.has_perm.return_value = True assert permission.has_permission(request, None) is True request.user.has_perm.assert_called_with(PERM_VIEW_PLUGIN) def test_post_requires_change_permission(self): """POST requests require change permission.""" from netbox_librenms_plugin.api.views import LibreNMSPluginPermission from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN permission = LibreNMSPluginPermission() request = MagicMock() request.method = "POST" request.user.has_perm.return_value = True assert permission.has_permission(request, None) is True request.user.has_perm.assert_called_with(PERM_CHANGE_PLUGIN) def test_put_requires_change_permission(self): """PUT requests require change permission.""" from netbox_librenms_plugin.api.views import LibreNMSPluginPermission from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN permission = LibreNMSPluginPermission() request = MagicMock() request.method = "PUT" request.user.has_perm.return_value = True assert permission.has_permission(request, None) is True request.user.has_perm.assert_called_with(PERM_CHANGE_PLUGIN) def test_delete_requires_change_permission(self): """DELETE requests require change permission.""" from netbox_librenms_plugin.api.views import LibreNMSPluginPermission from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN permission = LibreNMSPluginPermission() request = MagicMock() request.method = "DELETE" request.user.has_perm.return_value = True assert permission.has_permission(request, None) is True request.user.has_perm.assert_called_with(PERM_CHANGE_PLUGIN) def test_get_denied_without_view_permission(self): """GET requests denied without view permission.""" from netbox_librenms_plugin.api.views import LibreNMSPluginPermission permission = LibreNMSPluginPermission() request = MagicMock() request.method = "GET" request.user.has_perm.return_value = False assert permission.has_permission(request, None) is False def test_post_denied_without_change_permission(self): """POST requests denied without change permission.""" from netbox_librenms_plugin.api.views import LibreNMSPluginPermission permission = LibreNMSPluginPermission() request = MagicMock() request.method = "POST" request.user.has_perm.return_value = False assert permission.has_permission(request, None) is False class TestPermissionConstants: """Tests for permission constants.""" def test_view_permission_constant(self): """View permission constant is correct.""" from netbox_librenms_plugin.constants import PERM_VIEW_PLUGIN assert PERM_VIEW_PLUGIN == "netbox_librenms_plugin.view_librenmssettings" def test_change_permission_constant(self): """Change permission constant is correct.""" from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN assert PERM_CHANGE_PLUGIN == "netbox_librenms_plugin.change_librenmssettings" # ============================================================================= # Phase 2: Object Permission Tests # ============================================================================= class TestObjectPermissionHelpers: """Tests for Phase 2 object permission helper functions.""" def test_check_user_permissions_all_granted(self): """Returns True when user has all permissions.""" from netbox_librenms_plugin.import_utils import check_user_permissions user = MagicMock() user.has_perm.return_value = True has_all, missing = check_user_permissions(user, ["dcim.add_device", "dcim.add_interface"]) assert has_all is True assert missing == [] assert user.has_perm.call_count == 2 def test_check_user_permissions_some_missing(self): """Returns False with list of missing permissions.""" from netbox_librenms_plugin.import_utils import check_user_permissions user = MagicMock() user.has_perm.side_effect = lambda p: p != "dcim.add_interface" has_all, missing = check_user_permissions(user, ["dcim.add_device", "dcim.add_interface"]) assert has_all is False assert missing == ["dcim.add_interface"] def test_check_user_permissions_all_missing(self): """Returns False with all permissions listed as missing.""" from netbox_librenms_plugin.import_utils import check_user_permissions user = MagicMock() user.has_perm.return_value = False has_all, missing = check_user_permissions(user, ["dcim.add_device", "dcim.add_interface"]) assert has_all is False assert "dcim.add_device" in missing assert "dcim.add_interface" in missing def test_check_user_permissions_no_user(self): """Raises PermissionDenied when user is None.""" import pytest from django.core.exceptions import PermissionDenied from netbox_librenms_plugin.import_utils import check_user_permissions with pytest.raises(PermissionDenied, match="No user context"): check_user_permissions(None, ["dcim.add_device"]) def test_require_permissions_passes_when_granted(self): """Does not raise when user has all permissions.""" from netbox_librenms_plugin.import_utils import require_permissions user = MagicMock() user.has_perm.return_value = True # Should not raise require_permissions(user, ["dcim.add_device", "dcim.add_interface"], "import devices") def test_require_permissions_raises_on_missing(self): """Raises PermissionDenied with descriptive message.""" import pytest from django.core.exceptions import PermissionDenied from netbox_librenms_plugin.import_utils import require_permissions user = MagicMock() user.has_perm.return_value = False with pytest.raises(PermissionDenied) as exc_info: require_permissions(user, ["dcim.add_device"], "import devices") # Check error message contains action description and missing permission assert "import devices" in str(exc_info.value) assert "dcim.add_device" in str(exc_info.value) def test_require_permissions_lists_multiple_missing(self): """Error message includes all missing permissions.""" import pytest from django.core.exceptions import PermissionDenied from netbox_librenms_plugin.import_utils import require_permissions user = MagicMock() user.has_perm.return_value = False with pytest.raises(PermissionDenied) as exc_info: require_permissions( user, ["dcim.add_device", "dcim.add_interface"], "import devices", ) error_msg = str(exc_info.value) assert "dcim.add_device" in error_msg assert "dcim.add_interface" in error_msg class TestNetBoxObjectPermissionMixin: """Tests for the NetBoxObjectPermissionMixin class.""" def test_check_object_permissions_all_granted(self): """Returns True when user has all object permissions.""" from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin mixin = NetBoxObjectPermissionMixin() mixin.request = MagicMock() mixin.request.user.has_perm.return_value = True mock_model = MagicMock() mixin.required_object_permissions = { "POST": [("add", mock_model), ("change", mock_model)], } with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get: mock_get.side_effect = ["dcim.add_interface", "dcim.change_interface"] has_all, missing = mixin.check_object_permissions("POST") assert has_all is True assert missing == [] def test_check_object_permissions_some_missing(self): """Returns False with missing permission strings.""" from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin mixin = NetBoxObjectPermissionMixin() mixin.request = MagicMock() mixin.request.user.has_perm.side_effect = lambda p: p != "dcim.add_interface" mock_model = MagicMock() mixin.required_object_permissions = { "POST": [("add", mock_model)], } with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get: mock_get.return_value = "dcim.add_interface" has_all, missing = mixin.check_object_permissions("POST") assert has_all is False assert "dcim.add_interface" in missing def test_check_object_permissions_no_requirements(self): """Returns True when no permissions required for method.""" from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin mixin = NetBoxObjectPermissionMixin() mixin.request = MagicMock() mixin.required_object_permissions = {} # No requirements has_all, missing = mixin.check_object_permissions("POST") assert has_all is True assert missing == [] def test_require_object_permissions_returns_none_when_granted(self): """Returns None when all permissions are granted.""" from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin mixin = NetBoxObjectPermissionMixin() mixin.request = MagicMock() mixin.request.user.has_perm.return_value = True mock_model = MagicMock() mixin.required_object_permissions = { "POST": [("add", mock_model)], } with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get: mock_get.return_value = "dcim.add_cable" response = mixin.require_object_permissions("POST") assert response is None def test_require_object_permissions_returns_redirect_response(self): """Returns redirect response with message when permissions missing.""" from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin mixin = NetBoxObjectPermissionMixin() mixin.request = MagicMock() mixin.request.user.has_perm.return_value = False mixin.request.path = "/original/page/" mixin.request.META = {"HTTP_REFERER": "/original/page/"} mixin.request.get_host.return_value = "testserver" mixin.request.is_secure.return_value = False mixin.request.headers = {} # Not an HTMX request mock_model = MagicMock() mixin.required_object_permissions = { "POST": [("add", mock_model)], } with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get: with patch("netbox_librenms_plugin.views.mixins.messages") as mock_messages: with patch("netbox_librenms_plugin.views.mixins.redirect") as mock_redirect: mock_get.return_value = "dcim.add_cable" response = mixin.require_object_permissions("POST") assert response is not None # Verify error message was added mock_messages.error.assert_called_once() error_msg = mock_messages.error.call_args[0][1] assert "dcim.add_cable" in error_msg # Verify redirect was called mock_redirect.assert_called_once_with("/original/page/") def test_require_object_permissions_htmx_returns_hx_redirect(self): """HTMX request returns HX-Redirect header when permissions missing.""" from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin mixin = NetBoxObjectPermissionMixin() mixin.request = MagicMock() mixin.request.user.has_perm.return_value = False mixin.request.path = "/original/page/" mixin.request.META = {"HTTP_REFERER": "/original/page/"} mixin.request.get_host.return_value = "testserver" mixin.request.is_secure.return_value = False mixin.request.headers = {"HX-Request": "true"} mock_model = MagicMock() mixin.required_object_permissions = { "POST": [("add", mock_model)], } with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get: with patch("netbox_librenms_plugin.views.mixins.messages"): mock_get.return_value = "dcim.add_cable" response = mixin.require_object_permissions("POST") assert response is not None assert response["HX-Redirect"] == "/original/page/" def test_require_object_permissions_json_allowed(self): """Returns None when all object permissions are granted.""" from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin mixin = NetBoxObjectPermissionMixin() mixin.request = MagicMock() mixin.request.user.has_perm.return_value = True mock_model = MagicMock() mixin.required_object_permissions = { "POST": [("delete", mock_model)], } with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get: mock_get.return_value = "dcim.delete_interface" response = mixin.require_object_permissions_json("POST") assert response is None def test_require_object_permissions_json_denied(self): """Returns JsonResponse with 403 when object permissions missing.""" import json from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin mixin = NetBoxObjectPermissionMixin() mixin.request = MagicMock() mixin.request.user.has_perm.return_value = False mock_model = MagicMock() mixin.required_object_permissions = { "POST": [("delete", mock_model)], } with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get: mock_get.return_value = "dcim.delete_interface" response = mixin.require_object_permissions_json("POST") assert response is not None assert response.status_code == 403 content = json.loads(response.content) assert "dcim.delete_interface" in content["error"] def test_require_all_permissions_allowed(self): """Returns None when both write and object permissions granted.""" from netbox_librenms_plugin.views.mixins import ( LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, ) class TestView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin): pass mixin = TestView() mixin.request = MagicMock() mixin.request.user.has_perm.return_value = True mock_model = MagicMock() mixin.required_object_permissions = { "POST": [("change", mock_model)], } with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get: mock_get.return_value = "dcim.change_device" response = mixin.require_all_permissions("POST") assert response is None def test_require_all_permissions_denied_write(self): """Returns error when write permission denied (doesn't check object perms).""" from netbox_librenms_plugin.views.mixins import ( LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, ) class TestView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin): pass mixin = TestView() mixin.request = MagicMock() mixin.request.user.has_perm.return_value = False mixin.request.path = "/original/page/" mixin.request.META = {"HTTP_REFERER": "/original/page/"} mixin.request.get_host.return_value = "testserver" mixin.request.is_secure.return_value = False mixin.request.headers = {} mixin.required_object_permissions = {"POST": []} with patch("netbox_librenms_plugin.views.mixins.redirect") as mock_redirect: with patch("netbox_librenms_plugin.views.mixins.messages"): response = mixin.require_all_permissions("POST") assert response is not None mock_redirect.assert_called_once_with("/original/page/") def test_require_all_permissions_denied_object(self): """Returns error when object permissions denied (write passes).""" from netbox_librenms_plugin.views.mixins import ( LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, ) class TestView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin): pass mixin = TestView() mixin.request = MagicMock() # has_write_permission passes, but object perms fail mixin.request.user.has_perm.side_effect = lambda p: p == "netbox_librenms_plugin.change_librenmssettings" mixin.request.path = "/original/page/" mixin.request.META = {"HTTP_REFERER": "/original/page/"} mixin.request.get_host.return_value = "testserver" mixin.request.is_secure.return_value = False mixin.request.headers = {} mock_model = MagicMock() mixin.required_object_permissions = { "POST": [("add", mock_model)], } with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get: with patch("netbox_librenms_plugin.views.mixins.messages"): with patch("netbox_librenms_plugin.views.mixins.redirect") as mock_redirect: mock_get.return_value = "dcim.add_device" response = mixin.require_all_permissions("POST") assert response is not None mock_redirect.assert_called_once() def test_require_all_permissions_json_allowed(self): """Returns None when both write and object permissions granted (JSON variant).""" from netbox_librenms_plugin.views.mixins import ( LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, ) class TestView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin): pass mixin = TestView() mixin.request = MagicMock() mixin.request.user.has_perm.return_value = True mock_model = MagicMock() mixin.required_object_permissions = { "POST": [("delete", mock_model)], } with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get: mock_get.return_value = "dcim.delete_interface" response = mixin.require_all_permissions_json("POST") assert response is None def test_require_all_permissions_json_denied_write(self): """Returns JSON 403 when write permission denied (JSON variant).""" import json from netbox_librenms_plugin.views.mixins import ( LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, ) class TestView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin): pass mixin = TestView() mixin.request = MagicMock() mixin.request.user.has_perm.return_value = False response = mixin.require_all_permissions_json("POST") assert response is not None assert response.status_code == 403 content = json.loads(response.content) assert "error" in content class TestBulkImportPermissions: """Tests for permission checks in bulk import functions.""" @patch("netbox_librenms_plugin.import_utils.bulk_import.require_permissions") @patch("netbox_librenms_plugin.import_utils.bulk_import.LibreNMSAPI") def test_bulk_import_devices_checks_permissions(self, mock_api_class, mock_require): """bulk_import_devices_shared calls require_permissions.""" from netbox_librenms_plugin.import_utils import bulk_import_devices_shared user = MagicMock() mock_api = MagicMock() mock_api_class.return_value = mock_api # Set up API to return empty device so loop completes quickly mock_api.get_device_info.return_value = (False, None) bulk_import_devices_shared( device_ids=[1], user=user, server_key="default", ) mock_require.assert_called_once() call_args = mock_require.call_args assert user == call_args[0][0] assert "dcim.add_device" in call_args[0][1] assert "dcim.change_device" in call_args[0][1] assert "dcim.add_interface" not in call_args[0][1] @patch("netbox_librenms_plugin.import_utils.bulk_import.require_permissions") @patch("netbox_librenms_plugin.import_utils.bulk_import.LibreNMSAPI") def test_bulk_import_devices_extracts_user_from_job(self, mock_api_class, mock_require): """bulk_import_devices_shared extracts user from job if not provided.""" from netbox_librenms_plugin.import_utils import bulk_import_devices_shared job_user = MagicMock() job = MagicMock() job.job.user = job_user mock_api = MagicMock() mock_api_class.return_value = mock_api mock_api.get_device_info.return_value = (False, None) bulk_import_devices_shared( device_ids=[1], job=job, server_key="default", ) mock_require.assert_called_once() call_args = mock_require.call_args assert job_user == call_args[0][0] @patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions") def test_bulk_import_vms_checks_permissions(self, mock_require): """bulk_import_vms calls require_permissions.""" from netbox_librenms_plugin.import_utils import bulk_import_vms user = MagicMock() api = MagicMock() api.server_key = "default" # Empty vm_imports to complete quickly bulk_import_vms( vm_imports={}, api=api, user=user, ) mock_require.assert_called_once() call_args = mock_require.call_args assert user == call_args[0][0] assert "virtualization.add_virtualmachine" in call_args[0][1] @patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions") def test_bulk_import_vms_extracts_user_from_job(self, mock_require): """bulk_import_vms extracts user from job if not provided.""" from netbox_librenms_plugin.import_utils import bulk_import_vms job_user = MagicMock() job = MagicMock() job.job.user = job_user api = MagicMock() api.server_key = "default" bulk_import_vms( vm_imports={}, api=api, job=job, ) mock_require.assert_called_once() call_args = mock_require.call_args assert job_user == call_args[0][0] class TestBulkImportPermissionDenied: """Tests for permission denied behavior in bulk import.""" @patch("netbox_librenms_plugin.import_utils.permissions.check_user_permissions") def test_bulk_import_devices_raises_on_missing_permissions(self, mock_check): """bulk_import_devices_shared raises PermissionDenied when permissions missing.""" import pytest from django.core.exceptions import PermissionDenied from netbox_librenms_plugin.import_utils import bulk_import_devices_shared mock_check.return_value = (False, ["dcim.add_device"]) user = MagicMock() with pytest.raises(PermissionDenied): bulk_import_devices_shared( device_ids=[1], user=user, server_key="default", ) @patch("netbox_librenms_plugin.import_utils.permissions.check_user_permissions") def test_bulk_import_vms_raises_on_missing_permissions(self, mock_check): """bulk_import_vms raises PermissionDenied when permissions missing.""" import pytest from django.core.exceptions import PermissionDenied from netbox_librenms_plugin.import_utils import bulk_import_vms mock_check.return_value = (False, ["virtualization.add_virtualmachine"]) user = MagicMock() api = MagicMock() with pytest.raises(PermissionDenied): bulk_import_vms( vm_imports={1: {"cluster_id": 1}}, api=api, user=user, ) class TestSafeRedirectUrl: """Tests for the _get_safe_redirect_url helper.""" def test_internal_referrer_is_accepted(self): """Internal referrer URL is returned when host matches.""" from netbox_librenms_plugin.views.mixins import _get_safe_redirect_url request = MagicMock() request.META = {"HTTP_REFERER": "http://testserver/some/page/"} request.get_host.return_value = "testserver" request.is_secure.return_value = False request.path = "/fallback/" result = _get_safe_redirect_url(request) assert result == "http://testserver/some/page/" def test_external_referrer_is_rejected(self): """External referrer URL is rejected, falls back to request.path.""" from netbox_librenms_plugin.views.mixins import _get_safe_redirect_url request = MagicMock() request.META = {"HTTP_REFERER": "http://evil.com/attack"} request.get_host.return_value = "testserver" request.is_secure.return_value = False request.path = "/safe/fallback/" result = _get_safe_redirect_url(request) assert result == "/safe/fallback/" def test_no_referrer_falls_back_to_path(self): """Missing referrer falls back to request.path.""" from netbox_librenms_plugin.views.mixins import _get_safe_redirect_url request = MagicMock() request.META = {} request.path = "/current/page/" result = _get_safe_redirect_url(request) assert result == "/current/page/" def test_no_referrer_no_path_falls_back_to_slash(self): """Missing referrer and no path attribute falls back to '/'.""" from netbox_librenms_plugin.views.mixins import _get_safe_redirect_url request = MagicMock(spec=[]) # No attributes at all request.META = {} result = _get_safe_redirect_url(request) assert result == "/" def test_relative_referrer_is_accepted(self): """Relative referrer path is accepted (no host to mismatch).""" from netbox_librenms_plugin.views.mixins import _get_safe_redirect_url request = MagicMock() request.META = {"HTTP_REFERER": "/original/page/"} request.get_host.return_value = "testserver" request.is_secure.return_value = False request.path = "/fallback/" result = _get_safe_redirect_url(request) assert result == "/original/page/" def test_write_permission_denied_rejects_external_referrer(self): """Write permission denial with external referrer falls back to request.path.""" from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin mixin = LibreNMSPermissionMixin() mixin.request = MagicMock() mixin.request.user.has_perm.return_value = False mixin.request.path = "/safe/page/" mixin.request.META = {"HTTP_REFERER": "http://evil.com/steal"} mixin.request.get_host.return_value = "testserver" mixin.request.is_secure.return_value = False mixin.request.headers = {} with patch("netbox_librenms_plugin.views.mixins.redirect") as mock_redirect: with patch("netbox_librenms_plugin.views.mixins.messages"): mixin.require_write_permission() mock_redirect.assert_called_once_with("/safe/page/") def test_htmx_rejects_external_referrer(self): """HTMX request with external referrer uses fallback in HX-Redirect.""" from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin mixin = LibreNMSPermissionMixin() mixin.request = MagicMock() mixin.request.user.has_perm.return_value = False mixin.request.path = "/safe/page/" mixin.request.META = {"HTTP_REFERER": "http://evil.com/steal"} mixin.request.get_host.return_value = "testserver" mixin.request.is_secure.return_value = False mixin.request.headers = {"HX-Request": "true"} with patch("netbox_librenms_plugin.views.mixins.messages"): result = mixin.require_write_permission() assert result["HX-Redirect"] == "/safe/page/" class TestBulkImportVCPermission: """Tests that bulk import checks virtualchassis permission.""" @patch("netbox_librenms_plugin.import_utils.bulk_import.require_permissions") @patch("netbox_librenms_plugin.import_utils.bulk_import.LibreNMSAPI") def test_bulk_import_devices_checks_vc_permission(self, mock_api_class, mock_require): """bulk_import_devices_shared includes dcim.add_virtualchassis in required perms.""" from netbox_librenms_plugin.import_utils import bulk_import_devices_shared user = MagicMock() mock_api = MagicMock() mock_api_class.return_value = mock_api mock_api.get_device_info.return_value = (False, None) bulk_import_devices_shared( device_ids=[1], user=user, server_key="default", ) mock_require.assert_called_once() call_args = mock_require.call_args # After Fix 6: interface/VC permissions removed from initial check assert "dcim.add_virtualchassis" not in call_args[0][1] assert "dcim.add_device" in call_args[0][1] class TestObjectTypeValidation: """Tests that get_required_permissions_for_object_type validates object_type.""" def test_sync_interfaces_device_type(self): """SyncInterfacesView returns correct perms for device type.""" from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView view = SyncInterfacesView() perms = view.get_required_permissions_for_object_type("device") assert len(perms) == 2 def test_sync_interfaces_vm_type(self): """SyncInterfacesView returns correct perms for virtualmachine type.""" from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView view = SyncInterfacesView() perms = view.get_required_permissions_for_object_type("virtualmachine") assert len(perms) == 2 def test_sync_interfaces_invalid_type_raises_404(self): """SyncInterfacesView raises Http404 for invalid object type.""" import pytest from django.http import Http404 from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView view = SyncInterfacesView() with pytest.raises(Http404): view.get_required_permissions_for_object_type("invalid") def test_delete_interfaces_device_type(self): """DeleteNetBoxInterfacesView returns correct perms for device type.""" from netbox_librenms_plugin.views.sync.interfaces import DeleteNetBoxInterfacesView view = DeleteNetBoxInterfacesView() perms = view.get_required_permissions_for_object_type("device") assert len(perms) == 1 def test_delete_interfaces_vm_type(self): """DeleteNetBoxInterfacesView returns correct perms for virtualmachine type.""" from netbox_librenms_plugin.views.sync.interfaces import DeleteNetBoxInterfacesView view = DeleteNetBoxInterfacesView() perms = view.get_required_permissions_for_object_type("virtualmachine") assert len(perms) == 1 def test_delete_interfaces_invalid_type_raises_404(self): """DeleteNetBoxInterfacesView raises Http404 for invalid object type.""" import pytest from django.http import Http404 from netbox_librenms_plugin.views.sync.interfaces import DeleteNetBoxInterfacesView view = DeleteNetBoxInterfacesView() with pytest.raises(Http404): view.get_required_permissions_for_object_type("invalid") # --------------------------------------------------------------------------- # Tests for RemoveServerMappingView error handling (device_fields.py) # --------------------------------------------------------------------------- class TestRemoveServerMappingViewErrorHandling: """Test RemoveServerMappingView handles full_clean/save failures gracefully.""" def _make_view(self, server_key, post_extra=None): """Return a (view, request) pair with permissions satisfied.""" from unittest.mock import MagicMock from netbox_librenms_plugin.views.sync.device_fields import RemoveServerMappingView request = MagicMock() request.POST = {"server_key": server_key, **(post_extra or {})} request.user = MagicMock() request.user.has_perm.return_value = True view = RemoveServerMappingView() view.request = request # required by mixin's has_write_permission return view, request def test_validation_error_returns_error_message(self): """ValidationError from full_clean leads to error message, not 500.""" from unittest.mock import MagicMock, patch from django.core.exceptions import ValidationError view, request = self._make_view(server_key="orphan-server") mock_device = MagicMock() mock_device.custom_field_data = {"librenms_id": {"orphan-server": 99}} mock_locked = MagicMock() mock_locked.custom_field_data = {"librenms_id": {"orphan-server": 99}} mock_locked.full_clean.side_effect = ValidationError("CF validation failed") plugins_cfg = {"netbox_librenms_plugin": {"servers": {}}} # orphan-server NOT configured with ( patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device), patch("netbox_librenms_plugin.views.sync.device_fields.Device") as mock_Device_cls, patch("django.conf.settings") as mock_settings, patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_messages, patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), patch("netbox_librenms_plugin.views.sync.device_fields.transaction") as mock_tx, ): mock_settings.PLUGINS_CONFIG = plugins_cfg mock_Device_cls.objects.select_for_update.return_value.get.return_value = mock_locked # Make transaction.atomic() a no-op context manager mock_tx.atomic.return_value.__enter__ = lambda s: None mock_tx.atomic.return_value.__exit__ = lambda s, *a: None mock_tx.set_rollback = MagicMock() view.post(request, pk=1) mock_messages.error.assert_called_once() error_args = mock_messages.error.call_args[0] assert "Validation error" in str(error_args[1]) or "CF validation failed" in str(error_args[1]) def test_configured_server_refused(self): """Configured server mapping cannot be removed — error message shown.""" from unittest.mock import MagicMock, patch view, request = self._make_view(server_key="active-server") mock_device = MagicMock() mock_device.custom_field_data = {"librenms_id": {"active-server": 5}} plugins_cfg = {"netbox_librenms_plugin": {"servers": {"active-server": {"librenms_url": "http://x"}}}} with ( patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device), patch("django.conf.settings") as mock_settings, patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_messages, patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), ): mock_settings.PLUGINS_CONFIG = plugins_cfg view.post(request, pk=1) mock_messages.error.assert_called_once() assert "Cannot remove" in mock_messages.error.call_args[0][1] def test_successful_removal_mutates_and_saves(self): """Successful removal deletes the key from custom_field_data and saves the device.""" from unittest.mock import MagicMock, patch view, request = self._make_view(server_key="orphan-server") mock_device = MagicMock() mock_device.custom_field_data = {"librenms_id": {"orphan-server": 42, "other-server": 7}} mock_locked = MagicMock() mock_locked.custom_field_data = {"librenms_id": {"orphan-server": 42, "other-server": 7}} mock_locked.full_clean = MagicMock() mock_locked.save = MagicMock() plugins_cfg = {"netbox_librenms_plugin": {"servers": {}}} # orphan-server NOT configured with ( patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device), patch("netbox_librenms_plugin.views.sync.device_fields.Device") as mock_Device_cls, patch("django.conf.settings") as mock_settings, patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_messages, patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), patch("netbox_librenms_plugin.views.sync.device_fields.transaction") as mock_tx, ): mock_settings.PLUGINS_CONFIG = plugins_cfg mock_Device_cls.objects.select_for_update.return_value.get.return_value = mock_locked mock_tx.atomic.return_value.__enter__ = lambda s: None mock_tx.atomic.return_value.__exit__ = lambda s, *a: None mock_tx.set_rollback = MagicMock() view.post(request, pk=1) # The "orphan-server" key should have been removed and the device saved. # Assert the exact shape of custom_field_data so misspelled keys are caught. assert mock_locked.custom_field_data == {"librenms_id": {"other-server": 7}} remaining = mock_locked.custom_field_data["librenms_id"] assert "orphan-server" not in remaining assert remaining.get("other-server") == 7 # sibling key preserved mock_locked.save.assert_called_once() mock_messages.success.assert_called_once()