first commit
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

This commit is contained in:
Vlastislav Svatek
2026-06-05 10:39:05 +02:00
commit 673e67106e
217 changed files with 76612 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Unit test package for netbox_librenms_plugin."""

View File

@@ -0,0 +1,336 @@
"""Shared pytest fixtures for NetBox LibreNMS Plugin tests."""
from unittest.mock import MagicMock, patch
import pytest
# =============================================================================
# Configuration Fixtures
# =============================================================================
@pytest.fixture
def mock_multi_server_config():
"""Multi-server configuration dict."""
return {
"default": {
"librenms_url": "https://librenms-default.example.com",
"api_token": "default-token-12345",
"cache_timeout": 300,
"verify_ssl": True,
},
"secondary": {
"librenms_url": "https://librenms-secondary.example.com",
"api_token": "secondary-token-67890",
"cache_timeout": 600,
"verify_ssl": False,
},
}
@pytest.fixture
def mock_legacy_config():
"""Legacy single-server configuration dict (flat structure)."""
return {
"librenms_url": "https://librenms.example.com",
"api_token": "legacy-token-abcdef",
"cache_timeout": 300,
"verify_ssl": True,
}
# =============================================================================
# API Instance Fixtures
# =============================================================================
@pytest.fixture
def mock_librenms_api(mock_multi_server_config):
"""Pre-configured LibreNMSAPI instance with mocked dependencies."""
with patch("netbox_librenms_plugin.librenms_api.get_plugin_config") as mock_config:
mock_config.return_value = mock_multi_server_config
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
api = LibreNMSAPI(server_key="default")
yield api
# =============================================================================
# NetBox Object Mocks (Avoid Database)
# =============================================================================
@pytest.fixture
def mock_netbox_device():
"""Mock NetBox Device object without database."""
device = MagicMock()
device.name = "test-device"
device.cf = {} # Custom fields
device.primary_ip4 = MagicMock()
device.primary_ip4.address = MagicMock()
device.primary_ip4.address.ip = "192.168.1.1"
device.primary_ip4.__str__ = lambda self: "192.168.1.1/24"
device.primary_ip6 = None
device._meta.model_name = "device"
return device
@pytest.fixture
def mock_netbox_vm():
"""Mock NetBox VirtualMachine object without database."""
vm = MagicMock()
vm.name = "test-vm"
vm.cf = {}
vm.primary_ip4 = MagicMock()
vm.primary_ip4.address = MagicMock()
vm.primary_ip4.address.ip = "10.0.0.1"
vm.primary_ip6 = None
vm._meta.model_name = "virtualmachine"
return vm
# =============================================================================
# HTTP Response Fixtures
# =============================================================================
@pytest.fixture
def mock_response_factory():
"""Factory for creating mock HTTP responses."""
def _create_response(status_code=200, json_data=None, raise_for_status=None):
response = MagicMock()
response.status_code = status_code
response.json.return_value = json_data or {}
response.ok = 200 <= status_code < 300
if raise_for_status:
response.raise_for_status.side_effect = raise_for_status
return response
return _create_response
@pytest.fixture
def mock_success_response(mock_response_factory):
"""Standard successful API response."""
return mock_response_factory(status_code=200, json_data={"status": "ok", "message": "Success"})
@pytest.fixture
def mock_device_response(mock_response_factory):
"""Mock response for device info endpoint."""
return mock_response_factory(
status_code=200,
json_data={
"status": "ok",
"devices": [
{
"device_id": 42,
"hostname": "test-device.example.com",
"sysName": "test-device",
"ip": "192.168.1.1",
"status": 1,
"location": "Data Center 1",
}
],
},
)
@pytest.fixture
def mock_error_response(mock_response_factory):
"""Standard error API response."""
return mock_response_factory(
status_code=500,
json_data={"status": "error", "message": "Internal server error"},
)
@pytest.fixture
def mock_auth_error_response(mock_response_factory):
"""Authentication error response (401)."""
return mock_response_factory(status_code=401, json_data={"status": "error", "message": "Unauthorized"})
# =============================================================================
# Phase 2: Import Utilities Fixtures
# =============================================================================
@pytest.fixture
def sample_librenms_device():
"""Sample LibreNMS device data for import tests."""
return {
"device_id": 1,
"hostname": "switch-01.example.com",
"sysName": "switch-01",
"ip": "192.168.1.1",
"location": "DC1",
"os": "ios",
"hardware": "C9300-48P",
"version": "17.3.1",
"status": 1,
}
@pytest.fixture
def sample_librenms_device_minimal():
"""Minimal LibreNMS device data with missing fields."""
return {
"device_id": 2,
"hostname": "10.0.0.1",
"status": 1,
}
@pytest.fixture
def sample_validation_state():
"""Sample validation state for testing updates."""
return {
"device_id": 1,
"hostname": "switch-01",
"is_ready": False,
"can_import": False,
"import_as_vm": False,
"existing_device": None,
"issues": ["Device role must be manually selected before import"],
"warnings": [],
"site": {
"found": True,
"site": MagicMock(id=1, name="DC1"),
"match_type": "exact",
},
"device_type": {
"found": True,
"device_type": MagicMock(id=1, model="C9300-48P"),
"match_type": "exact",
},
"device_role": {"found": False, "role": None, "available_roles": []},
"cluster": {"found": False, "cluster": None, "available_clusters": []},
"platform": {
"found": True,
"platform": MagicMock(id=1, name="ios"),
"match_type": "exact",
},
}
@pytest.fixture
def sample_validation_state_vm():
"""Sample validation state for VM import testing."""
return {
"device_id": 1,
"hostname": "vm-01",
"is_ready": False,
"can_import": False,
"import_as_vm": True,
"existing_device": None,
"issues": ["Cluster must be manually selected before import"],
"warnings": [],
"cluster": {"found": False, "cluster": None, "available_clusters": []},
"device_role": {"found": False, "role": None, "available_roles": []},
}
@pytest.fixture
def mock_netbox_site():
"""Mock NetBox Site object."""
site = MagicMock()
site.id = 1
site.name = "DC1"
site.slug = "dc1"
return site
@pytest.fixture
def mock_netbox_platform():
"""Mock NetBox Platform object."""
platform = MagicMock()
platform.id = 1
platform.name = "Cisco IOS"
platform.slug = "cisco_ios"
return platform
@pytest.fixture
def mock_netbox_device_type():
"""Mock NetBox DeviceType object."""
dt = MagicMock()
dt.id = 1
dt.model = "C9300-48P"
dt.manufacturer = MagicMock(name="Cisco")
return dt
@pytest.fixture
def mock_netbox_device_role():
"""Mock NetBox DeviceRole object."""
role = MagicMock()
role.id = 1
role.name = "Access Switch"
role.slug = "access-switch"
return role
@pytest.fixture
def mock_netbox_cluster():
"""Mock NetBox Cluster object."""
cluster = MagicMock()
cluster.id = 1
cluster.name = "VMware Cluster 1"
return cluster
@pytest.fixture
def mock_netbox_rack():
"""Mock NetBox Rack object."""
rack = MagicMock()
rack.id = 1
rack.name = "Rack A1"
rack.site = MagicMock(id=1, name="DC1")
return rack
# =============================================================================
# Server Mapping Fixtures (used by test_sync_view_mismatch.py)
# =============================================================================
@pytest.fixture
def mock_plugins_config_single_server():
"""PLUGINS_CONFIG with a single 'production' server (for _build_all_server_mappings tests)."""
return {
"netbox_librenms_plugin": {
"servers": {
"production": {
"display_name": "Production LibreNMS",
"librenms_url": "https://librenms.example.com",
},
}
}
}
@pytest.fixture
def mock_plugins_config_empty_servers():
"""PLUGINS_CONFIG with no configured servers (simulates all orphaned)."""
return {"netbox_librenms_plugin": {"servers": {}}}
@pytest.fixture
def mock_plugins_config_multi_server_mapping():
"""PLUGINS_CONFIG with 'production' and 'mock-dev' servers (for multi-server mapping tests)."""
return {
"netbox_librenms_plugin": {
"servers": {
"production": {
"display_name": "Production LibreNMS",
"librenms_url": "https://librenms.example.com",
},
"mock-dev": {
"display_name": "Mock",
"librenms_url": "http://mock.example.com",
},
}
}
}

View File

@@ -0,0 +1,262 @@
"""
Minimal HTTP mock for LibreNMS API responses.
Usage in tests (add to conftest.py or inline):
from netbox_librenms_plugin.tests.mock_librenms_server import librenms_mock_server
@pytest.fixture
def librenms_server():
with librenms_mock_server() as server:
yield server
"""
import json
import threading
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse
class _LibreNMSHandler(BaseHTTPRequestHandler):
"""Request handler that dispatches to registered route responses."""
def log_message(self, format, *args): # noqa: A002
pass # Suppress request logs in tests
def _send_json(self, status, body):
data = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _handle_request(self, method, body=None):
"""Dispatch to the registered route for this path, with optional method+query fallback."""
parsed = urlparse(self.path)
path = parsed.path
query = parsed.query
routes = self.server.routes # type: ignore[attr-defined]
# Build lookup keys: prefer method+path+query, then path+query, then path-only.
candidates = []
if query:
candidates.append(f"{method} {path}?{query}")
candidates.append(f"{path}?{query}")
candidates.append(f"{method} {path}")
candidates.append(path)
for key in candidates:
if key in routes:
entry = routes[key]
if callable(entry):
status, resp_body = entry(
method=method,
path=path,
query=parse_qs(query),
headers=dict(self.headers),
body=body,
)
else:
status, resp_body = entry
self._send_json(status, resp_body)
return
self._send_json(404, {"status": "error", "message": f"No mock for {self.path}"})
def do_GET(self):
self._handle_request("GET")
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
raw_body = self.rfile.read(length) if length else b""
try:
body = json.loads(raw_body) if raw_body else None
except json.JSONDecodeError:
body = raw_body.decode(errors="replace")
self._handle_request("POST", body=body)
class MockLibreNMSServer:
"""
Context-manager wrapper around a simple HTTP mock server.
Attributes:
url (str): Base URL for the mock server (e.g. "http://127.0.0.1:PORT").
routes (dict): Mapping of URL path → (status_code, body_dict) or callable.
Callable routes receive keyword arguments: method, path, query, headers, body
and must return (status_code, body_dict).
Routes can also be keyed as "METHOD /path" for method-specific matching,
or "/path?query" for query-specific matching.
"""
def __init__(self):
self._server = HTTPServer(("127.0.0.1", 0), _LibreNMSHandler)
self._server.routes = {}
self.routes = self._server.routes # expose on wrapper as documented
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
_, port = self._server.server_address
self.url = f"http://127.0.0.1:{port}"
def register(self, path: str, body, status: int = 200, method: str | None = None):
"""
Register a mock response for a URL path.
If *method* is given the route is stored as ``"METHOD /path"`` and only
matches requests using that HTTP verb. Omit *method* (or pass ``None``)
to match any verb on that path.
*body* may be a ``dict`` (serialised to JSON) or a callable. When a
callable is provided it is stored directly and invoked by the handler on
each matching request; the *status* argument is ignored in that case.
"""
key = f"{method} {path}" if method else path
if callable(body):
self._server.routes[key] = body
else:
self._server.routes[key] = (status, body)
def start(self):
self._thread.start()
return self
def stop(self):
self._server.shutdown()
self._server.server_close()
self._thread.join(timeout=5)
if self._thread.is_alive():
import warnings
warnings.warn(
f"MockLibreNMSServer thread {self._thread.ident} did not exit within 5 s; "
"socket may not be fully released",
ResourceWarning,
stacklevel=2,
)
# ------- default LibreNMS-shaped responses -------
def add_device_response(self, device_id: int = 1, hostname: str = "test-host"):
self.register(
"/api/v0/devices",
{"status": "ok", "id": device_id, "hostname": hostname},
method="POST",
)
def device_info_response(
self,
device_id: int = 1,
hostname: str = "test-host",
hardware: str = "WS-C3560X-24T-S",
os: str = "ios",
serial: str = "SN123",
ip: str = "192.168.1.1",
version: str = "15.2(4)E7",
features: str = "-",
location: str = "-",
):
self.register(
f"/api/v0/devices/{device_id}",
{
"status": "ok",
"devices": [
{
"device_id": device_id,
"hostname": hostname,
"hardware": hardware,
"os": os,
"serial": serial,
"sysName": hostname,
"ip": ip,
"version": version,
"features": features,
"location": location,
}
],
},
)
def ports_response(self, device_id: int = 1, ports=None):
if ports is None:
ports = [
{
"port_id": 101,
"ifName": "GigabitEthernet0/1",
"ifDescr": "GigabitEthernet0/1",
"ifType": "ethernetCsmacd",
"ifSpeed": 1_000_000_000,
"ifAdminStatus": "up",
"ifAlias": "uplink",
"ifPhysAddress": "aa:bb:cc:dd:ee:01",
"ifMtu": 1500,
"ifVlan": 1,
"ifTrunk": 0,
}
]
self.register(f"/api/v0/devices/{device_id}/ports", {"status": "ok", "ports": ports})
def auth_error_response(self, path="/api/v0/devices"):
self.register(path, {"status": "error", "message": "Authentication failed"}, status=401)
def inventory_response(self, device_id: int, items: list, status: int = 200):
"""Register a plain inventory response for /api/v0/inventory/{device_id}/all."""
payload_status = "ok" if 200 <= status < 300 else "error"
payload = (
{"status": payload_status, "inventory": items} if payload_status == "ok" else {"status": payload_status}
)
self.register(
f"/api/v0/inventory/{device_id}/all",
payload,
status=status,
method="GET",
)
def vc_inventory_callable(self, device_id: int, root_items: list, children_by_parent_index: dict):
"""
Register a callable route for VC detection two-call pattern.
detect_virtual_chassis_from_inventory() calls get_inventory_filtered() twice:
1. entPhysicalContainedIn=0 → root items
2. entPhysicalClass=chassis&entPhysicalContainedIn=<parent_index> → member chassis items
children_by_parent_index: dict mapping parent index (int) → list of chassis items
"""
root = root_items
children = children_by_parent_index
def _handler(method, path, query, headers, body):
contained_in = query.get("entPhysicalContainedIn", [None])[0]
if contained_in == "0":
return 200, {"status": "ok", "inventory": root}
if contained_in is not None:
# Require entPhysicalClass=chassis for child queries so tests catch
# any regression where the production code stops sending the class filter.
phy_class = query.get("entPhysicalClass", [None])[0]
if phy_class != "chassis":
return 200, {"status": "ok", "inventory": []}
try:
idx = int(contained_in)
except (TypeError, ValueError):
return 404, {"status": "error", "message": "bad contained_in"}
items = children.get(idx, [])
return 200, {"status": "ok", "inventory": items}
# No filter → return all (fallback for /all)
all_items = list(root)
for v in children.values():
all_items.extend(v)
return 200, {"status": "ok", "inventory": all_items}
self.register(f"/api/v0/inventory/{device_id}", _handler, method="GET")
self.register(f"/api/v0/inventory/{device_id}/all", _handler, method="GET")
@contextmanager
def librenms_mock_server():
"""Context manager that starts and stops a MockLibreNMSServer."""
server = MockLibreNMSServer()
server.start()
try:
yield server
finally:
server.stop()

View File

@@ -0,0 +1,967 @@
"""
Tests for background job implementation.
Tests the FilterDevicesJob, ImportDevicesJob, should_use_background_job logic,
job result loading, and graceful fallback behavior.
Refactored to use pure pytest without Django database dependencies.
All tests use mocking and direct attribute manipulation instead of HTTP requests.
"""
from unittest.mock import MagicMock, patch
class TestShouldUseBackgroundJob:
"""Test background job decision logic."""
def test_checkbox_checked_returns_true(self):
"""When use_background_job form field is True, return True for superusers."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
view = LibreNMSImportView()
view._filter_form_data = {"use_background_job": True}
view.request = MagicMock()
view.request.user.is_superuser = True
assert view.should_use_background_job() is True
def test_checkbox_unchecked_returns_false(self):
"""When use_background_job form field is False, return False."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
view = LibreNMSImportView()
view._filter_form_data = {"use_background_job": False}
view.request = MagicMock()
view.request.user.is_superuser = True
assert view.should_use_background_job() is False
def test_default_when_field_missing(self):
"""When field is missing, default to True for superusers."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
view = LibreNMSImportView()
view._filter_form_data = {"some_other_field": "value"}
view.request = MagicMock()
view.request.user.is_superuser = True
assert view.should_use_background_job() is True
def test_empty_form_data_returns_default(self):
"""Empty form data returns default True for superusers."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
view = LibreNMSImportView()
view._filter_form_data = {}
view.request = MagicMock()
view.request.user.is_superuser = True
assert view.should_use_background_job() is True
def test_non_superuser_always_returns_false(self):
"""Non-superuser users always get synchronous mode."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
view = LibreNMSImportView()
view._filter_form_data = {"use_background_job": True}
view.request = MagicMock()
view.request.user.is_superuser = False
# Even when checkbox is True, non-superusers get False
assert view.should_use_background_job() is False
def create_mock_job_runner(job_class, job_pk=123):
"""Create a mock job runner instance without invoking real __init__."""
# Create instance without calling __init__
job = object.__new__(job_class)
# Set up required attributes
job.job = MagicMock()
job.job.pk = job_pk
job.job.data = {}
job.logger = MagicMock()
return job
class TestFilterDevicesJob:
"""Test FilterDevicesJob background job."""
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
@patch("netbox_librenms_plugin.import_utils.process_device_filters")
def test_run_processes_filters_successfully(self, mock_process, mock_api_class):
"""Job runs and processes filters correctly."""
from netbox_librenms_plugin.jobs import FilterDevicesJob
# Setup mocks
mock_api = MagicMock()
mock_api.cache_timeout = 300
mock_api.server_key = "default"
mock_api_class.return_value = mock_api
validated_devices = [
{"device_id": 1, "hostname": "test1", "_validation": {}},
{"device_id": 2, "hostname": "test2", "_validation": {}},
]
mock_process.return_value = validated_devices
# Create job instance without calling real __init__
job = create_mock_job_runner(FilterDevicesJob)
# Run job
filters = {"location": "site1"}
job.run(
filters=filters,
vc_detection_enabled=True,
clear_cache=False,
show_disabled=False,
)
# Verify process_device_filters was called with correct args
mock_process.assert_called_once()
call_kwargs = mock_process.call_args.kwargs
assert call_kwargs["filters"] == filters
assert call_kwargs["vc_detection_enabled"] is True
assert call_kwargs["clear_cache"] is False
assert call_kwargs["job"] == job
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
@patch("netbox_librenms_plugin.import_utils.process_device_filters")
def test_run_with_vc_detection_enabled(self, mock_process, mock_api_class):
"""vc_detection_enabled=True passed to processor."""
from netbox_librenms_plugin.jobs import FilterDevicesJob
mock_api = MagicMock()
mock_api.cache_timeout = 300
mock_api_class.return_value = mock_api
mock_process.return_value = []
job = create_mock_job_runner(FilterDevicesJob)
job.run(
filters={},
vc_detection_enabled=True,
clear_cache=False,
show_disabled=False,
)
call_kwargs = mock_process.call_args.kwargs
assert call_kwargs["vc_detection_enabled"] is True
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
@patch("netbox_librenms_plugin.import_utils.process_device_filters")
def test_run_with_clear_cache(self, mock_process, mock_api_class):
"""clear_cache=True triggers cache refresh."""
from netbox_librenms_plugin.jobs import FilterDevicesJob
mock_api = MagicMock()
mock_api.cache_timeout = 300
mock_api_class.return_value = mock_api
mock_process.return_value = []
job = create_mock_job_runner(FilterDevicesJob)
job.run(
filters={},
vc_detection_enabled=False,
clear_cache=True,
show_disabled=False,
)
call_kwargs = mock_process.call_args.kwargs
assert call_kwargs["clear_cache"] is True
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
@patch("netbox_librenms_plugin.import_utils.process_device_filters")
def test_run_with_show_disabled(self, mock_process, mock_api_class):
"""show_disabled=True includes disabled devices."""
from netbox_librenms_plugin.jobs import FilterDevicesJob
mock_api = MagicMock()
mock_api.cache_timeout = 300
mock_api_class.return_value = mock_api
mock_process.return_value = []
job = create_mock_job_runner(FilterDevicesJob)
job.run(
filters={},
vc_detection_enabled=False,
clear_cache=False,
show_disabled=True,
)
call_kwargs = mock_process.call_args.kwargs
assert call_kwargs["show_disabled"] is True
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
@patch("netbox_librenms_plugin.import_utils.process_device_filters")
def test_run_with_exclude_existing(self, mock_process, mock_api_class):
"""exclude_existing=True filters out NetBox devices."""
from netbox_librenms_plugin.jobs import FilterDevicesJob
mock_api = MagicMock()
mock_api.cache_timeout = 300
mock_api_class.return_value = mock_api
mock_process.return_value = []
job = create_mock_job_runner(FilterDevicesJob)
job.run(
filters={},
vc_detection_enabled=False,
clear_cache=False,
show_disabled=False,
exclude_existing=True,
)
call_kwargs = mock_process.call_args.kwargs
assert call_kwargs["exclude_existing"] is True
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
@patch("netbox_librenms_plugin.import_utils.process_device_filters")
def test_run_with_custom_server_key(self, mock_process, mock_api_class):
"""Non-default server_key used for API."""
from netbox_librenms_plugin.jobs import FilterDevicesJob
mock_api = MagicMock()
mock_api.cache_timeout = 300
mock_api.server_key = "secondary"
mock_api_class.return_value = mock_api
mock_process.return_value = [{"device_id": 1, "hostname": "test1"}]
job = create_mock_job_runner(FilterDevicesJob)
job.run(
filters={},
vc_detection_enabled=False,
clear_cache=False,
show_disabled=False,
server_key="secondary",
)
# Verify API was initialized with correct server_key
mock_api_class.assert_called_once_with(server_key="secondary")
# Verify server_key stored in job data
assert job.job.data["server_key"] == "secondary"
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
@patch("netbox_librenms_plugin.import_utils.process_device_filters")
def test_run_stores_job_data_correctly(self, mock_process, mock_api_class):
"""Job stores expected data structure."""
from netbox_librenms_plugin.jobs import FilterDevicesJob
mock_api = MagicMock()
mock_api.cache_timeout = 300
mock_api.server_key = "secondary"
mock_api_class.return_value = mock_api
mock_process.return_value = [
{"device_id": 1, "hostname": "test1"},
{"device_id": 2, "hostname": "test2"},
]
job = create_mock_job_runner(FilterDevicesJob, job_pk=456)
job.run(
filters={"location": "dc1"},
vc_detection_enabled=True,
clear_cache=False,
show_disabled=False,
server_key="secondary",
)
# Verify job.data structure
assert job.job.data["device_ids"] == [1, 2]
assert job.job.data["total_processed"] == 2
assert job.job.data["filters"] == {"location": "dc1"}
assert job.job.data["server_key"] == "secondary"
assert job.job.data["vc_detection_enabled"] is True
assert job.job.data["cache_timeout"] == 300
assert "cached_at" in job.job.data
assert job.job.data["completed"] is True
job.job.save.assert_called()
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
@patch("netbox_librenms_plugin.import_utils.process_device_filters")
def test_run_handles_empty_results(self, mock_process, mock_api_class):
"""Empty filter results handled gracefully."""
from netbox_librenms_plugin.jobs import FilterDevicesJob
mock_api = MagicMock()
mock_api.cache_timeout = 300
mock_api_class.return_value = mock_api
mock_process.return_value = []
job = create_mock_job_runner(FilterDevicesJob, job_pk=789)
job.run(
filters={"location": "nonexistent"},
vc_detection_enabled=False,
clear_cache=False,
show_disabled=False,
)
# Verify job data shows zero devices
assert job.job.data["device_ids"] == []
assert job.job.data["total_processed"] == 0
assert job.job.data["completed"] is True
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
@patch("netbox_librenms_plugin.import_utils.process_device_filters")
def test_run_logs_progress(self, mock_process, mock_api_class):
"""Logger called with expected messages."""
from netbox_librenms_plugin.jobs import FilterDevicesJob
mock_api = MagicMock()
mock_api.cache_timeout = 300
mock_api_class.return_value = mock_api
mock_process.return_value = [{"device_id": 1, "hostname": "test1"}]
job = create_mock_job_runner(FilterDevicesJob)
job.run(
filters={"location": "site1"},
vc_detection_enabled=True,
clear_cache=False,
show_disabled=False,
)
# Verify logger was called with expected messages
assert job.logger.info.call_count >= 3
info_calls = [call[0][0] for call in job.logger.info.call_args_list]
assert any("Starting" in msg for msg in info_calls)
assert any("completed" in msg.lower() for msg in info_calls)
def test_job_meta_name(self):
"""Job has correct Meta.name."""
from netbox_librenms_plugin.jobs import FilterDevicesJob
assert FilterDevicesJob.Meta.name == "LibreNMS Device Filter"
class TestImportDevicesJob:
"""Test ImportDevicesJob background job."""
@patch("netbox_librenms_plugin.import_utils.bulk_import_vms")
@patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared")
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
def test_run_device_only_import(self, mock_api_class, mock_bulk_devices, mock_bulk_vms):
"""Import devices without VMs."""
from netbox_librenms_plugin.jobs import ImportDevicesJob
mock_api_class.return_value = MagicMock()
# Mock successful device imports
mock_device_1 = MagicMock()
mock_device_1.pk = 100
mock_device_2 = MagicMock()
mock_device_2.pk = 101
mock_bulk_devices.return_value = {
"success": [
{"device": mock_device_1, "device_id": 1},
{"device": mock_device_2, "device_id": 2},
],
"failed": [],
"skipped": [],
"virtual_chassis_created": 0,
}
job = create_mock_job_runner(ImportDevicesJob, job_pk=789)
job.run(
device_ids=[1, 2],
vm_imports={},
server_key="default",
sync_options={"sync_interfaces": True},
)
# Verify device import was called
mock_bulk_devices.assert_called_once()
# VM import should not be called with empty dict
mock_bulk_vms.assert_not_called()
# Verify job.data
assert job.job.data["imported_device_pks"] == [100, 101]
assert job.job.data["imported_vm_pks"] == []
assert job.job.data["success_count"] == 2
assert job.job.data["failed_count"] == 0
@patch("netbox_librenms_plugin.import_utils.bulk_import_vms")
@patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared")
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
def test_run_vm_only_import(self, mock_api_class, mock_bulk_devices, mock_bulk_vms):
"""Import VMs without devices."""
from netbox_librenms_plugin.jobs import ImportDevicesJob
mock_api_class.return_value = MagicMock()
# Mock successful VM imports
mock_vm_1 = MagicMock()
mock_vm_1.pk = 200
mock_vm_2 = MagicMock()
mock_vm_2.pk = 201
mock_bulk_vms.return_value = {
"success": [
{"device": mock_vm_1, "device_id": 10},
{"device": mock_vm_2, "device_id": 11},
],
"failed": [],
"skipped": [],
}
job = create_mock_job_runner(ImportDevicesJob, job_pk=790)
job.run(
device_ids=[],
vm_imports={10: {"cluster_id": 1}, 11: {"cluster_id": 1}},
server_key="default",
)
# Verify device import was not called with empty list
mock_bulk_devices.assert_not_called()
# VM import should be called
mock_bulk_vms.assert_called_once()
# Verify job.data
assert job.job.data["imported_device_pks"] == []
assert job.job.data["imported_vm_pks"] == [200, 201]
assert job.job.data["success_count"] == 2
@patch("netbox_librenms_plugin.import_utils.bulk_import_vms")
@patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared")
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
def test_run_mixed_device_and_vm_import(self, mock_api_class, mock_bulk_devices, mock_bulk_vms):
"""Import both devices and VMs."""
from netbox_librenms_plugin.jobs import ImportDevicesJob
mock_api = MagicMock()
mock_api.server_key = "non-default"
mock_api_class.return_value = mock_api
# Mock device imports
mock_device = MagicMock()
mock_device.pk = 100
mock_bulk_devices.return_value = {
"success": [{"device": mock_device, "device_id": 1}],
"failed": [],
"skipped": [],
"virtual_chassis_created": 0,
"cancelled": False,
}
# Mock VM imports
mock_vm = MagicMock()
mock_vm.pk = 200
mock_bulk_vms.return_value = {
"success": [{"device": mock_vm, "device_id": 10}],
"failed": [],
"skipped": [],
}
job = create_mock_job_runner(ImportDevicesJob, job_pk=791)
job.run(
device_ids=[1],
vm_imports={10: {"cluster_id": 1}},
server_key="non-default",
)
# Both should be called
mock_bulk_devices.assert_called_once()
mock_bulk_vms.assert_called_once()
# Verify server_key (via api.server_key) is forwarded to bulk_import_devices_shared
bulk_devices_kwargs = mock_bulk_devices.call_args[1]
assert bulk_devices_kwargs.get("server_key") == "non-default"
# Verify bulk_import_vms received the api with the correct server_key
bulk_vms_positional = mock_bulk_vms.call_args[0]
assert bulk_vms_positional[1].server_key == "non-default"
# Verify combined results
assert job.job.data["imported_device_pks"] == [100]
assert job.job.data["imported_vm_pks"] == [200]
assert job.job.data["success_count"] == 2
assert job.job.data["total"] == 2
@patch("netbox_librenms_plugin.import_utils.bulk_import_vms")
@patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared")
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
def test_run_with_sync_options(self, mock_api_class, mock_bulk_devices, mock_bulk_vms):
"""Sync options passed to bulk import."""
from netbox_librenms_plugin.jobs import ImportDevicesJob
mock_api_class.return_value = MagicMock()
mock_bulk_devices.return_value = {
"success": [],
"failed": [],
"skipped": [],
"virtual_chassis_created": 0,
}
job = create_mock_job_runner(ImportDevicesJob, job_pk=792)
sync_options = {
"sync_interfaces": True,
"sync_cables": False,
"sync_ips": True,
"use_sysname": True,
"strip_domain": True,
}
job.run(
device_ids=[1],
vm_imports={},
server_key="default",
sync_options=sync_options,
)
# Verify sync_options passed to bulk_import_devices_shared
call_kwargs = mock_bulk_devices.call_args.kwargs
assert call_kwargs["sync_options"] == sync_options
@patch("netbox_librenms_plugin.import_utils.bulk_import_vms")
@patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared")
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
def test_run_with_manual_mappings(self, mock_api_class, mock_bulk_devices, mock_bulk_vms):
"""Manual mappings passed correctly."""
from netbox_librenms_plugin.jobs import ImportDevicesJob
mock_api_class.return_value = MagicMock()
mock_bulk_devices.return_value = {
"success": [],
"failed": [],
"skipped": [],
"virtual_chassis_created": 0,
}
job = create_mock_job_runner(ImportDevicesJob, job_pk=793)
manual_mappings = {
1: {"site_id": 10, "device_role_id": 5},
2: {"site_id": 11, "device_role_id": 6},
}
job.run(
device_ids=[1, 2],
vm_imports={},
manual_mappings_per_device=manual_mappings,
)
# Verify manual_mappings passed to bulk_import_devices_shared
call_kwargs = mock_bulk_devices.call_args.kwargs
assert call_kwargs["manual_mappings_per_device"] == manual_mappings
@patch("netbox_librenms_plugin.import_utils.bulk_import_vms")
@patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared")
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
def test_run_stores_imported_pks(self, mock_api_class, mock_bulk_devices, mock_bulk_vms):
"""Imported device/VM PKs stored in job.data."""
from netbox_librenms_plugin.jobs import ImportDevicesJob
mock_api_class.return_value = MagicMock()
mock_device = MagicMock()
mock_device.pk = 100
mock_bulk_devices.return_value = {
"success": [{"device": mock_device, "device_id": 1}],
"failed": [],
"skipped": [],
"virtual_chassis_created": 0,
}
job = create_mock_job_runner(ImportDevicesJob, job_pk=794)
job.run(device_ids=[1], vm_imports={})
assert 100 in job.job.data["imported_device_pks"]
@patch("netbox_librenms_plugin.import_utils.bulk_import_vms")
@patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared")
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
def test_run_stores_libre_device_ids(self, mock_api_class, mock_bulk_devices, mock_bulk_vms):
"""LibreNMS device IDs stored for re-render."""
from netbox_librenms_plugin.jobs import ImportDevicesJob
mock_api_class.return_value = MagicMock()
mock_device = MagicMock()
mock_device.pk = 100
mock_bulk_devices.return_value = {
"success": [{"device": mock_device, "device_id": 42}],
"failed": [],
"skipped": [],
"virtual_chassis_created": 0,
}
job = create_mock_job_runner(ImportDevicesJob, job_pk=795)
job.run(device_ids=[42], vm_imports={})
assert 42 in job.job.data["imported_libre_device_ids"]
@patch("netbox_librenms_plugin.import_utils.bulk_import_vms")
@patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared")
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
def test_run_aggregates_errors(self, mock_api_class, mock_bulk_devices, mock_bulk_vms):
"""Device and VM errors are combined in job.data."""
from netbox_librenms_plugin.jobs import ImportDevicesJob
mock_api_class.return_value = MagicMock()
# Mock mixed results
mock_bulk_devices.return_value = {
"success": [],
"failed": [{"device_id": 1, "error": "Device type not found"}],
"skipped": [],
"virtual_chassis_created": 0,
}
mock_bulk_vms.return_value = {
"success": [],
"failed": [{"device_id": 10, "error": "Cluster not specified"}],
"skipped": [],
}
job = create_mock_job_runner(ImportDevicesJob, job_pk=999)
job.run(
device_ids=[1],
vm_imports={10: {"cluster": None}},
)
# Verify errors aggregated
assert len(job.job.data["errors"]) == 2
assert job.job.data["failed_count"] == 2
assert job.job.data["success_count"] == 0
@patch("netbox_librenms_plugin.import_utils.bulk_import_vms")
@patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared")
@patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI")
def test_run_handles_all_failures(self, mock_api_class, mock_bulk_devices, mock_bulk_vms):
"""All imports fail gracefully."""
from netbox_librenms_plugin.jobs import ImportDevicesJob
mock_api_class.return_value = MagicMock()
mock_bulk_devices.return_value = {
"success": [],
"failed": [
{"device_id": 1, "error": "Error 1"},
{"device_id": 2, "error": "Error 2"},
],
"skipped": [],
"virtual_chassis_created": 0,
}
job = create_mock_job_runner(ImportDevicesJob, job_pk=800)
job.run(device_ids=[1, 2], vm_imports={})
# Should complete without exception
assert job.job.data["success_count"] == 0
assert job.job.data["failed_count"] == 2
assert job.job.data["completed"] is True
job.job.save.assert_called()
def test_job_meta_name(self):
"""Job has correct Meta.name."""
from netbox_librenms_plugin.jobs import ImportDevicesJob
assert ImportDevicesJob.Meta.name == "LibreNMS Device Import"
class TestLoadJobResults:
"""Test loading results from completed background jobs."""
@patch("netbox_librenms_plugin.views.imports.list.cache")
@patch("netbox_librenms_plugin.import_utils.get_validated_device_cache_key")
@patch("core.models.Job")
def test_load_success_uses_correct_cache_keys(self, mock_job_class, mock_get_key, mock_cache):
"""Load uses get_validated_device_cache_key with job data."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
# Setup mock job
mock_job = MagicMock()
mock_job.status = "completed"
mock_job.data = {
"device_ids": [1, 2],
"filters": {"location": "dc1"},
"server_key": "primary",
"vc_detection_enabled": True,
"cached_at": "2026-01-20T10:00:00Z",
"cache_timeout": 600,
"use_sysname": True,
"strip_domain": False,
}
mock_job_class.objects.get.return_value = mock_job
# Mock cache key generation
mock_get_key.side_effect = lambda **kw: f"key_{kw['device_id']}"
# Mock cache returns
mock_cache.get.side_effect = [
{"device_id": 1, "hostname": "test1"},
{"device_id": 2, "hostname": "test2"},
]
view = LibreNMSImportView()
results = view._load_job_results(123)
# Verify cache key function called with correct params
assert mock_get_key.call_count == 2
mock_get_key.assert_any_call(
server_key="primary",
filters={"location": "dc1"},
device_id=1,
vc_enabled=True,
use_sysname=True,
strip_domain=False,
)
mock_get_key.assert_any_call(
server_key="primary",
filters={"location": "dc1"},
device_id=2,
vc_enabled=True,
use_sysname=True,
strip_domain=False,
)
assert len(results) == 2
@patch("netbox_librenms_plugin.views.imports.list.cache")
@patch("netbox_librenms_plugin.import_utils.get_validated_device_cache_key")
@patch("core.models.Job")
def test_load_extracts_filters_from_job_data(self, mock_job_class, mock_get_key, mock_cache):
"""Filters, server_key, vc_enabled extracted from job data."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
mock_job = MagicMock()
mock_job.status = "completed"
mock_job.data = {
"device_ids": [1],
"filters": {"location": "dc2", "type": "router"},
"server_key": "secondary",
"vc_detection_enabled": False,
"cached_at": "2026-01-20T10:00:00Z",
"cache_timeout": 300,
"use_sysname": True,
"strip_domain": False,
}
mock_job_class.objects.get.return_value = mock_job
mock_get_key.return_value = "test_key"
mock_cache.get.return_value = {"device_id": 1}
view = LibreNMSImportView()
view._load_job_results(456)
# Verify get_validated_device_cache_key called with extracted values
mock_get_key.assert_called_once_with(
server_key="secondary",
filters={"location": "dc2", "type": "router"},
device_id=1,
vc_enabled=False,
use_sysname=True,
strip_domain=False,
)
@patch("netbox_librenms_plugin.views.imports.list.cache")
@patch("netbox_librenms_plugin.import_utils.get_validated_device_cache_key")
@patch("core.models.Job")
def test_load_returns_cached_devices(self, mock_job_class, mock_get_key, mock_cache):
"""Devices retrieved from cache."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
mock_job = MagicMock()
mock_job.status = "completed"
mock_job.data = {
"device_ids": [1, 2],
"filters": {},
"server_key": "default",
"vc_detection_enabled": False,
"cached_at": "2026-01-20T10:00:00Z",
"cache_timeout": 300,
}
mock_job_class.objects.get.return_value = mock_job
mock_get_key.side_effect = lambda **kw: f"key_{kw['device_id']}"
mock_cache.get.side_effect = [
{"device_id": 1, "hostname": "device1"},
{"device_id": 2, "hostname": "device2"},
]
view = LibreNMSImportView()
results = view._load_job_results(789)
assert len(results) == 2
assert results[0]["hostname"] == "device1"
assert results[1]["hostname"] == "device2"
@patch("netbox_librenms_plugin.views.imports.list.cache")
@patch("netbox_librenms_plugin.import_utils.get_validated_device_cache_key")
@patch("core.models.Job")
def test_load_sets_cache_metadata(self, mock_job_class, mock_get_key, mock_cache):
"""Load sets _cache_timestamp and _cache_timeout on view."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
mock_job = MagicMock()
mock_job.status = "completed"
mock_job.data = {
"device_ids": [1],
"filters": {},
"server_key": "default",
"vc_detection_enabled": False,
"cached_at": "2026-01-20T12:00:00Z",
"cache_timeout": 900,
}
mock_job_class.objects.get.return_value = mock_job
mock_get_key.return_value = "test_key"
mock_cache.get.return_value = {"device_id": 1}
view = LibreNMSImportView()
view._load_job_results(456)
assert view._cache_timestamp == "2026-01-20T12:00:00Z"
assert view._cache_timeout == 900
@patch("core.models.Job")
def test_load_job_not_found_returns_empty(self, mock_job_class):
"""Non-existent job returns empty list."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
# Create a mock DoesNotExist exception
mock_job_class.DoesNotExist = Exception
mock_job_class.objects.get.side_effect = mock_job_class.DoesNotExist
view = LibreNMSImportView()
results = view._load_job_results(999)
assert results == []
@patch("core.models.Job")
def test_load_job_not_completed_returns_empty(self, mock_job_class):
"""Running job returns empty list."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
mock_job = MagicMock()
mock_job.status = "running"
mock_job_class.objects.get.return_value = mock_job
view = LibreNMSImportView()
results = view._load_job_results(123)
assert results == []
@patch("netbox_librenms_plugin.views.imports.list.cache")
@patch("netbox_librenms_plugin.import_utils.get_validated_device_cache_key")
@patch("core.models.Job")
def test_load_expired_cache_returns_empty(self, mock_job_class, mock_get_key, mock_cache):
"""All cache misses returns empty list."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
mock_job = MagicMock()
mock_job.status = "completed"
mock_job.data = {
"device_ids": [1, 2],
"filters": {},
"server_key": "default",
"vc_detection_enabled": False,
"cached_at": "2026-01-20T10:00:00Z",
"cache_timeout": 300,
}
mock_job_class.objects.get.return_value = mock_job
mock_get_key.side_effect = lambda **kw: f"key_{kw['device_id']}"
# Simulate expired cache (returns None)
mock_cache.get.return_value = None
view = LibreNMSImportView()
results = view._load_job_results(123)
assert results == []
@patch("netbox_librenms_plugin.views.imports.list.cache")
@patch("netbox_librenms_plugin.import_utils.get_validated_device_cache_key")
@patch("core.models.Job")
def test_load_partial_cache_returns_available(self, mock_job_class, mock_get_key, mock_cache):
"""Some expired, returns available devices."""
from netbox_librenms_plugin.views.imports.list import LibreNMSImportView
mock_job = MagicMock()
mock_job.status = "completed"
mock_job.data = {
"device_ids": [1, 2, 3],
"filters": {},
"server_key": "default",
"vc_detection_enabled": False,
"cached_at": "2026-01-20T10:00:00Z",
"cache_timeout": 300,
}
mock_job_class.objects.get.return_value = mock_job
mock_get_key.side_effect = lambda **kw: f"key_{kw['device_id']}"
# First device in cache, second expired, third in cache
mock_cache.get.side_effect = [
{"device_id": 1, "hostname": "device1"},
None, # Expired
{"device_id": 3, "hostname": "device3"},
]
view = LibreNMSImportView()
results = view._load_job_results(123)
# Should return available devices only
assert len(results) == 2
assert results[0]["device_id"] == 1
assert results[1]["device_id"] == 3
class TestGracefulFallback:
"""Test graceful fallback when RQ workers unavailable."""
@patch("netbox_librenms_plugin.views.imports.list.get_workers_for_queue")
def test_no_workers_triggers_synchronous_processing(self, mock_get_workers):
"""No RQ workers triggers synchronous fallback."""
mock_get_workers.return_value = 0
# This test verifies the condition check, not full request handling
from netbox_librenms_plugin.views.imports.list import get_workers_for_queue
workers = get_workers_for_queue("default")
assert workers == 0
# When workers == 0, the code path skips job enqueuing
# and falls through to synchronous get_queryset processing
@patch("netbox_librenms_plugin.views.imports.list.get_workers_for_queue")
def test_workers_available_allows_background_job(self, mock_get_workers):
"""Available workers allow background job enqueue."""
mock_get_workers.return_value = 2
from netbox_librenms_plugin.views.imports.list import get_workers_for_queue
workers = get_workers_for_queue("default")
assert workers > 0
# When workers > 0, the code path proceeds to FilterDevicesJob.enqueue()
@patch("netbox_librenms_plugin.views.imports.list.get_workers_for_queue")
@patch("netbox_librenms_plugin.views.imports.list.logger")
def test_fallback_logs_warning(self, mock_logger, mock_get_workers):
"""Warning logged when falling back (checked via worker count)."""
mock_get_workers.return_value = 0
# Verify the function returns 0 workers which would trigger fallback
from netbox_librenms_plugin.views.imports.list import get_workers_for_queue
workers = get_workers_for_queue("default")
assert workers == 0
# The view would log a warning when it detects no workers and falls back
# This test verifies the condition that triggers the fallback path

View File

@@ -0,0 +1,311 @@
"""
Regression tests for SingleCableVerifyView.post().
Covers:
- Stale derived fields are stripped before re-enrichment (prevents
DoesNotExist when remote objects are deleted after caching).
- LibreNMS-sourced labels are HTML-escaped to prevent XSS.
"""
import json
from unittest.mock import MagicMock, patch
def _make_view(server_key="default"):
"""Create a SingleCableVerifyView instance without database access."""
from netbox_librenms_plugin.views.base.cables_view import SingleCableVerifyView
view = object.__new__(SingleCableVerifyView)
view._librenms_api = MagicMock()
view._librenms_api.server_key = server_key
view.request = MagicMock()
return view
def _make_request(body_dict):
"""Create a mock POST request with JSON body."""
request = MagicMock()
request.method = "POST"
request.body = json.dumps(body_dict).encode()
request.META = {"HTTP_X_REQUESTED_WITH": "XMLHttpRequest"}
return request
class TestStaleFieldStripping:
"""Cached link data with stale derived fields must be stripped before use."""
def test_stale_remote_fields_stripped_before_enrichment(self):
"""Stale netbox_remote_device_id / remote_device_url must not reach check_cable_status()."""
view = _make_view()
# Cached link with stale derived fields (from a previous enrichment)
cached_link = {
"local_port": "eth0",
"local_port_id": 100,
"remote_port": "eth1",
"remote_device": "switch-remote",
"remote_port_id": 200,
"remote_device_id": 42,
# Stale derived fields — remote device was deleted after caching
"netbox_remote_device_id": 999,
"remote_device_url": "/dcim/devices/999/",
"netbox_remote_interface_id": 888,
"remote_port_url": "/dcim/interfaces/888/",
"cable_status": "No Cable",
"can_create_cable": True,
}
cached_data = {"links": [cached_link]}
device = MagicMock()
device.pk = 1
device.id = 1
device.virtual_chassis = None
interface_mock = MagicMock()
interface_mock.pk = 10
# Track what link_data check_cable_status receives
received_link_data = {}
def fake_check_cable_status(link):
received_link_data.update(link)
link["cable_status"] = "No Cable"
link["can_create_cable"] = True
return link
def fake_process_remote_device(link, hostname, device_id, server_key=None):
assert link is not None
assert hostname is not None
assert device_id is not None
assert server_key == "default"
# Simulate successful remote enrichment with fresh IDs
link["remote_device_url"] = "/dcim/devices/777/"
link["netbox_remote_device_id"] = 777
link["remote_port_url"] = "/dcim/interfaces/666/"
link["netbox_remote_interface_id"] = 666
link["remote_port_name"] = "eth1"
return link
request = _make_request({"device_id": 1, "local_port_id": 100})
with (
patch("netbox_librenms_plugin.views.base.cables_view.get_object_or_404", return_value=device),
patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache,
patch.object(view, "get_cache_key", return_value="test_key"),
patch.object(view, "check_cable_status", side_effect=fake_check_cable_status),
patch.object(view, "process_remote_device", side_effect=fake_process_remote_device),
patch("netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device", return_value=device),
patch("netbox_librenms_plugin.views.base.cables_view.get_virtual_chassis_member", return_value=device),
patch("netbox_librenms_plugin.views.base.cables_view._librenms_id_q", return_value=MagicMock()),
patch("netbox_librenms_plugin.views.base.cables_view.get_token", return_value="csrf123"),
patch("netbox_librenms_plugin.views.base.cables_view.reverse", return_value="/fake/"),
):
mock_cache.get.return_value = cached_data
# Make the interface filter return our mock
device.interfaces.filter.return_value.first.return_value = interface_mock
view.post(request)
# check_cable_status should have received fresh IDs from process_remote_device,
# NOT the stale 999/888 from cache
assert received_link_data.get("netbox_remote_device_id") == 777
assert received_link_data.get("netbox_remote_interface_id") == 666
def test_post_strips_derived_fields_from_cached_link(self):
"""post() must strip derived fields (URLs, IDs) before re-enrichment.
Both _prepare_context and post() define a _raw_keys set that controls
which cached fields survive into re-enrichment. This test verifies the
behavior: derived fields in the cached link must not leak through.
"""
view = _make_view()
# Cached link with both raw and derived (stale) fields
cached_link = {
"local_port": "eth0",
"local_port_id": 100,
"remote_port": "eth1",
"remote_device": "switch-a",
"remote_port_id": 200,
"remote_device_id": 42,
# Derived fields that must be stripped:
"netbox_local_interface_id": 999,
"netbox_remote_interface_id": 888,
"netbox_remote_device_id": 777,
"local_port_url": "/stale/",
"remote_port_url": "/stale/",
"remote_device_url": "/stale/",
"cable_status": "stale",
"can_create_cable": True,
}
# Mock process_remote_device to avoid DB access during re-enrichment;
# it should receive the link WITHOUT derived fields.
received_link = {}
def fake_process_remote(link, hostname, device_id, server_key=None):
received_link.update(link)
return link
view.process_remote_device = fake_process_remote
with (
patch("netbox_librenms_plugin.views.base.cables_view.get_object_or_404") as mock_get,
patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache,
patch("netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device", return_value=None),
patch("netbox_librenms_plugin.views.base.cables_view.get_token", return_value="tok"),
):
device = MagicMock()
device.pk = 1
device.virtual_chassis = None
device.interfaces.filter.return_value.first.return_value = None
mock_get.return_value = device
mock_cache.get.return_value = {"links": [cached_link]}
request = MagicMock()
request.body = json.dumps(
{
"device_id": 1,
"local_port_id": 100,
"server_key": "default",
}
)
view.post(request)
# The link passed to process_remote_device must have derived fields stripped
assert "netbox_local_interface_id" not in received_link
assert "netbox_remote_interface_id" not in received_link
assert "netbox_remote_device_id" not in received_link
assert "local_port_url" not in received_link
assert "cable_status" not in received_link
class TestXSSEscaping:
"""LibreNMS-sourced labels must be HTML-escaped in cable verify output."""
def test_xss_in_local_port_name_escaped(self):
"""A malicious local_port name must be escaped in the HTML output."""
view = _make_view()
xss_port_name = '<script>alert("xss")</script>'
cached_link = {
"local_port": xss_port_name,
"local_port_id": 100,
"remote_port": "eth1",
"remote_device": "safe-switch",
"remote_port_id": 200,
"remote_device_id": 42,
}
cached_data = {"links": [cached_link]}
device = MagicMock()
device.pk = 1
device.id = 1
device.virtual_chassis = None
interface_mock = MagicMock()
interface_mock.pk = 10
def fake_process_remote_device(link, hostname, device_id, server_key=None):
link["remote_device_url"] = "/dcim/devices/2/"
link["netbox_remote_device_id"] = 2
link["remote_port_url"] = "/dcim/interfaces/20/"
link["netbox_remote_interface_id"] = 20
link["remote_port_name"] = "eth1"
return link
def fake_check_cable_status(link):
link["cable_status"] = "No Cable"
link["can_create_cable"] = False
return link
request = _make_request({"device_id": 1, "local_port_id": 100})
with (
patch("netbox_librenms_plugin.views.base.cables_view.get_object_or_404", return_value=device),
patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache,
patch.object(view, "get_cache_key", return_value="test_key"),
patch.object(view, "check_cable_status", side_effect=fake_check_cable_status),
patch.object(view, "process_remote_device", side_effect=fake_process_remote_device),
patch("netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device", return_value=device),
patch("netbox_librenms_plugin.views.base.cables_view._librenms_id_q", return_value=MagicMock()),
patch("netbox_librenms_plugin.views.base.cables_view.get_token", return_value="csrf123"),
patch("netbox_librenms_plugin.views.base.cables_view.reverse", return_value="/fake/"),
):
mock_cache.get.return_value = cached_data
device.interfaces.filter.return_value.first.return_value = interface_mock
response = view.post(request)
content = json.loads(response.content)
row = content.get("formatted_row", {})
local_port_html = row.get("local_port", "")
# The raw script tag must NOT appear unescaped
assert "<script>" not in local_port_html
# The escaped version should be present
assert "&lt;script&gt;" in local_port_html
def test_xss_in_remote_device_name_escaped(self):
"""A malicious remote_device name must be escaped in the HTML output."""
view = _make_view()
xss_device = "<img src=x onerror=alert(1)>"
cached_link = {
"local_port": "eth0",
"local_port_id": 100,
"remote_port": "eth1",
"remote_device": xss_device,
"remote_port_id": 200,
"remote_device_id": 42,
}
cached_data = {"links": [cached_link]}
device = MagicMock()
device.pk = 1
device.id = 1
device.virtual_chassis = None
interface_mock = MagicMock()
interface_mock.pk = 10
def fake_process_remote_device(link, hostname, device_id, server_key=None):
# Remote device found — but name is the XSS payload
link["remote_device_url"] = "/dcim/devices/2/"
link["netbox_remote_device_id"] = 2
link["remote_port_url"] = "/dcim/interfaces/20/"
link["netbox_remote_interface_id"] = 20
link["remote_port_name"] = "eth1"
return link
def fake_check_cable_status(link):
link["cable_status"] = "No Cable"
link["can_create_cable"] = False
return link
request = _make_request({"device_id": 1, "local_port_id": 100})
with (
patch("netbox_librenms_plugin.views.base.cables_view.get_object_or_404", return_value=device),
patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache,
patch.object(view, "get_cache_key", return_value="test_key"),
patch.object(view, "check_cable_status", side_effect=fake_check_cable_status),
patch.object(view, "process_remote_device", side_effect=fake_process_remote_device),
patch("netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device", return_value=device),
patch("netbox_librenms_plugin.views.base.cables_view._librenms_id_q", return_value=MagicMock()),
patch("netbox_librenms_plugin.views.base.cables_view.get_token", return_value="csrf123"),
patch("netbox_librenms_plugin.views.base.cables_view.reverse", return_value="/fake/"),
):
mock_cache.get.return_value = cached_data
device.interfaces.filter.return_value.first.return_value = interface_mock
response = view.post(request)
content = json.loads(response.content)
row = content.get("formatted_row", {})
remote_device_html = row.get("remote_device", "")
# Raw HTML tag must not appear — angle brackets must be escaped
assert "<img " not in remote_device_html
# Escaped version should be present (browser renders as text, not tag)
assert "&lt;img" in remote_device_html

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,707 @@
"""
Coverage tests for:
- netbox_librenms_plugin/api/views.py (sync_job_status, InterfaceTypeMappingViewSet)
- netbox_librenms_plugin/filtersets.py
- netbox_librenms_plugin/models.py (lines 45, 48, 68, 76)
"""
import json
from unittest.mock import MagicMock, patch
# ===========================================================================
# Helpers
# ===========================================================================
def _make_post_request():
"""Return a minimal Django HttpRequest suitable for DRF view tests."""
from django.http import HttpRequest
request = HttpRequest()
request.method = "POST"
request.META["SERVER_NAME"] = "localhost"
request.META["SERVER_PORT"] = "80"
return request
def _call_sync_job_status(job_pk, job_patch, rq_patch=None, queue_patch=None):
"""
Call sync_job_status view, bypassing DRF auth/permission layer.
Returns the raw Django response object.
"""
from netbox_librenms_plugin.api.views import sync_job_status
request = _make_post_request()
# Bypass DRF initial() so we skip auth/permissions entirely
with patch("rest_framework.views.APIView.initial"):
with patch("netbox_librenms_plugin.api.views.Job", job_patch):
if rq_patch is not None and queue_patch is not None:
with patch("netbox_librenms_plugin.api.views.RQJob", rq_patch):
with patch("netbox_librenms_plugin.api.views.get_queue", queue_patch):
return sync_job_status(request, job_pk=job_pk)
return sync_job_status(request, job_pk=job_pk)
# ===========================================================================
# api/views.py sync_job_status
# ===========================================================================
class TestSyncJobStatusJobNotFound:
"""Test sync_job_status when the DB job does not exist."""
def test_returns_404_when_job_missing(self):
class _DoesNotExist(Exception):
pass
mock_job_cls = MagicMock()
mock_job_cls.DoesNotExist = _DoesNotExist
mock_job_cls.objects.get.side_effect = _DoesNotExist
response = _call_sync_job_status(job_pk=999, job_patch=mock_job_cls)
assert response.status_code == 404
data = json.loads(response.content)
assert data["error"] == "Job not found"
class TestSyncJobStatusRQJobActive:
"""Test sync_job_status when RQ job is still active (no update needed)."""
def test_no_change_when_rq_job_running(self):
from core.choices import JobStatusChoices
mock_db_job = MagicMock()
mock_db_job.pk = 1
mock_db_job.status = JobStatusChoices.STATUS_RUNNING
mock_db_job.completed = None
class _DoesNotExist(Exception):
pass
mock_job_cls = MagicMock()
mock_job_cls.DoesNotExist = _DoesNotExist
mock_job_cls.objects.get.return_value = mock_db_job
mock_rq_job = MagicMock()
mock_rq_job.is_stopped = False
mock_rq_job.is_failed = False
mock_rq_job.get_status.return_value = "started"
mock_rq_cls = MagicMock()
mock_rq_cls.fetch.return_value = mock_rq_job
mock_queue = MagicMock()
mock_queue_fn = MagicMock(return_value=mock_queue)
response = _call_sync_job_status(
job_pk=1,
job_patch=mock_job_cls,
rq_patch=mock_rq_cls,
queue_patch=mock_queue_fn,
)
assert response.status_code == 200
data = json.loads(response.content)
assert data["status"] == "no_change"
assert data["rq_status"] == "started"
mock_db_job.save.assert_not_called()
class TestSyncJobStatusRQJobStopped:
"""Test sync_job_status when RQ job is stopped/failed."""
def test_updates_db_when_rq_stopped_and_not_yet_completed(self):
from core.choices import JobStatusChoices
mock_db_job = MagicMock()
mock_db_job.pk = 2
mock_db_job.status = JobStatusChoices.STATUS_RUNNING
mock_db_job.completed = None
class _DoesNotExist(Exception):
pass
mock_job_cls = MagicMock()
mock_job_cls.DoesNotExist = _DoesNotExist
mock_job_cls.objects.get.return_value = mock_db_job
mock_rq_job = MagicMock()
mock_rq_job.is_stopped = True
mock_rq_job.is_failed = False
mock_rq_job.get_status.return_value = "stopped"
mock_rq_cls = MagicMock()
mock_rq_cls.fetch.return_value = mock_rq_job
mock_queue = MagicMock()
mock_queue_fn = MagicMock(return_value=mock_queue)
with patch("netbox_librenms_plugin.api.views.timezone") as mock_tz:
mock_tz.now.return_value = "2024-01-01"
response = _call_sync_job_status(
job_pk=2,
job_patch=mock_job_cls,
rq_patch=mock_rq_cls,
queue_patch=mock_queue_fn,
)
assert response.status_code == 200
data = json.loads(response.content)
assert data["status"] == "updated"
assert data["rq_status"] == "stopped"
mock_db_job.save.assert_called_once_with(update_fields=["status", "completed"])
assert mock_db_job.completed == "2024-01-01"
def test_updates_db_when_rq_failed(self):
from core.choices import JobStatusChoices
mock_db_job = MagicMock()
mock_db_job.pk = 3
mock_db_job.status = JobStatusChoices.STATUS_RUNNING
mock_db_job.completed = None
class _DoesNotExist(Exception):
pass
mock_job_cls = MagicMock()
mock_job_cls.DoesNotExist = _DoesNotExist
mock_job_cls.objects.get.return_value = mock_db_job
mock_rq_job = MagicMock()
mock_rq_job.is_stopped = False
mock_rq_job.is_failed = True
mock_rq_job.get_status.return_value = "failed"
mock_rq_cls = MagicMock()
mock_rq_cls.fetch.return_value = mock_rq_job
mock_queue = MagicMock()
mock_queue_fn = MagicMock(return_value=mock_queue)
with patch("netbox_librenms_plugin.api.views.timezone") as mock_tz:
mock_tz.now.return_value = "2024-01-02"
response = _call_sync_job_status(
job_pk=3,
job_patch=mock_job_cls,
rq_patch=mock_rq_cls,
queue_patch=mock_queue_fn,
)
assert response.status_code == 200
data = json.loads(response.content)
assert data["status"] == "updated"
assert data["rq_status"] == "failed"
assert mock_db_job.completed == "2024-01-02"
mock_db_job.save.assert_called_once_with(update_fields=["status", "completed"])
def test_does_not_overwrite_existing_completed_timestamp(self):
from core.choices import JobStatusChoices
mock_db_job = MagicMock()
mock_db_job.pk = 4
mock_db_job.status = JobStatusChoices.STATUS_RUNNING
mock_db_job.completed = "2024-01-01T10:00:00" # already set
class _DoesNotExist(Exception):
pass
mock_job_cls = MagicMock()
mock_job_cls.DoesNotExist = _DoesNotExist
mock_job_cls.objects.get.return_value = mock_db_job
mock_rq_job = MagicMock()
mock_rq_job.is_stopped = True
mock_rq_job.is_failed = False
mock_rq_job.get_status.return_value = "stopped"
mock_rq_cls = MagicMock()
mock_rq_cls.fetch.return_value = mock_rq_job
mock_queue = MagicMock()
mock_queue_fn = MagicMock(return_value=mock_queue)
with patch("netbox_librenms_plugin.api.views.timezone") as mock_tz:
response = _call_sync_job_status(
job_pk=4,
job_patch=mock_job_cls,
rq_patch=mock_rq_cls,
queue_patch=mock_queue_fn,
)
# timezone.now() should NOT have been called since completed is already set
mock_tz.now.assert_not_called()
assert response.status_code == 200
from core.choices import JobStatusChoices
assert mock_db_job.status == JobStatusChoices.STATUS_FAILED
mock_db_job.save.assert_called_once_with(update_fields=["status", "completed"])
class TestSyncJobStatusRQJobNotInQueue:
"""Test sync_job_status when RQ.fetch raises NoSuchJobError."""
def test_updates_db_to_failed_when_running_and_not_in_rq(self):
from core.choices import JobStatusChoices
from rq.exceptions import NoSuchJobError
mock_db_job = MagicMock()
mock_db_job.pk = 5
mock_db_job.status = JobStatusChoices.STATUS_RUNNING
mock_db_job.completed = None
class _DoesNotExist(Exception):
pass
mock_job_cls = MagicMock()
mock_job_cls.DoesNotExist = _DoesNotExist
mock_job_cls.objects.get.return_value = mock_db_job
mock_rq_cls = MagicMock()
mock_rq_cls.fetch.side_effect = NoSuchJobError("not found in redis")
mock_queue = MagicMock()
mock_queue_fn = MagicMock(return_value=mock_queue)
with patch("netbox_librenms_plugin.api.views.timezone") as mock_tz:
mock_tz.now.return_value = "2024-01-03"
response = _call_sync_job_status(
job_pk=5,
job_patch=mock_job_cls,
rq_patch=mock_rq_cls,
queue_patch=mock_queue_fn,
)
assert response.status_code == 200
data = json.loads(response.content)
assert data["status"] == "updated"
assert data["rq_status"] == "not_found"
mock_db_job.save.assert_called_once_with(update_fields=["status", "completed"])
def test_no_change_when_not_running_and_not_in_rq(self):
from core.choices import JobStatusChoices
from rq.exceptions import NoSuchJobError
mock_db_job = MagicMock()
mock_db_job.pk = 6
mock_db_job.status = JobStatusChoices.STATUS_COMPLETED
mock_db_job.completed = "2024-01-01"
class _DoesNotExist(Exception):
pass
mock_job_cls = MagicMock()
mock_job_cls.DoesNotExist = _DoesNotExist
mock_job_cls.objects.get.return_value = mock_db_job
mock_rq_cls = MagicMock()
mock_rq_cls.fetch.side_effect = NoSuchJobError("not found in redis")
mock_queue = MagicMock()
mock_queue_fn = MagicMock(return_value=mock_queue)
response = _call_sync_job_status(
job_pk=6,
job_patch=mock_job_cls,
rq_patch=mock_rq_cls,
queue_patch=mock_queue_fn,
)
assert response.status_code == 200
data = json.loads(response.content)
assert data["status"] == "no_change"
assert data["rq_status"] == "not_found"
mock_db_job.save.assert_not_called()
def test_does_not_overwrite_completed_when_not_in_rq(self):
from core.choices import JobStatusChoices
from rq.exceptions import NoSuchJobError
mock_db_job = MagicMock()
mock_db_job.pk = 7
mock_db_job.status = JobStatusChoices.STATUS_RUNNING
mock_db_job.completed = "2024-01-01T08:00:00" # already set
class _DoesNotExist(Exception):
pass
mock_job_cls = MagicMock()
mock_job_cls.DoesNotExist = _DoesNotExist
mock_job_cls.objects.get.return_value = mock_db_job
mock_rq_cls = MagicMock()
mock_rq_cls.fetch.side_effect = NoSuchJobError("gone")
mock_queue = MagicMock()
mock_queue_fn = MagicMock(return_value=mock_queue)
with patch("netbox_librenms_plugin.api.views.timezone") as mock_tz:
response = _call_sync_job_status(
job_pk=7,
job_patch=mock_job_cls,
rq_patch=mock_rq_cls,
queue_patch=mock_queue_fn,
)
mock_tz.now.assert_not_called()
assert response.status_code == 200
# ===========================================================================
# api/views.py InterfaceTypeMappingViewSet (class attributes)
# ===========================================================================
class TestInterfaceTypeMappingViewSet:
"""Test that InterfaceTypeMappingViewSet has expected class-level attributes."""
def test_viewset_has_correct_permission_classes(self):
from netbox_librenms_plugin.api.views import InterfaceTypeMappingViewSet, LibreNMSPluginPermission
assert LibreNMSPluginPermission in InterfaceTypeMappingViewSet.permission_classes
def test_viewset_has_serializer_class(self):
from netbox_librenms_plugin.api.views import InterfaceTypeMappingViewSet
from netbox_librenms_plugin.api.serializers import InterfaceTypeMappingSerializer
assert InterfaceTypeMappingViewSet.serializer_class is InterfaceTypeMappingSerializer
# ===========================================================================
# filtersets.py SiteLocationFilterSet
# ===========================================================================
class TestSiteLocationFilterSet:
"""Tests for SiteLocationFilterSet."""
def test_qs_returns_full_queryset_when_no_q(self):
from netbox_librenms_plugin.filtersets import SiteLocationFilterSet
mock_item = MagicMock()
queryset = [mock_item]
fs = SiteLocationFilterSet(data={}, queryset=queryset)
assert fs.qs == queryset
def test_qs_filters_when_q_provided(self):
from netbox_librenms_plugin.filtersets import SiteLocationFilterSet
matching_item = MagicMock()
matching_item.netbox_site.name = "Amsterdam"
matching_item.netbox_site.latitude = "52.37"
matching_item.netbox_site.longitude = "4.89"
matching_item.librenms_location = "AMS-DC1"
non_matching_item = MagicMock()
non_matching_item.netbox_site.name = "London"
non_matching_item.netbox_site.latitude = "51.5"
non_matching_item.netbox_site.longitude = "-0.12"
non_matching_item.librenms_location = "LON-DC1"
fs = SiteLocationFilterSet(data={"q": "amsterdam"}, queryset=[matching_item, non_matching_item])
result = fs.qs
assert len(result) == 1
assert result[0] is matching_item
def test_qs_empty_q_returns_all(self):
"""Empty string for q is falsy should return the full queryset."""
from netbox_librenms_plugin.filtersets import SiteLocationFilterSet
items = [MagicMock(), MagicMock()]
fs = SiteLocationFilterSet(data={"q": ""}, queryset=items)
assert fs.qs == items
def test_matches_by_site_name(self):
from netbox_librenms_plugin.filtersets import SiteLocationFilterSet
item = MagicMock()
item.netbox_site.name = "TestSite"
item.netbox_site.latitude = "0"
item.netbox_site.longitude = "0"
item.librenms_location = None
fs = SiteLocationFilterSet(data={"q": "testsite"}, queryset=[item])
assert fs.qs == [item]
def test_matches_by_latitude(self):
from netbox_librenms_plugin.filtersets import SiteLocationFilterSet
item = MagicMock()
item.netbox_site.name = "Nowhere"
item.netbox_site.latitude = "48.8566"
item.netbox_site.longitude = "0.0"
item.librenms_location = None
fs = SiteLocationFilterSet(data={"q": "48.8566"}, queryset=[item])
assert fs.qs == [item]
def test_matches_by_librenms_location(self):
from netbox_librenms_plugin.filtersets import SiteLocationFilterSet
item = MagicMock()
item.netbox_site.name = "X"
item.netbox_site.latitude = "0"
item.netbox_site.longitude = "0"
item.librenms_location = "Paris-DC"
fs = SiteLocationFilterSet(data={"q": "paris"}, queryset=[item])
assert fs.qs == [item]
def test_no_match_returns_empty(self):
from netbox_librenms_plugin.filtersets import SiteLocationFilterSet
item = MagicMock()
item.netbox_site.name = "Tokyo"
item.netbox_site.latitude = "35.0"
item.netbox_site.longitude = "139.0"
item.librenms_location = "TKY-1"
fs = SiteLocationFilterSet(data={"q": "berlin"}, queryset=[item])
assert fs.qs == []
def test_librenms_location_none_does_not_raise(self):
from netbox_librenms_plugin.filtersets import SiteLocationFilterSet
item = MagicMock()
item.netbox_site.name = "NoLocation"
item.netbox_site.latitude = "10"
item.netbox_site.longitude = "20"
item.librenms_location = None
fs = SiteLocationFilterSet(data={"q": "nolocation"}, queryset=[item])
# Should not raise, librenms_location treated as empty string
result = fs.qs
assert len(result) == 1
def test_form_property_returns_bound_form(self):
from netbox_librenms_plugin.filtersets import SiteLocationFilterSet
from django import forms
fs = SiteLocationFilterSet(data={"q": "test"}, queryset=[])
form = fs.form
assert isinstance(form, forms.Form)
assert form.is_bound
assert "q" in form.fields
def test_form_property_returns_unbound_form_when_no_data(self):
from netbox_librenms_plugin.filtersets import SiteLocationFilterSet
from django import forms
fs = SiteLocationFilterSet(data=None, queryset=[])
form = fs.form
assert isinstance(form, forms.Form)
assert not form.is_bound
# ===========================================================================
# filtersets.py DeviceStatusFilterSet.search()
# ===========================================================================
class TestDeviceStatusFilterSetSearch:
"""Tests for DeviceStatusFilterSet.search()."""
def test_search_empty_value_returns_queryset_unchanged(self):
from netbox_librenms_plugin.filtersets import DeviceStatusFilterSet
fs = object.__new__(DeviceStatusFilterSet)
mock_qs = MagicMock()
result = fs.search(mock_qs, "name", " ")
assert result is mock_qs
mock_qs.filter.assert_not_called()
def test_search_with_value_calls_filter(self):
from netbox_librenms_plugin.filtersets import DeviceStatusFilterSet
fs = object.__new__(DeviceStatusFilterSet)
mock_qs = MagicMock()
mock_qs.filter.return_value = mock_qs
result = fs.search(mock_qs, "name", "router01")
mock_qs.filter.assert_called_once()
assert result is mock_qs
def test_search_builds_q_filter_for_name(self):
from netbox_librenms_plugin.filtersets import DeviceStatusFilterSet
fs = object.__new__(DeviceStatusFilterSet)
mock_qs = MagicMock()
mock_qs.filter.return_value = mock_qs
fs.search(mock_qs, "name", "router")
call_args = mock_qs.filter.call_args
assert call_args is not None
q_obj = call_args[0][0]
q_str = str(q_obj)
assert "name__icontains" in q_str
assert "site__name__icontains" in q_str
assert "device_type__model__icontains" in q_str
assert "role__name__icontains" in q_str
assert "rack__name__icontains" in q_str
def test_search_whitespace_only_returns_qs(self):
from netbox_librenms_plugin.filtersets import DeviceStatusFilterSet
fs = object.__new__(DeviceStatusFilterSet)
mock_qs = MagicMock()
result = fs.search(mock_qs, "q", "\t\n")
assert result is mock_qs
# ===========================================================================
# filtersets.py VMStatusFilterSet.search()
# ===========================================================================
class TestVMStatusFilterSetSearch:
"""Tests for VMStatusFilterSet.search()."""
def test_search_empty_value_returns_queryset_unchanged(self):
from netbox_librenms_plugin.filtersets import VMStatusFilterSet
fs = object.__new__(VMStatusFilterSet)
mock_qs = MagicMock()
result = fs.search(mock_qs, "name", "")
assert result is mock_qs
mock_qs.filter.assert_not_called()
def test_search_with_value_calls_filter(self):
from netbox_librenms_plugin.filtersets import VMStatusFilterSet
fs = object.__new__(VMStatusFilterSet)
mock_qs = MagicMock()
mock_qs.filter.return_value = mock_qs
result = fs.search(mock_qs, "name", "vm-prod-01")
mock_qs.filter.assert_called_once()
assert result is mock_qs
def test_search_builds_filter_with_name_site_cluster_platform(self):
from netbox_librenms_plugin.filtersets import VMStatusFilterSet
fs = object.__new__(VMStatusFilterSet)
mock_qs = MagicMock()
mock_qs.filter.return_value = mock_qs
fs.search(mock_qs, "q", "production")
call_args = mock_qs.filter.call_args
assert call_args is not None
q_obj = call_args[0][0]
q_str = str(q_obj)
assert "name__icontains" in q_str
assert "site__name__icontains" in q_str
assert "cluster__name__icontains" in q_str
assert "platform__name__icontains" in q_str
def test_search_whitespace_only_returns_qs(self):
from netbox_librenms_plugin.filtersets import VMStatusFilterSet
fs = object.__new__(VMStatusFilterSet)
mock_qs = MagicMock()
result = fs.search(mock_qs, "q", " ")
assert result is mock_qs
# ===========================================================================
# models.py missing lines 45, 48, 68, 76
# ===========================================================================
class TestLibreNMSSettingsModel:
"""Tests for LibreNMSSettings model methods (lines 45, 48)."""
def test_get_absolute_url_calls_reverse(self):
"""Line 45: get_absolute_url() returns the settings page URL."""
from netbox_librenms_plugin.models import LibreNMSSettings
instance = object.__new__(LibreNMSSettings)
instance.selected_server = "default"
with patch("netbox_librenms_plugin.models.reverse") as mock_reverse:
mock_reverse.return_value = "/plugins/librenms/settings/"
url = instance.get_absolute_url()
mock_reverse.assert_called_once_with("plugins:netbox_librenms_plugin:settings")
assert url == "/plugins/librenms/settings/"
def test_str_returns_formatted_string(self):
"""Line 48: __str__() includes selected_server name."""
from netbox_librenms_plugin.models import LibreNMSSettings
instance = object.__new__(LibreNMSSettings)
instance.selected_server = "my_server"
result = str(instance)
assert result == "LibreNMS Settings - Server: my_server"
def test_str_with_default_server(self):
"""__str__() works with 'default' server."""
from netbox_librenms_plugin.models import LibreNMSSettings
instance = object.__new__(LibreNMSSettings)
instance.selected_server = "default"
assert str(instance) == "LibreNMS Settings - Server: default"
class TestInterfaceTypeMappingModel:
"""Tests for InterfaceTypeMapping model methods (lines 68, 76)."""
def test_get_absolute_url_calls_reverse_with_pk(self):
"""Line 68: get_absolute_url() passes self.pk to reverse."""
from netbox_librenms_plugin.models import InterfaceTypeMapping
instance = object.__new__(InterfaceTypeMapping)
instance.pk = 42
with patch("netbox_librenms_plugin.models.reverse") as mock_reverse:
mock_reverse.return_value = "/plugins/librenms/mappings/42/"
url = instance.get_absolute_url()
mock_reverse.assert_called_once_with(
"plugins:netbox_librenms_plugin:interfacetypemapping_detail",
args=[42],
)
assert url == "/plugins/librenms/mappings/42/"
def test_str_returns_type_speed_netbox_type(self):
"""Line 76: __str__() formats librenms_type + librenms_speed -> netbox_type."""
from netbox_librenms_plugin.models import InterfaceTypeMapping
instance = object.__new__(InterfaceTypeMapping)
instance.librenms_type = "ethernet"
instance.librenms_speed = 1000000
instance.netbox_type = "1000base-t"
result = str(instance)
assert result == "ethernet + 1000000 -> 1000base-t"
def test_str_with_none_speed(self):
"""__str__() works when librenms_speed is None."""
from netbox_librenms_plugin.models import InterfaceTypeMapping
instance = object.__new__(InterfaceTypeMapping)
instance.librenms_type = "fiber"
instance.librenms_speed = None
instance.netbox_type = "other"
result = str(instance)
assert result == "fiber + None -> other"
def test_get_absolute_url_with_different_pk(self):
"""get_absolute_url() works for any pk value."""
from netbox_librenms_plugin.models import InterfaceTypeMapping
instance = object.__new__(InterfaceTypeMapping)
instance.pk = 1
with patch("netbox_librenms_plugin.models.reverse") as mock_reverse:
mock_reverse.return_value = "/plugins/librenms/mappings/1/"
url = instance.get_absolute_url()
assert url == "/plugins/librenms/mappings/1/"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,320 @@
"""Coverage tests for netbox_librenms_plugin.import_utils.cache module."""
from unittest.mock import patch
class TestGetLocationChoicesCacheKey:
"""Tests for get_location_choices_cache_key (line 14)."""
def test_returns_correct_format(self):
from netbox_librenms_plugin.import_utils.cache import get_location_choices_cache_key
result = get_location_choices_cache_key("default")
assert result == "librenms_locations_choices:default"
def test_different_server_keys(self):
from netbox_librenms_plugin.import_utils.cache import get_location_choices_cache_key
assert get_location_choices_cache_key("primary") == "librenms_locations_choices:primary"
assert get_location_choices_cache_key("secondary") == "librenms_locations_choices:secondary"
class TestGetActiveCachedSearches:
"""Tests for get_active_cached_searches (lines 52-131)."""
@patch("netbox_librenms_plugin.import_utils.cache.cache")
def test_empty_cache_index_returns_empty_list(self, mock_cache):
from netbox_librenms_plugin.import_utils.cache import get_active_cached_searches
mock_cache.get.return_value = []
result = get_active_cached_searches("default")
assert result == []
@patch("netbox_librenms_plugin.import_utils.cache.cache")
def test_none_cache_index_returns_empty_list(self, mock_cache):
from netbox_librenms_plugin.import_utils.cache import get_active_cached_searches
# cache.get(cache_index_key, []) returns [] when cache misses
mock_cache.get.side_effect = lambda key, default=None: default if "cache_index" in key else None
result = get_active_cached_searches("default")
assert result == []
@patch("netbox_librenms_plugin.import_utils.cache.cache")
def test_entry_with_remaining_time_is_returned(self, mock_cache):
from datetime import datetime, timezone
from netbox_librenms_plugin.import_utils.cache import get_active_cached_searches
now = datetime.now(timezone.utc)
cached_at = now.isoformat()
def mock_get(key, default=None):
if "cache_index" in key:
return ["some_cache_key"]
if "librenms_locations_choices" in key:
return None
if key == "some_cache_key":
return {
"cache_timeout": 300,
"cached_at": cached_at,
"filters": {},
}
return default
mock_cache.get.side_effect = mock_get
result = get_active_cached_searches("default")
assert len(result) == 1
assert result[0]["remaining_seconds"] > 0
assert result[0]["cache_key"] == "some_cache_key"
assert result[0]["display_filters"] == {}
@patch("netbox_librenms_plugin.import_utils.cache.cache")
def test_expired_entry_is_cleaned_up(self, mock_cache):
from datetime import datetime, timezone
from netbox_librenms_plugin.import_utils.cache import get_active_cached_searches
# Cached at epoch (way in the past)
old_time = datetime.fromtimestamp(0, timezone.utc).isoformat()
def mock_get(key, default=None):
if "cache_index" in key:
return ["expired_key"]
if "librenms_locations_choices" in key:
return None
if key == "expired_key":
return {
"cache_timeout": 300,
"cached_at": old_time,
"filters": {},
}
return default
mock_cache.get.side_effect = mock_get
result = get_active_cached_searches("default")
# Expired entries should NOT be in results
assert result == []
# Cache index should be updated to remove expired keys
mock_cache.set.assert_called_once()
call_args = mock_cache.set.call_args
assert "cache_index" in call_args[0][0]
assert call_args[0][1] == []
@patch("netbox_librenms_plugin.import_utils.cache.cache")
def test_location_id_enriched_from_cache(self, mock_cache):
from datetime import datetime, timezone
from netbox_librenms_plugin.import_utils.cache import get_active_cached_searches
now = datetime.now(timezone.utc)
cached_at = now.isoformat()
def mock_get(key, default=None):
if "cache_index" in key:
return ["search_key"]
if key == "librenms_locations_choices:default":
return [("42", "New York DC"), ("99", "London DC")]
if key == "search_key":
return {
"cache_timeout": 300,
"cached_at": cached_at,
"filters": {"location": "42"},
}
return default
mock_cache.get.side_effect = mock_get
result = get_active_cached_searches("default")
assert len(result) == 1
assert result[0]["display_filters"]["location"] == "New York DC"
@patch("netbox_librenms_plugin.import_utils.cache.cache")
def test_type_code_enriched_to_display_name(self, mock_cache):
from datetime import datetime, timezone
from netbox_librenms_plugin.import_utils.cache import get_active_cached_searches
now = datetime.now(timezone.utc)
cached_at = now.isoformat()
def mock_get(key, default=None):
if "cache_index" in key:
return ["search_key"]
if "librenms_locations_choices" in key:
return None
if key == "search_key":
return {
"cache_timeout": 300,
"cached_at": cached_at,
"filters": {"type": "network"},
}
return default
mock_cache.get.side_effect = mock_get
result = get_active_cached_searches("default")
assert len(result) == 1
assert result[0]["display_filters"]["type"] == "Network"
@patch("netbox_librenms_plugin.import_utils.cache.cache")
def test_missing_filters_key_falls_back_to_empty_dict(self, mock_cache):
from datetime import datetime, timezone
from netbox_librenms_plugin.import_utils.cache import get_active_cached_searches
now = datetime.now(timezone.utc)
cached_at = now.isoformat()
def mock_get(key, default=None):
if "cache_index" in key:
return ["search_key"]
if "librenms_locations_choices" in key:
return None
if key == "search_key":
# No 'filters' key
return {
"cache_timeout": 300,
"cached_at": cached_at,
}
return default
mock_cache.get.side_effect = mock_get
result = get_active_cached_searches("default")
assert len(result) == 1
assert result[0]["display_filters"] == {}
@patch("netbox_librenms_plugin.import_utils.cache.cache")
def test_timezone_naive_cached_at_normalized_to_utc(self, mock_cache):
from netbox_librenms_plugin.import_utils.cache import get_active_cached_searches
# naive datetime string (no tzinfo)
naive_ts = "2099-01-01T12:00:00"
def mock_get(key, default=None):
if "cache_index" in key:
return ["search_key"]
if "librenms_locations_choices" in key:
return None
if key == "search_key":
return {
"cache_timeout": 99999999,
"cached_at": naive_ts,
"filters": {},
}
return default
mock_cache.get.side_effect = mock_get
result = get_active_cached_searches("default")
# Should not raise; remaining_seconds should be > 0
assert len(result) == 1
assert result[0]["remaining_seconds"] > 0
@patch("netbox_librenms_plugin.import_utils.cache.cache")
def test_malformed_cached_at_falls_back_to_epoch(self, mock_cache):
from netbox_librenms_plugin.import_utils.cache import get_active_cached_searches
def mock_get(key, default=None):
if "cache_index" in key:
return ["search_key"]
if "librenms_locations_choices" in key:
return None
if key == "search_key":
return {
"cache_timeout": 300,
"cached_at": "NOT_A_VALID_DATETIME",
"filters": {},
}
return default
mock_cache.get.side_effect = mock_get
# malformed cached_at → epoch → expired → empty result
result = get_active_cached_searches("default")
assert result == []
@patch("netbox_librenms_plugin.import_utils.cache.cache")
def test_metadata_none_skipped(self, mock_cache):
"""Cache key in index but metadata is None → skip."""
from netbox_librenms_plugin.import_utils.cache import get_active_cached_searches
def mock_get(key, default=None):
if "cache_index" in key:
return ["gone_key"]
if "librenms_locations_choices" in key:
return None
# metadata expired from cache
return default
mock_cache.get.side_effect = mock_get
result = get_active_cached_searches("default")
assert result == []
# Should update index to remove the gone key
mock_cache.set.assert_called_once()
@patch("netbox_librenms_plugin.import_utils.cache.cache")
def test_results_sorted_by_cached_at_most_recent_first(self, mock_cache):
from datetime import datetime, timedelta, timezone
from netbox_librenms_plugin.import_utils.cache import get_active_cached_searches
now = datetime.now(timezone.utc)
older = (now - timedelta(seconds=60)).isoformat()
newer = now.isoformat()
def mock_get(key, default=None):
if "cache_index" in key:
return ["older_key", "newer_key"]
if "librenms_locations_choices" in key:
return None
if key == "older_key":
return {"cache_timeout": 300, "cached_at": older, "filters": {}}
if key == "newer_key":
return {"cache_timeout": 300, "cached_at": newer, "filters": {}}
return default
mock_cache.get.side_effect = mock_get
result = get_active_cached_searches("default")
assert len(result) == 2
assert result[0]["cached_at"] >= result[1]["cached_at"]
class TestGetCacheMetadataKeyDeterminism:
"""Tests that get_cache_metadata_key is deterministic."""
def test_different_filter_values_produce_different_keys(self):
"""Different filter values should produce different cache keys."""
from netbox_librenms_plugin.import_utils.cache import get_cache_metadata_key
key1 = get_cache_metadata_key("default", {"location": "NYC"}, False)
key2 = get_cache_metadata_key("default", {"location": "LON"}, False)
assert key1 != key2
def test_same_filters_produce_same_key(self):
"""Same filters in any insertion order should produce the same cache key."""
from netbox_librenms_plugin.import_utils.cache import get_cache_metadata_key
key1 = get_cache_metadata_key("default", {"location": "NYC", "type": "network"}, True)
key2 = get_cache_metadata_key("default", {"type": "network", "location": "NYC"}, True)
assert key1 == key2
def test_none_values_excluded_from_hash(self):
"""None filter values should be excluded and produce same key as absent."""
from netbox_librenms_plugin.import_utils.cache import get_cache_metadata_key
key_with_none = get_cache_metadata_key("default", {"location": "NYC", "type": None}, False)
key_without = get_cache_metadata_key("default", {"location": "NYC"}, False)
assert key_with_none == key_without
def test_different_server_keys_produce_different_keys(self):
"""Different server keys should produce different cache metadata keys."""
from netbox_librenms_plugin.import_utils.cache import get_cache_metadata_key
key1 = get_cache_metadata_key("production", {"location": "NYC"}, False)
key2 = get_cache_metadata_key("staging", {"location": "NYC"}, False)
assert key1 != key2

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,768 @@
"""Coverage tests for netbox_librenms_plugin.import_utils.filters module."""
from unittest.mock import MagicMock, patch
class TestGetDeviceCountForFilters:
"""Tests for get_device_count_for_filters (line 101)."""
@patch("netbox_librenms_plugin.import_utils.filters.get_librenms_devices_for_import")
def test_returns_device_count(self, mock_get):
from netbox_librenms_plugin.import_utils.filters import get_device_count_for_filters
mock_get.return_value = [{"device_id": 1}, {"device_id": 2}]
api = MagicMock()
result = get_device_count_for_filters(api, {})
assert result == 2
@patch("netbox_librenms_plugin.import_utils.filters.get_librenms_devices_for_import")
def test_excludes_disabled_when_show_disabled_false(self, mock_get):
from netbox_librenms_plugin.import_utils.filters import get_device_count_for_filters
mock_get.return_value = [
{"device_id": 1, "disabled": 0},
{"device_id": 2, "disabled": 1},
]
api = MagicMock()
result = get_device_count_for_filters(api, {}, show_disabled=False)
assert result == 1
@patch("netbox_librenms_plugin.import_utils.filters.get_librenms_devices_for_import")
def test_includes_disabled_when_show_disabled_true(self, mock_get):
from netbox_librenms_plugin.import_utils.filters import get_device_count_for_filters
mock_get.return_value = [
{"device_id": 1, "disabled": 0},
{"device_id": 2, "disabled": 1},
]
api = MagicMock()
result = get_device_count_for_filters(api, {}, show_disabled=True)
assert result == 2
@patch("netbox_librenms_plugin.import_utils.filters.get_librenms_devices_for_import")
def test_passes_force_refresh_as_force_refresh(self, mock_get):
from netbox_librenms_plugin.import_utils.filters import get_device_count_for_filters
mock_get.return_value = []
api = MagicMock()
get_device_count_for_filters(api, {}, clear_cache=True)
mock_get.assert_called_once_with(api, filters={}, force_refresh=True)
class TestGetLibreNMSDevicesForImport:
"""Tests for get_librenms_devices_for_import (lines 112-244)."""
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_status_filter_up(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (True, [{"device_id": 1}])
get_librenms_devices_for_import(api, filters={"status": "1"})
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "up"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_status_filter_down(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (True, [{"device_id": 1}])
get_librenms_devices_for_import(api, filters={"status": "0"})
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "down"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_location_filter_goes_to_api(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (True, [])
get_librenms_devices_for_import(api, filters={"location": "10"})
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "location_id"
assert call_args["query"] == "10"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_type_filter_goes_to_api(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (True, [])
get_librenms_devices_for_import(api, filters={"type": "network"})
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "type"
assert call_args["query"] == "network"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_os_filter_goes_to_api(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (True, [])
get_librenms_devices_for_import(api, filters={"os": "ios"})
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "os"
assert call_args["query"] == "ios"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_hostname_filter_goes_to_api(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (True, [])
get_librenms_devices_for_import(api, filters={"hostname": "router1"})
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "hostname"
assert call_args["query"] == "router1"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_sysname_filter_goes_to_api(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (True, [])
get_librenms_devices_for_import(api, filters={"sysname": "core-sw"})
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "sysName"
assert call_args["query"] == "core-sw"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_hardware_filter_goes_to_client_side(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (
True,
[
{"hardware": "Cisco C9300", "device_id": 1},
{"hardware": "Other Device", "device_id": 2},
],
)
result = get_librenms_devices_for_import(api, filters={"hardware": "C9300"})
# API gets no filters
api.list_devices.assert_called_once_with(None)
# Only the matching device survives client-side filtering
assert len(result) == 1
assert result[0]["device_id"] == 1
assert 2 not in [d["device_id"] for d in result]
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_location_plus_type_location_to_api_type_to_client(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
devices = [
{"device_id": 1, "type": "network", "location_id": 10},
{"device_id": 2, "type": "server", "location_id": 10},
]
api.list_devices.return_value = (True, devices)
result = get_librenms_devices_for_import(api, filters={"location": "10", "type": "network"})
call_args = api.list_devices.call_args[0][0]
# location goes to API
assert call_args["type"] == "location_id"
# only the matching device survives client-side type filter
assert len(result) == 1
assert result[0]["device_id"] == 1
assert 2 not in [d["device_id"] for d in result]
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_force_refresh_deletes_cache(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (True, [])
get_librenms_devices_for_import(api, filters={}, force_refresh=True)
mock_cache.delete.assert_called_once()
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_cache_hit_returns_early_with_from_cache_true(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
cached_devices = [{"device_id": 1}]
mock_cache.get.return_value = cached_devices
api = MagicMock()
api.server_key = "default"
result, from_cache = get_librenms_devices_for_import(api, filters={}, return_cache_status=True)
assert from_cache is True
assert result == cached_devices
api.list_devices.assert_not_called()
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_api_failure_returns_empty_list(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (False, "Connection error")
result = get_librenms_devices_for_import(api, filters={})
assert result == []
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_api_failure_with_return_cache_status(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (False, "Connection error")
result, from_cache = get_librenms_devices_for_import(api, filters={}, return_cache_status=True)
assert result == []
assert from_cache is False
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_exception_returns_empty_list(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.list_devices.side_effect = RuntimeError("Unexpected error")
result = get_librenms_devices_for_import(api, filters={})
assert result == []
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_exception_with_return_cache_status(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.list_devices.side_effect = RuntimeError("Unexpected error")
result, from_cache = get_librenms_devices_for_import(api, filters={}, return_cache_status=True)
assert result == []
assert from_cache is False
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_success_caches_result(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
devices = [{"device_id": 1}]
api.list_devices.return_value = (True, devices)
get_librenms_devices_for_import(api, filters={})
mock_cache.set.assert_called_once()
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_creates_api_when_none_provided(self, mock_cache):
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
mock_api_instance = MagicMock()
mock_api_instance.server_key = "default"
mock_api_instance.cache_timeout = 300
mock_api_instance.list_devices.return_value = (True, [])
with patch("netbox_librenms_plugin.import_utils.filters.LibreNMSAPI") as MockAPI:
MockAPI.return_value = mock_api_instance
get_librenms_devices_for_import(server_key="default")
MockAPI.assert_called_once_with(server_key="default")
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_status_with_other_filters_go_to_client(self, mock_cache):
"""When status is set, all other filters go client-side."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
devices = [{"device_id": 1, "type": "server", "location_id": 5}]
api.list_devices.return_value = (True, devices)
get_librenms_devices_for_import(api, filters={"status": "1", "location": "5", "type": "server"})
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "up"
class TestApplyClientFilters:
"""Tests for _apply_client_filters (lines 258-284)."""
def test_filter_by_location(self):
from netbox_librenms_plugin.import_utils.filters import _apply_client_filters
devices = [
{"device_id": 1, "location_id": 10},
{"device_id": 2, "location_id": 20},
]
result = _apply_client_filters(devices, {"location": "10"})
assert len(result) == 1
assert result[0]["device_id"] == 1
def test_filter_by_type(self):
from netbox_librenms_plugin.import_utils.filters import _apply_client_filters
devices = [
{"device_id": 1, "type": "network"},
{"device_id": 2, "type": "server"},
]
result = _apply_client_filters(devices, {"type": "network"})
assert len(result) == 1
assert result[0]["device_id"] == 1
def test_filter_by_os(self):
from netbox_librenms_plugin.import_utils.filters import _apply_client_filters
devices = [
{"device_id": 1, "os": "ios"},
{"device_id": 2, "os": "linux"},
]
result = _apply_client_filters(devices, {"os": "ios"})
assert len(result) == 1
def test_filter_by_hostname(self):
from netbox_librenms_plugin.import_utils.filters import _apply_client_filters
devices = [
{"device_id": 1, "hostname": "router01.example.com"},
{"device_id": 2, "hostname": "switch01.example.com"},
]
result = _apply_client_filters(devices, {"hostname": "router"})
assert len(result) == 1
assert result[0]["device_id"] == 1
def test_filter_by_sysname(self):
from netbox_librenms_plugin.import_utils.filters import _apply_client_filters
devices = [
{"device_id": 1, "sysName": "core-router"},
{"device_id": 2, "sysName": "access-switch"},
]
result = _apply_client_filters(devices, {"sysname": "core"})
assert len(result) == 1
def test_filter_by_hardware(self):
from netbox_librenms_plugin.import_utils.filters import _apply_client_filters
devices = [
{"device_id": 1, "hardware": "Cisco C9300-48P"},
{"device_id": 2, "hardware": "Juniper MX480"},
]
result = _apply_client_filters(devices, {"hardware": "C9300"})
assert len(result) == 1
def test_hardware_none_value_handled(self):
from netbox_librenms_plugin.import_utils.filters import _apply_client_filters
devices = [
{"device_id": 1, "hardware": None},
{"device_id": 2, "hardware": "Cisco C9300"},
]
result = _apply_client_filters(devices, {"hardware": "C9300"})
assert len(result) == 1
assert result[0]["device_id"] == 2
def test_no_filters_returns_all(self):
from netbox_librenms_plugin.import_utils.filters import _apply_client_filters
devices = [{"device_id": 1}, {"device_id": 2}]
result = _apply_client_filters(devices, {})
assert len(result) == 2
class TestGetLibreNMSDevicesMoreCoverage:
"""More tests for missing filter branches."""
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_status_invalid_string_sets_none(self, mock_cache):
"""Lines 116-117: ValueError/TypeError when status is not a valid int."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (True, [{"device_id": 1}])
result = get_librenms_devices_for_import(api, filters={"status": "invalid_value"})
assert isinstance(result, list)
# Invalid status means api.list_devices is called with None (no API type filter)
api.list_devices.assert_called_once_with(None)
# The single device returned from the API is passed through unchanged
assert len(result) == 1
assert result[0]["device_id"] == 1
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_status_with_all_other_filters_go_to_client(self, mock_cache):
"""Lines 130-136: When status set, all filters (loc/type/os/hostname/sysname/hw) go client-side."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (
True,
[
{
"device_id": 1,
"type": "server",
"location_id": 5,
"os": "linux",
"hostname": "srv01",
"sysName": "srv01",
"hardware": "Dell",
},
{
"device_id": 2,
"type": "other",
"location_id": 99,
"os": "windows",
"hostname": "othersrv",
"sysName": "othersrv",
"hardware": "HP",
},
],
)
result = get_librenms_devices_for_import(
api,
filters={
"status": "1",
"location": "5",
"type": "server",
"os": "linux",
"hostname": "srv01",
"sysname": "srv01",
"hardware": "Dell",
},
)
assert isinstance(result, list)
# The matching device should be present, but the non-matching device should not
device_ids = [d["device_id"] for d in result]
assert 1 in device_ids
assert len(result) == 1
assert 2 not in device_ids
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_location_with_remaining_client_filters(self, mock_cache):
"""Lines 150-156: location API filter with type/os/hostname/sysname/hardware as client filters."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (
True,
[
{
"device_id": 1,
"type": "network",
"os": "ios",
"hostname": "router01",
"sysName": "router01",
"hardware": "Cisco",
"location_id": "5",
},
{
"device_id": 2,
"type": "network",
"os": "ios",
"hostname": "switch99",
"sysName": "switch99",
"hardware": "Cisco",
"location_id": "5",
},
],
)
result = get_librenms_devices_for_import(
api,
filters={
"location": "5",
"type": "network",
"os": "ios",
"hostname": "router01",
"sysname": "router01",
"hardware": "Cisco",
},
)
assert len(result) == 1
assert result[0]["device_id"] == 1
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "location_id"
assert call_args["query"] == "5"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_type_filter_with_remaining_client_filters(self, mock_cache):
"""Lines 162-168: type API filter with os/hostname/sysname/hardware as client filters."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (
True,
[
{
"device_id": 1,
"type": "network",
"os": "ios",
"hostname": "router01",
"sysName": "router01",
"hardware": "Cisco",
},
],
)
get_librenms_devices_for_import(
api,
filters={
"type": "network",
"os": "ios",
"hostname": "router01",
"sysname": "router01",
"hardware": "Cisco",
},
)
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "type"
assert call_args["query"] == "network"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_os_filter_with_remaining_client_filters(self, mock_cache):
"""Lines 174-178: os API filter with hostname/sysname/hardware as client filters."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (
True,
[
{"device_id": 1, "os": "ios", "hostname": "router01", "sysName": "router01", "hardware": "Cisco"},
],
)
get_librenms_devices_for_import(
api,
filters={
"os": "ios",
"hostname": "router01",
"sysname": "router01",
"hardware": "Cisco",
},
)
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "os"
assert call_args["query"] == "ios"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_hostname_filter_with_sysname_and_hardware(self, mock_cache):
"""Lines 184-186: hostname API filter with sysname/hardware as client filters."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (
True,
[
{"device_id": 1, "hostname": "router01", "sysName": "router01", "hardware": "Cisco"},
],
)
get_librenms_devices_for_import(
api,
filters={
"hostname": "router01",
"sysname": "router01",
"hardware": "Cisco",
},
)
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "hostname"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_sysname_filter_with_hardware(self, mock_cache):
"""Line 194: sysname API filter with hardware as client filter."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (
True,
[
{"device_id": 1, "sysName": "router01", "hardware": "Cisco"},
],
)
get_librenms_devices_for_import(
api,
filters={
"sysname": "router01",
"hardware": "Cisco",
},
)
call_args = api.list_devices.call_args[0][0]
assert call_args["type"] == "sysName"
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_client_filters_applied_to_results(self, mock_cache):
"""Line 237: _apply_client_filters is called when client_filters is set."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
# Two devices, one matches hardware filter, one doesn't
api.list_devices.return_value = (
True,
[
{"device_id": 1, "hardware": "Cisco C9300", "location_id": 5},
{"device_id": 2, "hardware": "Juniper MX480", "location_id": 5},
],
)
result = get_librenms_devices_for_import(
api,
filters={
"location": "5",
"hardware": "C9300", # Goes to client_filters
},
)
# Should only return the Cisco device after client filtering
assert len(result) == 1
assert result[0]["device_id"] == 1
class TestGetLibreNMSReturnCacheStatus:
"""Tests for return_cache_status path (line 237)."""
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_return_cache_status_with_fresh_data(self, mock_cache):
"""Line 237: return devices, from_cache when return_cache_status=True."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (True, [{"device_id": 1}])
result = get_librenms_devices_for_import(api, return_cache_status=True)
assert isinstance(result, tuple)
devices, from_cache = result
assert from_cache is False
assert len(devices) == 1
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_return_cache_status_with_cached_data(self, mock_cache):
"""Line 218: return devices, from_cache when cache hit + return_cache_status=True."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
cached_devices = [{"device_id": 1}]
mock_cache.get.return_value = cached_devices
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
result = get_librenms_devices_for_import(api, return_cache_status=True)
assert isinstance(result, tuple)
devices, from_cache = result
assert from_cache is True
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_api_failure_with_return_cache_status(self, mock_cache):
"""Line 225: return [], False when API fails and return_cache_status=True."""
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api = MagicMock()
api.server_key = "default"
api.cache_timeout = 300
api.list_devices.return_value = (False, "Error")
result = get_librenms_devices_for_import(api, return_cache_status=True)
assert isinstance(result, tuple)
devices, from_cache = result
assert devices == []
assert from_cache is False
class TestCacheKeyServerKeyIsolation:
"""Test that cache keys are isolated per server key (Thread 38)."""
@patch("netbox_librenms_plugin.import_utils.filters.cache")
def test_cache_key_uses_api_server_key(self, mock_cache):
"""Different server_keys produce different cache keys."""
from unittest.mock import MagicMock
from netbox_librenms_plugin.import_utils.filters import get_librenms_devices_for_import
mock_cache.get.return_value = None
api1 = MagicMock()
api1.server_key = "server1"
api1.cache_timeout = 300
api2 = MagicMock()
api2.server_key = "server2"
api2.cache_timeout = 300
api1.list_devices.return_value = (True, [])
api2.list_devices.return_value = (True, [])
get_librenms_devices_for_import(api1, filters={})
get_librenms_devices_for_import(api2, filters={})
assert mock_cache.set.call_count == 2
keys = [call.args[0] for call in mock_cache.set.call_args_list]
assert keys[0] != keys[1]

View File

@@ -0,0 +1,61 @@
"""Coverage tests for netbox_librenms_plugin.forms — has_option_only empty-data guard."""
from unittest.mock import patch
class TestLibreNMSFilterFormBackgroundJobDefault:
"""Tests for use_background_job default injection in LibreNMSImportFilterForm.__init__.
The form checks args[0] (positional) for the data dict, matching how Django
binds forms from request.GET/POST. Tests pass data positionally to match.
"""
def _make_form(self, data):
"""Instantiate LibreNMSImportFilterForm with mocked server settings.
Pass data as positional arg to match how Django provides request.GET.
"""
with (
patch("netbox_librenms_plugin.forms.LibreNMSImportFilterForm._populate_librenms_locations"),
):
from netbox_librenms_plugin.forms import LibreNMSImportFilterForm
# Pass data positionally — the form's __init__ checks args[0], not kwargs
return LibreNMSImportFilterForm(data)
def test_empty_data_sets_use_background_job_on(self):
"""LibreNMSImportFilterForm({}) should set use_background_job='on' (initial GET)."""
form = self._make_form({})
assert form.data.get("use_background_job") == "on"
def test_option_only_data_does_not_auto_set_background_job(self):
"""Submitting only option-only fields should NOT auto-set use_background_job."""
# show_disabled is an option-only field — not a real filter field
# has_option_only = bool({show_disabled}) and not non_option_fields and not has_filters
# = True and True and True = True → condition fails → use_background_job NOT injected
form = self._make_form({"show_disabled": "on"})
assert form.data.get("use_background_job") is None
def test_filter_data_does_not_auto_set_background_job(self):
"""When real filter fields are submitted, use_background_job is not auto-injected."""
form = self._make_form({"librenms_hostname": "switch01"})
assert form.data.get("use_background_job") is None
def test_use_background_job_preserved_when_already_set(self):
"""If use_background_job is already in data, it should not be overridden."""
form = self._make_form({"use_background_job": "off"})
assert form.data.get("use_background_job") == "off"
def test_no_positional_args_no_injection(self):
"""Unbound form (no positional args) should not inject use_background_job."""
with (
patch("netbox_librenms_plugin.forms.LibreNMSImportFilterForm._populate_librenms_locations"),
):
from netbox_librenms_plugin.forms import LibreNMSImportFilterForm
form = LibreNMSImportFilterForm()
assert form.data.get("use_background_job") is None
def test_pagination_param_does_not_inject_background_job(self):
"""Auxiliary params like 'page' must not trigger background-job default injection."""
form = self._make_form({"page": "2"})
assert form.data.get("use_background_job") is None

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,692 @@
"""Coverage tests for views/base/librenms_sync_view.py missing lines."""
from unittest.mock import MagicMock, patch
def _make_view():
"""Create a BaseLibreNMSSyncView instance bypassing __init__."""
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
view = object.__new__(BaseLibreNMSSyncView)
view.request = MagicMock()
view.tab = "librenms_sync"
view.model = MagicMock()
view.queryset = MagicMock()
view.kwargs = {}
view._librenms_api = MagicMock()
view._librenms_api.server_key = "default"
view._librenms_api.librenms_url = "https://x.example.com"
view._librenms_api.cache_timeout = 300
return view
class TestBaseLibreNMSSyncViewGet:
"""Tests for get() method (lines 29-53)."""
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.render")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_object_or_404")
def test_get_non_vc_device(self, mock_get_obj, mock_render):
"""Non-VC device: librenms_lookup_device stays as obj."""
view = _make_view()
obj = MagicMock()
obj.virtual_chassis = None
mock_get_obj.return_value = obj
view._librenms_api = MagicMock()
view._librenms_api.server_key = "default"
view._librenms_api.get_librenms_id.return_value = 42
view.get_context_data = MagicMock(return_value={"test": "ctx"})
mock_render.return_value = MagicMock()
request = MagicMock()
view.get(request, pk=1)
# lookup device should be obj
assert view._librenms_lookup_device is obj
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.render")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_object_or_404")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_librenms_sync_device")
def test_get_vc_member_always_delegates_to_sync_device(self, mock_get_sync, mock_get_obj, mock_render):
"""VC member: no own librenms_id - get_librenms_sync_device returns VC primary."""
view = _make_view()
obj = MagicMock()
obj.virtual_chassis = MagicMock()
mock_get_obj.return_value = obj
vc_primary = MagicMock() # Represents the VC primary device
mock_get_sync.return_value = vc_primary
view._librenms_api = MagicMock()
view._librenms_api.server_key = "default"
view._librenms_api.get_librenms_id.return_value = 99
view.get_context_data = MagicMock(return_value={})
mock_render.return_value = MagicMock()
request = MagicMock()
view.get(request, pk=1)
mock_get_sync.assert_called_once_with(obj, server_key="default")
# When member has no own ID, lookup uses the VC primary returned by get_librenms_sync_device
assert view._librenms_lookup_device is vc_primary
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.render")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_object_or_404")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_librenms_sync_device")
def test_get_vc_member_with_own_librenms_id_uses_itself(self, mock_get_sync, mock_get_obj, mock_render):
"""VC member: has own librenms_id - get_librenms_sync_device still called, returns member itself."""
view = _make_view()
obj = MagicMock()
obj.virtual_chassis = MagicMock()
mock_get_obj.return_value = obj
# get_librenms_sync_device returns obj itself (member has own librenms_id, priority 1)
mock_get_sync.return_value = obj
view._librenms_api = MagicMock()
view._librenms_api.server_key = "default"
view._librenms_api.get_librenms_id.return_value = 55
view.get_context_data = MagicMock(return_value={})
mock_render.return_value = MagicMock()
request = MagicMock()
view.get(request, pk=1)
mock_get_sync.assert_called_once_with(obj, server_key="default")
# When member has its own ID, get_librenms_sync_device returns the member itself
assert view._librenms_lookup_device is obj
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.render")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_object_or_404")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_librenms_sync_device")
def test_get_vc_member_no_sync_device_falls_back_to_obj(self, mock_get_sync, mock_get_obj, mock_render):
"""VC member: when get_librenms_sync_device returns None, keeps obj."""
view = _make_view()
obj = MagicMock()
obj.virtual_chassis = MagicMock()
mock_get_obj.return_value = obj
mock_get_sync.return_value = None
view._librenms_api = MagicMock()
view._librenms_api.server_key = "default"
view._librenms_api.get_librenms_id.return_value = 55
view.get_context_data = MagicMock(return_value={})
mock_render.return_value = MagicMock()
request = MagicMock()
view.get(request, pk=1)
mock_get_sync.assert_called_once_with(obj, server_key="default")
assert view._librenms_lookup_device is obj
class TestGetContextDataVC:
"""Tests for get_context_data() VC context (lines 69-91)."""
def test_vc_context_sync_device_has_id_and_ip(self):
"""VC device: sync_device_has_librenms_id and sync_device_has_primary_ip set."""
view = _make_view()
view.librenms_id = 42
view._librenms_lookup_device = MagicMock()
obj = MagicMock()
obj.virtual_chassis = MagicMock()
obj._meta = MagicMock()
obj._meta.model_name = "device"
sync_device = MagicMock()
sync_device.primary_ip = MagicMock()
sync_device._meta.model_name = "device"
sync_device.pk = 10
view._librenms_api = MagicMock()
view._librenms_api.server_key = "default"
view._librenms_api.librenms_url = "https://x.example.com"
view.get_librenms_device_info = MagicMock(
return_value={
"found_in_librenms": True,
"librenms_device_details": {
"librenms_device_serial": "SN001",
"librenms_device_hardware": "Cisco",
"librenms_device_os": "ios",
"librenms_device_version": "16.9",
"librenms_device_features": "-",
"librenms_device_location": "NYC",
"librenms_device_hardware_match": None,
"vc_inventory_serials": [],
},
"mismatched_device": False,
}
)
view.get_interface_context = MagicMock(return_value=None)
view.get_cable_context = MagicMock(return_value=None)
view.get_ip_context = MagicMock(return_value=None)
view.get_vlan_context = MagicMock(return_value=None)
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_librenms_sync_device") as mock_sync:
mock_sync.return_value = sync_device
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_librenms_device_id") as mock_id:
mock_id.return_value = 42
with patch(
"netbox_librenms_plugin.views.base.librenms_sync_view.get_interface_name_field",
return_value="ifName",
):
with patch(
"netbox_librenms_plugin.views.base.librenms_sync_view.BaseLibreNMSSyncView._build_all_server_mappings",
return_value=None,
):
with patch(
"netbox_librenms_plugin.views.base.librenms_sync_view.BaseLibreNMSSyncView._get_platform_info",
return_value={},
):
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.AddToLIbreSNMPV1V2"):
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.AddToLIbreSNMPV3"):
with patch("dcim.models.Manufacturer") as MockMfr:
MockMfr.objects.all.return_value.order_by.return_value = []
with patch.object(view, "get_context_data", wraps=view.get_context_data):
# Call parent get_context_data via a mock of super()
with patch(
"netbox_librenms_plugin.views.base.librenms_sync_view.LibreNMSAPIMixin.get_context_data",
return_value={},
):
ctx = view.get_context_data(MagicMock(), obj)
assert ctx.get("is_vc_member") is True
assert ctx.get("sync_device_has_librenms_id") is True
assert ctx.get("sync_device_has_primary_ip") is True
class TestBuildAllServerMappings:
"""Tests for _build_all_server_mappings (lines 181, 193, 200, 207-208)."""
def test_returns_none_for_non_dict_cf(self):
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
obj = MagicMock()
obj.custom_field_data = {"librenms_id": 42} # legacy bare int
result = BaseLibreNMSSyncView._build_all_server_mappings(obj, "default")
assert result is None
def test_returns_none_for_empty_dict_cf(self):
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {}}
result = BaseLibreNMSSyncView._build_all_server_mappings(obj, "default")
assert result is None
def test_valid_dict_cf_returns_list(self):
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"default": 42, "secondary": 99}}
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings") as mock_settings:
mock_settings.PLUGINS_CONFIG = {
"netbox_librenms_plugin": {
"servers": {
"default": {"librenms_url": "https://x.example.com", "display_name": "Default"},
"secondary": {"librenms_url": "https://y.example.com", "display_name": "Secondary"},
}
}
}
result = BaseLibreNMSSyncView._build_all_server_mappings(obj, "default")
assert result is not None
assert len(result) == 2
# Active server should be first
assert result[0]["is_active"] is True
assert result[0]["server_key"] == "default"
def test_bool_value_skipped(self):
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"default": True, "other": 42}}
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings") as mock_settings:
mock_settings.PLUGINS_CONFIG = {
"netbox_librenms_plugin": {
"servers": {"other": {"librenms_url": "https://x.example.com", "display_name": "Other"}}
}
}
result = BaseLibreNMSSyncView._build_all_server_mappings(obj, "default")
assert result is not None
assert len(result) == 1
assert result[0]["server_key"] == "other"
def test_string_device_id_converted_to_int(self):
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"default": "77"}}
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings") as mock_settings:
mock_settings.PLUGINS_CONFIG = {
"netbox_librenms_plugin": {
"servers": {"default": {"librenms_url": "https://x.example.com", "display_name": "Default"}}
}
}
result = BaseLibreNMSSyncView._build_all_server_mappings(obj, "default")
assert result[0]["device_id"] == 77
def test_non_digit_string_skipped(self):
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"default": "not-a-number"}}
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings") as mock_settings:
mock_settings.PLUGINS_CONFIG = {"netbox_librenms_plugin": {"servers": {}}}
result = BaseLibreNMSSyncView._build_all_server_mappings(obj, "default")
assert result is None
def test_legacy_default_key_falls_back_to_root_librenms_url(self):
"""'default' key with no matching servers entry uses root librenms_url."""
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"default": 42}}
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings") as mock_settings:
mock_settings.PLUGINS_CONFIG = {
"netbox_librenms_plugin": {
"librenms_url": "https://legacy.example.com",
"display_name": "Legacy Server",
"servers": {},
}
}
result = BaseLibreNMSSyncView._build_all_server_mappings(obj, "default")
assert result is not None
assert result[0]["librenms_url"] == "https://legacy.example.com"
def test_malformed_server_config_treated_as_unconfigured(self):
"""Non-dict server config entry → is_configured=False."""
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"default": 42}}
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings") as mock_settings:
mock_settings.PLUGINS_CONFIG = {"netbox_librenms_plugin": {"servers": {"default": "this-is-not-a-dict"}}}
result = BaseLibreNMSSyncView._build_all_server_mappings(obj, "default")
assert result is not None
assert result[0]["is_configured"] is False
class TestGetLibreNMSDeviceInfo:
"""Tests for get_librenms_device_info (lines 228+)."""
def test_no_librenms_id_returns_defaults(self):
view = _make_view()
view.librenms_id = None
view._librenms_api = MagicMock()
obj = MagicMock()
result = view.get_librenms_device_info(obj)
assert result["found_in_librenms"] is False
assert result["mismatched_device"] is False
def test_librenms_id_success_sets_found(self):
view = _make_view()
view.librenms_id = 42
view._librenms_api = MagicMock()
view._librenms_api.librenms_url = "https://x.example.com"
obj = MagicMock()
obj.primary_ip = None
obj.name = "mydevice"
obj.virtual_chassis = None
obj.serial = "SN001"
obj.platform = None
device_info = {
"hardware": "Cisco C9300",
"serial": "SN001",
"os": "ios",
"version": "16.9",
"features": "-",
"sysName": "mydevice",
"hostname": "mydevice.example.com",
"ip": "10.0.0.1",
"location": "NYC",
}
view._librenms_api.get_device_info.return_value = (True, device_info)
with patch(
"netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type"
) as mock_match:
mock_match.return_value = {"matched": False, "device_type": None, "match_type": None}
result = view.get_librenms_device_info(obj)
assert result["found_in_librenms"] is True
def test_mismatched_device_when_names_differ(self):
view = _make_view()
view.librenms_id = 42
view._librenms_api = MagicMock()
view._librenms_api.librenms_url = "https://x.example.com"
obj = MagicMock()
obj.primary_ip = None
obj.name = "device-netbox"
obj.virtual_chassis = None
obj.serial = ""
obj.platform = None
device_info = {
"hardware": "-",
"serial": "-",
"os": "-",
"version": "-",
"features": "-",
"sysName": "completely-different",
"hostname": "also-different.example.com",
"ip": "192.168.0.1",
"location": "-",
}
view._librenms_api.get_device_info.return_value = (True, device_info)
with patch(
"netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type"
) as mock_match:
mock_match.return_value = {"matched": False, "device_type": None, "match_type": None}
with patch(
"netbox_librenms_plugin.views.base.librenms_sync_view.BaseLibreNMSSyncView._strip_vc_pattern",
return_value=None,
):
result = view.get_librenms_device_info(obj)
assert result["mismatched_device"] is True
class TestStripVcPattern:
"""Tests for _strip_vc_pattern (lines 378+)."""
def test_strips_default_pattern(self):
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
mock_settings_cls = MagicMock()
settings_obj = MagicMock()
settings_obj.vc_member_name_pattern = "-M{position}"
mock_settings_cls.objects.first.return_value = settings_obj
with patch("netbox_librenms_plugin.models.LibreNMSSettings", mock_settings_cls, create=True):
result = BaseLibreNMSSyncView._strip_vc_pattern("switch01-m2")
# The suffix -m2 should be stripped, returning "switch01"
assert result == "switch01" # suffix -m2 must be stripped
def test_returns_none_on_exception(self):
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
mock_settings_cls = MagicMock()
mock_settings_cls.objects.first.side_effect = Exception("DB error")
with patch("netbox_librenms_plugin.models.LibreNMSSettings", mock_settings_cls, create=True):
result = BaseLibreNMSSyncView._strip_vc_pattern("some-device")
assert result is None
class TestLibreNMSIdLegacyDetection:
"""Tests for librenms_id_is_legacy detection (lines 113-115)."""
def test_bare_int_cf_detected_as_legacy(self):
"""bare int CF → librenms_id_is_legacy = True."""
view = _make_view()
view.librenms_id = 42
view._librenms_lookup_device = MagicMock()
view._librenms_lookup_device.cf = {"librenms_id": 42}
obj = MagicMock()
obj.virtual_chassis = None
obj._meta = MagicMock()
obj._meta.model_name = "device"
obj.pk = 1
obj.serial = "SN"
obj.platform = None
view._librenms_api = MagicMock()
view._librenms_api.server_key = "default"
view._librenms_api.librenms_url = "https://x.example.com"
view.get_librenms_device_info = MagicMock(
return_value={
"found_in_librenms": True,
"librenms_device_details": {
"librenms_device_serial": "SN",
"librenms_device_hardware": "-",
"librenms_device_os": "-",
"librenms_device_version": "-",
"librenms_device_features": "-",
"librenms_device_location": "-",
"librenms_device_hardware_match": None,
"vc_inventory_serials": [],
},
"mismatched_device": False,
}
)
view.get_interface_context = MagicMock(return_value=None)
view.get_cable_context = MagicMock(return_value=None)
view.get_ip_context = MagicMock(return_value=None)
view.get_vlan_context = MagicMock(return_value=None)
with patch(
"netbox_librenms_plugin.views.base.librenms_sync_view.get_interface_name_field", return_value="ifName"
):
with patch(
"netbox_librenms_plugin.views.base.librenms_sync_view.BaseLibreNMSSyncView._build_all_server_mappings",
return_value=None,
):
with patch(
"netbox_librenms_plugin.views.base.librenms_sync_view.BaseLibreNMSSyncView._get_platform_info",
return_value={},
):
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.AddToLIbreSNMPV1V2"):
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.AddToLIbreSNMPV3"):
with patch("dcim.models.Manufacturer") as MockMfr:
MockMfr.objects.all.return_value.order_by.return_value = []
with patch(
"netbox_librenms_plugin.views.base.librenms_sync_view.LibreNMSAPIMixin.get_context_data",
return_value={},
):
ctx = view.get_context_data(MagicMock(), obj)
assert ctx.get("librenms_id_is_legacy") is True
class TestAbstractMethods:
"""Tests for abstract get_*_context methods (lines 349-376)."""
def test_get_interface_context_returns_none(self):
view = _make_view()
result = view.get_interface_context(MagicMock(), MagicMock())
assert result is None
def test_get_cable_context_returns_none(self):
view = _make_view()
result = view.get_cable_context(MagicMock(), MagicMock())
assert result is None
def test_get_ip_context_returns_none(self):
view = _make_view()
result = view.get_ip_context(MagicMock(), MagicMock())
assert result is None
def test_get_vlan_context_returns_none(self):
view = _make_view()
result = view.get_vlan_context(MagicMock(), MagicMock())
assert result is None
class TestGetVCInventorySerials:
"""Tests for _get_vc_inventory_serials (lines 412-452)."""
def test_no_inventory_returns_empty(self):
view = _make_view()
view.librenms_id = 42
view._librenms_api.get_device_inventory.return_value = (False, [])
obj = MagicMock()
obj.virtual_chassis = MagicMock()
obj.virtual_chassis.members.all.return_value = []
result = view._get_vc_inventory_serials(obj)
assert result == []
def test_chassis_components_matched(self):
view = _make_view()
view.librenms_id = 42
inventory = [
{
"entPhysicalClass": "chassis",
"entPhysicalSerialNum": "SN001",
"entPhysicalDescr": "Chassis",
"entPhysicalModelName": "C9300",
},
{
"entPhysicalClass": "module",
"entPhysicalSerialNum": "SN002",
"entPhysicalDescr": "Module",
"entPhysicalModelName": "",
},
]
view._librenms_api.get_device_inventory.return_value = (True, inventory)
member = MagicMock()
member.serial = "SN001"
obj = MagicMock()
obj.virtual_chassis = MagicMock()
obj.virtual_chassis.members.all.return_value = [member]
result = view._get_vc_inventory_serials(obj)
assert len(result) == 1
assert result[0]["serial"] == "SN001"
assert result[0]["assigned_member"] is member
def test_unassigned_serial_returns_none_member(self):
view = _make_view()
view.librenms_id = 42
inventory = [
{
"entPhysicalClass": "chassis",
"entPhysicalSerialNum": "UNKNOWN_SN",
"entPhysicalDescr": "Chassis",
"entPhysicalModelName": "MX480",
},
]
view._librenms_api.get_device_inventory.return_value = (True, inventory)
member = MagicMock()
member.serial = "SN001" # Different serial
obj = MagicMock()
obj.virtual_chassis = MagicMock()
obj.virtual_chassis.members.all.return_value = [member]
result = view._get_vc_inventory_serials(obj)
assert len(result) == 1
assert result[0]["assigned_member"] is None
def test_empty_serial_skipped(self):
view = _make_view()
view.librenms_id = 42
inventory = [
{
"entPhysicalClass": "chassis",
"entPhysicalSerialNum": "-",
"entPhysicalDescr": "Chassis",
"entPhysicalModelName": "",
},
]
view._librenms_api.get_device_inventory.return_value = (True, inventory)
obj = MagicMock()
obj.virtual_chassis = MagicMock()
obj.virtual_chassis.members.all.return_value = []
result = view._get_vc_inventory_serials(obj)
assert result == []
class TestGetPlatformInfo:
"""Tests for _get_platform_info (lines 463-502)."""
def test_no_os_returns_no_platform(self):
view = _make_view()
obj = MagicMock()
obj.platform = None
librenms_info = {
"librenms_device_details": {
"librenms_device_os": "-",
"librenms_device_version": "-",
}
}
with patch("dcim.models.Platform") as MockPlatform:
MockPlatform.DoesNotExist = type("DoesNotExist", (Exception,), {})
MockPlatform.objects.get.side_effect = MockPlatform.DoesNotExist()
result = view._get_platform_info(librenms_info, obj)
assert result["platform_exists"] is False
assert result["platform_name"] is None
def test_matching_platform_found(self):
view = _make_view()
obj = MagicMock()
mock_platform = MagicMock()
librenms_info = {
"librenms_device_details": {
"librenms_device_os": "ios",
"librenms_device_version": "16.9",
}
}
with patch("dcim.models.Platform") as MockPlatform:
MockPlatform.DoesNotExist = type("DoesNotExist", (Exception,), {})
MockPlatform.objects.get.return_value = mock_platform
result = view._get_platform_info(librenms_info, obj)
assert result["platform_exists"] is True
assert result["matching_platform"] is mock_platform
def test_platform_does_not_exist(self):
view = _make_view()
obj = MagicMock()
obj.platform = None
librenms_info = {
"librenms_device_details": {
"librenms_device_os": "eos",
"librenms_device_version": "4.28",
}
}
with patch("dcim.models.Platform") as MockPlatform:
MockPlatform.DoesNotExist = type("DoesNotExist", (Exception,), {})
MockPlatform.objects.get.side_effect = MockPlatform.DoesNotExist()
result = view._get_platform_info(librenms_info, obj)
assert result["platform_exists"] is False
assert result["matching_platform"] is None

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,980 @@
"""
Coverage tests for remaining gaps in views/sync/.
Targets:
- interfaces.py (SyncInterfacesView + DeleteNetBoxInterfacesView) - was 34%
- cables.py lines 147-149 (exception path in process_interface_sync)
- devices.py lines 77, 81-82 (port_association_mode, invalid poller_group)
- locations.py lines 26-28, 32-35, 44-49 (get_table, get_context_data, get_queryset)
- vlans.py lines 134-139 (grouped VLAN update/skip paths)
"""
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_iv():
from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView
v = object.__new__(SyncInterfacesView)
v._librenms_api = MagicMock()
v._librenms_api.server_key = "default"
v._post_server_key = "default"
v.request = MagicMock()
v.request.POST.get = lambda k, *a: None
v.object = MagicMock()
return v
def _make_dv():
from netbox_librenms_plugin.views.sync.interfaces import DeleteNetBoxInterfacesView
v = object.__new__(DeleteNetBoxInterfacesView)
v._librenms_api = MagicMock()
v.request = MagicMock()
return v
@contextmanager
def _pa():
"""Passthrough atomic: real context manager that does not suppress exceptions."""
yield
# ===========================================================================
# SyncInterfacesView.get_required_permissions_for_object_type
# ===========================================================================
class TestGetRequiredPermissionsForObjectType:
def test_device_returns_interface_perms(self):
from dcim.models import Interface
v = _make_iv()
perms = v.get_required_permissions_for_object_type("device")
assert any(a == "add" and m is Interface for a, m in perms)
assert any(a == "change" and m is Interface for a, m in perms)
def test_vm_returns_vminterface_perms(self):
from virtualization.models import VMInterface
v = _make_iv()
perms = v.get_required_permissions_for_object_type("virtualmachine")
assert any(a == "add" and m is VMInterface for a, m in perms)
def test_invalid_raises_http404(self):
import pytest
from django.http import Http404
v = _make_iv()
with pytest.raises(Http404):
v.get_required_permissions_for_object_type("rack")
# ===========================================================================
# SyncInterfacesView.get_object
# ===========================================================================
class TestSyncInterfacesGetObject:
def test_device_type(self):
v = _make_iv()
mock_obj = MagicMock()
with patch("netbox_librenms_plugin.views.sync.interfaces.get_object_or_404", return_value=mock_obj):
assert v.get_object("device", 1) is mock_obj
def test_vm_type(self):
v = _make_iv()
mock_obj = MagicMock()
with patch("netbox_librenms_plugin.views.sync.interfaces.get_object_or_404", return_value=mock_obj):
assert v.get_object("virtualmachine", 2) is mock_obj
def test_invalid_raises_http404(self):
import pytest
from django.http import Http404
v = _make_iv()
with pytest.raises(Http404):
v.get_object("rack", 1)
# ===========================================================================
# SyncInterfacesView.get_selected_interfaces
# ===========================================================================
class TestSyncGetSelectedInterfaces:
def test_empty_returns_none_and_error(self):
v = _make_iv()
req = MagicMock()
req.POST.getlist.return_value = []
with patch("netbox_librenms_plugin.views.sync.interfaces.messages") as mm:
result = v.get_selected_interfaces(req, "ifName")
assert result is None
mm.error.assert_called_once()
def test_with_values_returns_list(self):
v = _make_iv()
req = MagicMock()
req.POST.getlist.return_value = ["eth0", "eth1"]
assert v.get_selected_interfaces(req, "ifName") == ["eth0", "eth1"]
# ===========================================================================
# SyncInterfacesView.get_cached_ports_data
# ===========================================================================
class TestGetCachedPortsData:
def test_cache_miss_warns_and_returns_none(self):
v = _make_iv()
v.get_cache_key = MagicMock(return_value="k")
with patch("netbox_librenms_plugin.views.sync.interfaces.cache") as mc:
mc.get.return_value = None
with patch("netbox_librenms_plugin.views.sync.interfaces.messages") as mm:
result = v.get_cached_ports_data(MagicMock(), MagicMock())
assert result is None
mm.warning.assert_called_once()
def test_cache_hit_returns_ports(self):
v = _make_iv()
v.get_cache_key = MagicMock(return_value="k")
ports = [{"ifName": "eth0"}]
with patch("netbox_librenms_plugin.views.sync.interfaces.cache") as mc:
mc.get.return_value = {"ports": ports}
assert v.get_cached_ports_data(MagicMock(), MagicMock()) == ports
# ===========================================================================
# SyncInterfacesView.post
# ===========================================================================
class TestSyncInterfacesPost:
def _s(self):
v = _make_iv()
v.require_all_permissions = MagicMock(return_value=None)
v.get_vlan_groups_for_device = MagicMock(return_value=[])
v._build_vlan_lookup_maps = MagicMock(return_value={})
return v
def test_permission_denied(self):
v = self._s()
err = MagicMock()
v.require_all_permissions = MagicMock(return_value=err)
assert v.post(MagicMock(), "device", 1) is err
def test_no_selected_redirects(self):
from dcim.models import Device
v = self._s()
obj = MagicMock(spec=Device)
obj.pk = 1
v.get_object = MagicMock(return_value=obj)
v.get_selected_interfaces = MagicMock(return_value=None)
req = MagicMock()
req.POST.get = lambda k, *a: None
req.POST.getlist = lambda k: []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_interface_name_field", return_value="ifName"):
with patch("netbox_librenms_plugin.views.sync.interfaces.reverse", return_value="/s/"):
with patch("netbox_librenms_plugin.views.sync.interfaces.redirect") as mr:
v.post(req, "device", 1)
mr.assert_called_once()
def test_no_ports_data_redirects(self):
from dcim.models import Device
v = self._s()
obj = MagicMock(spec=Device)
obj.pk = 1
v.get_object = MagicMock(return_value=obj)
v.get_selected_interfaces = MagicMock(return_value=["eth0"])
v.get_cached_ports_data = MagicMock(return_value=None)
req = MagicMock()
req.POST.get = lambda k, *a: None
req.POST.getlist = lambda k: []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_interface_name_field", return_value="ifName"):
with patch("netbox_librenms_plugin.views.sync.interfaces.reverse", return_value="/s/"):
with patch("netbox_librenms_plugin.views.sync.interfaces.redirect") as mr:
v.post(req, "device", 1)
mr.assert_called_once()
def test_full_success_device(self):
from dcim.models import Device
v = self._s()
obj = MagicMock(spec=Device)
obj.pk = 1
v.get_object = MagicMock(return_value=obj)
v.get_selected_interfaces = MagicMock(return_value=["eth0"])
v.get_cached_ports_data = MagicMock(return_value=[{"ifName": "eth0"}])
v.sync_selected_interfaces = MagicMock()
req = MagicMock()
req.POST.get = lambda k, *a: "default" if k == "server_key" else None
req.POST.getlist = lambda k: []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_interface_name_field", return_value="ifName"):
with patch("netbox_librenms_plugin.views.sync.interfaces.reverse", return_value="/s/"):
with patch("netbox_librenms_plugin.views.sync.interfaces.redirect") as mr:
with patch("netbox_librenms_plugin.views.sync.interfaces.messages") as mm:
v.post(req, "device", 1)
v.sync_selected_interfaces.assert_called_once()
mm.success.assert_called_once()
mr.assert_called_once()
def test_full_success_vm(self):
from virtualization.models import VirtualMachine
v = self._s()
obj = MagicMock(spec=VirtualMachine)
obj.pk = 2
v.get_object = MagicMock(return_value=obj)
v.get_selected_interfaces = MagicMock(return_value=["eth0"])
v.get_cached_ports_data = MagicMock(return_value=[{"ifName": "eth0"}])
v.sync_selected_interfaces = MagicMock()
req = MagicMock()
req.POST.get = lambda k, *a: None
req.POST.getlist = lambda k: []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_interface_name_field", return_value="ifName"):
with patch("netbox_librenms_plugin.views.sync.interfaces.reverse", return_value="/s/"):
with patch("netbox_librenms_plugin.views.sync.interfaces.redirect"):
with patch("netbox_librenms_plugin.views.sync.interfaces.messages"):
v.post(req, "virtualmachine", 2)
v.sync_selected_interfaces.assert_called_once()
# ===========================================================================
# SyncInterfacesView.sync_selected_interfaces
# ===========================================================================
class TestSyncSelectedInterfaces:
def test_only_selected_processed(self):
from dcim.models import Device
v = _make_iv()
v.sync_interface = MagicMock()
obj = MagicMock(spec=Device)
ports = [{"ifName": "eth0"}, {"ifName": "eth1"}]
with patch("netbox_librenms_plugin.views.sync.interfaces.transaction"):
v.sync_selected_interfaces(obj, ["eth0"], ports, [], "ifName")
assert v.sync_interface.call_count == 1
assert v.sync_interface.call_args[0][1]["ifName"] == "eth0"
# ===========================================================================
# SyncInterfacesView.sync_interface
# ===========================================================================
class TestSyncInterface:
def _v(self):
v = _make_iv()
v.update_interface_attributes = MagicMock()
v._sync_interface_vlans = MagicMock()
v.get_netbox_interface_type = MagicMock(return_value="1000base-t")
v._lookup_maps = {}
return v
def test_device_no_vc_uses_obj(self):
from dcim.models import Device
v = self._v()
obj = MagicMock(spec=Device)
obj.id = 1
obj.virtual_chassis = None
iface = MagicMock()
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get_or_create.return_value = (iface, True)
v.sync_interface(obj, {"ifName": "eth0"}, [], "ifName")
mc.objects.get_or_create.assert_called_once_with(device=obj, name="eth0")
v.update_interface_attributes.assert_called_once()
def test_device_vc_target_in_valid_ids(self):
from dcim.models import Device
v = self._v()
obj = MagicMock(spec=Device)
obj.id = 1
vc = MagicMock()
vc.members.values_list.return_value = [1, 2, 3]
obj.virtual_chassis = vc
target = MagicMock()
target.id = 2
v.request.POST.get = lambda k, *a: "2" if k == "device_selection_eth0" else None
iface = MagicMock()
# Patch only Device.objects.get, not Device itself (isinstance must work)
with patch("netbox_librenms_plugin.views.sync.interfaces.Device.objects.get", return_value=target):
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get_or_create.return_value = (iface, True)
v.sync_interface(obj, {"ifName": "eth0"}, [], "ifName")
mc.objects.get_or_create.assert_called_once_with(device=target, name="eth0")
def test_device_vc_target_not_in_valid_ids_falls_back(self):
from dcim.models import Device
v = self._v()
obj = MagicMock(spec=Device)
obj.id = 1
vc = MagicMock()
vc.members.values_list.return_value = [1, 2, 3]
obj.virtual_chassis = vc
target = MagicMock()
target.id = 99
v.request.POST.get = lambda k, *a: "99" if k == "device_selection_eth0" else None
iface = MagicMock()
with patch("netbox_librenms_plugin.views.sync.interfaces.Device.objects.get", return_value=target):
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get_or_create.return_value = (iface, True)
v.sync_interface(obj, {"ifName": "eth0"}, [], "ifName")
mc.objects.get_or_create.assert_called_once_with(device=obj, name="eth0")
def test_device_no_vc_wrong_selection_falls_back(self):
from dcim.models import Device
v = self._v()
obj = MagicMock(spec=Device)
obj.id = 1
obj.virtual_chassis = None
target = MagicMock()
target.id = 99
v.request.POST.get = lambda k, *a: "99" if k == "device_selection_eth0" else None
iface = MagicMock()
with patch("netbox_librenms_plugin.views.sync.interfaces.Device.objects.get", return_value=target):
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get_or_create.return_value = (iface, True)
v.sync_interface(obj, {"ifName": "eth0"}, [], "ifName")
mc.objects.get_or_create.assert_called_once_with(device=obj, name="eth0")
def test_device_selection_does_not_exist_falls_back(self):
from dcim.models import Device
v = self._v()
obj = MagicMock(spec=Device)
obj.id = 1
obj.virtual_chassis = None
v.request.POST.get = lambda k, *a: "999" if k == "device_selection_eth0" else None
iface = MagicMock()
with patch(
"netbox_librenms_plugin.views.sync.interfaces.Device.objects.get",
side_effect=Device.DoesNotExist,
):
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get_or_create.return_value = (iface, True)
v.sync_interface(obj, {"ifName": "eth0"}, [], "ifName")
mc.objects.get_or_create.assert_called_once_with(device=obj, name="eth0")
def test_vm_uses_vminterface(self):
from virtualization.models import VirtualMachine
v = self._v()
obj = MagicMock(spec=VirtualMachine)
iface = MagicMock()
with patch("netbox_librenms_plugin.views.sync.interfaces.VMInterface") as mc:
mc.objects.get_or_create.return_value = (iface, True)
v.sync_interface(obj, {"ifName": "eth0"}, [], "ifName")
mc.objects.get_or_create.assert_called_once_with(virtual_machine=obj, name="eth0")
v.update_interface_attributes.assert_called_once()
def test_vlans_excluded_skips_sync(self):
from dcim.models import Device
v = self._v()
obj = MagicMock(spec=Device)
obj.id = 1
obj.virtual_chassis = None
iface = MagicMock()
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get_or_create.return_value = (iface, True)
v.sync_interface(obj, {"ifName": "eth0"}, ["vlans"], "ifName")
v._sync_interface_vlans.assert_not_called()
def test_vlans_not_excluded_calls_sync(self):
from dcim.models import Device
v = self._v()
obj = MagicMock(spec=Device)
obj.id = 1
obj.virtual_chassis = None
iface = MagicMock()
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get_or_create.return_value = (iface, True)
v.sync_interface(obj, {"ifName": "eth0"}, [], "ifName")
v._sync_interface_vlans.assert_called_once()
# ===========================================================================
# SyncInterfacesView.get_netbox_interface_type
# ===========================================================================
class TestGetNetboxInterfaceType:
def test_speed_mapping_found(self):
v = _make_iv()
mm = MagicMock()
mm.netbox_type = "1000base-t"
with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=1000000):
with patch("netbox_librenms_plugin.views.sync.interfaces.InterfaceTypeMapping") as mc:
qs = MagicMock()
mc.objects.filter.return_value = qs
qs.filter.return_value.order_by.return_value.first.return_value = mm
result = v.get_netbox_interface_type({"ifType": "ethernetCsmacd", "ifSpeed": 1000000000})
assert result == "1000base-t"
def test_speed_not_found_falls_back_to_null(self):
v = _make_iv()
null_m = MagicMock()
null_m.netbox_type = "null-type"
with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=1000000):
with patch("netbox_librenms_plugin.views.sync.interfaces.InterfaceTypeMapping") as mc:
qs = MagicMock()
mc.objects.filter.return_value = qs
qs.filter.return_value.order_by.return_value.first.return_value = None
qs.filter.return_value.first.return_value = null_m
result = v.get_netbox_interface_type({"ifType": "ethernetCsmacd", "ifSpeed": 1000000000})
assert result == "null-type"
def test_no_speed_uses_null_mapping(self):
v = _make_iv()
m = MagicMock()
m.netbox_type = "virtual"
with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None):
with patch("netbox_librenms_plugin.views.sync.interfaces.InterfaceTypeMapping") as mc:
qs = MagicMock()
mc.objects.filter.return_value = qs
qs.filter.return_value.first.return_value = m
result = v.get_netbox_interface_type({"ifType": "eth", "ifSpeed": None})
assert result == "virtual"
def test_no_mapping_returns_other(self):
v = _make_iv()
with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None):
with patch("netbox_librenms_plugin.views.sync.interfaces.InterfaceTypeMapping") as mc:
qs = MagicMock()
mc.objects.filter.return_value = qs
qs.filter.return_value.first.return_value = None
result = v.get_netbox_interface_type({"ifType": "unknown", "ifSpeed": None})
assert result == "other"
# ===========================================================================
# SyncInterfacesView._sync_interface_vlans
# ===========================================================================
class TestSyncInterfaceVlans:
def test_builds_vlan_group_map_for_untagged_and_tagged(self):
v = _make_iv()
v._lookup_maps = {}
v._update_interface_vlan_assignment = MagicMock()
iface = MagicMock()
port = {"untagged_vlan": 100, "tagged_vlans": [200]}
def pg(key, default=""):
return {"vlan_group_eth0_100": "5", "vlan_group_eth0_200": "5"}.get(key, default)
v.request.POST.get = pg
v._sync_interface_vlans(iface, port, "eth0")
args = v._update_interface_vlan_assignment.call_args[0]
assert args[2].get("100") == "5"
assert args[2].get("200") == "5"
def test_no_vlans_empty_map(self):
v = _make_iv()
v._lookup_maps = {}
v._update_interface_vlan_assignment = MagicMock()
v.request.POST.get = lambda k, *a: ""
v._sync_interface_vlans(MagicMock(), {"untagged_vlan": None, "tagged_vlans": []}, "eth0")
assert v._update_interface_vlan_assignment.call_args[0][2] == {}
def test_special_chars_in_name(self):
v = _make_iv()
v._lookup_maps = {}
v._update_interface_vlan_assignment = MagicMock()
v.request.POST.get = lambda k, *a: ""
v._sync_interface_vlans(MagicMock(), {"untagged_vlan": None, "tagged_vlans": []}, "eth0/1:2")
v._update_interface_vlan_assignment.assert_called_once()
# ===========================================================================
# DeleteNetBoxInterfacesView.get_required_permissions_for_object_type
# ===========================================================================
class TestDeleteGetRequiredPermissions:
def test_device_delete_interface(self):
from dcim.models import Interface
v = _make_dv()
perms = v.get_required_permissions_for_object_type("device")
assert any(a == "delete" and m is Interface for a, m in perms)
def test_vm_delete_vminterface(self):
from virtualization.models import VMInterface
v = _make_dv()
perms = v.get_required_permissions_for_object_type("virtualmachine")
assert any(a == "delete" and m is VMInterface for a, m in perms)
def test_invalid_raises_http404(self):
import pytest
from django.http import Http404
v = _make_dv()
with pytest.raises(Http404):
v.get_required_permissions_for_object_type("invalid")
# ===========================================================================
# DeleteNetBoxInterfacesView.post
# ===========================================================================
class TestDeleteNetBoxInterfacesPost:
def test_permission_denied(self):
v = _make_dv()
err = MagicMock()
v.require_all_permissions_json = MagicMock(return_value=err)
v.get_required_permissions_for_object_type = MagicMock(return_value=[])
req = MagicMock()
req.POST.getlist.return_value = ["1"]
assert v.post(req, "device", 1) is err
def test_invalid_object_type_400(self):
v = _make_dv()
v.require_all_permissions_json = MagicMock(return_value=None)
v.get_required_permissions_for_object_type = MagicMock(return_value=[])
req = MagicMock()
req.POST.getlist.return_value = ["1"]
resp = v.post(req, "rack", 1)
assert resp.status_code == 400
def test_no_ids_400(self):
v = _make_dv()
v.require_all_permissions_json = MagicMock(return_value=None)
v.get_required_permissions_for_object_type = MagicMock(return_value=[])
req = MagicMock()
req.POST.getlist.return_value = []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_object_or_404"):
resp = v.post(req, "device", 1)
assert resp.status_code == 400
def test_device_successful_delete(self):
import json
v = _make_dv()
v.require_all_permissions_json = MagicMock(return_value=None)
v.get_required_permissions_for_object_type = MagicMock(return_value=[])
obj = MagicMock()
obj.id = 1
obj.virtual_chassis = None
iface = MagicMock()
iface.name = "eth0"
iface.device_id = 1
req = MagicMock()
req.POST.getlist.side_effect = lambda k: ["10"] if k == "interface_ids" else []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_object_or_404", return_value=obj):
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get.return_value = iface
with patch("netbox_librenms_plugin.views.sync.interfaces.transaction") as mt:
mt.atomic = _pa
resp = v.post(req, "device", 1)
data = json.loads(resp.content)
assert data["deleted_count"] == 1
iface.delete.assert_called_once()
def test_device_wrong_device_id_error(self):
import json
v = _make_dv()
v.require_all_permissions_json = MagicMock(return_value=None)
v.get_required_permissions_for_object_type = MagicMock(return_value=[])
obj = MagicMock()
obj.id = 1
obj.virtual_chassis = None
iface = MagicMock()
iface.name = "eth0"
iface.device_id = 99
req = MagicMock()
req.POST.getlist.side_effect = lambda k: ["10"] if k == "interface_ids" else []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_object_or_404", return_value=obj):
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get.return_value = iface
with patch("netbox_librenms_plugin.views.sync.interfaces.transaction") as mt:
mt.atomic = _pa
resp = v.post(req, "device", 1)
data = json.loads(resp.content)
assert data["deleted_count"] == 0
assert len(data["errors"]) > 0
def test_device_vc_interface_not_in_members(self):
import json
v = _make_dv()
v.require_all_permissions_json = MagicMock(return_value=None)
v.get_required_permissions_for_object_type = MagicMock(return_value=[])
obj = MagicMock()
obj.id = 1
vc = MagicMock()
m1 = MagicMock()
m1.id = 1
m2 = MagicMock()
m2.id = 2
vc.members.all.return_value = [m1, m2]
obj.virtual_chassis = vc
iface = MagicMock()
iface.name = "eth0"
iface.device_id = 99
req = MagicMock()
req.POST.getlist.side_effect = lambda k: ["10"] if k == "interface_ids" else []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_object_or_404", return_value=obj):
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get.return_value = iface
with patch("netbox_librenms_plugin.views.sync.interfaces.transaction") as mt:
mt.atomic = _pa
resp = v.post(req, "device", 1)
data = json.loads(resp.content)
assert data["deleted_count"] == 0
assert len(data["errors"]) > 0
def test_device_vc_interface_in_members_deleted(self):
import json
v = _make_dv()
v.require_all_permissions_json = MagicMock(return_value=None)
v.get_required_permissions_for_object_type = MagicMock(return_value=[])
obj = MagicMock()
obj.id = 1
vc = MagicMock()
m1 = MagicMock()
m1.id = 1
m2 = MagicMock()
m2.id = 2
vc.members.all.return_value = [m1, m2]
obj.virtual_chassis = vc
iface = MagicMock()
iface.name = "eth0"
iface.device_id = 2
req = MagicMock()
req.POST.getlist.side_effect = lambda k: ["10"] if k == "interface_ids" else []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_object_or_404", return_value=obj):
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get.return_value = iface
with patch("netbox_librenms_plugin.views.sync.interfaces.transaction") as mt:
mt.atomic = _pa
resp = v.post(req, "device", 1)
data = json.loads(resp.content)
assert data["deleted_count"] == 1
def test_vm_successful_delete(self):
import json
v = _make_dv()
v.require_all_permissions_json = MagicMock(return_value=None)
v.get_required_permissions_for_object_type = MagicMock(return_value=[])
obj = MagicMock()
obj.id = 5
iface = MagicMock()
iface.name = "eth0"
iface.virtual_machine_id = 5
req = MagicMock()
req.POST.getlist.side_effect = lambda k: ["20"] if k == "interface_ids" else []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_object_or_404", return_value=obj):
with patch("netbox_librenms_plugin.views.sync.interfaces.VMInterface") as mc:
mc.objects.get.return_value = iface
with patch("netbox_librenms_plugin.views.sync.interfaces.transaction") as mt:
mt.atomic = _pa
resp = v.post(req, "virtualmachine", 5)
data = json.loads(resp.content)
assert data["deleted_count"] == 1
iface.delete.assert_called_once()
def test_vm_wrong_vm_error(self):
import json
v = _make_dv()
v.require_all_permissions_json = MagicMock(return_value=None)
v.get_required_permissions_for_object_type = MagicMock(return_value=[])
obj = MagicMock()
obj.id = 5
iface = MagicMock()
iface.name = "eth0"
iface.virtual_machine_id = 99
req = MagicMock()
req.POST.getlist.side_effect = lambda k: ["20"] if k == "interface_ids" else []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_object_or_404", return_value=obj):
with patch("netbox_librenms_plugin.views.sync.interfaces.VMInterface") as mc:
mc.objects.get.return_value = iface
with patch("netbox_librenms_plugin.views.sync.interfaces.transaction") as mt:
mt.atomic = _pa
resp = v.post(req, "virtualmachine", 5)
data = json.loads(resp.content)
assert data["deleted_count"] == 0
assert len(data["errors"]) > 0
def test_interface_not_found_adds_error(self):
import json
from dcim.models import Interface
from virtualization.models import VMInterface as VMI
v = _make_dv()
v.require_all_permissions_json = MagicMock(return_value=None)
v.get_required_permissions_for_object_type = MagicMock(return_value=[])
obj = MagicMock()
obj.id = 1
obj.virtual_chassis = None
req = MagicMock()
req.POST.getlist.side_effect = lambda k: ["999"] if k == "interface_ids" else []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_object_or_404", return_value=obj):
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.DoesNotExist = Interface.DoesNotExist
mc.objects.get.side_effect = Interface.DoesNotExist
with patch("netbox_librenms_plugin.views.sync.interfaces.VMInterface") as mvc:
mvc.DoesNotExist = VMI.DoesNotExist
with patch("netbox_librenms_plugin.views.sync.interfaces.transaction") as mt:
mt.atomic = _pa
resp = v.post(req, "device", 1)
data = json.loads(resp.content)
assert any("999" in e for e in data.get("errors", []))
def test_response_with_errors_includes_error_message(self):
import json
v = _make_dv()
v.require_all_permissions_json = MagicMock(return_value=None)
v.get_required_permissions_for_object_type = MagicMock(return_value=[])
obj = MagicMock()
obj.id = 1
obj.virtual_chassis = None
iface_ok = MagicMock()
iface_ok.name = "eth0"
iface_ok.device_id = 1
iface_bad = MagicMock()
iface_bad.name = "eth1"
iface_bad.device_id = 99
n = [0]
def get_se(**kw):
n[0] += 1
return iface_ok if n[0] == 1 else iface_bad
req = MagicMock()
req.POST.getlist.side_effect = lambda k: ["10", "20"] if k == "interface_ids" else []
with patch("netbox_librenms_plugin.views.sync.interfaces.get_object_or_404", return_value=obj):
with patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mc:
mc.objects.get.side_effect = get_se
with patch("netbox_librenms_plugin.views.sync.interfaces.transaction") as mt:
mt.atomic = _pa
resp = v.post(req, "device", 1)
data = json.loads(resp.content)
assert data["deleted_count"] == 1
assert "error(s)" in data["message"]
# ===========================================================================
# cables.py lines 147-149: exception path in process_interface_sync
# ===========================================================================
class TestCablesExceptionPath:
def test_exception_hits_147_to_149(self):
"""Lines 147-149: logger.exception + invalid.append when _passthrough_atomic used."""
from netbox_librenms_plugin.views.sync.cables import SyncCablesView
v = object.__new__(SyncCablesView)
v._librenms_api = MagicMock()
v.request = MagicMock()
def raise_err(iface, links):
raise RuntimeError("deliberate for coverage")
v.process_single_interface = raise_err
with patch("netbox_librenms_plugin.views.sync.cables.transaction") as mt:
mt.atomic = _pa
results = v.process_interface_sync([{"local_port_id": "eth_x"}], [])
assert "eth_x" in results["invalid"]
# ===========================================================================
# devices.py lines 77, 81-82: port_association_mode + invalid poller_group
# ===========================================================================
class TestDevicesFormValidEdgeCases:
def _v(self):
from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView
v = object.__new__(AddDeviceToLibreNMSView)
v._librenms_api = MagicMock()
v._librenms_api.add_device.return_value = (True, "Added")
v._librenms_api.server_key = "default"
v.request = MagicMock()
v.object = MagicMock()
v.object.get_absolute_url.return_value = "/d/"
return v
def test_port_association_mode_line_77(self):
"""Line 77: device_data[port_association_mode] set when truthy."""
v = self._v()
f = MagicMock()
f.cleaned_data = {"hostname": "h", "force_add": False, "port_association_mode": 2, "community": "pub"}
with patch("netbox_librenms_plugin.views.sync.devices.messages"):
with patch("netbox_librenms_plugin.views.sync.devices.redirect"):
v.form_valid(f, snmp_version="v2c")
dd = v._librenms_api.add_device.call_args[0][0]
assert dd["port_association_mode"] == 2
def test_invalid_poller_group_lines_81_82(self):
"""Lines 81-82: except (ValueError, TypeError) silently catches invalid int."""
v = self._v()
f = MagicMock()
f.cleaned_data = {"hostname": "h", "force_add": False, "poller_group": "bad-int", "community": "pub"}
with patch("netbox_librenms_plugin.views.sync.devices.messages"):
with patch("netbox_librenms_plugin.views.sync.devices.redirect"):
v.form_valid(f, snmp_version="v2c")
dd = v._librenms_api.add_device.call_args[0][0]
assert "poller_group" not in dd
# ===========================================================================
# locations.py lines 26-28, 32-35, 44-49
# ===========================================================================
class TestSyncSiteLocationViewGetTable:
def test_get_table_configures_table(self):
"""Lines 26-28: get_table calls super().get_table then table.configure(request)."""
import django_tables2
from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView
v = object.__new__(SyncSiteLocationView)
v.request = MagicMock()
mt = MagicMock()
with patch.object(django_tables2.SingleTableView, "get_table", return_value=mt):
result = v.get_table()
mt.configure.assert_called_once_with(v.request)
assert result is mt
class TestSyncSiteLocationViewGetContextData:
def test_adds_filter_form(self):
"""Lines 32-35: adds filter_form to context."""
import django_tables2
from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView
v = object.__new__(SyncSiteLocationView)
v.request = MagicMock()
v.request.GET = {}
mf = MagicMock()
mf.return_value.form = MagicMock()
v.filterset = mf
with patch.object(django_tables2.SingleTableView, "get_context_data", return_value={}):
with patch.object(type(v), "get_queryset", return_value=[]):
ctx = v.get_context_data()
assert "filter_form" in ctx
class TestSyncSiteLocationViewGetQuerysetSuccess:
def test_returns_sync_data(self):
"""Lines 44, 49: build sync_data list and return it."""
from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView
v = object.__new__(SyncSiteLocationView)
v.request = MagicMock()
v.request.GET = {}
v.filterset = None
sd = MagicMock()
with patch("netbox_librenms_plugin.views.sync.locations.Site") as ms:
ms.objects.all.return_value = [MagicMock()]
with patch.object(v, "get_librenms_locations", return_value=(True, [{"location": "T"}])):
with patch.object(v, "create_sync_data", return_value=sd):
result = v.get_queryset()
assert result == [sd]
def test_filterset_branch(self):
"""Lines 46-47: filterset branch when request.GET is truthy."""
from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView
v = object.__new__(SyncSiteLocationView)
v.request = MagicMock()
v.request.GET = {"name": "x"}
mf = MagicMock()
filtered = [MagicMock()]
mf.return_value.qs = filtered
v.filterset = mf
with patch("netbox_librenms_plugin.views.sync.locations.Site") as ms:
ms.objects.all.return_value = [MagicMock()]
with patch.object(v, "get_librenms_locations", return_value=(True, [{"location": "T"}])):
with patch.object(v, "create_sync_data", return_value=MagicMock()):
result = v.get_queryset()
assert result is filtered
# ===========================================================================
# vlans.py lines 134-139: grouped VLAN update/skip within if row_vlan_group: block
# ===========================================================================
class TestVlansGroupedUpdateAndSkip:
def _v(self):
from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView
v = object.__new__(SyncVLANsView)
v._librenms_api = MagicMock()
v._librenms_api.server_key = "default"
v._post_server_key = "default"
v.get_cache_key = MagicMock(return_value="k")
v._redirect = MagicMock(return_value=MagicMock())
req = MagicMock()
req.POST.getlist = lambda k: ["100"] if k == "select" else []
req.POST.get = lambda key, default="": "3" if key == "vlan_group_100" else default
v.request = req
return v
def test_grouped_update_path_lines_134_to_137(self):
"""elif vlan.name != librenms_name: update triggered."""
from ipam.models import VLANGroup
v = self._v()
mg = MagicMock()
mv = MagicMock()
mv.name = "OldName"
with patch("netbox_librenms_plugin.views.sync.vlans.cache") as mc:
mc.get.return_value = [{"vlan_vlan": 100, "vlan_name": "NewName"}]
with patch("netbox_librenms_plugin.views.sync.vlans.VLANGroup") as mvg:
mvg.DoesNotExist = VLANGroup.DoesNotExist
mvg.objects.get.return_value = mg
with patch("netbox_librenms_plugin.views.sync.vlans.VLAN") as mvl:
mvl.objects.get_or_create.return_value = (mv, False)
with patch("netbox_librenms_plugin.views.sync.vlans.transaction"):
with patch("netbox_librenms_plugin.views.sync.vlans.messages") as mm:
v._handle_create_vlans(v.request, MagicMock(), "device", 1)
mv.save.assert_called_once()
assert mv.name == "NewName"
assert "updated" in str(mm.success.call_args)
def test_grouped_skip_path_lines_138_to_139(self):
"""else: skipped_count when name unchanged."""
from ipam.models import VLANGroup
v = self._v()
mg = MagicMock()
mv = MagicMock()
mv.name = "Same"
with patch("netbox_librenms_plugin.views.sync.vlans.cache") as mc:
mc.get.return_value = [{"vlan_vlan": 100, "vlan_name": "Same"}]
with patch("netbox_librenms_plugin.views.sync.vlans.VLANGroup") as mvg:
mvg.DoesNotExist = VLANGroup.DoesNotExist
mvg.objects.get.return_value = mg
with patch("netbox_librenms_plugin.views.sync.vlans.VLAN") as mvl:
mvl.objects.get_or_create.return_value = (mv, False)
with patch("netbox_librenms_plugin.views.sync.vlans.transaction"):
with patch("netbox_librenms_plugin.views.sync.vlans.messages") as mm:
v._handle_create_vlans(v.request, MagicMock(), "device", 1)
mv.save.assert_not_called()
assert "unchanged" in str(mm.success.call_args)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,551 @@
"""Coverage tests for utils.py missing lines."""
from unittest.mock import MagicMock, patch
class TestConvertSpeedToKbps:
"""Boundary and type tests for convert_speed_to_kbps."""
def test_none_returns_none(self):
from netbox_librenms_plugin.utils import convert_speed_to_kbps
assert convert_speed_to_kbps(None) is None
def test_zero_returns_zero(self):
from netbox_librenms_plugin.utils import convert_speed_to_kbps
assert convert_speed_to_kbps(0) == 0
def test_sub_kbps_rounds_down_to_zero(self):
from netbox_librenms_plugin.utils import convert_speed_to_kbps
assert convert_speed_to_kbps(1) == 0
assert convert_speed_to_kbps(999) == 0
def test_exact_kbps_boundary(self):
from netbox_librenms_plugin.utils import convert_speed_to_kbps
assert convert_speed_to_kbps(1000) == 1
def test_1gbps(self):
from netbox_librenms_plugin.utils import convert_speed_to_kbps
assert convert_speed_to_kbps(1_000_000_000) == 1_000_000
def test_string_input_raises_type_error(self):
import pytest
from netbox_librenms_plugin.utils import convert_speed_to_kbps
with pytest.raises(TypeError):
convert_speed_to_kbps("1000000")
class TestGetVirtualChassisMemberException:
"""Tests for get_virtual_chassis_member exception path (lines 76-77)."""
def test_exception_returns_original_device(self):
"""When ObjectDoesNotExist raised, return original device."""
from django.core.exceptions import ObjectDoesNotExist
from netbox_librenms_plugin.utils import get_virtual_chassis_member
device = MagicMock()
device.virtual_chassis = MagicMock()
device.virtual_chassis.members.get.side_effect = ObjectDoesNotExist("not found")
result = get_virtual_chassis_member(device, "Ethernet1")
assert result is device
def test_no_virtual_chassis_returns_device(self):
from netbox_librenms_plugin.utils import get_virtual_chassis_member
device = MagicMock()
device.virtual_chassis = None
result = get_virtual_chassis_member(device, "Ethernet1")
assert result is device
def test_port_name_no_digit_returns_device(self):
from netbox_librenms_plugin.utils import get_virtual_chassis_member
device = MagicMock()
device.virtual_chassis = MagicMock()
# Port name with no leading digit after alpha chars → no match
result = get_virtual_chassis_member(device, "Management")
assert result is device
class TestGetLibreNMSSyncDeviceServerKey:
"""Tests for get_librenms_sync_device with server_key (lines 113-125)."""
def test_returns_member_with_dict_cf_for_server_key(self):
"""Priority 1: member with dict CF matching server_key."""
from netbox_librenms_plugin.utils import get_librenms_sync_device
device = MagicMock()
vc = MagicMock()
device.virtual_chassis = vc
member1 = MagicMock()
member1.cf = {"librenms_id": {"default": 42}}
member2 = MagicMock()
member2.cf = {"librenms_id": None}
vc.members.all.return_value = [member1, member2]
result = get_librenms_sync_device(device, server_key="default")
assert result is member1
def test_falls_back_to_get_librenms_device_id_when_no_dict(self):
"""Priority 2 legacy: falls back to get_librenms_device_id."""
from netbox_librenms_plugin.utils import get_librenms_sync_device
device = MagicMock()
vc = MagicMock()
device.virtual_chassis = vc
member = MagicMock()
member.cf = {"librenms_id": None}
member.primary_ip = MagicMock()
vc.members.all.return_value = [member]
vc.master = None
with patch("netbox_librenms_plugin.utils.get_librenms_device_id") as mock_get_id:
mock_get_id.return_value = 99
result = get_librenms_sync_device(device, server_key="default")
assert result is member
def test_server_key_none_matches_any_dict_member(self):
"""server_key=None: matches any member with any librenms_id in dict."""
from netbox_librenms_plugin.utils import get_librenms_sync_device
device = MagicMock()
vc = MagicMock()
device.virtual_chassis = vc
member_with_id = MagicMock()
member_with_id.cf = {"librenms_id": {"primary": 10}}
member_without_id = MagicMock()
member_without_id.cf = {"librenms_id": None}
vc.members.all.return_value = [member_without_id, member_with_id]
result = get_librenms_sync_device(device, server_key=None)
assert result is member_with_id
def test_server_key_none_matches_legacy_cf(self):
"""server_key=None: matches member with legacy bare int librenms_id."""
from netbox_librenms_plugin.utils import get_librenms_sync_device
device = MagicMock()
vc = MagicMock()
device.virtual_chassis = vc
member = MagicMock()
member.cf = {"librenms_id": 42} # legacy bare int
vc.members.all.return_value = [member]
result = get_librenms_sync_device(device, server_key=None)
assert result is member
class TestGetLibreNMSSyncDeviceLegacyInt:
"""Tests for get_librenms_sync_device legacy int CF (lines 132-133)."""
def test_legacy_int_cf_with_server_key_uses_get_id(self):
"""server_key set, raw_cf is legacy int → doesn't match dict path, falls back."""
from netbox_librenms_plugin.utils import get_librenms_sync_device
device = MagicMock()
vc = MagicMock()
device.virtual_chassis = vc
member = MagicMock()
member.cf = {"librenms_id": 55} # legacy int, not dict
vc.members.all.return_value = [member]
vc.master = None
with patch("netbox_librenms_plugin.utils.get_librenms_device_id") as mock_get_id:
mock_get_id.return_value = 55
result = get_librenms_sync_device(device, server_key="default")
assert result is member
class TestGetLibreNMSSyncDeviceFallbacks:
"""Tests for get_librenms_sync_device fallback paths (lines 138-150)."""
def test_falls_back_to_master_with_primary_ip(self):
"""When no member has librenms_id, uses master with primary IP."""
from netbox_librenms_plugin.utils import get_librenms_sync_device
device = MagicMock()
vc = MagicMock()
device.virtual_chassis = vc
member = MagicMock()
member.cf = {"librenms_id": None}
master = MagicMock()
master.primary_ip = MagicMock()
vc.master = master
vc.members.all.return_value = [member]
with patch("netbox_librenms_plugin.utils.get_librenms_device_id", return_value=None):
result = get_librenms_sync_device(device, server_key="default")
assert result is master
def test_falls_back_to_any_member_with_primary_ip(self):
"""When no master, falls back to any member with primary IP."""
from netbox_librenms_plugin.utils import get_librenms_sync_device
device = MagicMock()
vc = MagicMock()
device.virtual_chassis = vc
member_no_ip = MagicMock()
member_no_ip.cf = {"librenms_id": None}
member_no_ip.primary_ip = None
member_with_ip = MagicMock()
member_with_ip.cf = {"librenms_id": None}
member_with_ip.primary_ip = MagicMock()
vc.master = None
vc.members.all.return_value = [member_no_ip, member_with_ip]
with patch("netbox_librenms_plugin.utils.get_librenms_device_id", return_value=None):
result = get_librenms_sync_device(device, server_key="default")
assert result is member_with_ip
def test_falls_back_to_lowest_vc_position(self):
"""Fallback to member with lowest vc_position when no IPs."""
from netbox_librenms_plugin.utils import get_librenms_sync_device
device = MagicMock()
vc = MagicMock()
device.virtual_chassis = vc
m1 = MagicMock()
m1.cf = {"librenms_id": None}
m1.primary_ip = None
m1.vc_position = 3
m2 = MagicMock()
m2.cf = {"librenms_id": None}
m2.primary_ip = None
m2.vc_position = 1
vc.master = None
vc.members.all.return_value = [m1, m2]
with patch("netbox_librenms_plugin.utils.get_librenms_device_id", return_value=None):
result = get_librenms_sync_device(device, server_key="default")
assert result is m2
class TestGetTablePaginateCountValueError:
"""Tests for get_table_paginate_count ValueError path (lines 169-170)."""
def test_invalid_per_page_falls_back_to_default(self):
from netbox_librenms_plugin.utils import get_table_paginate_count
request = MagicMock()
request.GET = {"table_per_page": "not_a_number"}
with patch("netbox_librenms_plugin.utils.get_config"):
with patch("netbox_librenms_plugin.utils.netbox_get_paginate_count") as mock_paginate:
mock_paginate.return_value = 50
result = get_table_paginate_count(request, "table_")
assert result == 50
class TestGetUserPrefNoConfig:
"""Tests for get_user_pref when user has no config (line 179)."""
def test_returns_default_when_no_config_attr(self):
from netbox_librenms_plugin.utils import get_user_pref
request = MagicMock(spec=["user"])
request.user = MagicMock(spec=["has_perm"]) # No 'config' attr
result = get_user_pref(request, "some.pref", default="fallback")
assert result == "fallback"
def test_returns_none_when_no_user(self):
from netbox_librenms_plugin.utils import get_user_pref
request = MagicMock(spec=[]) # No 'user' attr
result = get_user_pref(request, "some.pref")
assert result is None
class TestSaveUserPrefExceptions:
"""Tests for save_user_pref TypeError/ValueError exceptions (lines 187-188)."""
def test_type_error_is_swallowed(self):
from netbox_librenms_plugin.utils import save_user_pref
request = MagicMock()
request.user = MagicMock()
request.user.config.set.side_effect = TypeError("bad type")
# Should not raise
save_user_pref(request, "some.pref", "value")
def test_value_error_is_swallowed(self):
from netbox_librenms_plugin.utils import save_user_pref
request = MagicMock()
request.user = MagicMock()
request.user.config.set.side_effect = ValueError("bad value")
save_user_pref(request, "some.pref", "value")
class TestMatchLibrenmsHardwareImportError:
"""Tests for DeviceTypeMapping ImportError guard (line 242)."""
def test_no_hardware_returns_no_match(self):
"""Empty hardware string returns no match."""
from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type
result = match_librenms_hardware_to_device_type("")
assert result["matched"] is False
def test_dash_hardware_returns_no_match(self):
"""'-' hardware returns no match."""
from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type
result = match_librenms_hardware_to_device_type("-")
assert result["matched"] is False
class TestMatchLibrenmsHardwareDeviceTypeMappingPaths:
"""Tests for DeviceTypeMapping paths (lines 251-261)."""
def test_device_type_mapping_found(self):
"""DeviceTypeMapping.objects.get returns match → return mapping result."""
from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type
mock_device_type = MagicMock()
mock_mapping = MagicMock()
mock_mapping.netbox_device_type = mock_device_type
DoesNotExist = type("DoesNotExist", (Exception,), {})
MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {})
mock_dtm_class = MagicMock()
mock_dtm_class.DoesNotExist = DoesNotExist
mock_dtm_class.MultipleObjectsReturned = MultipleObjectsReturned
mock_dtm_class.objects.get.return_value = mock_mapping
with patch("netbox_librenms_plugin.models.DeviceTypeMapping", mock_dtm_class, create=True):
result = match_librenms_hardware_to_device_type("C9300-48P")
assert result["matched"] is True
assert result["device_type"] is mock_device_type
assert result["match_type"] == "mapping"
def test_device_type_mapping_multiple_returns_logs_warning(self):
"""DeviceTypeMapping.MultipleObjectsReturned → logs warning and skips mapping."""
from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type
DoesNotExist = type("DoesNotExist", (Exception,), {})
MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {})
mock_dtm_class = MagicMock()
mock_dtm_class.DoesNotExist = DoesNotExist
mock_dtm_class.MultipleObjectsReturned = MultipleObjectsReturned
mock_dtm_class.objects.get.side_effect = MultipleObjectsReturned("multiple")
dt_DoesNotExist = type("DoesNotExist", (Exception,), {})
dt_MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {})
with patch("netbox_librenms_plugin.models.DeviceTypeMapping", mock_dtm_class, create=True):
with patch("dcim.models.DeviceType") as MockDT:
MockDT.DoesNotExist = dt_DoesNotExist
MockDT.MultipleObjectsReturned = dt_MultipleObjectsReturned
MockDT.objects.get.side_effect = dt_DoesNotExist("no match")
result = match_librenms_hardware_to_device_type("Ambiguous Hardware")
assert result is None # multiple DeviceTypeMapping matches returns None (ambiguous)
class TestMatchLibrenmsHardwareDeviceTypeMultipleReturned:
"""Tests for DeviceType MultipleObjectsReturned — ambiguity surfaces as None."""
def test_part_number_multiple_returns_none(self):
"""DeviceType.MultipleObjectsReturned for part_number → return None (not silently pick first)."""
from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type
DoesNotExist = type("DoesNotExist", (Exception,), {})
MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {})
dtm_DoesNotExist = type("DoesNotExist", (Exception,), {})
mock_dtm = MagicMock()
mock_dtm.DoesNotExist = dtm_DoesNotExist
mock_dtm.MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {})
mock_dtm.objects.get.side_effect = dtm_DoesNotExist()
with patch("netbox_librenms_plugin.models.DeviceTypeMapping", mock_dtm, create=True):
with patch("dcim.models.DeviceType") as MockDT:
MockDT.DoesNotExist = DoesNotExist
MockDT.MultipleObjectsReturned = MultipleObjectsReturned
MockDT.objects.get.side_effect = MultipleObjectsReturned("multiple")
result = match_librenms_hardware_to_device_type("C9300")
assert result is None
def test_model_multiple_returns_none(self):
"""DeviceType.MultipleObjectsReturned for model → return None (not silently pick first)."""
from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type
DoesNotExist = type("DoesNotExist", (Exception,), {})
MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {})
def get_side_effect(**kwargs):
if "part_number__iexact" in kwargs:
raise DoesNotExist("no part number")
raise MultipleObjectsReturned("multiple models")
dtm_DoesNotExist = type("DoesNotExist", (Exception,), {})
mock_dtm = MagicMock()
mock_dtm.DoesNotExist = dtm_DoesNotExist
mock_dtm.MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {})
mock_dtm.objects.get.side_effect = dtm_DoesNotExist()
with patch("netbox_librenms_plugin.models.DeviceTypeMapping", mock_dtm, create=True):
with patch("dcim.models.DeviceType") as MockDT:
MockDT.DoesNotExist = DoesNotExist
MockDT.MultipleObjectsReturned = MultipleObjectsReturned
MockDT.objects.get.side_effect = get_side_effect
result = match_librenms_hardware_to_device_type("SomeModel")
assert result is None
class TestFindMatchingSiteMultipleReturned:
"""Tests for find_matching_site MultipleObjectsReturned (lines 325-327)."""
def test_multiple_objects_returned_uses_first(self):
from netbox_librenms_plugin.utils import find_matching_site
mock_site = MagicMock()
Site_DoesNotExist = type("DoesNotExist", (Exception,), {})
Site_MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {})
with patch("dcim.models.Site") as MockSite:
MockSite.DoesNotExist = Site_DoesNotExist
MockSite.MultipleObjectsReturned = Site_MultipleObjectsReturned
MockSite.objects.get.side_effect = Site_MultipleObjectsReturned("multiple")
MockSite.objects.filter.return_value.first.return_value = mock_site
result = find_matching_site("NYC")
assert result["found"] is True
assert result["site"] is mock_site
class TestFindMatchingPlatformMultipleReturned:
"""Tests for find_matching_platform MultipleObjectsReturned (lines 358-360)."""
def test_multiple_objects_returned_uses_first(self):
from netbox_librenms_plugin.utils import find_matching_platform
mock_platform = MagicMock()
Platform_DoesNotExist = type("DoesNotExist", (Exception,), {})
Platform_MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {})
with patch("dcim.models.Platform") as MockPlatform:
MockPlatform.DoesNotExist = Platform_DoesNotExist
MockPlatform.MultipleObjectsReturned = Platform_MultipleObjectsReturned
MockPlatform.objects.get.side_effect = Platform_MultipleObjectsReturned("multiple")
MockPlatform.objects.filter.return_value.first.return_value = mock_platform
result = find_matching_platform("ios")
assert result["found"] is True
assert result["platform"] is mock_platform
class TestGetMissingVlanWarning:
"""Tests for get_missing_vlan_warning when vid in missing_vlans (lines 462-467)."""
def test_vid_in_missing_vlans_returns_warning_html(self):
from netbox_librenms_plugin.utils import get_missing_vlan_warning
result = get_missing_vlan_warning(100, [100, 200])
assert "mdi-alert" in result
assert "text-danger" in result
def test_vid_not_in_missing_vlans_returns_empty_string(self):
from netbox_librenms_plugin.utils import get_missing_vlan_warning
result = get_missing_vlan_warning(999, [100, 200])
assert result == ""
class TestGetLibreNMSDeviceIdStringNormalization:
"""Tests for get_librenms_device_id string normalization (lines 557-558)."""
def test_string_id_normalized_to_int_and_saved(self):
"""String stored as librenms_id is normalized to int and saved."""
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": "42"}
obj.custom_field_data = {"librenms_id": "42"}
result = get_librenms_device_id(obj, "default", auto_save=True)
assert result == 42
# Should save to normalize
obj.save.assert_called_once()
def test_string_id_returned_without_save_when_auto_save_false(self):
"""String normalized but not saved when auto_save=False."""
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": "99"}
obj.custom_field_data = {"librenms_id": "99"}
result = get_librenms_device_id(obj, "default", auto_save=False)
assert result == 99
obj.save.assert_not_called()
def test_dict_with_string_value_normalized(self):
"""Dict entry with string value is normalized to int."""
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": {"default": "77"}}
obj.custom_field_data = {"librenms_id": {"default": "77"}}
result = get_librenms_device_id(obj, "default", auto_save=True)
assert result == 77
obj.save.assert_called_once()
def test_invalid_string_returns_none(self):
"""Non-digit string in librenms_id returns None."""
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": "not-a-number"}
obj.custom_field_data = {"librenms_id": "not-a-number"}
result = get_librenms_device_id(obj, "default")
assert result is None
class TestFindByLibreNMSIdNoneGuard:
"""Verify find_by_librenms_id returns None for None input without querying the DB."""
def test_none_id_returns_none_without_query(self):
"""find_by_librenms_id(None, ...) must return None without hitting the DB."""
from netbox_librenms_plugin.utils import find_by_librenms_id
model = MagicMock()
result = find_by_librenms_id(model, None, server_key="default")
assert result is None
model.objects.filter.assert_not_called()

View File

@@ -0,0 +1,294 @@
"""Coverage tests for virtual_chassis.py lines 431 and 435."""
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
def _make_master_device(serial="MASTER001"):
"""Build a mock master Device for VC creation tests."""
master = MagicMock()
master.name = "switch-master"
master.serial = serial
master.pk = 1
master.rack = None
master.location = None
master.device_type = MagicMock()
master.role = MagicMock()
master.site = MagicMock()
master.platform = MagicMock()
return master
class TestCreateVirtualChassisWithMembersPositionConflict:
"""Tests specifically for lines 431 and 435 - position conflict resolution."""
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.transaction")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.VirtualChassis")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.Device")
def test_line_431_position_conflict_sets_discovered_pos_to_none(
self, mock_Device, mock_VirtualChassis, mock_load_pattern, mock_transaction
):
"""
Line 431: discovered_pos = None when position already in used_positions.
Scenario: master is at position 1 (used_positions = {1}).
First member takes position 2. Second member also claims position 2
→ discovered_pos set to None → falls back to sequential (position 3).
"""
from netbox_librenms_plugin.import_utils.virtual_chassis import create_virtual_chassis_with_members
# Make transaction.atomic() a no-op context manager
@contextmanager
def noop_atomic():
yield
mock_transaction.atomic = noop_atomic
mock_load_pattern.return_value = "-M{position}"
master = _make_master_device("MASTER001")
vc_mock = MagicMock()
vc_mock.members.count.return_value = 3
mock_VirtualChassis.objects.create.return_value = vc_mock
# Device.objects.filter(...).exists() → False (no conflicts)
mock_filter = MagicMock()
mock_filter.exists.return_value = False
mock_filter.exclude.return_value = mock_filter
mock_Device.objects.filter.return_value = mock_filter
mock_Device.objects.create.return_value = MagicMock()
# Members: first at position 2, second ALSO at position 2 (conflict)
members_info = [
{"serial": "SN002", "position": 2, "name": "Member2"},
{"serial": "SN003", "position": 2, "name": "Member3-conflict"}, # triggers line 431
]
libre_device = {"device_id": 99}
create_virtual_chassis_with_members(master, members_info, libre_device)
# VC should be created
mock_VirtualChassis.objects.create.assert_called_once()
# Two Device.objects.create calls for the two non-master members
create_calls = mock_Device.objects.create.call_args_list
assert len(create_calls) == 2
# Map serial -> vc_position for precise identity assertions
serial_to_pos = {c.kwargs.get("serial"): c.kwargs.get("vc_position") for c in create_calls}
# First member (SN002) takes its explicit position 2
assert serial_to_pos.get("SN002") == 2
# Second member (SN003) conflicts at 2, falls back to 3
assert serial_to_pos.get("SN003") == 3
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.transaction")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.VirtualChassis")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.Device")
def test_line_435_while_loop_skips_taken_slots(
self, mock_Device, mock_VirtualChassis, mock_load_pattern, mock_transaction
):
"""Line 435: position += 1 in while loop when sequential slot is taken."""
from netbox_librenms_plugin.import_utils.virtual_chassis import create_virtual_chassis_with_members
@contextmanager
def noop_atomic():
yield
mock_transaction.atomic = noop_atomic
mock_load_pattern.return_value = "-M{position}"
master = _make_master_device("MASTER001")
vc_mock = MagicMock()
vc_mock.members.count.return_value = 3
mock_VirtualChassis.objects.create.return_value = vc_mock
mock_filter = MagicMock()
mock_filter.exists.return_value = False
mock_filter.exclude.return_value = mock_filter
mock_Device.objects.filter.return_value = mock_filter
mock_Device.objects.create.return_value = MagicMock()
# Member A explicitly at position 2
# Member B has no position → sequential starts at 2 → taken → increments to 3 (line 435)
members_info = [
{"serial": "SN002", "position": 2, "name": "Member-explicit-2"},
{"serial": "SN003", "position": None, "name": "Member-no-pos"}, # triggers line 435
]
libre_device = {"device_id": 99}
create_virtual_chassis_with_members(master, members_info, libre_device)
mock_VirtualChassis.objects.create.assert_called_once()
create_calls = mock_Device.objects.create.call_args_list
positions_used = [c.kwargs.get("vc_position") for c in create_calls]
# First member gets explicit position 2; second (no position) gets 3 after 2 is taken
assert sorted(positions_used) == [2, 3]
actual_entries = sorted([(c.kwargs.get("serial"), c.kwargs.get("vc_position")) for c in create_calls])
assert actual_entries == [("SN002", 2), ("SN003", 3)]
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.transaction")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.VirtualChassis")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.Device")
def test_multiple_sequential_slots_taken_skips_all(
self, mock_Device, mock_VirtualChassis, mock_load_pattern, mock_transaction
):
"""Multiple sequential increments: position = 2, 3 all taken → gets 4."""
from netbox_librenms_plugin.import_utils.virtual_chassis import create_virtual_chassis_with_members
@contextmanager
def noop_atomic():
yield
mock_transaction.atomic = noop_atomic
mock_load_pattern.return_value = "-M{position}"
master = _make_master_device("MASTER001")
vc_mock = MagicMock()
vc_mock.members.count.return_value = 4
mock_VirtualChassis.objects.create.return_value = vc_mock
mock_filter = MagicMock()
mock_filter.exists.return_value = False
mock_filter.exclude.return_value = mock_filter
mock_Device.objects.filter.return_value = mock_filter
mock_Device.objects.create.return_value = MagicMock()
# Members at positions 2 and 3; then one with no position → should get 4
members_info = [
{"serial": "SN002", "position": 2, "name": "M2"},
{"serial": "SN003", "position": 3, "name": "M3"},
{"serial": "SN004", "position": None, "name": "M-no-pos"}, # should get 4
]
libre_device = {"device_id": 10}
create_virtual_chassis_with_members(master, members_info, libre_device)
create_calls = mock_Device.objects.create.call_args_list
positions_used = [c.kwargs.get("vc_position") for c in create_calls]
# Members at 2 and 3 are explicit; the member with no position gets 4
assert sorted(positions_used) == [2, 3, 4]
actual_entries = sorted([(c.kwargs.get("serial"), c.kwargs.get("vc_position")) for c in create_calls])
assert actual_entries == [("SN002", 2), ("SN003", 3), ("SN004", 4)]
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.transaction")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.VirtualChassis")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.Device")
def test_member_with_same_serial_as_master_is_skipped(
self, mock_Device, mock_VirtualChassis, mock_load_pattern, mock_transaction
):
"""Members with same serial as master device should be skipped."""
from netbox_librenms_plugin.import_utils.virtual_chassis import create_virtual_chassis_with_members
@contextmanager
def noop_atomic():
yield
mock_transaction.atomic = noop_atomic
mock_load_pattern.return_value = "-M{position}"
master = _make_master_device("MASTER_SERIAL")
vc_mock = MagicMock()
vc_mock.members.count.return_value = 1
mock_VirtualChassis.objects.create.return_value = vc_mock
mock_filter = MagicMock()
mock_filter.exists.return_value = False
mock_filter.exclude.return_value = mock_filter
mock_Device.objects.filter.return_value = mock_filter
mock_Device.objects.create.return_value = MagicMock()
members_info = [
{"serial": "MASTER_SERIAL", "position": 2, "name": "Master-dup"}, # skipped
{"serial": "SN999", "position": 3, "name": "Real member"},
]
libre_device = {"device_id": 5}
create_virtual_chassis_with_members(master, members_info, libre_device)
# Only one Device.objects.create for the non-duplicate member
create_calls = mock_Device.objects.create.call_args_list
assert len(create_calls) == 1
assert create_calls[0].kwargs.get("serial") == "SN999"
class TestCreateVirtualChassisServerKeyDomain:
"""Tests for server_key parameter in create_virtual_chassis_with_members domain."""
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.transaction")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.VirtualChassis")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.Device")
def test_server_key_included_in_domain(self, mock_Device, mock_VirtualChassis, mock_load_pattern, mock_transaction):
"""With server_key='production', domain should contain 'librenms-production-'."""
from contextlib import contextmanager
from netbox_librenms_plugin.import_utils.virtual_chassis import create_virtual_chassis_with_members
@contextmanager
def noop_atomic():
yield
mock_transaction.atomic = noop_atomic
mock_load_pattern.return_value = "-M{position}"
master = _make_master_device("SN001")
vc_mock = MagicMock()
vc_mock.members.count.return_value = 1
mock_VirtualChassis.objects.create.return_value = vc_mock
mock_filter = MagicMock()
mock_filter.exists.return_value = False
mock_filter.exclude.return_value = mock_filter
mock_Device.objects.filter.return_value = mock_filter
mock_Device.objects.create.return_value = MagicMock()
libre_device = {"device_id": 42}
create_virtual_chassis_with_members(master, [], libre_device, server_key="production")
call_kwargs = mock_VirtualChassis.objects.create.call_args.kwargs
assert "librenms-production-" in call_kwargs["domain"], f"domain was: {call_kwargs['domain']}"
assert "42" in call_kwargs["domain"]
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.transaction")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.VirtualChassis")
@patch("netbox_librenms_plugin.import_utils.virtual_chassis.Device")
def test_no_server_key_domain_prefix_is_librenms(
self, mock_Device, mock_VirtualChassis, mock_load_pattern, mock_transaction
):
"""Without server_key, domain should start with 'librenms-' (no server suffix)."""
from contextlib import contextmanager
from netbox_librenms_plugin.import_utils.virtual_chassis import create_virtual_chassis_with_members
@contextmanager
def noop_atomic():
yield
mock_transaction.atomic = noop_atomic
mock_load_pattern.return_value = "-M{position}"
master = _make_master_device("SN002")
vc_mock = MagicMock()
vc_mock.members.count.return_value = 1
mock_VirtualChassis.objects.create.return_value = vc_mock
mock_filter = MagicMock()
mock_filter.exists.return_value = False
mock_filter.exclude.return_value = mock_filter
mock_Device.objects.filter.return_value = mock_filter
mock_Device.objects.create.return_value = MagicMock()
libre_device = {"device_id": 99}
create_virtual_chassis_with_members(master, [], libre_device, server_key=None)
call_kwargs = mock_VirtualChassis.objects.create.call_args.kwargs
domain = call_kwargs["domain"]
assert domain.startswith("librenms-"), f"domain was: {domain}"
# Should not have a second prefix like 'librenms-None-'
assert "librenms-None" not in domain
assert "99" in domain

View File

@@ -0,0 +1,367 @@
"""
Coverage tests for netbox_librenms_plugin/tables/vlans.py
Tests cover all render methods and the configure() method of LibreNMSVLANTable.
"""
from unittest.mock import MagicMock, patch
def _make_table(data=None, vlan_groups=None):
"""Create a LibreNMSVLANTable instance with minimal data."""
from netbox_librenms_plugin.tables.vlans import LibreNMSVLANTable
return LibreNMSVLANTable(data=data or [], vlan_groups=vlan_groups)
# ===========================================================================
# __init__ / construction
# ===========================================================================
class TestLibreNMSVLANTableInit:
"""Tests for LibreNMSVLANTable.__init__()."""
def test_default_prefix_set(self):
table = _make_table()
assert table.prefix == "vlans_"
def test_vlan_groups_default_to_empty_list(self):
table = _make_table()
assert table.vlan_groups == []
def test_vlan_groups_stored_when_provided(self):
mock_group = MagicMock()
table = _make_table(vlan_groups=[mock_group])
assert table.vlan_groups == [mock_group]
def test_none_vlan_groups_normalised_to_empty_list(self):
table = _make_table(vlan_groups=None)
assert table.vlan_groups == []
# ===========================================================================
# render_vlan_id
# ===========================================================================
class TestRenderVlanId:
"""Tests for LibreNMSVLANTable.render_vlan_id()."""
def test_text_success_when_exists_and_name_matches(self):
table = _make_table()
record = {"exists_in_netbox": True, "name_matches": True}
html = str(table.render_vlan_id(100, record))
assert "text-success" in html
assert "100" in html
def test_text_warning_when_exists_but_name_mismatch(self):
table = _make_table()
record = {"exists_in_netbox": True, "name_matches": False}
html = str(table.render_vlan_id(200, record))
assert "text-warning" in html
assert "200" in html
def test_text_danger_when_not_in_netbox(self):
table = _make_table()
record = {"exists_in_netbox": False, "name_matches": True}
html = str(table.render_vlan_id(300, record))
assert "text-danger" in html
assert "300" in html
def test_default_name_matches_true_when_absent(self):
"""When name_matches key is absent, defaults to True → text-success if exists."""
table = _make_table()
record = {"exists_in_netbox": True} # name_matches key absent
html = str(table.render_vlan_id(10, record))
assert "text-success" in html
# ===========================================================================
# render_name
# ===========================================================================
class TestRenderName:
"""Tests for LibreNMSVLANTable.render_name()."""
def test_text_success_when_synced(self):
table = _make_table()
record = {"exists_in_netbox": True, "name_matches": True}
html = str(table.render_name("DATA", record))
assert "text-success" in html
assert "DATA" in html
def test_text_danger_when_not_in_netbox(self):
table = _make_table()
record = {"exists_in_netbox": False, "name_matches": True}
html = str(table.render_name("VOICE", record))
assert "text-danger" in html
assert "VOICE" in html
def test_tooltip_added_on_name_mismatch(self):
"""When exists_in_netbox=True and name_matches=False, tooltip with NetBox name is shown."""
table = _make_table()
record = {
"exists_in_netbox": True,
"name_matches": False,
"netbox_vlan_name": "OLD_NAME",
}
html = str(table.render_name("NEW_NAME", record))
assert "text-warning" in html
assert "NEW_NAME" in html
assert "OLD_NAME" in html
assert "title=" in html
def test_empty_name_rendered_as_empty_string(self):
"""render_name handles None/empty value."""
table = _make_table()
record = {"exists_in_netbox": False, "name_matches": True}
html = str(table.render_name(None, record))
assert "text-danger" in html
def test_tooltip_contains_both_names(self):
table = _make_table()
record = {
"exists_in_netbox": True,
"name_matches": False,
"netbox_vlan_name": "NetBox-VLANName",
}
html = str(table.render_name("LibreNMSName", record))
assert "NetBox-VLANName" in html
assert "LibreNMSName" in html
def test_no_tooltip_when_names_match(self):
table = _make_table()
record = {"exists_in_netbox": True, "name_matches": True}
html = str(table.render_name("MGMT", record))
# Tooltip (title=) should NOT be present when names match
assert 'title="' not in html
# ===========================================================================
# render_vlan_group_selection
# ===========================================================================
class TestRenderVlanGroupSelection:
"""Tests for LibreNMSVLANTable.render_vlan_group_selection()."""
def _make_group(self, pk, name, scope=None):
group = MagicMock()
group.pk = pk
group.name = name
group.scope = scope
return group
def test_select_element_rendered(self):
table = _make_table(vlan_groups=[self._make_group(1, "Site VLANs")])
record = {"vlan_id": 10, "name": "DATA", "exists_in_netbox": False}
html = str(table.render_vlan_group_selection(None, record))
assert "<select" in html
assert 'name="vlan_group_10"' in html
def test_no_selection_by_default(self):
"""When no auto-select criteria match, no option is pre-selected."""
group = self._make_group(1, "Global")
table = _make_table(vlan_groups=[group])
record = {"vlan_id": 5, "name": "TEST", "exists_in_netbox": False}
html = str(table.render_vlan_group_selection(None, record))
# 'selected' should not appear for the group option
assert "selected" not in html
def test_existing_netbox_vlan_group_preselected(self):
"""Priority 1: existing NetBox VLAN group is pre-selected."""
group = self._make_group(pk=7, name="Existing Group")
table = _make_table(vlan_groups=[group])
record = {
"vlan_id": 20,
"name": "EXISTING",
"exists_in_netbox": True,
"netbox_vlan_group_id": 7,
}
html = str(table.render_vlan_group_selection(None, record))
assert "selected" in html
def test_auto_selected_group_preselected(self):
"""Priority 2: auto_selected_group_id is pre-selected when exists_in_netbox is False."""
group = self._make_group(pk=3, name="Auto Group")
table = _make_table(vlan_groups=[group])
record = {
"vlan_id": 30,
"name": "AUTO",
"exists_in_netbox": False,
"auto_selected_group_id": 3,
}
html = str(table.render_vlan_group_selection(None, record))
assert "selected" in html
def test_warning_icon_when_ambiguous_and_not_in_netbox(self):
"""is_ambiguous=True and exists_in_netbox=False shows a warning icon."""
table = _make_table(vlan_groups=[])
record = {
"vlan_id": 40,
"name": "AMBIG",
"exists_in_netbox": False,
"is_ambiguous": True,
}
html = str(table.render_vlan_group_selection(None, record))
assert "mdi-alert" in html
def test_no_warning_icon_when_ambiguous_but_in_netbox(self):
"""Warning icon is NOT shown when exists_in_netbox=True even if is_ambiguous."""
table = _make_table(vlan_groups=[])
record = {
"vlan_id": 50,
"name": "IN_NB",
"exists_in_netbox": True,
"is_ambiguous": True,
"netbox_vlan_group_id": None,
}
html = str(table.render_vlan_group_selection(None, record))
assert "mdi-alert" not in html
def test_no_warning_icon_when_not_ambiguous(self):
"""No warning icon when is_ambiguous is False."""
table = _make_table(vlan_groups=[])
record = {
"vlan_id": 60,
"name": "CLEAR",
"exists_in_netbox": False,
"is_ambiguous": False,
}
html = str(table.render_vlan_group_selection(None, record))
assert "mdi-alert" not in html
def test_empty_groups_shows_no_group_option_only(self):
table = _make_table(vlan_groups=[])
record = {"vlan_id": 70, "name": "NOVLAN", "exists_in_netbox": False}
html = str(table.render_vlan_group_selection(None, record))
assert "No Group" in html
def test_scope_info_appended_when_scope_present(self):
"""If group.scope is truthy, scope string is included in option."""
group = self._make_group(pk=11, name="Rack VLANs", scope="rack1")
table = _make_table(vlan_groups=[group])
record = {"vlan_id": 80, "name": "RACK", "exists_in_netbox": False}
html = str(table.render_vlan_group_selection(None, record))
assert "rack1" in html
def test_no_scope_info_when_scope_is_falsy(self):
"""If group.scope is falsy, no extra parenthetical appears."""
group = self._make_group(pk=12, name="Global VLANs", scope=None)
table = _make_table(vlan_groups=[group])
record = {"vlan_id": 90, "name": "GLOBAL", "exists_in_netbox": False}
html = str(table.render_vlan_group_selection(None, record))
# The option text should just be the group name without extra suffix
assert "Global VLANs" in html
assert "(None)" not in html
def test_vlan_id_and_name_embedded_in_select(self):
table = _make_table(vlan_groups=[])
record = {"vlan_id": 100, "name": "MY_VLAN", "exists_in_netbox": False}
html = str(table.render_vlan_group_selection(None, record))
assert 'data-vlan-id="100"' in html
assert 'data-vlan-name="MY_VLAN"' in html
# ===========================================================================
# render_state
# ===========================================================================
class TestRenderState:
"""Tests for LibreNMSVLANTable.render_state()."""
def test_active_integer_one_renders_active(self):
"""LIBRENMS_VLAN_STATE_ACTIVE == 1 → 'Active' with text-success."""
from netbox_librenms_plugin.constants import LIBRENMS_VLAN_STATE_ACTIVE
table = _make_table()
html = str(table.render_state(LIBRENMS_VLAN_STATE_ACTIVE, {}))
assert "text-success" in html
assert "Active" in html
def test_active_string_renders_active(self):
"""'active' string also renders as Active."""
table = _make_table()
html = str(table.render_state("active", {}))
assert "text-success" in html
assert "Active" in html
def test_other_value_renders_inactive(self):
table = _make_table()
html = str(table.render_state(0, {}))
assert "text-muted" in html
assert "Inactive" in html
def test_unknown_string_renders_inactive(self):
table = _make_table()
html = str(table.render_state("inactive", {}))
assert "text-muted" in html
assert "Inactive" in html
def test_none_renders_inactive(self):
table = _make_table()
html = str(table.render_state(None, {}))
assert "text-muted" in html
# ===========================================================================
# configure()
# ===========================================================================
class TestLibreNMSVLANTableConfigure:
"""Tests for LibreNMSVLANTable.configure()."""
def test_configure_calls_request_config(self):
from netbox_librenms_plugin.tables.vlans import LibreNMSVLANTable
table = LibreNMSVLANTable(data=[])
mock_request = MagicMock()
with patch("netbox_librenms_plugin.tables.vlans.tables.RequestConfig") as mock_rc_cls:
with patch("netbox_librenms_plugin.tables.vlans.get_table_paginate_count", return_value=50):
mock_rc_instance = MagicMock()
mock_rc_cls.return_value = mock_rc_instance
table.configure(mock_request)
mock_rc_cls.assert_called_once()
mock_rc_instance.configure.assert_called_once_with(table)
def test_configure_passes_enhanced_paginator(self):
from netbox_librenms_plugin.tables.vlans import LibreNMSVLANTable
from utilities.paginator import EnhancedPaginator
table = LibreNMSVLANTable(data=[])
mock_request = MagicMock()
captured_paginate = {}
def capture_rc(request, paginate):
captured_paginate.update(paginate)
rc = MagicMock()
rc.configure = MagicMock()
return rc
with patch("netbox_librenms_plugin.tables.vlans.tables.RequestConfig", side_effect=capture_rc):
with patch("netbox_librenms_plugin.tables.vlans.get_table_paginate_count", return_value=25):
table.configure(mock_request)
assert captured_paginate.get("paginator_class") is EnhancedPaginator
assert captured_paginate.get("per_page") == 25
def test_configure_uses_table_prefix_for_paginate_count(self):
from netbox_librenms_plugin.tables.vlans import LibreNMSVLANTable
table = LibreNMSVLANTable(data=[])
mock_request = MagicMock()
with patch("netbox_librenms_plugin.tables.vlans.tables.RequestConfig") as mock_rc_cls:
mock_rc_cls.return_value.configure = MagicMock()
with patch("netbox_librenms_plugin.tables.vlans.get_table_paginate_count") as mock_paginate:
mock_paginate.return_value = 10
table.configure(mock_request)
mock_paginate.assert_called_once_with(mock_request, "vlans_")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,368 @@
"""
Tests for netbox_librenms_plugin.import_validation_helpers module.
Phase 2 tests covering validation state updates, model retrieval,
and selection extraction functions.
"""
from unittest.mock import MagicMock
# =============================================================================
# TestGetModelById - 4 tests
# =============================================================================
class TestFetchModelById:
"""Test generic model retrieval helper."""
def test_fetch_model_by_id_success(self):
"""Return model instance when found."""
mock_model_class = MagicMock()
mock_instance = MagicMock(id=1, name="Access Switch")
mock_model_class.objects.get.return_value = mock_instance
from netbox_librenms_plugin.import_validation_helpers import fetch_model_by_id
result = fetch_model_by_id(mock_model_class, 1)
assert result == mock_instance
mock_model_class.objects.get.assert_called_once_with(pk=1)
def test_fetch_model_by_id_not_found(self):
"""Return None when ID doesn't exist."""
mock_model_class = MagicMock()
mock_model_class.DoesNotExist = Exception
mock_model_class.objects.get.side_effect = mock_model_class.DoesNotExist
from netbox_librenms_plugin.import_validation_helpers import fetch_model_by_id
result = fetch_model_by_id(mock_model_class, 999)
assert result is None
def test_fetch_model_by_id_invalid_id(self):
"""Handle invalid ID gracefully."""
mock_model_class = MagicMock()
mock_model_class.DoesNotExist = type("DoesNotExist", (Exception,), {})
from netbox_librenms_plugin.import_validation_helpers import fetch_model_by_id
result = fetch_model_by_id(mock_model_class, "not-a-number")
assert result is None
def test_fetch_model_by_id_none_id(self):
"""Handle None ID gracefully."""
mock_model_class = MagicMock()
from netbox_librenms_plugin.import_validation_helpers import fetch_model_by_id
result = fetch_model_by_id(mock_model_class, None)
assert result is None
mock_model_class.objects.get.assert_not_called()
# =============================================================================
# TestExtractSelections - 4 tests
# =============================================================================
class TestExtractDeviceSelections:
"""Test extraction of device selections from request."""
def test_extract_selections_all_present(self):
"""All selections extracted from POST request."""
from netbox_librenms_plugin.import_validation_helpers import (
extract_device_selections,
)
mock_request = MagicMock()
mock_request.method = "POST"
mock_request.POST = {
"cluster_1234": "5",
"role_1234": "10",
"rack_1234": "15",
}
result = extract_device_selections(mock_request, device_id=1234)
assert result["cluster_id"] == "5"
assert result["role_id"] == "10"
assert result["rack_id"] == "15"
def test_extract_selections_partial(self):
"""Missing fields return None."""
from netbox_librenms_plugin.import_validation_helpers import (
extract_device_selections,
)
mock_request = MagicMock()
mock_request.method = "POST"
mock_request.POST = {
"role_1234": "10",
}
result = extract_device_selections(mock_request, device_id=1234)
assert result["cluster_id"] is None
assert result["role_id"] == "10"
assert result["rack_id"] is None
def test_extract_selections_from_get(self):
"""Selections extracted from GET request."""
from netbox_librenms_plugin.import_validation_helpers import (
extract_device_selections,
)
mock_request = MagicMock()
mock_request.method = "GET"
mock_request.GET = {
"cluster_999": "3",
"role_999": "7",
"rack_999": "11",
}
result = extract_device_selections(mock_request, device_id=999)
assert result["cluster_id"] == "3"
assert result["role_id"] == "7"
assert result["rack_id"] == "11"
def test_extract_selections_empty_values(self):
"""Empty strings handled correctly."""
from netbox_librenms_plugin.import_validation_helpers import (
extract_device_selections,
)
mock_request = MagicMock()
mock_request.method = "POST"
mock_request.POST = {
"cluster_1234": "",
"role_1234": "",
"rack_1234": "",
}
result = extract_device_selections(mock_request, device_id=1234)
# Empty strings are returned as-is (caller decides meaning)
assert result["cluster_id"] == ""
assert result["role_id"] == ""
assert result["rack_id"] == ""
# =============================================================================
# TestValidationStateUpdates - 10 tests
# =============================================================================
class TestValidationStateUpdates:
"""Test validation state mutation functions."""
def test_apply_role_to_validation_success(self):
"""Role selection updates state correctly."""
from netbox_librenms_plugin.import_validation_helpers import (
apply_role_to_validation,
)
mock_role = MagicMock(id=1, name="Access Switch")
validation = {
"device_role": {"found": False, "role": None},
"issues": ["Device role must be manually selected before import"],
"can_import": False,
"is_ready": False,
"site": {"found": True},
"device_type": {"found": True},
}
apply_role_to_validation(validation, mock_role, is_vm=False)
assert validation["device_role"]["found"] is True
assert validation["device_role"]["role"] == mock_role
def test_apply_role_to_validation_clears_issue(self):
"""Selecting role should clear 'role' related validation issue."""
from netbox_librenms_plugin.import_validation_helpers import (
apply_role_to_validation,
)
mock_role = MagicMock(id=1, name="Access Switch")
validation = {
"device_role": {"found": False, "role": None},
"issues": ["Device role must be manually selected before import"],
"can_import": False,
"is_ready": False,
"site": {"found": True},
"device_type": {"found": True},
}
apply_role_to_validation(validation, mock_role, is_vm=False)
assert len(validation["issues"]) == 0
def test_apply_cluster_to_validation_success(self):
"""Cluster selection updates state for VM import."""
from netbox_librenms_plugin.import_validation_helpers import (
apply_cluster_to_validation,
)
mock_cluster = MagicMock(id=1, name="VMware Cluster 1")
validation = {
"cluster": {"found": False, "cluster": None},
"issues": ["Cluster must be manually selected before import"],
"can_import": False,
"is_ready": False,
}
apply_cluster_to_validation(validation, mock_cluster)
assert validation["cluster"]["found"] is True
assert validation["cluster"]["cluster"] == mock_cluster
def test_apply_rack_to_validation_success(self):
"""Rack selection updates state for device import."""
from netbox_librenms_plugin.import_validation_helpers import (
apply_rack_to_validation,
)
mock_rack = MagicMock(id=1, name="Rack A1")
validation = {
"issues": [],
"can_import": True,
"is_ready": True,
}
apply_rack_to_validation(validation, mock_rack)
assert validation["rack"]["found"] is True
assert validation["rack"]["rack"] == mock_rack
def test_remove_validation_issue_single(self):
"""Remove single issue by keyword."""
from netbox_librenms_plugin.import_validation_helpers import (
remove_validation_issue,
)
validation = {
"issues": [
"Device role must be manually selected before import",
"Site not found for location 'DC1'",
]
}
remove_validation_issue(validation, "role")
assert len(validation["issues"]) == 1
assert "Site not found" in validation["issues"][0]
def test_remove_validation_issue_multiple(self):
"""Remove multiple matching issues."""
from netbox_librenms_plugin.import_validation_helpers import (
remove_validation_issue,
)
validation = {
"issues": [
"Device role must be selected",
"Role is required for import",
"Site not found",
]
}
remove_validation_issue(validation, "role")
assert len(validation["issues"]) == 1
assert "Site not found" in validation["issues"][0]
def test_remove_validation_issue_no_match(self):
"""No change when keyword not found."""
from netbox_librenms_plugin.import_validation_helpers import (
remove_validation_issue,
)
validation = {
"issues": [
"Site not found for location 'DC1'",
"Device type not matched",
]
}
remove_validation_issue(validation, "cluster")
assert len(validation["issues"]) == 2
def test_recalculate_can_import_all_ready_device(self):
"""can_import=True when all requirements met for device."""
from netbox_librenms_plugin.import_validation_helpers import (
recalculate_validation_status,
)
validation = {
"issues": [],
"can_import": False,
"is_ready": False,
"site": {"found": True},
"device_type": {"found": True},
"device_role": {"found": True},
}
recalculate_validation_status(validation, is_vm=False)
assert validation["can_import"] is True
assert validation["is_ready"] is True
def test_recalculate_can_import_missing_required_device(self):
"""can_import=False when required field missing for device."""
from netbox_librenms_plugin.import_validation_helpers import (
recalculate_validation_status,
)
validation = {
"issues": ["Site not found"],
"can_import": True, # Should become False
"is_ready": True,
"site": {"found": False},
"device_type": {"found": True},
"device_role": {"found": True},
}
recalculate_validation_status(validation, is_vm=False)
assert validation["can_import"] is False
assert validation["is_ready"] is False
def test_recalculate_can_import_vm_cluster_required(self):
"""VM import requires cluster to be ready."""
from netbox_librenms_plugin.import_validation_helpers import (
recalculate_validation_status,
)
validation = {
"issues": [],
"can_import": False,
"is_ready": False,
"cluster": {"found": True},
}
recalculate_validation_status(validation, is_vm=True)
assert validation["can_import"] is True
assert validation["is_ready"] is True
def test_recalculate_can_import_vm_missing_cluster(self):
"""VM import not ready without cluster."""
from netbox_librenms_plugin.import_validation_helpers import (
recalculate_validation_status,
)
validation = {
"issues": [],
"can_import": False,
"is_ready": False,
"cluster": {"found": False},
}
recalculate_validation_status(validation, is_vm=True)
assert validation["can_import"] is True # No issues
assert validation["is_ready"] is False # But not ready without cluster

View File

@@ -0,0 +1,216 @@
"""Tests for netbox_librenms_plugin.__init__ module.
Covers the _ensure_librenms_id_custom_field post_migrate signal handler.
"""
from unittest.mock import MagicMock, patch
# =============================================================================
# TestEnsureLibreNMSIdCustomField - 6 tests
# =============================================================================
class TestEnsureLibreNMSIdCustomField:
"""Test _ensure_librenms_id_custom_field signal handler."""
def setup_method(self):
"""Reset per-alias execution tracking before each test."""
from netbox_librenms_plugin import _ensure_librenms_id_custom_field
_ensure_librenms_id_custom_field._executed_aliases = set()
def _setup_cf_mock(self, MockCustomField, mock_cf, created):
"""Wire MockCustomField so that objects.using(alias).get_or_create(...) returns (mock_cf, created)."""
MockCustomField.objects.using.return_value.get_or_create.return_value = (mock_cf, created)
def _setup_ct_mock(self, MockContentType, mock_ct):
"""Wire MockContentType so that objects.db_manager(alias).get_for_model(...) returns mock_ct."""
MockContentType.objects.db_manager.return_value.get_for_model.return_value = mock_ct
@patch("dcim.models.Interface", new_callable=MagicMock)
@patch("dcim.models.Device", new_callable=MagicMock)
@patch("virtualization.models.VMInterface", new_callable=MagicMock)
@patch("virtualization.models.VirtualMachine", new_callable=MagicMock)
@patch("django.contrib.contenttypes.models.ContentType")
@patch("extras.models.CustomField")
def test_creates_custom_field_when_missing(
self, MockCustomField, MockContentType, mock_vm, mock_vmif, mock_device, mock_iface
):
"""Custom field is created with correct defaults when it does not exist."""
from netbox_librenms_plugin import _ensure_librenms_id_custom_field
mock_cf = MagicMock()
mock_cf.object_types.values_list.return_value = []
self._setup_cf_mock(MockCustomField, mock_cf, True)
mock_ct = MagicMock()
mock_ct.pk = 1
self._setup_ct_mock(MockContentType, mock_ct)
with patch("logging.getLogger") as mock_get_logger:
_ensure_librenms_id_custom_field(sender=None)
MockCustomField.objects.using.return_value.get_or_create.assert_called_once_with(
name="librenms_id",
defaults={
"type": "json",
"label": "LibreNMS ID",
"description": "LibreNMS Device ID for synchronization (auto-created by plugin)",
"required": False,
"ui_visible": "if-set",
"ui_editable": "yes",
"is_cloneable": False,
},
)
# Should have added content types for all 4 models
assert mock_cf.object_types.add.call_count == 4
# Should log when created
mock_get_logger.assert_called_with("netbox_librenms_plugin")
def test_skips_when_already_executed(self):
"""Handler is a no-op on second invocation for the same DB alias."""
from netbox_librenms_plugin import _ensure_librenms_id_custom_field
_ensure_librenms_id_custom_field._executed_aliases = {"default"}
with patch("extras.models.CustomField") as MockCustomField:
_ensure_librenms_id_custom_field(sender=None)
MockCustomField.objects.using.assert_not_called()
@patch("dcim.models.Interface", new_callable=MagicMock)
@patch("dcim.models.Device", new_callable=MagicMock)
@patch("virtualization.models.VMInterface", new_callable=MagicMock)
@patch("virtualization.models.VirtualMachine", new_callable=MagicMock)
@patch("django.contrib.contenttypes.models.ContentType")
@patch("extras.models.CustomField")
def test_existing_field_not_recreated(
self, MockCustomField, MockContentType, mock_vm, mock_vmif, mock_device, mock_iface
):
"""When custom field already exists, it is not recreated but types are checked."""
from netbox_librenms_plugin import _ensure_librenms_id_custom_field
mock_cf = MagicMock()
mock_cf.object_types.values_list.return_value = [1, 2, 3, 4]
self._setup_cf_mock(MockCustomField, mock_cf, False)
mock_ct = MagicMock()
mock_ct.pk = 1
self._setup_ct_mock(MockContentType, mock_ct)
_ensure_librenms_id_custom_field(sender=None)
# All pks already present, no types should be added
mock_cf.object_types.add.assert_not_called()
@patch("dcim.models.Interface", new_callable=MagicMock)
@patch("dcim.models.Device", new_callable=MagicMock)
@patch("virtualization.models.VMInterface", new_callable=MagicMock)
@patch("virtualization.models.VirtualMachine", new_callable=MagicMock)
@patch("django.contrib.contenttypes.models.ContentType")
@patch("extras.models.CustomField")
def test_adds_missing_content_types(
self, MockCustomField, MockContentType, mock_vm, mock_vmif, mock_device, mock_iface
):
"""When some content types are missing, only those are added."""
from netbox_librenms_plugin import _ensure_librenms_id_custom_field
mock_cf = MagicMock()
mock_cf.object_types.values_list.return_value = [1, 2]
self._setup_cf_mock(MockCustomField, mock_cf, False)
ct_existing = MagicMock()
ct_existing.pk = 1
ct_new = MagicMock()
ct_new.pk = 99
MockContentType.objects.db_manager.return_value.get_for_model.side_effect = [
ct_existing,
ct_existing,
ct_new,
ct_new,
]
_ensure_librenms_id_custom_field(sender=None)
assert mock_cf.object_types.add.call_count == 2
mock_cf.object_types.add.assert_any_call(ct_new)
@patch("extras.models.CustomField")
def test_exception_does_not_propagate(self, MockCustomField):
"""Exceptions during custom field creation are caught and logged."""
from netbox_librenms_plugin import _ensure_librenms_id_custom_field
MockCustomField.objects.using.return_value.get_or_create.side_effect = Exception("DB not ready")
with patch("logging.getLogger") as mock_get_logger:
# Should not raise
_ensure_librenms_id_custom_field(sender=None)
# Verify the exception was logged
logger_instance = mock_get_logger.return_value
logger_instance.exception.assert_called_once()
call_args = logger_instance.exception.call_args
assert "librenms_id" in call_args[0][0]
@patch("dcim.models.Interface", new_callable=MagicMock)
@patch("dcim.models.Device", new_callable=MagicMock)
@patch("virtualization.models.VMInterface", new_callable=MagicMock)
@patch("virtualization.models.VirtualMachine", new_callable=MagicMock)
@patch("django.contrib.contenttypes.models.ContentType")
@patch("extras.models.CustomField")
def test_no_log_when_field_already_exists(
self, MockCustomField, MockContentType, mock_vm, mock_vmif, mock_device, mock_iface
):
"""No log message when the custom field already existed."""
from netbox_librenms_plugin import _ensure_librenms_id_custom_field
mock_cf = MagicMock()
mock_cf.object_types.values_list.return_value = [1, 2, 3, 4]
self._setup_cf_mock(MockCustomField, mock_cf, False)
mock_ct = MagicMock()
mock_ct.pk = 1
self._setup_ct_mock(MockContentType, mock_ct)
with patch("logging.getLogger") as mock_get_logger:
_ensure_librenms_id_custom_field(sender=None)
# When the field already exists (created=False), the info log should
# not be emitted. We verify via the logger instance rather than
# asserting getLogger was never called, which is fragile.
logger_instance = mock_get_logger.return_value
logger_instance.info.assert_not_called()
@patch("dcim.models.Interface", new_callable=MagicMock)
@patch("dcim.models.Device", new_callable=MagicMock)
@patch("virtualization.models.VMInterface", new_callable=MagicMock)
@patch("virtualization.models.VirtualMachine", new_callable=MagicMock)
@patch("django.contrib.contenttypes.models.ContentType")
@patch("extras.models.CustomField")
def test_integer_field_migrated_to_json(
self, MockCustomField, MockContentType, mock_vm, mock_vmif, mock_device, mock_iface
):
"""When existing field has type='integer', it is migrated to 'json' and saved on the given alias."""
from netbox_librenms_plugin import _ensure_librenms_id_custom_field
db_alias = "other"
mock_cf = MagicMock()
mock_cf.type = "integer"
mock_cf.object_types.values_list.return_value = [1, 2, 3, 4]
self._setup_cf_mock(MockCustomField, mock_cf, False)
mock_ct = MagicMock()
mock_ct.pk = 1
self._setup_ct_mock(MockContentType, mock_ct)
_ensure_librenms_id_custom_field(sender=None, using=db_alias)
assert mock_cf.type == "json"
# Alias must flow through both the CustomField lookup and the ContentType lookup,
# exercising the per-alias guard path (_executed_aliases).
MockCustomField.objects.using.assert_called_with(db_alias)
MockContentType.objects.db_manager.assert_called_with(db_alias)
mock_cf.save.assert_called_once_with(using=db_alias, update_fields=["type"])
assert db_alias in _ensure_librenms_id_custom_field._executed_aliases

View File

@@ -0,0 +1,399 @@
"""
Integration tests using the mock LibreNMS HTTP server.
These tests verify that LibreNMSAPI correctly parses responses from a real
(but local, mocked) HTTP server, and that the full request/response cycle works.
No Django database access is used; NetBox model interactions are mocked.
"""
import json
import pytest
from netbox_librenms_plugin.tests.mock_librenms_server import librenms_mock_server
@pytest.fixture
def mock_server():
with librenms_mock_server() as server:
yield server
def _make_api(url, token="test-token"):
"""Create a LibreNMSAPI instance pointed at the mock server."""
from unittest.mock import patch
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
servers_config = {
"test": {
"librenms_url": url,
"api_token": token,
"cache_timeout": 0,
"verify_ssl": False,
}
}
with patch("netbox_librenms_plugin.librenms_api.get_plugin_config") as mock_cfg:
mock_cfg.side_effect = lambda _plugin, key: servers_config if key == "servers" else None
api = LibreNMSAPI(server_key="test")
assert api.server_key == "test"
return api
class TestMockServerSanity:
"""The mock server itself must start, serve, and stop cleanly."""
def test_server_starts_and_responds(self, mock_server):
import urllib.request
mock_server.register("/api/v0/test", {"status": "ok"})
with urllib.request.urlopen(f"{mock_server.url}/api/v0/test") as resp:
data = json.loads(resp.read())
assert data["status"] == "ok"
def test_404_for_unregistered_path(self, mock_server):
import urllib.request
from urllib.error import HTTPError
try:
urllib.request.urlopen(f"{mock_server.url}/api/v0/nonexistent")
except HTTPError as e:
assert e.code == 404
else:
pytest.fail("Expected 404 HTTPError")
class TestLibreNMSAPIPortsFetch:
"""LibreNMSAPI.get_ports() correctly parses mock server responses."""
def test_get_ports_returns_dict_with_ports_key(self, mock_server):
"""
get_ports() returns a parsed dict and sends the required query parameters.
A callable route is used so we can capture the outgoing query string and
assert that both the ``columns`` field list and ``with=vlans`` are present —
if either is ever dropped, the sync page will silently lose data.
"""
captured_query: dict = {}
ports_body = {
"status": "ok",
"ports": [
{
"port_id": 101,
"ifName": "GigabitEthernet0/1",
"ifDescr": "GigabitEthernet0/1",
"ifType": "ethernetCsmacd",
"ifSpeed": 1_000_000_000,
"ifAdminStatus": "up",
"ifAlias": "uplink",
"ifPhysAddress": "aa:bb:cc:dd:ee:01",
"ifMtu": 1500,
"ifVlan": 1,
"ifTrunk": 0,
}
],
}
def _route(method, path, query, headers, body):
# query is already parsed by the mock server (dict of lists)
captured_query.update(query)
return 200, ports_body
mock_server.routes["/api/v0/devices/1/ports"] = _route
api = _make_api(mock_server.url)
success, data = api.get_ports(1)
assert success is True
assert isinstance(data, dict)
assert "ports" in data
assert data["ports"][0]["ifName"] == "GigabitEthernet0/1"
assert "columns" in captured_query, "get_ports() must send a 'columns' query param"
assert "vlans" in captured_query.get("with", []), "get_ports() must request 'with=vlans'"
def test_get_ports_returns_false_on_auth_error(self, mock_server):
mock_server.auth_error_response(path="/api/v0/devices/1/ports")
api = _make_api(mock_server.url)
success, _ = api.get_ports(1)
assert success is False
def test_get_ports_empty_list_when_no_ports(self, mock_server):
mock_server.register("/api/v0/devices/99/ports", {"status": "ok", "ports": []})
api = _make_api(mock_server.url)
success, data = api.get_ports(99)
assert success is True
assert data["ports"] == []
class TestLibreNMSAPIDeviceInfo:
"""LibreNMSAPI.get_device_info() correctly parses device details."""
def test_returns_device_info_dict(self, mock_server):
mock_server.device_info_response(device_id=5, hostname="rtr01", hardware="ISR4351")
api = _make_api(mock_server.url)
success, info = api.get_device_info(5)
assert success is True
assert isinstance(info, dict)
assert info["hostname"] == "rtr01"
def test_returns_false_on_404(self, mock_server):
# /api/v0/devices/999 not registered → 404
api = _make_api(mock_server.url)
success, info = api.get_device_info(999)
assert success is False
assert info is None
class TestLibreNMSAPIAddDevice:
"""LibreNMSAPI.add_device() posts correctly and interprets the response."""
def test_add_device_success(self, mock_server):
mock_server.add_device_response(device_id=10)
api = _make_api(mock_server.url)
success, message = api.add_device(
{
"hostname": "switch1.example.com",
"snmp_version": "v2c",
"community": "public",
"force_add": False,
}
)
assert success is True
def test_add_device_failure_on_server_error(self, mock_server):
mock_server.register("/api/v0/devices", {"status": "error", "message": "duplicate"}, status=500)
api = _make_api(mock_server.url)
success, message = api.add_device(
{
"hostname": "dup.example.com",
"snmp_version": "v2c",
"community": "public",
}
)
assert success is False
class TestLibreNMSAPIInventory:
"""LibreNMSAPI.get_device_inventory() correctly parses mock server responses."""
def test_returns_inventory_list(self, mock_server):
inventory = [
{
"entPhysicalIndex": 1,
"entPhysicalDescr": "Chassis",
"entPhysicalClass": "chassis",
"entPhysicalSerialNum": "SN-CHASSIS-001",
"entPhysicalModelName": "WS-C4900M",
"entPhysicalName": "Chassis 1",
"entPhysicalContainedIn": 0,
},
{
"entPhysicalIndex": 2,
"entPhysicalDescr": "Linecard",
"entPhysicalClass": "module",
"entPhysicalSerialNum": "SN-CARD-002",
"entPhysicalModelName": "WS-X4748-RJ45V+E",
"entPhysicalName": "Slot 1",
"entPhysicalContainedIn": 1,
},
]
mock_server.register("/api/v0/inventory/7/all", {"status": "ok", "inventory": inventory})
api = _make_api(mock_server.url)
success, data = api.get_device_inventory(7)
assert success is True
assert isinstance(data, list)
assert len(data) == 2
assert data[0]["entPhysicalClass"] == "chassis"
assert data[1]["entPhysicalModelName"] == "WS-X4748-RJ45V+E"
def test_returns_empty_list_when_no_inventory(self, mock_server):
mock_server.register("/api/v0/inventory/99/all", {"status": "ok", "inventory": []})
api = _make_api(mock_server.url)
success, data = api.get_device_inventory(99)
assert success is True
assert data == []
def test_returns_false_on_network_error(self, mock_server):
# Unregistered path → 404 → raise_for_status → RequestException
api = _make_api(mock_server.url)
success, _ = api.get_device_inventory(404)
assert success is False
def test_inventory_items_preserve_all_fields(self, mock_server):
inventory = [
{
"entPhysicalIndex": 5,
"entPhysicalDescr": "10 Gigabit Ethernet Module",
"entPhysicalClass": "module",
"entPhysicalSerialNum": "JAE123XYZ",
"entPhysicalModelName": "X2-10GB-LR",
"entPhysicalName": "TenGigabitEthernet1/1",
"entPhysicalContainedIn": 1,
"entPhysicalParentRelPos": 1,
}
]
mock_server.register("/api/v0/inventory/3/all", {"status": "ok", "inventory": inventory})
api = _make_api(mock_server.url)
success, data = api.get_device_inventory(3)
assert success is True
item = data[0]
assert item["entPhysicalParentRelPos"] == 1
assert item["entPhysicalSerialNum"] == "JAE123XYZ"
class TestLibreNMSAPIDiscovery:
"""
LibreNMSAPI device-ID discovery: lookup by IP and hostname fallback.
Covers get_device_id_by_ip(), get_device_id_by_hostname(), and the
get_librenms_id() fallback chain (IP → DNS name → hostname).
"""
_DEVICE_RESPONSE = {
"status": "ok",
"devices": [{"device_id": 42, "hostname": "sw01.example.com"}],
}
def test_get_device_id_by_ip_returns_id(self, mock_server):
mock_server.register("/api/v0/devices/10.0.0.1", self._DEVICE_RESPONSE)
api = _make_api(mock_server.url)
device_id = api.get_device_id_by_ip("10.0.0.1")
assert device_id == 42
def test_get_device_id_by_ip_returns_none_on_404(self, mock_server):
# no route registered → 404
api = _make_api(mock_server.url)
device_id = api.get_device_id_by_ip("10.0.0.2")
assert device_id is None
def test_get_device_id_by_hostname_returns_id(self, mock_server):
mock_server.register("/api/v0/devices/sw01.example.com", self._DEVICE_RESPONSE)
api = _make_api(mock_server.url)
device_id = api.get_device_id_by_hostname("sw01.example.com")
assert device_id == 42
def test_get_device_id_by_hostname_returns_none_on_404(self, mock_server):
api = _make_api(mock_server.url)
device_id = api.get_device_id_by_hostname("unknown.example.com")
assert device_id is None
def test_get_librenms_id_resolves_by_ip(self, mock_server):
"""get_librenms_id() resolves via IP when the device has a primary_ip."""
from unittest.mock import MagicMock, patch
mock_server.register("/api/v0/devices/10.0.0.10", self._DEVICE_RESPONSE)
api = _make_api(mock_server.url)
obj = MagicMock()
obj.cf = {} # no stored ID
obj.primary_ip.address.ip = "10.0.0.10"
obj.primary_ip.dns_name = None
obj.name = "sw01"
with patch.object(api, "_get_cache_key", return_value="test-key"):
with patch("netbox_librenms_plugin.librenms_api.cache") as mock_cache:
mock_cache.get.return_value = None
with patch.object(api, "_store_librenms_id"):
result = api.get_librenms_id(obj)
assert result == 42
def test_get_librenms_id_falls_back_to_hostname_when_ip_fails(self, mock_server):
"""get_librenms_id() falls back to hostname when IP lookup returns no result."""
from unittest.mock import MagicMock, patch
# IP path returns 404 (unregistered) → fallback to hostname
mock_server.register("/api/v0/devices/sw01.example.com", self._DEVICE_RESPONSE)
api = _make_api(mock_server.url)
obj = MagicMock()
obj.cf = {} # no stored ID
obj.primary_ip.address.ip = "192.0.2.1" # unregistered → 404 → None
obj.primary_ip.dns_name = None
obj.name = "sw01.example.com"
with patch.object(api, "_get_cache_key", return_value="test-key"):
with patch("netbox_librenms_plugin.librenms_api.cache") as mock_cache:
mock_cache.get.return_value = None
with patch.object(api, "_store_librenms_id"):
result = api.get_librenms_id(obj)
assert result == 42
class TestLibreNMSAPIErrorResponses:
"""Integration tests: API client handles unusual server responses gracefully."""
def test_401_returns_false(self, mock_server):
"""HTTP 401 Unauthorized must return (False, ...) not raise."""
api = _make_api(mock_server.url)
mock_server.register("/api/v0/devices", {"message": "Unauthorized"}, status=401)
success, data = api.list_devices()
assert success is False
def test_500_returns_false(self, mock_server):
"""HTTP 500 must return (False, ...) not raise."""
api = _make_api(mock_server.url)
mock_server.register("/api/v0/devices", {"message": "Internal error"}, status=500)
success, data = api.list_devices()
assert success is False
def test_null_inventory_returns_false(self, mock_server):
"""{"status":"ok","inventory":null} must not raise, must return (False, error_string)."""
api = _make_api(mock_server.url)
mock_server.register("/api/v0/inventory/1/all", {"status": "ok", "inventory": None})
success, data = api.get_device_inventory(1)
assert success is False
assert isinstance(data, str) and data, "expected a non-empty error string describing the malformed inventory"
def test_null_devices_field_get_device_info(self, mock_server):
"""{"devices": null} in get_device_info must return (False, None) not crash."""
api = _make_api(mock_server.url)
mock_server.register("/api/v0/devices/42", {"devices": None})
success, data = api.get_device_info(42)
assert success is False
assert data is None
def test_empty_devices_list_get_device_info(self, mock_server):
"""{"devices": []} in get_device_info must return (False, None)."""
api = _make_api(mock_server.url)
mock_server.register("/api/v0/devices/42", {"devices": []})
success, data = api.get_device_info(42)
assert success is False
assert data is None
def test_404_get_device_info(self, mock_server):
"""404 on device endpoint must return (False, None)."""
api = _make_api(mock_server.url)
mock_server.register("/api/v0/devices/99", {"message": "Device not found"}, status=404)
success, data = api.get_device_info(99)
assert success is False
assert data is None

View File

@@ -0,0 +1,854 @@
"""
Integration tests for Virtual Chassis detection using the mock LibreNMS HTTP server.
These tests verify that detect_virtual_chassis_from_inventory(), get_virtual_chassis_data(),
and prefetch_vc_data_for_devices() work correctly end-to-end through real HTTP calls
to a local mock server — no mocking of the detection logic itself.
Run:
python -m pytest netbox_librenms_plugin/tests/test_integration_virtual_chassis.py -v
"""
import pytest
from unittest.mock import patch
from netbox_librenms_plugin.tests.mock_librenms_server import librenms_mock_server
@pytest.fixture
def mock_server():
with librenms_mock_server() as server:
yield server
def _make_api(url, token="test-token", server_key="test"):
"""Create a LibreNMSAPI instance pointed at the mock server."""
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
servers_config = {
server_key: {
"librenms_url": url,
"api_token": token,
"cache_timeout": 300,
"verify_ssl": False,
}
}
with patch("netbox_librenms_plugin.librenms_api.get_plugin_config") as mock_cfg:
mock_cfg.side_effect = lambda _plugin, key: servers_config if key == "servers" else None
api = LibreNMSAPI(server_key=server_key)
return api
def _chassis(index, serial, model="WS-C3750X", name="", descr="", position=None, contained_in=None):
"""Build a minimal ENTITY-MIB chassis entry."""
item = {
"entPhysicalIndex": index,
"entPhysicalClass": "chassis",
"entPhysicalSerialNum": serial,
"entPhysicalModelName": model,
"entPhysicalName": name or f"Chassis-{index}",
"entPhysicalDescr": descr or f"Chassis {index}",
}
if position is not None:
item["entPhysicalParentRelPos"] = position
if contained_in is not None:
item["entPhysicalContainedIn"] = contained_in
return item
def _stack_root(index=1):
"""Build a 'stack' class root entry (e.g., Cisco StackWise)."""
return {
"entPhysicalIndex": index,
"entPhysicalClass": "stack",
"entPhysicalSerialNum": "",
"entPhysicalModelName": "",
"entPhysicalName": "StackSub-0/0",
"entPhysicalDescr": "Cisco StackWise",
"entPhysicalContainedIn": 0,
}
class TestDetectVCCiscoStack:
"""Cisco StackWise topology: root has stack-class entry; children are chassis members."""
def test_three_member_stack(self, mock_server):
"""3 chassis members under a stack root → is_stack=True, member_count=3."""
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 10
mock_server.device_info_response(device_id=device_id, hostname="sw-stack", serial="MASTER")
root_items = [_stack_root(index=1)]
member_items = [
_chassis(100, "SN-A", position=1),
_chassis(200, "SN-B", position=2),
_chassis(300, "SN-C", position=3),
]
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
assert result is not None
assert result["is_stack"] is True
assert result["member_count"] == 3
serials = [m["serial"] for m in result["members"]]
assert serials == ["SN-A", "SN-B", "SN-C"]
def test_members_sorted_by_position(self, mock_server):
"""Members returned in position order regardless of API order."""
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 11
mock_server.device_info_response(device_id=device_id, hostname="sw-stack-2")
root_items = [_stack_root(index=5)]
# Deliberately out of order: 3, 1, 2
member_items = [
_chassis(301, "SN-3", position=3),
_chassis(101, "SN-1", position=1),
_chassis(201, "SN-2", position=2),
]
mock_server.vc_inventory_callable(device_id, root_items, {5: member_items})
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
assert result is not None
positions = [m["position"] for m in result["members"]]
assert positions == [1, 2, 3]
def test_position_zero_falls_back_to_idx_plus_one(self, mock_server):
"""position=0 in entPhysicalParentRelPos → fallback to idx+1 (never 0)."""
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 12
mock_server.device_info_response(device_id=device_id, hostname="sw-stack-3")
root_items = [_stack_root(index=1)]
member_items = [
_chassis(100, "SN-X", position=0), # 0 → fallback to idx+1=1
_chassis(200, "SN-Y", position=0), # 0 → fallback to idx+1=2
]
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
assert result is not None
positions = [m["position"] for m in result["members"]]
# Both had position=0, so they fall back to idx+1: positions [1, 2]
assert all(p >= 1 for p in positions)
def test_member_fields_extracted_correctly(self, mock_server):
"""serial, model, name, description all extracted from chassis entries."""
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 13
mock_server.device_info_response(device_id=device_id, hostname="sw-stack-4")
root_items = [_stack_root(index=1)]
member_items = [
{
"entPhysicalIndex": 100,
"entPhysicalClass": "chassis",
"entPhysicalSerialNum": "SERIAL-ABC",
"entPhysicalModelName": "WS-C3750X-48P",
"entPhysicalName": "Slot 1",
"entPhysicalDescr": "48-port PoE switch",
"entPhysicalParentRelPos": 1,
},
{
"entPhysicalIndex": 200,
"entPhysicalClass": "chassis",
"entPhysicalSerialNum": "SERIAL-DEF",
"entPhysicalModelName": "WS-C3750X-24T",
"entPhysicalName": "Slot 2",
"entPhysicalDescr": "24-port switch",
"entPhysicalParentRelPos": 2,
},
]
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
assert result is not None
m1, m2 = result["members"]
assert m1["serial"] == "SERIAL-ABC"
assert m1["model"] == "WS-C3750X-48P"
assert m1["name"] == "Slot 1"
assert m1["description"] == "48-port PoE switch"
assert m2["serial"] == "SERIAL-DEF"
def test_suggested_name_uses_master_sysname(self, mock_server):
"""suggested_name generated from master device sysName."""
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 14
mock_server.device_info_response(device_id=device_id, hostname="sw-master", serial="MASTER01")
root_items = [_stack_root(index=1)]
member_items = [
_chassis(100, "SN-1", position=1),
_chassis(200, "SN-2", position=2),
]
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
assert result is not None
# Members should have non-empty suggested names
for member in result["members"]:
assert "suggested_name" in member
assert member["suggested_name"] # non-empty
class TestDetectVCJuniperStyle:
"""Juniper-style: root has chassis-class entry; children are chassis members."""
def test_two_member_vc(self, mock_server):
"""2 chassis members under a chassis root → is_stack=True, member_count=2."""
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 20
mock_server.device_info_response(device_id=device_id, hostname="vc-switch")
# Root: a chassis entry (not stack)
root_items = [
{
"entPhysicalIndex": 10,
"entPhysicalClass": "chassis",
"entPhysicalSerialNum": "",
"entPhysicalModelName": "",
"entPhysicalName": "Virtual Chassis",
"entPhysicalDescr": "EX4300 Virtual Chassis",
"entPhysicalContainedIn": 0,
}
]
member_items = [
_chassis(100, "JN-SN-1", position=0), # Juniper uses position=0,1 (1-based after fallback)
_chassis(200, "JN-SN-2", position=1),
]
mock_server.vc_inventory_callable(device_id, root_items, {10: member_items})
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
assert result is not None
assert result["is_stack"] is True
assert result["member_count"] == 2
class TestDetectVCStackPreferredOverChassis:
"""When root has both stack and chassis entries, stack index takes priority."""
def test_stack_index_used_not_chassis(self, mock_server):
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 30
mock_server.device_info_response(device_id=device_id, hostname="sw-mixed")
# Root has BOTH stack (index=5) and chassis (index=6)
root_items = [
{
"entPhysicalIndex": 5,
"entPhysicalClass": "stack",
"entPhysicalName": "Stack-0",
"entPhysicalSerialNum": "",
"entPhysicalModelName": "",
"entPhysicalDescr": "",
"entPhysicalContainedIn": 0,
},
{
"entPhysicalIndex": 6,
"entPhysicalClass": "chassis",
"entPhysicalName": "Chassis-0",
"entPhysicalSerialNum": "",
"entPhysicalModelName": "",
"entPhysicalDescr": "",
"entPhysicalContainedIn": 0,
},
]
# Stack index=5 has 2 members, chassis index=6 has 0
children = {
5: [_chassis(100, "SN-1", position=1), _chassis(200, "SN-2", position=2)],
6: [], # chassis has no children
}
mock_server.vc_inventory_callable(device_id, root_items, children)
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
# Must have detected 2 members (via stack index), not 0 (via chassis index)
assert result is not None
assert result["member_count"] == 2
class TestDetectVCSingleDevice:
"""Non-stack device: only 1 chassis child → returns None."""
def test_single_chassis_child_returns_none(self, mock_server):
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 40
mock_server.device_info_response(device_id=device_id, hostname="single-sw")
root_items = [_stack_root(index=1)]
# Only 1 chassis child → not a VC
member_items = [_chassis(100, "SN-ONLY", position=1)]
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
assert result is None
def test_no_stack_or_chassis_root_returns_none(self, mock_server):
"""Root has only non-stack/chassis entries → returns None."""
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 41
mock_server.device_info_response(device_id=device_id, hostname="plain-router")
root_items = [
{
"entPhysicalIndex": 1,
"entPhysicalClass": "module",
"entPhysicalName": "Main Module",
"entPhysicalSerialNum": "SN1",
"entPhysicalModelName": "ASR1001-X",
"entPhysicalDescr": "ASR1001-X",
"entPhysicalContainedIn": 0,
}
]
# Register root-only, no children needed
mock_server.vc_inventory_callable(device_id, root_items, {})
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
assert result is None
class TestDetectVCEdgeCases:
"""API errors and empty responses."""
def test_empty_root_inventory_returns_none(self, mock_server):
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 50
mock_server.device_info_response(device_id=device_id, hostname="empty-sw")
mock_server.vc_inventory_callable(device_id, [], {}) # empty root
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
assert result is None
def test_api_error_on_root_returns_none(self, mock_server):
"""500 error on root inventory → returns None."""
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 51
mock_server.device_info_response(device_id=device_id, hostname="error-sw")
# Register 500 for inventory calls
mock_server.register(f"/api/v0/inventory/{device_id}", {"status": "error"}, status=500)
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
assert result is None
def test_empty_serial_included_in_members(self, mock_server):
"""Members with empty entPhysicalSerialNum are included, not skipped."""
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 52
mock_server.device_info_response(device_id=device_id, hostname="nosn-sw")
root_items = [_stack_root(index=1)]
member_items = [
_chassis(100, "", position=1), # empty serial
_chassis(200, "SN-B", position=2),
]
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
assert result is not None
assert result["member_count"] == 2
assert result["members"][0]["serial"] == "" # empty is preserved
def test_device_info_failure_still_detects_vc(self, mock_server):
"""get_device_info() returning False → detection still works, suggested_name uses fallback."""
from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory
api = _make_api(mock_server.url)
device_id = 53
# No device_info registered → 404
root_items = [_stack_root(index=1)]
member_items = [
_chassis(100, "SN-1", position=1),
_chassis(200, "SN-2", position=2),
]
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = detect_virtual_chassis_from_inventory(api, device_id)
# Should still detect VC even without device info
assert result is not None
assert result["member_count"] == 2
# Without master name, suggested_name falls back to "Member-{position}"
for member in result["members"]:
assert member["suggested_name"].startswith("Member-")
class TestGetVCDataHTTP:
"""get_virtual_chassis_data() integrating with mock HTTP server and patched cache."""
def test_cache_miss_fetches_via_http(self, mock_server):
"""Cache miss triggers detect_virtual_chassis_from_inventory via HTTP."""
from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data
api = _make_api(mock_server.url)
device_id = 60
mock_server.device_info_response(device_id=device_id, hostname="cached-sw")
root_items = [_stack_root(index=1)]
member_items = [
_chassis(100, "SN-1", position=1),
_chassis(200, "SN-2", position=2),
]
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
mock_cache.get.return_value = None # cache miss
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = get_virtual_chassis_data(api, device_id)
# Should have called cache.set to store result
assert mock_cache.set.called
assert result is not None
assert result["is_stack"] is True
assert result["member_count"] == 2
def test_cache_hit_returns_without_http(self, mock_server):
"""Cache hit returns immediately without making any HTTP calls."""
from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data
api = _make_api(mock_server.url)
device_id = 61
# Include detection_error to match what _clone_virtual_chassis_data adds
cached_data = {"is_stack": True, "member_count": 3, "members": [], "detection_error": None}
with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
mock_cache.get.return_value = cached_data
result = get_virtual_chassis_data(api, device_id)
# cache.set should NOT be called (no new fetch)
assert not mock_cache.set.called
assert result["is_stack"] is True
assert result["member_count"] == 3
def test_force_refresh_fetches_even_if_cached(self, mock_server):
"""force_refresh=True bypasses cache and fetches from HTTP."""
from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data
api = _make_api(mock_server.url)
device_id = 62
mock_server.device_info_response(device_id=device_id, hostname="refresh-sw")
root_items = [_stack_root(index=1)]
member_items = [
_chassis(100, "SN-A", position=1),
_chassis(200, "SN-B", position=2),
]
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
# Even with cached data, force_refresh should hit the API
old_cached = {"is_stack": True, "member_count": 1, "members": [{"serial": "OLD"}], "detection_error": None}
with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
mock_cache.get.return_value = old_cached
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = get_virtual_chassis_data(api, device_id, force_refresh=True)
# Should have fetched fresh data, not used old_cached
assert result is not None
assert result["member_count"] == 2 # new data, not old
def test_non_vc_device_returns_empty_dict(self, mock_server):
"""Single device (not VC) → detect returns None → get_virtual_chassis_data returns empty."""
from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data
api = _make_api(mock_server.url)
device_id = 63
mock_server.device_info_response(device_id=device_id, hostname="single-sw")
root_items = [_stack_root(index=1)]
member_items = [_chassis(100, "SN-ONLY", position=1)] # only 1 → not VC
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
mock_cache.get.return_value = None
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = get_virtual_chassis_data(api, device_id)
assert result is not None
assert result.get("is_stack") is False
class TestPrefetchVCHTTP:
"""prefetch_vc_data_for_devices() fetches multiple devices in batch."""
def test_prefetch_multiple_vc_devices(self, mock_server):
"""Three VC devices → cache populated for all three."""
from netbox_librenms_plugin.import_utils.virtual_chassis import prefetch_vc_data_for_devices
api = _make_api(mock_server.url)
for dev_id, hostname in [(70, "sw-70"), (71, "sw-71"), (72, "sw-72")]:
mock_server.device_info_response(device_id=dev_id, hostname=hostname)
root_items = [_stack_root(index=1)]
member_items = [
_chassis(100, f"SN-{dev_id}-1", position=1),
_chassis(200, f"SN-{dev_id}-2", position=2),
]
mock_server.vc_inventory_callable(dev_id, root_items, {1: member_items})
cache_store = {}
def mock_cache_set(key, val, timeout=None):
cache_store[key] = val
def mock_cache_get(key):
return cache_store.get(key)
with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
mock_cache.get.side_effect = mock_cache_get
mock_cache.set.side_effect = mock_cache_set
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
prefetch_vc_data_for_devices(api, [70, 71, 72])
# Cache should have entries for all 3 VC devices
assert len(cache_store) >= 3
def test_prefetch_mix_vc_and_single(self, mock_server):
"""Mix of VC and single devices → VC is cached, single is processed without error."""
from netbox_librenms_plugin.import_utils.virtual_chassis import prefetch_vc_data_for_devices
api = _make_api(mock_server.url)
# Device 80 = 2-member VC
mock_server.device_info_response(device_id=80, hostname="sw-stack")
root_items_80 = [_stack_root(index=1)]
member_items_80 = [_chassis(100, "SN-80-1", position=1), _chassis(200, "SN-80-2", position=2)]
mock_server.vc_inventory_callable(80, root_items_80, {1: member_items_80})
# Device 81 = single (no VC)
mock_server.device_info_response(device_id=81, hostname="sw-single")
root_items_81 = [_stack_root(index=1)]
member_items_81 = [_chassis(100, "SN-81", position=1)] # only 1 → not VC
mock_server.vc_inventory_callable(81, root_items_81, {1: member_items_81})
cache_store = {}
def mock_cache_set(key, val, timeout=None):
cache_store[key] = val
def mock_cache_get(key):
return cache_store.get(key)
with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
mock_cache.get.side_effect = mock_cache_get
mock_cache.set.side_effect = mock_cache_set
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
prefetch_vc_data_for_devices(api, [80, 81])
# Both the VC device (80) and the non-VC device (81) should be cached.
# Non-VC devices get an empty_virtual_chassis_data() cached so prefetch
# suppresses repeated API hits on subsequent renders.
assert len(cache_store) == 2
class TestNegativeVCCaching:
"""Negative results (non-stack, API errors) must be cached to suppress repeated hits."""
def test_non_vc_device_result_is_cached(self, mock_server):
"""Single device (not a stack) → detect returns None → empty result cached."""
from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data
api = _make_api(mock_server.url)
device_id = 200
mock_server.device_info_response(device_id=device_id, hostname="single-sw")
root_items = [_stack_root(index=1)]
member_items = [_chassis(100, "SN-ONLY", position=1)] # 1 chassis only → not VC
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
cache_store = {}
def mock_cache_set(key, val, timeout=None):
cache_store[key] = val
def mock_cache_get(key):
return cache_store.get(key)
with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
mock_cache.get.side_effect = mock_cache_get
mock_cache.set.side_effect = mock_cache_set
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = get_virtual_chassis_data(api, device_id)
assert result is not None
assert result.get("is_stack") is False
assert result.get("member_count") == 0
# The empty result must have been written to cache so a second call is a hit.
assert len(cache_store) == 1
cached = list(cache_store.values())[0]
assert cached.get("is_stack") is False
def test_api_error_result_is_cached(self, mock_server):
"""API 500 on inventory → detect returns None → empty result still cached."""
from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data
api = _make_api(mock_server.url)
device_id = 201
# Register a 500 for the root inventory call
mock_server.routes[f"/api/v0/inventory/{device_id}"] = (500, {"status": "error", "message": "internal"})
cache_store = {}
def mock_cache_set(key, val, timeout=None):
cache_store[key] = val
def mock_cache_get(key):
return cache_store.get(key)
with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
mock_cache.get.side_effect = mock_cache_get
mock_cache.set.side_effect = mock_cache_set
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
result = get_virtual_chassis_data(api, device_id)
assert result is not None
assert result.get("is_stack") is False
# Even API failures get cached to suppress repeated hits until TTL expires.
assert len(cache_store) == 1
def test_force_refresh_bypasses_negative_cache(self, mock_server):
"""force_refresh=True re-fetches even when a negative result is cached."""
from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data
api = _make_api(mock_server.url)
device_id = 202
mock_server.device_info_response(device_id=device_id, hostname="single-sw-202")
root_items = [_stack_root(index=1)]
member_items = [_chassis(100, "SN-202", position=1)] # 1 chassis → not VC
mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})
# Pre-populate cache with an empty (negative) result.
empty_cached = {"is_stack": False, "member_count": 0, "members": [], "detection_error": None}
call_count = {"n": 0}
def mock_cache_get(key):
return empty_cached # always returns cached negative
cache_set_calls = []
def mock_cache_set(key, val, timeout=None):
cache_set_calls.append((key, val))
call_count["n"] += 1
with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
mock_cache.get.side_effect = mock_cache_get
mock_cache.set.side_effect = mock_cache_set
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="{master}-m{position}",
):
# Normal call — should use cache, NOT call set again
result_cached = get_virtual_chassis_data(api, device_id)
assert call_count["n"] == 0 # no new set; cache hit returned
# force_refresh=True — must bypass cache and re-fetch + re-cache
result_fresh = get_virtual_chassis_data(api, device_id, force_refresh=True)
assert call_count["n"] == 1 # set called once for the re-fetch
assert result_cached.get("is_stack") is False
assert result_fresh.get("is_stack") is False
class TestVCPortFetch:
"""Port fetching for VC master: port names with VC member suffixes."""
def test_ports_with_vc_suffixes_returned_as_is(self, mock_server):
"""Port names like Gi1/0/1 and Gi2/0/1 preserved from API response."""
api = _make_api(mock_server.url)
vc_ports = [
{
"port_id": 101,
"ifName": "GigabitEthernet1/0/1",
"ifDescr": "GigabitEthernet1/0/1",
"ifType": "ethernetCsmacd",
"ifSpeed": 1_000_000_000,
"ifAdminStatus": "up",
"ifAlias": "uplink-m1",
"ifPhysAddress": "aa:bb:cc:dd:ee:01",
"ifMtu": 1500,
"ifVlan": 1,
"ifTrunk": 0,
},
{
"port_id": 201,
"ifName": "GigabitEthernet2/0/1",
"ifDescr": "GigabitEthernet2/0/1",
"ifType": "ethernetCsmacd",
"ifSpeed": 1_000_000_000,
"ifAdminStatus": "up",
"ifAlias": "uplink-m2",
"ifPhysAddress": "aa:bb:cc:dd:ee:02",
"ifMtu": 1500,
"ifVlan": 1,
"ifTrunk": 0,
},
{
"port_id": 301,
"ifName": "GigabitEthernet1/0/2",
"ifDescr": "GigabitEthernet1/0/2",
"ifType": "ethernetCsmacd",
"ifSpeed": 1_000_000_000,
"ifAdminStatus": "down",
"ifAlias": "",
"ifPhysAddress": "aa:bb:cc:dd:ee:03",
"ifMtu": 1500,
"ifVlan": 10,
"ifTrunk": 0,
},
]
mock_server.ports_response(device_id=90, ports=vc_ports)
ok, data = api.get_ports(90)
assert ok is True
names = [p["ifName"] for p in data["ports"]]
assert "GigabitEthernet1/0/1" in names
assert "GigabitEthernet2/0/1" in names
assert "GigabitEthernet1/0/2" in names
def test_all_port_fields_preserved(self, mock_server):
"""ifName, ifDescr, ifAlias, ifSpeed all preserved from LibreNMS response."""
api = _make_api(mock_server.url)
ports_data = [
{
"port_id": 111,
"ifName": "GigabitEthernet1/0/1",
"ifDescr": "GigabitEthernet1/0/1",
"ifType": "ethernetCsmacd",
"ifSpeed": 1_000_000_000,
"ifAdminStatus": "up",
"ifAlias": "server-link",
"ifPhysAddress": "aa:bb:cc:00:00:01",
"ifMtu": 9000,
"ifVlan": 100,
"ifTrunk": 1,
},
]
mock_server.ports_response(device_id=91, ports=ports_data)
ok, data = api.get_ports(91)
assert ok is True
port = data["ports"][0]
assert port["ifName"] == "GigabitEthernet1/0/1"
assert port["ifAlias"] == "server-link"
assert port["ifSpeed"] == 1_000_000_000
assert port["ifMtu"] == 9000

View File

@@ -0,0 +1,563 @@
"""
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

View File

@@ -0,0 +1,137 @@
"""
Regression tests for SingleIPAddressVerifyView.post().
Covers:
- Cache key uses CacheMixin.get_cache_key() (server-aware) instead of
the old private _get_cache_key() that produced a different format.
- server_key from POST body is threaded into the cache lookup so
non-default servers hit the correct cache entry.
"""
import json
from unittest.mock import MagicMock, patch
import pytest
def _make_view():
"""Create a SingleIPAddressVerifyView instance without database access."""
from netbox_librenms_plugin.views.base.ip_addresses_view import SingleIPAddressVerifyView
view = object.__new__(SingleIPAddressVerifyView)
return view
def _make_request(body_dict):
"""Create a mock POST request with JSON body."""
request = MagicMock()
request.method = "POST"
request.body = json.dumps(body_dict).encode()
return request
def _mock_device(pk=1):
"""Create a mock Device with _meta for cache key generation."""
device = MagicMock()
device.pk = pk
device._meta.model_name = "device"
device.name = "test-device"
device.get_absolute_url.return_value = f"/dcim/devices/{pk}/"
device.interfaces.first.return_value = None
return device
class TestCacheKeyFormat:
"""SingleIPAddressVerifyView must use CacheMixin.get_cache_key()."""
def test_no_private_get_cache_key_method(self):
"""The old _get_cache_key method must not exist on SingleIPAddressVerifyView."""
from netbox_librenms_plugin.views.base.ip_addresses_view import SingleIPAddressVerifyView
assert not hasattr(SingleIPAddressVerifyView, "_get_cache_key"), (
"SingleIPAddressVerifyView still has _get_cache_key; it should use CacheMixin.get_cache_key() instead"
)
def test_cache_key_matches_writer_format(self):
"""The cache key used by post() must match the format used by _prepare_context()."""
view = _make_view()
device = _mock_device(pk=42)
# CacheMixin.get_cache_key produces this format
expected_key = "librenms_ip_addresses_device_42_prod"
assert view.get_cache_key(device, "ip_addresses", "prod") == expected_key
def test_cache_key_default_server(self):
"""Default server key produces the expected cache key format."""
view = _make_view()
device = _mock_device(pk=7)
expected_key = "librenms_ip_addresses_device_7_default"
assert view.get_cache_key(device, "ip_addresses", "default") == expected_key
class TestServerKeyFromPost:
"""server_key from POST body must be used for cache lookup."""
@pytest.fixture(autouse=True)
def _patch_ip_models(self):
"""Patch IPAddress.objects to avoid DB access."""
with patch("netbox_librenms_plugin.views.base.ip_addresses_view.IPAddress") as mock_ip:
mock_ip.objects.filter.return_value.first.return_value = None
yield
def _run_post(self, body, device=None):
"""Execute view.post() with mocks and return the cache key used."""
view = _make_view()
if device is None:
device = _mock_device()
request = _make_request(body)
captured_cache_key = {}
def fake_cache_get(key):
captured_cache_key["key"] = key
return {"ip_addresses": []}
with (
patch(
"netbox_librenms_plugin.views.base.ip_addresses_view.get_object_or_404",
return_value=device,
),
patch("netbox_librenms_plugin.views.base.ip_addresses_view.cache") as mock_cache,
):
mock_cache.get.side_effect = fake_cache_get
view.post(request)
return captured_cache_key.get("key")
def test_server_key_threaded_to_cache_lookup(self):
"""post() must include server_key in the cache key."""
device = _mock_device(pk=5)
key = self._run_post(
{"device_id": 5, "ip_address": "10.0.0.1/24", "server_key": "prod", "object_type": "device"},
device=device,
)
assert key == "librenms_ip_addresses_device_5_prod"
def test_default_server_key_when_missing(self):
"""When server_key is absent from POST, default to 'default'."""
device = _mock_device(pk=5)
key = self._run_post(
{"device_id": 5, "ip_address": "10.0.0.1/24", "object_type": "device"},
device=device,
)
assert key == "librenms_ip_addresses_device_5_default"
def test_null_server_key_falls_back_to_default(self):
"""When server_key is explicitly null, fall back to 'default'."""
device = _mock_device(pk=5)
key = self._run_post(
{"device_id": 5, "ip_address": "10.0.0.1/24", "server_key": None, "object_type": "device"},
device=device,
)
assert key == "librenms_ip_addresses_device_5_default"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
"""Helper fixtures for LibreNMS API tests."""
from unittest.mock import patch
import pytest
@pytest.fixture(autouse=True)
def mock_librenms_config():
"""Auto-mock LibreNMS configuration for all API tests."""
with (
patch("netbox_librenms_plugin.librenms_api.get_plugin_config") as mock_config,
patch("netbox_librenms_plugin.models.LibreNMSSettings") as mock_settings,
):
# Default config
mock_config.return_value = {
"default": {
"librenms_url": "https://librenms.example.com",
"api_token": "test-token",
"cache_timeout": 300,
"verify_ssl": True,
}
}
mock_settings.objects.filter.return_value.first.return_value = None
yield {"mock_config": mock_config, "mock_settings": mock_settings}

View File

@@ -0,0 +1,367 @@
"""
Tests for multi-server librenms_id helpers.
Covers get_librenms_device_id, set_librenms_device_id, find_by_librenms_id,
and migrate_legacy_librenms_id.
"""
from unittest.mock import MagicMock
class TestGetLibreNMSDeviceId:
"""Tests for get_librenms_device_id()."""
def test_returns_none_when_cf_missing(self):
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {}
result = get_librenms_device_id(obj, "default")
assert result is None
def test_returns_int_for_legacy_bare_integer(self):
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": 42}
result = get_librenms_device_id(obj, "default")
assert result == 42
def test_legacy_bare_int_returned_for_any_server_key(self):
"""
Legacy bare integers are returned as a universal fallback for any server_key.
Devices imported before multi-server support store a bare integer in
librenms_id. These must remain discoverable regardless of which server is
active, so the bare-int is returned as-is for any server_key.
"""
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": 99}
assert get_librenms_device_id(obj, "default") == 99
assert get_librenms_device_id(obj, "production") == 99
assert get_librenms_device_id(obj, "secondary") == 99
def test_returns_value_for_matching_server_key(self):
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": {"production": 7, "secondary": 12}}
assert get_librenms_device_id(obj, "production") == 7
def test_returns_none_for_missing_server_key_in_dict(self):
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": {"production": 7}}
result = get_librenms_device_id(obj, "secondary")
assert result is None
def test_returns_none_for_unexpected_type(self):
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": "not-an-int-or-dict"}
result = get_librenms_device_id(obj, "default")
assert result is None
def test_legacy_string_int_returned_for_any_server_key(self):
"""A bare string integer ('42') is coerced and returned for any server_key."""
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": "42"}
assert get_librenms_device_id(obj, "default") == 42
assert get_librenms_device_id(obj, "production") == 42
def test_returns_none_for_bare_boolean(self):
"""bool is a subclass of int; bare True/False must not be treated as a valid ID."""
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": True}
assert get_librenms_device_id(obj, "default") is None
obj.cf = {"librenms_id": False}
assert get_librenms_device_id(obj, "default") is None
def test_returns_none_for_boolean_inside_dict(self):
"""Boolean values inside the JSON dict must be rejected."""
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": {"default": True}}
assert get_librenms_device_id(obj, "default") is None
def test_default_server_key_is_default(self):
from netbox_librenms_plugin.utils import get_librenms_device_id
obj = MagicMock()
obj.cf = {"librenms_id": {"default": 5}}
assert get_librenms_device_id(obj) == 5
class TestFindByLibreNMSId:
"""Tests for find_by_librenms_id()."""
def test_queries_server_key_and_legacy_integer(self):
"""
find_by_librenms_id() issues a Q that covers both the JSON server-key branch
and the legacy bare-int branch in a single filter() call.
We inspect the Q object's children directly because the two branches must
coexist — matching only one would silently miss devices stored in the other
format.
"""
from unittest.mock import MagicMock
from django.db.models import Q
from netbox_librenms_plugin.utils import find_by_librenms_id
mock_model = MagicMock()
mock_qs = MagicMock()
mock_model.objects.filter.return_value = mock_qs
mock_qs.first.return_value = None
find_by_librenms_id(mock_model, 42, "default")
mock_model.objects.filter.assert_called_once()
# Verify the Q predicate covers both the server-key JSON branch and legacy bare-int/string branches
call_args = mock_model.objects.filter.call_args
q_arg = call_args[0][0]
assert isinstance(q_arg, Q)
assert q_arg.connector == "OR"
# The combined Q should contain four children: JSON key (int), JSON key (str), bare-int, bare-string
q_str = str(q_arg)
assert "librenms_id__default" in q_str
assert "custom_field_data__librenms_id__default" in q_str
assert "custom_field_data__librenms_id" in q_str
assert "42" in q_str
def test_returns_first_matching_object(self):
from netbox_librenms_plugin.utils import find_by_librenms_id
expected = MagicMock()
mock_model = MagicMock()
mock_qs = MagicMock()
mock_model.objects.filter.return_value = mock_qs
mock_qs.first.return_value = expected
result = find_by_librenms_id(mock_model, 42, "default")
assert result is expected
def test_returns_none_when_not_found(self):
from unittest.mock import MagicMock
from django.db.models import Q
from netbox_librenms_plugin.utils import find_by_librenms_id
mock_model = MagicMock()
mock_qs = MagicMock()
mock_model.objects.filter.return_value = mock_qs
mock_qs.first.return_value = None
result = find_by_librenms_id(mock_model, 999, "production")
assert result is None
# Any server_key must include legacy bare-int/string fallback conditions
# so that devices imported before multi-server support are still found.
call_args = mock_model.objects.filter.call_args
q_arg = call_args[0][0]
assert isinstance(q_arg, Q)
q_str = str(q_arg)
assert "custom_field_data__librenms_id__production" in q_str
assert "custom_field_data__librenms_id" in q_str
def test_default_server_key_is_default(self):
"""find_by_librenms_id() uses "default" as the server key when no key is passed.
We inspect the Q predicate's children to confirm the key embedded in the
JSON path is exactly "default", not some other fallback value.
"""
from unittest.mock import MagicMock
from django.db.models import Q
from netbox_librenms_plugin.utils import find_by_librenms_id
mock_model = MagicMock()
mock_qs = MagicMock()
mock_model.objects.filter.return_value = mock_qs
mock_qs.first.return_value = None
find_by_librenms_id(mock_model, 42)
mock_model.objects.filter.assert_called_once()
call_args = mock_model.objects.filter.call_args
q_arg = call_args[0][0]
assert isinstance(q_arg, Q)
assert q_arg.connector == "OR"
q_str = str(q_arg)
assert "custom_field_data__librenms_id__default" in q_str
class TestMigrateLegacyLibreNMSId:
"""Tests for migrate_legacy_librenms_id()."""
def test_returns_true_when_migrated(self):
from netbox_librenms_plugin.utils import migrate_legacy_librenms_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": 42}
result = migrate_legacy_librenms_id(obj, "default")
assert result is True
def test_migrates_integer_to_dict_format(self):
from netbox_librenms_plugin.utils import migrate_legacy_librenms_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": 42}
migrate_legacy_librenms_id(obj, "production")
assert obj.custom_field_data["librenms_id"] == {"production": 42}
def test_returns_false_when_already_dict(self):
from netbox_librenms_plugin.utils import migrate_legacy_librenms_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"default": 42}}
result = migrate_legacy_librenms_id(obj, "default")
assert result is False
def test_returns_false_when_value_is_none(self):
from netbox_librenms_plugin.utils import migrate_legacy_librenms_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": None}
result = migrate_legacy_librenms_id(obj, "default")
assert result is False
def test_returns_false_for_boolean_value(self):
"""bool is a subclass of int; True/False must not be migrated."""
from netbox_librenms_plugin.utils import migrate_legacy_librenms_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": True}
assert migrate_legacy_librenms_id(obj, "default") is False
assert obj.custom_field_data["librenms_id"] is True # unchanged
def test_does_not_call_save(self):
"""migrate_legacy_librenms_id must NOT call obj.save() — caller is responsible."""
from netbox_librenms_plugin.utils import migrate_legacy_librenms_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": 7}
migrate_legacy_librenms_id(obj, "default")
obj.save.assert_not_called()
def test_preserves_value_in_migrated_dict(self):
from netbox_librenms_plugin.utils import migrate_legacy_librenms_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": 99}
migrate_legacy_librenms_id(obj, "secondary")
assert obj.custom_field_data["librenms_id"]["secondary"] == 99
class TestLibreNMSIdRoundtrip:
"""get_librenms_device_id should see the value set by set_librenms_device_id."""
def test_set_then_get_returns_same_value(self):
from netbox_librenms_plugin.utils import get_librenms_device_id, set_librenms_device_id
obj = MagicMock()
obj.custom_field_data = {}
obj.cf = obj.custom_field_data # make cf a live view of custom_field_data
set_librenms_device_id(obj, 42, "production")
result = get_librenms_device_id(obj, "production")
assert result == 42
def test_set_multiple_servers_get_correct_each(self):
from netbox_librenms_plugin.utils import get_librenms_device_id, set_librenms_device_id
obj = MagicMock()
obj.custom_field_data = {}
obj.cf = obj.custom_field_data
set_librenms_device_id(obj, 10, "primary")
set_librenms_device_id(obj, 20, "secondary")
assert get_librenms_device_id(obj, "primary") == 10
assert get_librenms_device_id(obj, "secondary") == 20
def test_migrate_then_get_returns_value(self):
from netbox_librenms_plugin.utils import get_librenms_device_id, migrate_legacy_librenms_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": 55}
obj.cf = obj.custom_field_data
migrate_legacy_librenms_id(obj, "default")
result = get_librenms_device_id(obj, "default")
assert result == 55
class TestSetLibreNMSDeviceId:
"""Tests for set_librenms_device_id in utils.py."""
def test_stores_int_for_valid_device_id(self):
"""Valid integer device_id is stored under server_key."""
from netbox_librenms_plugin.utils import set_librenms_device_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": None}
set_librenms_device_id(obj, 42, server_key="primary")
assert obj.custom_field_data["librenms_id"] == {"primary": 42}
def test_invalid_device_id_not_stored(self):
"""Non-integer device_id is rejected and nothing is written."""
from netbox_librenms_plugin.utils import set_librenms_device_id
obj = MagicMock()
obj.custom_field_data = {}
set_librenms_device_id(obj, "not-an-int", server_key="primary")
assert "librenms_id" not in obj.custom_field_data
def test_invalid_device_id_does_not_overwrite_existing(self):
"""Existing valid value is preserved when new device_id is invalid."""
from netbox_librenms_plugin.utils import set_librenms_device_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"primary": 10}}
set_librenms_device_id(obj, None, server_key="primary")
assert obj.custom_field_data["librenms_id"] == {"primary": 10}
def test_legacy_bare_int_blocks_write(self):
"""Legacy bare-integer value blocks the write (no silent migration)."""
from netbox_librenms_plugin.utils import set_librenms_device_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": 7}
set_librenms_device_id(obj, 99, server_key="secondary")
# Write must be skipped; user must use the migration workflow.
assert obj.custom_field_data["librenms_id"] == 7
def test_adds_new_server_key_to_existing_dict(self):
"""Adding a new server key preserves existing keys."""
from netbox_librenms_plugin.utils import set_librenms_device_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"primary": 5}}
set_librenms_device_id(obj, 20, server_key="secondary")
assert obj.custom_field_data["librenms_id"] == {"primary": 5, "secondary": 20}
def test_string_integer_is_coerced(self):
"""String '42' is coerced to int 42."""
from netbox_librenms_plugin.utils import set_librenms_device_id
obj = MagicMock()
obj.custom_field_data = {}
set_librenms_device_id(obj, "42", server_key="primary")
assert obj.custom_field_data["librenms_id"] == {"primary": 42}
def test_unexpected_cf_type_reset_to_empty(self):
"""If custom_field_data has unexpected type for librenms_id, it is reset."""
from netbox_librenms_plugin.utils import set_librenms_device_id
obj = MagicMock()
obj.custom_field_data = {"librenms_id": "unexpected-string"}
set_librenms_device_id(obj, 5, server_key="primary")
assert obj.custom_field_data["librenms_id"] == {"primary": 5}

View File

@@ -0,0 +1,207 @@
"""Tests for view mixins: LibreNMSAPIMixin and CacheMixin."""
from unittest.mock import MagicMock, patch
class TestLibreNMSAPIMixinLazyInit:
"""LibreNMSAPIMixin.librenms_api is lazy — not created until first access."""
def _make_mixin(self):
from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin
mixin = object.__new__(LibreNMSAPIMixin)
mixin._librenms_api = None
return mixin
def test_starts_with_none(self):
mixin = self._make_mixin()
assert mixin._librenms_api is None
def test_first_access_creates_instance(self):
mixin = self._make_mixin()
fake_api = MagicMock()
with patch("netbox_librenms_plugin.views.mixins.LibreNMSAPI", return_value=fake_api):
api = mixin.librenms_api
assert api is fake_api
def test_second_access_returns_same_instance(self):
mixin = self._make_mixin()
fake_api = MagicMock()
with patch("netbox_librenms_plugin.views.mixins.LibreNMSAPI", return_value=fake_api) as mock_cls:
api1 = mixin.librenms_api
api2 = mixin.librenms_api
assert api1 is api2
mock_cls.assert_called_once() # constructor called only once
def test_librenms_api_is_property_descriptor(self):
from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin
assert isinstance(LibreNMSAPIMixin.__dict__["librenms_api"], property)
class TestLibreNMSAPIMixinGetServerInfo:
"""get_server_info() returns correct structure for multi-server and legacy configs."""
def _make_mixin_with_api(self, server_key="default"):
from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin
mixin = object.__new__(LibreNMSAPIMixin)
fake_api = MagicMock()
fake_api.server_key = server_key
mixin._librenms_api = fake_api
return mixin
def test_multi_server_returns_display_name_and_url(self, mock_multi_server_config):
mixin = self._make_mixin_with_api("default")
with patch("netbox.plugins.get_plugin_config") as mock_config:
mock_config.side_effect = lambda _plugin, key: mock_multi_server_config if key == "servers" else None
info = mixin.get_server_info()
assert info["display_name"] == "default" # falls back to server_key (no display_name in fixture)
assert info["url"] == mock_multi_server_config["default"]["librenms_url"]
assert info["is_legacy"] is False
assert info["server_key"] == "default"
def test_legacy_config_sets_is_legacy_true(self, mock_legacy_config):
mixin = self._make_mixin_with_api("default")
def mock_plugin_config(_plugin, key):
if key == "servers":
return None
if key == "librenms_url":
return mock_legacy_config["librenms_url"]
return None
with patch("netbox.plugins.get_plugin_config", side_effect=mock_plugin_config):
info = mixin.get_server_info()
assert info["is_legacy"] is True
assert info["url"] == mock_legacy_config["librenms_url"]
def test_returns_error_info_on_exception(self):
mixin = self._make_mixin_with_api("default")
with patch("netbox.plugins.get_plugin_config", side_effect=ImportError):
info = mixin.get_server_info()
assert "is_legacy" in info
assert info["is_legacy"] is True
class TestCacheMixinKeyGeneration:
"""CacheMixin generates consistent, predictable cache keys."""
def _make_mixin(self):
from netbox_librenms_plugin.views.mixins import CacheMixin
return object.__new__(CacheMixin)
def test_get_cache_key_format(self):
mixin = self._make_mixin()
obj = MagicMock()
obj._meta.model_name = "device"
obj.pk = 5
key = mixin.get_cache_key(obj, "ports")
assert key == "librenms_ports_device_5"
def test_get_cache_key_includes_server_key(self):
"""
Cache keys must be namespaced per server so two servers' data never collide.
Without server_key isolation a second server's stale ports list could be
returned to the wrong sync session.
"""
mixin = self._make_mixin()
obj = MagicMock()
obj._meta.model_name = "device"
obj.pk = 5
key = mixin.get_cache_key(obj, "ports", server_key="srv1")
assert "srv1" in key
assert key == "librenms_ports_device_5_srv1"
def test_get_cache_key_includes_model_name(self):
mixin = self._make_mixin()
obj = MagicMock()
obj._meta.model_name = "virtualmachine"
obj.pk = 10
key = mixin.get_cache_key(obj, "interfaces")
assert "virtualmachine" in key
assert "10" in key
def test_get_cache_key_different_data_types(self):
mixin = self._make_mixin()
obj = MagicMock()
obj._meta.model_name = "device"
obj.pk = 1
key_ports = mixin.get_cache_key(obj, "ports", server_key="prod")
key_ips = mixin.get_cache_key(obj, "ips", server_key="prod")
assert key_ports != key_ips
def test_get_last_fetched_key_format(self):
mixin = self._make_mixin()
obj = MagicMock()
obj._meta.model_name = "device"
obj.pk = 3
key = mixin.get_last_fetched_key(obj, "ports")
assert key == "librenms_ports_last_fetched_device_3" # exact string
def test_get_last_fetched_key_includes_server_key(self):
"""
The last-fetched timestamp key must also be server-scoped.
If two servers share the same key the cache countdown would reflect the
wrong server's fetch time.
"""
mixin = self._make_mixin()
obj = MagicMock()
obj._meta.model_name = "device"
obj.pk = 3
key = mixin.get_last_fetched_key(obj, "ports", server_key="srv1")
assert key == "librenms_ports_last_fetched_device_3_srv1" # exact string
def test_cache_key_different_pks_differ(self):
mixin = self._make_mixin()
obj1 = MagicMock()
obj1._meta.model_name = "device"
obj1.pk = 1
obj2 = MagicMock()
obj2._meta.model_name = "device"
obj2.pk = 2
assert mixin.get_cache_key(obj1, "ports") != mixin.get_cache_key(obj2, "ports")
def test_get_vlan_overrides_key_exists_and_differs_from_data_key(self):
"""VLAN group overrides use a separate cache key from the VLAN data key."""
mixin = self._make_mixin()
obj = MagicMock()
obj._meta.model_name = "device"
obj.pk = 7
vlan_key = mixin.get_vlan_overrides_key(obj)
assert vlan_key == "librenms_vlan_group_overrides_device_7"
data_key = mixin.get_cache_key(obj, "vlans")
assert vlan_key != data_key
def test_get_vlan_overrides_key_server_scoped(self):
"""VLAN overrides key includes server_key to avoid cross-server leakage."""
mixin = self._make_mixin()
obj = MagicMock()
obj._meta.model_name = "device"
obj.pk = 7
key_no_server = mixin.get_vlan_overrides_key(obj)
key_with_server = mixin.get_vlan_overrides_key(obj, server_key="prod")
assert key_with_server == "librenms_vlan_group_overrides_device_7_prod"
assert key_no_server != key_with_server

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env python
"""Tests for `netbox_librenms_plugin` package."""

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,426 @@
"""
Regression tests for reviewer-requested fixes.
Covers: _load_vc_member_name_pattern validation, _generate_vc_member_name pattern
handling, _normalize_librenms_mapping guards, all_server_mappings did validation,
render_device_selection XSS escape, SingleCableVerifyView server_key from POST,
import_single_device lazy validation api passthrough, CreateAndAssignPlatformView
full_clean before save.
"""
from unittest.mock import MagicMock, patch
# ---------------------------------------------------------------------------
# _load_vc_member_name_pattern
# ---------------------------------------------------------------------------
class TestLoadVcMemberNamePattern:
"""_load_vc_member_name_pattern must return valid string or default."""
DEFAULT = "-M{position}"
def _call(self):
from netbox_librenms_plugin.import_utils.virtual_chassis import _load_vc_member_name_pattern
return _load_vc_member_name_pattern()
def _patch_settings(self, settings_obj):
"""Patch the deferred import of LibreNMSSettings inside the function."""
return patch(
"netbox_librenms_plugin.models.LibreNMSSettings.objects",
**{"order_by.return_value.first.return_value": settings_obj},
)
def test_returns_valid_pattern(self):
settings = MagicMock()
settings.vc_member_name_pattern = "-SW{position}"
with self._patch_settings(settings):
assert self._call() == "-SW{position}"
def test_returns_default_for_none_pattern(self):
settings = MagicMock()
settings.vc_member_name_pattern = None
with self._patch_settings(settings):
assert self._call() == self.DEFAULT
def test_returns_default_for_empty_string(self):
settings = MagicMock()
settings.vc_member_name_pattern = ""
with self._patch_settings(settings):
assert self._call() == self.DEFAULT
def test_returns_default_for_whitespace_only(self):
settings = MagicMock()
settings.vc_member_name_pattern = " "
with self._patch_settings(settings):
assert self._call() == self.DEFAULT
def test_returns_default_for_boolean(self):
settings = MagicMock()
settings.vc_member_name_pattern = True
with self._patch_settings(settings):
assert self._call() == self.DEFAULT
def test_returns_default_when_no_settings(self):
with self._patch_settings(None):
assert self._call() == self.DEFAULT
def test_returns_default_on_exception(self):
with patch(
"netbox_librenms_plugin.models.LibreNMSSettings.objects",
) as mock_objs:
mock_objs.order_by.side_effect = RuntimeError("db error")
assert self._call() == self.DEFAULT
# ---------------------------------------------------------------------------
# _normalize_librenms_mapping
# ---------------------------------------------------------------------------
class TestNormalizeLibreNMSMapping:
"""_normalize_librenms_mapping must reject booleans and non-digit strings."""
def _call(self, value):
# Instantiate the view class minimally to access the method
from netbox_librenms_plugin.views.sync.device_fields import RemoveServerMappingView
view = object.__new__(RemoveServerMappingView)
return view._normalize_librenms_mapping(value)
def test_int_becomes_default_dict(self):
assert self._call(42) == {"default": 42}
def test_bool_true_returns_empty(self):
assert self._call(True) == {}
def test_bool_false_returns_empty(self):
assert self._call(False) == {}
def test_digit_string_coerced(self):
assert self._call("42") == {"default": 42}
def test_non_digit_string_returns_empty(self):
assert self._call("not-a-number") == {}
def test_plus_prefix_rejected(self):
"""'+1' is not strictly digit-only."""
assert self._call("+1") == {}
def test_space_padded_rejected(self):
"""' 42 ' is not strictly digit-only."""
assert self._call(" 42 ") == {}
def test_dict_passed_through(self):
d = {"production": 7}
assert self._call(d) is d
def test_none_returns_empty(self):
assert self._call(None) == {}
def test_list_returns_empty(self):
assert self._call([1, 2]) == {}
# ---------------------------------------------------------------------------
# all_server_mappings — did validation
# ---------------------------------------------------------------------------
class TestAllServerMappingsDidValidation:
"""all_server_mappings must skip invalid device IDs in the cf_value dict."""
def _call(self, obj, active_server_key="default"):
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
return BaseLibreNMSSyncView._build_all_server_mappings(obj, active_server_key)
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings")
def test_skips_boolean_did(self, mock_settings):
mock_settings.PLUGINS_CONFIG = {"netbox_librenms_plugin": {"servers": {}}}
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"default": True, "prod": 42}}
result = self._call(obj)
# Only prod=42 should survive
assert len(result) == 1
assert result[0]["device_id"] == 42
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings")
def test_skips_none_did(self, mock_settings):
mock_settings.PLUGINS_CONFIG = {"netbox_librenms_plugin": {"servers": {}}}
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"default": None}}
result = self._call(obj)
assert result is None # empty list → returns None
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings")
def test_coerces_digit_string_did(self, mock_settings):
mock_settings.PLUGINS_CONFIG = {"netbox_librenms_plugin": {"servers": {}}}
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"prod": "99"}}
result = self._call(obj)
assert len(result) == 1
assert result[0]["device_id"] == 99
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings")
def test_skips_non_digit_string_did(self, mock_settings):
mock_settings.PLUGINS_CONFIG = {"netbox_librenms_plugin": {"servers": {}}}
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"default": "bogus"}}
result = self._call(obj)
assert result is None
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings")
def test_valid_int_passes_through(self, mock_settings):
mock_settings.PLUGINS_CONFIG = {"netbox_librenms_plugin": {"servers": {}}}
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"default": 5, "secondary": 10}}
result = self._call(obj)
assert len(result) == 2
ids = {e["device_id"] for e in result}
assert ids == {5, 10}
# ---------------------------------------------------------------------------
# render_device_selection — XSS escape
# ---------------------------------------------------------------------------
class TestRenderDeviceSelectionEscape:
"""render_device_selection must HTML-escape member.name."""
def test_member_name_is_escaped(self):
from netbox_librenms_plugin.tables.cables import VCCableTable
device = MagicMock()
device.id = 1
vc = MagicMock()
member = MagicMock()
member.id = 1
member.name = '<script>alert("xss")</script>'
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))
# The raw <script> tag must NOT appear — it should be escaped
assert "<script>" not in html
assert "&lt;script&gt;" in html
# ---------------------------------------------------------------------------
# _generate_vc_member_name — pattern handling
# ---------------------------------------------------------------------------
class TestGenerateVcMemberName:
"""_generate_vc_member_name must respect caller-supplied pattern and catch format errors."""
def _call(self, master_name, position, serial=None, pattern=None):
from netbox_librenms_plugin.import_utils.virtual_chassis import _generate_vc_member_name
return _generate_vc_member_name(master_name, position, serial=serial, pattern=pattern)
def test_explicit_pattern_used(self):
"""When pattern is passed, it should be used directly (no DB query)."""
result = self._call("switch01", 2, pattern="-SW{position}")
assert result == "switch01-SW2"
def test_serial_in_pattern(self):
result = self._call("switch01", 2, serial="ABC123", pattern=" [{serial}]")
assert result == "switch01 [ABC123]"
def test_none_pattern_loads_from_settings(self):
"""When pattern is None, _load_vc_member_name_pattern is called."""
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="-STACK{position}",
):
result = self._call("core01", 3, pattern=None)
assert result == "core01-STACK3"
def test_malformed_pattern_falls_back_to_default(self):
"""Invalid format spec falls back to -M{position}."""
result = self._call("switch01", 2, pattern="{position!z}")
assert result == "switch01-M2"
def test_missing_key_falls_back_to_default(self):
"""Unknown placeholder falls back to -M{position}."""
result = self._call("switch01", 2, pattern="-{unknown_key}")
assert result == "switch01-M2"
def test_default_pattern(self):
result = self._call("switch01", 2, pattern="-M{position}")
assert result == "switch01-M2"
# ---------------------------------------------------------------------------
# SingleCableVerifyView — server_key from POST body
# ---------------------------------------------------------------------------
class TestSingleCableVerifyServerKey:
"""SingleCableVerifyView.post() must read server_key from POST body."""
def test_server_key_used_for_cache_lookup(self):
"""The server_key from POST body is passed to get_cache_key and get_librenms_sync_device."""
import json
from netbox_librenms_plugin.views.base.cables_view import SingleCableVerifyView
view = object.__new__(SingleCableVerifyView)
view._librenms_api = MagicMock()
view._librenms_api.server_key = "default-server"
request = MagicMock()
request.body = json.dumps(
{
"device_id": 1,
"local_port_id": "42",
"server_key": "production",
}
).encode()
mock_device = MagicMock()
with (
patch("netbox_librenms_plugin.views.base.cables_view.get_object_or_404") as mock_get_obj,
patch(
"netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device",
return_value=mock_device,
) as mock_sync_device,
patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache,
):
mock_get_obj.return_value = mock_device
mock_cache.get.return_value = None # No cached data
view.post(request)
# get_librenms_sync_device should be called with the posted server_key
mock_sync_device.assert_called_once_with(mock_device, server_key="production")
# cache lookup must also use the posted server_key (not the api default)
cache_key_arg = mock_cache.get.call_args[0][0]
assert "production" in cache_key_arg
def test_fallback_to_api_server_key(self):
"""When POST body has no server_key, falls back to self.librenms_api.server_key."""
import json
from netbox_librenms_plugin.views.base.cables_view import SingleCableVerifyView
view = object.__new__(SingleCableVerifyView)
view._librenms_api = MagicMock()
view._librenms_api.server_key = "fallback-server"
request = MagicMock()
request.body = json.dumps(
{
"device_id": 1,
"local_port_id": "42",
}
).encode()
with (
patch("netbox_librenms_plugin.views.base.cables_view.get_object_or_404") as mock_get_obj,
patch(
"netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device",
side_effect=lambda dev, **kw: dev,
) as mock_sync_device,
patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache,
):
mock_get_obj.return_value = MagicMock()
mock_cache.get.return_value = None
view.post(request)
mock_sync_device.assert_called_once()
assert mock_sync_device.call_args[1]["server_key"] == "fallback-server"
# cache lookup must also use the fallback server_key
cache_key_arg = mock_cache.get.call_args[0][0]
assert "fallback-server" in cache_key_arg
# ---------------------------------------------------------------------------
# import_single_device — lazy validation passes api
# ---------------------------------------------------------------------------
class TestImportSingleDeviceLazyValidation:
"""import_single_device must pass api=api to validate_device_for_import when validation is None."""
def test_api_passed_to_validate(self):
from netbox_librenms_plugin.import_utils.device_operations import import_single_device
mock_api = MagicMock()
mock_api.server_key = "prod"
mock_validation = {
"existing_device": MagicMock(name="existing"),
"can_import": False,
}
with (
patch(
"netbox_librenms_plugin.import_utils.device_operations.LibreNMSAPI",
return_value=mock_api,
),
patch(
"netbox_librenms_plugin.import_utils.device_operations.validate_device_for_import",
return_value=mock_validation,
) as mock_validate,
):
# Call with validation=None so lazy path triggers
import_single_device(
42,
server_key="prod",
sync_options={"use_sysname": True, "strip_domain": False},
validation=None,
libre_device={"device_id": 42, "hostname": "test"},
)
mock_validate.assert_called_once()
# api must be passed as keyword arg
assert mock_validate.call_args[1].get("api") is mock_api
# ---------------------------------------------------------------------------
# CreateAndAssignPlatformView — full_clean before save
# ---------------------------------------------------------------------------
class TestCreatePlatformFullClean:
"""CreateAndAssignPlatformView must call full_clean() so ValidationError is catchable."""
def test_validation_error_caught_on_slug_collision(self):
"""When full_clean raises ValidationError, user sees error message instead of 500."""
from django.core.exceptions import ValidationError
from netbox_librenms_plugin.views.sync.device_fields import CreateAndAssignPlatformView
view = object.__new__(CreateAndAssignPlatformView)
request = MagicMock()
request.method = "POST"
request.POST = {"platform_name": "test-platform"}
request.user.has_perm.return_value = True
view.request = request
with (
patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404"),
patch("netbox_librenms_plugin.views.sync.device_fields.Manufacturer"),
patch("netbox_librenms_plugin.views.sync.device_fields.Platform") as MockPlatform,
patch("netbox_librenms_plugin.views.sync.device_fields.transaction"),
patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_messages,
patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
):
# objects.filter().exists() returns False (no existing platform)
MockPlatform.objects.filter.return_value.exists.return_value = False
# Simulate full_clean raising ValidationError (slug collision)
platform_instance = MagicMock()
platform_instance.full_clean.side_effect = ValidationError("Slug already exists")
MockPlatform.return_value = platform_instance
view.post(request, pk=1)
# full_clean must have been called (not just .create())
platform_instance.full_clean.assert_called_once()
# save must NOT have been called (ValidationError raised before save)
platform_instance.save.assert_not_called()
# Error message should be shown to user with the actual validation detail
mock_messages.error.assert_called_once()
error_msg = mock_messages.error.call_args[0][1]
assert "could not be created" in error_msg
assert "Slug already exists" in error_msg

View File

@@ -0,0 +1,314 @@
"""Tests for device sync views: AddDeviceToLibreNMSView and field update views."""
from unittest.mock import MagicMock, patch
def _make_view(cls_name, module_path="netbox_librenms_plugin.views.sync.devices"):
import importlib
mod = importlib.import_module(module_path)
cls = getattr(mod, cls_name)
view = object.__new__(cls)
view._librenms_api = MagicMock()
view._librenms_api.server_key = "default"
view.request = MagicMock()
return view
def _make_field_view(cls_name):
return _make_view(cls_name, "netbox_librenms_plugin.views.sync.device_fields")
class TestAddDeviceToLibreNMSViewWiring:
"""AddDeviceToLibreNMSView must be correctly wired to LibreNMSAPIMixin."""
def test_has_librenms_api_mixin(self):
from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView
from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin
assert LibreNMSAPIMixin in AddDeviceToLibreNMSView.__mro__
def test_has_permission_mixin(self):
from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView
from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin
assert LibreNMSPermissionMixin in AddDeviceToLibreNMSView.__mro__
class TestAddDeviceToLibreNMSViewFormValid:
"""form_valid() builds correct device_data payload and calls librenms_api.add_device."""
def _make_view(self):
from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView
view = object.__new__(AddDeviceToLibreNMSView)
view._librenms_api = MagicMock()
view.request = MagicMock()
view.object = MagicMock()
view.object.get_absolute_url.return_value = "/dcim/devices/1/"
return view
def _make_form(self, data):
form = MagicMock()
form.cleaned_data = data
return form
def test_v2c_form_includes_community(self):
view = self._make_view()
view._librenms_api.add_device.return_value = (True, "Device added")
form = self._make_form(
{
"hostname": "switch1.example.com",
"community": "public",
"force_add": False,
}
)
with patch("netbox_librenms_plugin.views.sync.devices.redirect"):
with patch("netbox_librenms_plugin.views.sync.devices.messages"):
view.form_valid(form, snmp_version="v2c")
call_args = view._librenms_api.add_device.call_args[0][0]
assert call_args["snmp_version"] == "v2c"
assert call_args["community"] == "public"
assert call_args["hostname"] == "switch1.example.com"
def test_v3_form_includes_auth_fields(self):
view = self._make_view()
view._librenms_api.add_device.return_value = (True, "Device added")
form = self._make_form(
{
"hostname": "switch2.example.com",
"authlevel": "authPriv",
"authname": "admin",
"authpass": "secret",
"authalgo": "SHA",
"cryptopass": "crypt",
"cryptoalgo": "AES",
"force_add": False,
}
)
with patch("netbox_librenms_plugin.views.sync.devices.redirect"):
with patch("netbox_librenms_plugin.views.sync.devices.messages"):
view.form_valid(form, snmp_version="v3")
call_args = view._librenms_api.add_device.call_args[0][0]
assert call_args["snmp_version"] == "v3"
assert call_args["authlevel"] == "authPriv"
assert "community" not in call_args
def test_api_failure_adds_error_message(self):
view = self._make_view()
view._librenms_api.add_device.return_value = (False, "Connection refused")
form = self._make_form(
{
"hostname": "fail.example.com",
"community": "public",
"force_add": False,
}
)
with patch("netbox_librenms_plugin.views.sync.devices.redirect"):
with patch("netbox_librenms_plugin.views.sync.devices.messages") as mock_msg:
view.form_valid(form, snmp_version="v2c")
mock_msg.error.assert_called_once()
class TestUpdateDeviceLocationView:
"""UpdateDeviceLocationView.post calls update_device_field with site name."""
def test_calls_update_device_field_with_site(self):
"""
post() resolves the NetBox site name and passes the exact API payload
expected by LibreNMS's PATCH /api/v0/devices/{id}/field endpoint:
``{"field": ["location", "override_sysLocation"], "data": [name, "1"]}``.
"""
from netbox_librenms_plugin.views.sync.devices import UpdateDeviceLocationView
view = object.__new__(UpdateDeviceLocationView)
view._librenms_api = MagicMock()
view._librenms_api.get_librenms_id.return_value = 42
view._librenms_api.update_device_field.return_value = (True, "ok")
view.request = MagicMock()
device = MagicMock()
device.site = MagicMock()
device.site.name = "London"
device.get_absolute_url.return_value = "/dcim/devices/1/"
with patch("netbox_librenms_plugin.views.sync.devices.get_object_or_404", return_value=device):
with patch("netbox_librenms_plugin.views.sync.devices.redirect"):
with patch("netbox_librenms_plugin.views.sync.devices.messages") as mock_msg:
view.post(view.request, pk=1)
view._librenms_api.update_device_field.assert_called_once_with(
42,
{"field": ["location", "override_sysLocation"], "data": ["London", "1"]},
)
mock_msg.success.assert_called_once()
def test_warning_when_no_site(self):
from netbox_librenms_plugin.views.sync.devices import UpdateDeviceLocationView
view = object.__new__(UpdateDeviceLocationView)
view._librenms_api = MagicMock()
view._librenms_api.get_librenms_id.return_value = 42
view.request = MagicMock()
device = MagicMock()
device.site = None
device.pk = 1
with patch("netbox_librenms_plugin.views.sync.devices.get_object_or_404", return_value=device):
with patch("netbox_librenms_plugin.views.sync.devices.redirect"):
with patch("netbox_librenms_plugin.views.sync.devices.messages") as mock_msg:
view.post(view.request, pk=1)
view._librenms_api.update_device_field.assert_not_called()
mock_msg.warning.assert_called_once()
class TestUpdateDeviceNameViewWiring:
def test_has_all_required_mixins(self):
from netbox_librenms_plugin.views.sync.device_fields import UpdateDeviceNameView
from netbox_librenms_plugin.views.mixins import (
LibreNMSAPIMixin,
LibreNMSPermissionMixin,
NetBoxObjectPermissionMixin,
)
mro = UpdateDeviceNameView.__mro__
assert LibreNMSAPIMixin in mro
assert LibreNMSPermissionMixin in mro
assert NetBoxObjectPermissionMixin in mro
def test_requires_change_device_permission(self):
from netbox_librenms_plugin.views.sync.device_fields import UpdateDeviceNameView
from dcim.models import Device
perms = UpdateDeviceNameView.required_object_permissions
assert "POST" in perms
assert any(action == "change" and model == Device for action, model in perms["POST"])
class TestUpdateDeviceSerialViewWiring:
def test_has_all_required_mixins(self):
from netbox_librenms_plugin.views.sync.device_fields import UpdateDeviceSerialView
from netbox_librenms_plugin.views.mixins import (
LibreNMSAPIMixin,
LibreNMSPermissionMixin,
NetBoxObjectPermissionMixin,
)
assert LibreNMSAPIMixin in UpdateDeviceSerialView.__mro__
assert LibreNMSPermissionMixin in UpdateDeviceSerialView.__mro__
assert NetBoxObjectPermissionMixin in UpdateDeviceSerialView.__mro__
def test_requires_change_device_permission(self):
from netbox_librenms_plugin.views.sync.device_fields import UpdateDeviceSerialView
from dcim.models import Device
perms = UpdateDeviceSerialView.required_object_permissions
assert "POST" in perms
assert any(action == "change" and model == Device for action, model in perms["POST"])
class TestCreatePlatformFullClean:
"""CreateAndAssignPlatformView must call full_clean() so ValidationError is catchable."""
def test_validation_error_caught_on_slug_collision(self):
"""When full_clean raises ValidationError, user sees error message instead of 500."""
from django.core.exceptions import ValidationError
from netbox_librenms_plugin.views.sync.device_fields import CreateAndAssignPlatformView
view = object.__new__(CreateAndAssignPlatformView)
request = MagicMock()
request.method = "POST"
request.POST = {"platform_name": "test-platform"}
request.user.has_perm.return_value = True
view.request = request
with (
patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404"),
patch("netbox_librenms_plugin.views.sync.device_fields.Manufacturer"),
patch("netbox_librenms_plugin.views.sync.device_fields.Platform") as MockPlatform,
patch("netbox_librenms_plugin.views.sync.device_fields.transaction"),
patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_messages,
patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
):
MockPlatform.objects.filter.return_value.exists.return_value = False
platform_instance = MagicMock()
platform_instance.full_clean.side_effect = ValidationError({"slug": ["Slug already exists"]})
MockPlatform.return_value = platform_instance
view.post(request, pk=1)
platform_instance.full_clean.assert_called_once()
platform_instance.save.assert_not_called()
mock_messages.error.assert_called_once()
error_msg = mock_messages.error.call_args[0][1]
assert "could not be created" in error_msg
assert "Slug already exists" in error_msg
class TestRemoveServerMappingViewWiring:
def test_does_not_have_librenms_api_mixin(self):
"""RemoveServerMappingView does not call LibreNMS API — it only modifies NetBox."""
from netbox_librenms_plugin.views.sync.device_fields import RemoveServerMappingView
from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin
assert LibreNMSAPIMixin not in RemoveServerMappingView.__mro__
def test_has_permission_mixin(self):
from netbox_librenms_plugin.views.sync.device_fields import RemoveServerMappingView
from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin, NetBoxObjectPermissionMixin
assert LibreNMSPermissionMixin in RemoveServerMappingView.__mro__
assert NetBoxObjectPermissionMixin in RemoveServerMappingView.__mro__
def test_post_with_virtualmachine_sets_vm_permissions_and_redirects(self):
"""post() with object_type='virtualmachine' sets VirtualMachine permissions and redirects to VM URL."""
from netbox_librenms_plugin.views.sync.device_fields import RemoveServerMappingView
from virtualization.models import VirtualMachine
view = object.__new__(RemoveServerMappingView)
permissions_at_check = {}
def capture_perms(method):
permissions_at_check[method] = list(view.required_object_permissions.get(method, []))
return None # permission passes
mock_vm = MagicMock()
mock_vm.pk = 10
mock_vm.custom_field_data = {"librenms_id": {"orphaned-server": 42}}
# Use a mock model class so the select_for_update().get() call doesn't hit the DB
mock_model = MagicMock()
mock_model.objects.select_for_update.return_value.get.return_value = mock_vm
request = MagicMock()
request.POST = {"object_type": "virtualmachine", "server_key": "orphaned-server"}
with (
patch.object(view, "require_all_permissions", side_effect=capture_perms),
patch.object(view, "_get_object", return_value=(mock_vm, mock_model)),
patch("netbox_librenms_plugin.views.sync.device_fields.messages"),
patch("netbox_librenms_plugin.views.sync.device_fields.redirect") as mock_redirect,
patch("netbox_librenms_plugin.views.sync.device_fields.transaction"),
patch(
"django.conf.settings",
PLUGINS_CONFIG={"netbox_librenms_plugin": {}},
),
):
view.post(request, pk=10)
# required_object_permissions must be scoped to VirtualMachine, not Device
assert ("change", VirtualMachine) in permissions_at_check.get("POST", [])
# Response must redirect to the VM-specific sync URL
mock_redirect.assert_called_with("plugins:netbox_librenms_plugin:vm_librenms_sync", pk=10)

View File

@@ -0,0 +1,302 @@
"""Unit tests for SyncInterfacesView: update_interface_attributes and handle_mac_address."""
from unittest.mock import MagicMock, patch
import pytest
class TestUpdateInterfaceAttributes:
"""update_interface_attributes() must set fields respecting exclude_columns."""
@pytest.fixture
def view(self, mock_librenms_api):
"""Return a SyncInterfacesView wired to the shared mock API fixture."""
from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView
v = object.__new__(SyncInterfacesView)
v._librenms_api = mock_librenms_api
v.request = MagicMock()
v._lookup_maps = {}
return v
def _make_device_interface(self, **extra):
"""Return a MagicMock mimicking a dcim.Interface."""
from dcim.models import Interface # noqa: F401
iface = MagicMock(
spec=[
"name",
"type",
"speed",
"description",
"mtu",
"enabled",
"save",
"cf",
"custom_field_data",
"mac_addresses",
"primary_mac_address",
]
)
iface.cf = {"librenms_id": {"default": 1}}
iface.__class__ = Interface
for k, v in extra.items():
setattr(iface, k, v)
return iface
def test_sets_speed_via_convert(self, view):
iface = self._make_device_interface()
librenms_data = {"ifName": "eth0", "ifSpeed": 1_000_000_000}
with patch(
"netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=1_000_000
) as mock_convert:
with patch("netbox_librenms_plugin.views.sync.interfaces.set_librenms_device_id"):
view.update_interface_attributes(iface, librenms_data, "1000base-t", set(), "ifName")
mock_convert.assert_called_once_with(1_000_000_000)
assert iface.speed == 1_000_000
def test_skips_excluded_columns(self, view):
speed_sentinel = object()
iface = self._make_device_interface(speed=speed_sentinel)
librenms_data = {"ifName": "eth0", "ifSpeed": 1_000_000_000, "ifAlias": "uplink"}
with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=1_000_000):
with patch("netbox_librenms_plugin.views.sync.interfaces.set_librenms_device_id"):
view.update_interface_attributes(iface, librenms_data, "1000base-t", {"speed"}, "ifName")
# speed should NOT have been mutated (excluded)
assert iface.speed is speed_sentinel
def test_sets_type_for_device_interface(self, view):
from dcim.models import Interface
iface = MagicMock()
iface.__class__ = Interface
iface.cf = {}
iface.mac_addresses = MagicMock()
librenms_data = {"ifName": "eth0", "ifType": "ethernetCsmacd"}
with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None):
view.update_interface_attributes(iface, librenms_data, "1000base-t", set(), "ifName")
assert iface.type == "1000base-t"
def test_does_not_set_type_for_vm_interface(self, view):
from virtualization.models import VMInterface
iface = MagicMock()
iface.__class__ = VMInterface
iface.cf = {}
iface.mac_addresses = MagicMock()
original_type = "some_type"
iface.type = original_type
librenms_data = {"ifName": "eth0", "ifType": "ethernetCsmacd"}
with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None):
view.update_interface_attributes(iface, librenms_data, "1000base-t", set(), "ifName")
# type is NOT in the mapping for non-device interfaces (type set only if is_device_interface)
assert iface.type == original_type
def test_sets_description_only_when_alias_differs_from_name(self, view):
from dcim.models import Interface
iface = MagicMock()
iface.__class__ = Interface
iface.cf = {}
iface.mac_addresses = MagicMock()
desc_sentinel = object()
iface.description = desc_sentinel
# ifAlias == interface name field value → description should NOT be set
librenms_data = {"ifName": "eth0", "ifAlias": "eth0"}
with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None):
view.update_interface_attributes(iface, librenms_data, None, {"type", "speed", "mtu"}, "ifName")
assert iface.description is desc_sentinel # untouched: alias == name, no update
def test_sets_description_when_alias_differs(self, view):
from dcim.models import Interface
iface = MagicMock()
iface.__class__ = Interface
iface.cf = {}
iface.mac_addresses = MagicMock()
librenms_data = {"ifName": "eth0", "ifAlias": "uplink-port"}
with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None):
view.update_interface_attributes(iface, librenms_data, None, {"type", "speed", "mtu"}, "ifName")
assert iface.description == "uplink-port"
def test_sets_librenms_id_when_port_id_present(self, view):
"""
set_librenms_device_id() is called unconditionally when port_id is not None.
Historically the call was guarded by ``"librenms_id" in interface.cf``, which
prevented the mapping from being created for brand-new interfaces. This test
ensures the mapping is created even when no existing custom-field mapping is present.
"""
from dcim.models import Interface
iface = MagicMock()
iface.__class__ = Interface
iface.cf = {} # empty — first-time write, no existing mapping
iface.mac_addresses = MagicMock()
librenms_data = {"ifName": "eth0", "port_id": 77}
with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None):
with patch("netbox_librenms_plugin.views.sync.interfaces.set_librenms_device_id") as mock_set:
view.update_interface_attributes(iface, librenms_data, None, {"type", "speed", "mtu"}, "ifName")
mock_set.assert_called_once_with(iface, 77, view._librenms_api.server_key)
def test_does_not_set_librenms_id_when_port_id_none(self, view):
from dcim.models import Interface
iface = MagicMock()
iface.__class__ = Interface
iface.cf = {"librenms_id": {"default": 1}}
iface.mac_addresses = MagicMock()
librenms_data = {"ifName": "eth0", "port_id": None}
with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None):
with patch("netbox_librenms_plugin.views.sync.interfaces.set_librenms_device_id") as mock_set:
view.update_interface_attributes(iface, librenms_data, None, {"type", "speed", "mtu"}, "ifName")
mock_set.assert_not_called()
def test_sets_enabled_true_when_admin_status_none(self, view):
from dcim.models import Interface
iface = MagicMock()
iface.__class__ = Interface
iface.cf = {}
iface.mac_addresses = MagicMock()
librenms_data = {"ifName": "eth0", "ifAdminStatus": None}
with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None):
view.update_interface_attributes(iface, librenms_data, None, {"type", "speed", "mtu"}, "ifName")
assert iface.enabled is True
def test_sets_enabled_based_on_admin_status_string(self, view):
from dcim.models import Interface
iface = MagicMock()
iface.__class__ = Interface
iface.cf = {}
iface.mac_addresses = MagicMock()
librenms_data = {"ifName": "eth0", "ifAdminStatus": "down"}
with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None):
view.update_interface_attributes(iface, librenms_data, None, {"type", "speed", "mtu"}, "ifName")
assert iface.enabled is False
def test_calls_save_at_end(self, view):
from dcim.models import Interface
iface = MagicMock()
iface.__class__ = Interface
iface.cf = {}
iface.mac_addresses = MagicMock()
librenms_data = {"ifName": "eth0"}
with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None):
view.update_interface_attributes(iface, librenms_data, None, {"type", "speed", "mtu"}, "ifName")
iface.save.assert_called_once()
def test_excludes_mac_address_when_in_excluded(self, view):
from dcim.models import Interface
iface = MagicMock()
iface.__class__ = Interface
iface.cf = {}
iface.mac_addresses = MagicMock()
librenms_data = {"ifName": "eth0", "ifPhysAddress": "aa:bb:cc:dd:ee:ff"}
with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None):
with patch.object(view, "handle_mac_address") as mock_mac:
view.update_interface_attributes(iface, librenms_data, None, {"mac_address"}, "ifName")
mock_mac.assert_not_called()
class TestHandleMacAddress:
"""
handle_mac_address() must work for both Interface (has primary_mac_address)
and VMInterface (does not have primary_mac_address)."""
@pytest.fixture
def view(self, mock_librenms_api):
"""Return a SyncInterfacesView wired to the shared mock API fixture."""
from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView
v = object.__new__(SyncInterfacesView)
v._librenms_api = mock_librenms_api
v.request = MagicMock()
v._lookup_maps = {}
return v
def test_creates_new_mac_and_adds_to_interface(self, view):
iface = MagicMock()
iface.mac_addresses = MagicMock()
iface.mac_addresses.filter.return_value.first.return_value = None
new_mac = MagicMock()
with patch("netbox_librenms_plugin.views.sync.interfaces.MACAddress") as mock_cls:
mock_cls.objects.create.return_value = new_mac
view.handle_mac_address(iface, "aa:bb:cc:dd:ee:ff")
mock_cls.objects.create.assert_called_once_with(mac_address="aa:bb:cc:dd:ee:ff")
iface.mac_addresses.add.assert_called_once_with(new_mac)
def test_reuses_existing_mac(self, view):
existing_mac = MagicMock()
iface = MagicMock()
iface.mac_addresses = MagicMock()
iface.mac_addresses.filter.return_value.first.return_value = existing_mac
with patch("netbox_librenms_plugin.views.sync.interfaces.MACAddress") as mock_cls:
view.handle_mac_address(iface, "aa:bb:cc:dd:ee:ff")
mock_cls.objects.create.assert_not_called()
iface.mac_addresses.add.assert_called_once_with(existing_mac)
def test_sets_primary_mac_when_attribute_present(self, view):
mac_obj = MagicMock()
iface = MagicMock(spec=["mac_addresses", "primary_mac_address"])
iface.mac_addresses = MagicMock()
iface.mac_addresses.filter.return_value.first.return_value = None
with patch("netbox_librenms_plugin.views.sync.interfaces.MACAddress") as mock_cls:
mock_cls.objects.create.return_value = mac_obj
view.handle_mac_address(iface, "aa:bb:cc:dd:ee:ff")
assert iface.primary_mac_address is mac_obj
def test_no_error_when_primary_mac_attribute_absent(self, view):
"""VMInterface does not have primary_mac_address — handle_mac_address must not raise."""
mac_obj = MagicMock()
iface = MagicMock(spec=["mac_addresses"]) # no primary_mac_address attr
iface.mac_addresses = MagicMock()
iface.mac_addresses.filter.return_value.first.return_value = None
with patch("netbox_librenms_plugin.views.sync.interfaces.MACAddress") as mock_cls:
mock_cls.objects.create.return_value = mac_obj
# Must not raise AttributeError
view.handle_mac_address(iface, "aa:bb:cc:dd:ee:ff")
def test_noop_when_mac_address_is_falsy(self, view):
iface = MagicMock()
with patch("netbox_librenms_plugin.views.sync.interfaces.MACAddress") as mock_cls:
view.handle_mac_address(iface, "")
view.handle_mac_address(iface, None)
mock_cls.objects.create.assert_not_called()

View File

@@ -0,0 +1,589 @@
"""
Tests for device mismatch detection in get_librenms_device_info.
Covers the identity cross-matching logic that determines whether a
mismatched_device warning banner is shown on the LibreNMS Sync page,
as well as the resolved_name computation that drives the name-match
icon and sync button.
Match rule: mismatch is False when ANY NetBox identity (device name,
primary IP, DNS name) matches ANY LibreNMS identity (sysName, hostname, ip).
"""
from unittest.mock import MagicMock, patch
def _make_view(librenms_id, device_info, librenms_url="https://librenms.example.com"):
"""Create a minimal BaseLibreNMSSyncView instance with mocked dependencies."""
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
view = object.__new__(BaseLibreNMSSyncView)
view.librenms_id = librenms_id
api = MagicMock()
api.librenms_url = librenms_url
api.get_device_info.return_value = (True, device_info)
api.get_device_inventory.return_value = (True, [])
view._librenms_api = api
return view
def _make_obj(name, primary_ip=None, dns_name=None, virtual_chassis=None, cf=None, vc_position=None, serial=None):
"""Create a mock NetBox device object."""
obj = MagicMock()
obj.name = name
obj.cf = cf or {}
if primary_ip:
obj.primary_ip = MagicMock()
obj.primary_ip.address.ip = primary_ip
obj.primary_ip.dns_name = dns_name or ""
else:
obj.primary_ip = None
obj.virtual_chassis = virtual_chassis
obj.vc_position = vc_position
obj.serial = serial
return obj
def _make_request(use_sysname=None, strip_domain=None):
"""Create a mock request with user preferences for naming settings."""
request = MagicMock()
request.POST = {}
request.GET = {}
config = {}
if use_sysname is not None:
config["plugins.netbox_librenms_plugin.use_sysname"] = use_sysname
if strip_domain is not None:
config["plugins.netbox_librenms_plugin.strip_domain"] = strip_domain
request.user.config.get = lambda path, default=None: config.get(path, default)
return request
class TestMismatchDetection:
"""Tests for identity cross-matching logic."""
# -- No device / API failure -------------------------------------------
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
def test_no_librenms_id_returns_not_found(self, mock_hw):
"""No librenms_id means device is not found."""
view = _make_view(librenms_id=None, device_info=None)
result = view.get_librenms_device_info(_make_obj("sw01"))
assert result["found_in_librenms"] is False
assert result["mismatched_device"] is False
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
def test_api_failure_returns_not_found(self, mock_hw):
"""API failure (success=False) means device is not found."""
view = _make_view(librenms_id=42, device_info=None)
view.librenms_api.get_device_info.return_value = (False, None)
result = view.get_librenms_device_info(_make_obj("sw01"))
assert result["found_in_librenms"] is False
assert result["mismatched_device"] is False
# -- Name matches ------------------------------------------------------
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
def test_exact_sysname_match(self, mock_hw):
"""NetBox name matches LibreNMS sysName (case-insensitive)."""
view = _make_view(42, {"sysName": "SW01", "ip": "10.0.0.2"})
obj = _make_obj("sw01", primary_ip="10.0.0.1")
result = view.get_librenms_device_info(obj)
assert result["found_in_librenms"] is True
assert result["mismatched_device"] is False
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
def test_netbox_name_matches_librenms_hostname(self, mock_hw):
"""NetBox name matches LibreNMS hostname field."""
view = _make_view(42, {"sysName": "something-else", "hostname": "sw01", "ip": "10.0.0.2"})
obj = _make_obj("sw01", primary_ip="10.0.0.1")
result = view.get_librenms_device_info(obj)
assert result["found_in_librenms"] is True
assert result["mismatched_device"] is False
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
def test_fqdn_match(self, mock_hw):
"""Full FQDN match -- no mismatch."""
view = _make_view(42, {"sysName": "sw01.example.net", "ip": "10.0.0.2"})
obj = _make_obj("sw01.example.net", primary_ip="10.0.0.1")
result = view.get_librenms_device_info(obj)
assert result["found_in_librenms"] is True
assert result["mismatched_device"] is False
# -- IP matches --------------------------------------------------------
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
def test_netbox_ip_matches_librenms_ip(self, mock_hw):
"""NetBox primary IP matches LibreNMS IP -- no mismatch."""
view = _make_view(42, {"sysName": "different", "ip": "10.0.0.1"})
obj = _make_obj("sw01", primary_ip="10.0.0.1")
result = view.get_librenms_device_info(obj)
assert result["found_in_librenms"] is True
assert result["mismatched_device"] is False
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
def test_netbox_ip_matches_librenms_hostname_ip(self, mock_hw):
"""LibreNMS hostname is an IP that matches NetBox primary IP."""
view = _make_view(42, {"sysName": "different", "hostname": "10.0.0.1", "ip": "10.0.0.1"})
obj = _make_obj("sw01", primary_ip="10.0.0.1")
result = view.get_librenms_device_info(obj)
assert result["found_in_librenms"] is True
assert result["mismatched_device"] is False
# -- DNS name matches --------------------------------------------------
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
def test_dns_name_matches_sysname(self, mock_hw):
"""NetBox DNS name matches LibreNMS sysName."""
view = _make_view(42, {"sysName": "sw01.example.net", "ip": "10.0.0.2"})
obj = _make_obj("sw01", primary_ip="10.0.0.1", dns_name="sw01.example.net")
result = view.get_librenms_device_info(obj)
assert result["found_in_librenms"] is True
assert result["mismatched_device"] is False
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
def test_dns_name_matches_librenms_hostname(self, mock_hw):
"""NetBox DNS name matches LibreNMS hostname field."""
view = _make_view(42, {"sysName": "something", "hostname": "sw01.example.net", "ip": "10.0.0.2"})
obj = _make_obj("sw01", primary_ip="10.0.0.1", dns_name="sw01.example.net")
result = view.get_librenms_device_info(obj)
assert result["found_in_librenms"] is True
assert result["mismatched_device"] is False
# -- Mismatches --------------------------------------------------------
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
def test_completely_different_is_mismatch(self, mock_hw):
"""No identities overlap -- mismatch."""
view = _make_view(42, {"sysName": "router-01", "hostname": "router-01.corp", "ip": "10.0.0.2"})
obj = _make_obj("switch-05", primary_ip="10.0.0.1", dns_name="switch-05.corp")
result = view.get_librenms_device_info(obj)
assert result["found_in_librenms"] is True
assert result["mismatched_device"] is True
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
def test_short_vs_fqdn_matches_via_domain_strip(self, mock_hw):
"""Short name vs FQDN -- matches after domain stripping."""
view = _make_view(42, {"sysName": "sw01.example.net", "ip": "10.0.0.2"})
obj = _make_obj("sw01", primary_ip="10.0.0.1")
result = view.get_librenms_device_info(obj)
assert result["found_in_librenms"] is True
assert result["mismatched_device"] is False
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
def test_fqdn_domain_differs_matches_via_domain_strip(self, mock_hw):
"""
Different FQDN domains -- matches because domain-stripped
LibreNMS short name 'sw01' matches NetBox FQDN split 'sw01'.
NetBox name 'sw01.example.net' is compared as-is (no stripping),
but the LibreNMS domain-stripped 'sw01' does NOT appear in the
NetBox identities since NetBox names are not domain-stripped.
However, both sides share the short name via NetBox raw name
normalization — actually NetBox keeps the full name.
"""
view = _make_view(42, {"sysName": "sw01.other.net", "ip": "10.0.0.2"})
obj = _make_obj("sw01.example.net", primary_ip="10.0.0.1")
result = view.get_librenms_device_info(obj)
assert result["found_in_librenms"] is True
# NetBox identities: {"sw01.example.net", "10.0.0.1"}
# LibreNMS identities: {"sw01.other.net", "sw01", "10.0.0.2"}
# No overlap → mismatch
assert result["mismatched_device"] is True
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
def test_no_netbox_name_no_ip_match(self, mock_hw):
"""No NetBox name and IPs differ -- mismatch."""
view = _make_view(42, {"sysName": "sw01", "ip": "10.0.0.2"})
obj = _make_obj(None, primary_ip="10.0.0.1")
result = view.get_librenms_device_info(obj)
assert result["found_in_librenms"] is True
assert result["mismatched_device"] is True
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
def test_no_librenms_sysname_no_match(self, mock_hw):
"""No sysName, no hostname, IPs differ -- mismatch."""
view = _make_view(42, {"sysName": None, "ip": "10.0.0.2"})
obj = _make_obj("sw01", primary_ip="10.0.0.1")
result = view.get_librenms_device_info(obj)
assert result["found_in_librenms"] is True
assert result["mismatched_device"] is True
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
def test_no_identities_at_all(self, mock_hw):
"""Both sides have no identities -- mismatch (cannot confirm)."""
view = _make_view(42, {"sysName": None, "ip": None})
obj = _make_obj(None, primary_ip=None)
result = view.get_librenms_device_info(obj)
assert result["found_in_librenms"] is True
assert result["mismatched_device"] is True
# -- Virtual Chassis ---------------------------------------------------
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
def test_vc_suffix_stripped(self, mock_hw):
"""VC member suffix ' (1)' is stripped before comparison."""
view = _make_view(42, {"sysName": "switch-1", "ip": "10.0.0.2"})
obj = _make_obj("switch-1 (1)", primary_ip="10.0.0.1")
result = view.get_librenms_device_info(obj)
assert result["found_in_librenms"] is True
assert result["mismatched_device"] is False
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
def test_vc_different_name_is_mismatch(self, mock_hw):
"""VC member with different name after suffix strip -- mismatch."""
vc = MagicMock()
view = _make_view(42, {"sysName": "switch-1", "ip": "10.0.0.2"})
obj = _make_obj("switch-2 (2)", primary_ip="10.0.0.1", virtual_chassis=vc, cf={"librenms_id": 42})
result = view.get_librenms_device_info(obj)
assert result["found_in_librenms"] is True
assert result["mismatched_device"] is True
# -- found_in_librenms always True with valid ID -----------------------
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
def test_found_in_librenms_always_true_with_valid_id(self, mock_hw):
"""found_in_librenms is True even when identities mismatch."""
view = _make_view(42, {"sysName": "totally-different", "ip": "10.0.0.2"})
obj = _make_obj("my-device", primary_ip="10.0.0.1")
result = view.get_librenms_device_info(obj)
assert result["found_in_librenms"] is True
# -- Domain stripping --------------------------------------------------
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
def test_domain_strip_hostname(self, mock_hw):
"""LibreNMS hostname FQDN stripped to short name matches NetBox name."""
view = _make_view(42, {"sysName": "other", "hostname": "sw01.example.net", "ip": "10.0.0.2"})
obj = _make_obj("sw01", primary_ip="10.0.0.1")
result = view.get_librenms_device_info(obj)
assert result["mismatched_device"] is False
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
def test_domain_strip_sysname(self, mock_hw):
"""LibreNMS sysName FQDN stripped to short name matches NetBox name."""
view = _make_view(42, {"sysName": "sw01.corp.local", "ip": "10.0.0.2"})
obj = _make_obj("sw01", primary_ip="10.0.0.1")
result = view.get_librenms_device_info(obj)
assert result["mismatched_device"] is False
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
def test_domain_strip_no_false_positive(self, mock_hw):
"""Domain stripping doesn't cause false match when short names differ."""
view = _make_view(42, {"sysName": "router01.example.net", "ip": "10.0.0.2"})
obj = _make_obj("switch01", primary_ip="10.0.0.1")
result = view.get_librenms_device_info(obj)
assert result["mismatched_device"] is True
# -- VC pattern stripping ----------------------------------------------
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
@patch("netbox_librenms_plugin.models.LibreNMSSettings.objects")
def test_vc_pattern_strip_default(self, mock_settings_qs, mock_hw):
"""Default VC pattern '-M{position}' is stripped from NetBox name."""
settings_obj = MagicMock()
settings_obj.vc_member_name_pattern = "-M{position}"
mock_settings_qs.first.return_value = settings_obj
view = _make_view(42, {"sysName": "switch01", "ip": "10.0.0.2"})
obj = _make_obj("switch01-M2", primary_ip="10.0.0.1")
result = view.get_librenms_device_info(obj)
assert result["mismatched_device"] is False
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
@patch("netbox_librenms_plugin.models.LibreNMSSettings.objects")
def test_vc_pattern_strip_custom(self, mock_settings_qs, mock_hw):
"""Custom VC pattern '-SW{position}' is stripped from NetBox name."""
settings_obj = MagicMock()
settings_obj.vc_member_name_pattern = "-SW{position}"
mock_settings_qs.first.return_value = settings_obj
view = _make_view(42, {"sysName": "switch01", "ip": "10.0.0.2"})
obj = _make_obj("switch01-SW3", primary_ip="10.0.0.1")
result = view.get_librenms_device_info(obj)
assert result["mismatched_device"] is False
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
@patch("netbox_librenms_plugin.models.LibreNMSSettings.objects")
def test_vc_pattern_no_match_leaves_name(self, mock_settings_qs, mock_hw):
"""VC pattern doesn't match -- name unchanged, still mismatched."""
settings_obj = MagicMock()
settings_obj.vc_member_name_pattern = "-M{position}"
mock_settings_qs.first.return_value = settings_obj
view = _make_view(42, {"sysName": "switch01", "ip": "10.0.0.2"})
obj = _make_obj("switch99", primary_ip="10.0.0.1")
result = view.get_librenms_device_info(obj)
assert result["mismatched_device"] is True
# ---------------------------------------------------------------------------
# Tests for VC lookup delegation in get()
# ---------------------------------------------------------------------------
class TestVCLookupDelegation:
"""Verify that BaseLibreNMSSyncView.get() always delegates VC device
resolution to get_librenms_sync_device(), even when the viewed member
has its own librenms_id."""
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.render")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_object_or_404")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_librenms_sync_device")
def test_vc_member_with_own_id_delegates_to_sync_device(self, mock_sync_device, mock_get_object, mock_render):
"""A VC member with its own librenms_id should still delegate to
get_librenms_sync_device, which may return a different member."""
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
# Viewed device: member A with its own librenms_id
member_a = MagicMock()
member_a.pk = 1
member_a.cf = {"librenms_id": {"default": 42}}
member_a.virtual_chassis = MagicMock()
# Sync device: member B (returned by get_librenms_sync_device)
member_b = MagicMock()
member_b.pk = 2
mock_get_object.return_value = member_a
mock_sync_device.return_value = member_b
view = object.__new__(BaseLibreNMSSyncView)
view.model = MagicMock()
api = MagicMock()
api.server_key = "default"
api.get_librenms_id.return_value = 42
view._librenms_api = api
view.tab = MagicMock()
view.get_context_data = MagicMock(return_value={})
mock_render.return_value = MagicMock()
request = MagicMock()
view.get(request, pk=1)
# get_librenms_sync_device must be called unconditionally for VC members
mock_sync_device.assert_called_once_with(member_a, server_key="default")
# get_librenms_id should be called on the sync device (member_b)
api.get_librenms_id.assert_called_once_with(member_b)
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.render")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_object_or_404")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_librenms_sync_device")
def test_non_vc_device_skips_sync_device_lookup(self, mock_sync_device, mock_get_object, mock_render):
"""A device without a virtual chassis should not call
get_librenms_sync_device at all."""
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
device = MagicMock()
device.pk = 1
device.virtual_chassis = None
mock_get_object.return_value = device
view = object.__new__(BaseLibreNMSSyncView)
view.model = MagicMock()
api = MagicMock()
api.server_key = "default"
api.get_librenms_id.return_value = 42
view._librenms_api = api
view.tab = MagicMock()
view.get_context_data = MagicMock(return_value={})
mock_render.return_value = MagicMock()
request = MagicMock()
view.get(request, pk=1)
mock_sync_device.assert_not_called()
# ---------------------------------------------------------------------------
# Tests for _build_all_server_mappings
# ---------------------------------------------------------------------------
class TestBuildAllServerMappings:
"""Tests for BaseLibreNMSSyncView._build_all_server_mappings."""
def test_returns_none_for_legacy_int(self, mock_netbox_device):
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
mock_netbox_device.custom_field_data = {"librenms_id": 42}
result = BaseLibreNMSSyncView._build_all_server_mappings(mock_netbox_device, "production")
assert result is None
def test_returns_none_for_missing_cf(self, mock_netbox_device):
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
mock_netbox_device.custom_field_data = {"librenms_id": None}
result = BaseLibreNMSSyncView._build_all_server_mappings(mock_netbox_device, "production")
assert result is None
def test_single_configured_server(self, mock_netbox_device, mock_plugins_config_single_server):
from unittest.mock import patch
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
mock_netbox_device.custom_field_data = {"librenms_id": {"production": 42}}
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings") as mock_settings:
mock_settings.PLUGINS_CONFIG = mock_plugins_config_single_server
result = BaseLibreNMSSyncView._build_all_server_mappings(mock_netbox_device, "production")
assert result is not None
assert len(result) == 1
entry = result[0]
assert entry["server_key"] == "production"
assert entry["device_id"] == 42
assert entry["display_name"] == "Production LibreNMS"
assert entry["is_configured"] is True
assert entry["is_active"] is True
assert entry["device_url"] == "https://librenms.example.com/device/device=42/"
def test_orphaned_server_is_not_configured(self, mock_netbox_device, mock_plugins_config_empty_servers):
from unittest.mock import patch
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
mock_netbox_device.custom_field_data = {"librenms_id": {"deleted-server": 77}}
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings") as mock_settings:
mock_settings.PLUGINS_CONFIG = mock_plugins_config_empty_servers
result = BaseLibreNMSSyncView._build_all_server_mappings(mock_netbox_device, "production")
assert result is not None
assert len(result) == 1
entry = result[0]
assert entry["server_key"] == "deleted-server"
assert entry["device_id"] == 77
assert entry["is_configured"] is False
assert entry["is_active"] is False
assert entry["device_url"] is None
def test_multiple_servers_sorted_active_first(self, mock_netbox_device, mock_plugins_config_multi_server_mapping):
from unittest.mock import patch
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
mock_netbox_device.custom_field_data = {"librenms_id": {"mock-dev": 99, "production": 42, "old-server": 11}}
with patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings") as mock_settings:
mock_settings.PLUGINS_CONFIG = mock_plugins_config_multi_server_mapping
result = BaseLibreNMSSyncView._build_all_server_mappings(mock_netbox_device, "production")
assert result is not None
assert len(result) == 3
# Active (production) first
assert result[0]["server_key"] == "production"
assert result[0]["is_active"] is True
# Configured (mock-dev) second
assert result[1]["server_key"] == "mock-dev"
assert result[1]["is_configured"] is True
assert result[1]["is_active"] is False
# Orphaned last
assert result[2]["server_key"] == "old-server"
assert result[2]["is_configured"] is False
class TestResolvedName:
"""Tests for resolved_name computation using naming preferences."""
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.resolve_naming_preferences")
def test_resolved_name_uses_sysname_by_default(self, mock_prefs, mock_hw):
"""resolved_name defaults to sysName when use_sysname=True."""
mock_prefs.return_value = (True, False)
view = _make_view(42, {"sysName": "sw01.example.com", "hostname": "10.0.0.2", "ip": "10.0.0.2"})
obj = _make_obj("sw01.example.com", primary_ip="10.0.0.2")
request = _make_request(use_sysname=True, strip_domain=False)
result = view.get_librenms_device_info(obj, request)
assert result["librenms_device_details"]["resolved_name"] == "sw01.example.com"
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.resolve_naming_preferences")
def test_resolved_name_with_strip_domain(self, mock_prefs, mock_hw):
"""resolved_name strips domain when strip_domain=True."""
mock_prefs.return_value = (True, True)
view = _make_view(42, {"sysName": "sw01.example.com", "hostname": "10.0.0.2", "ip": "10.0.0.2"})
obj = _make_obj("sw01", primary_ip="10.0.0.2")
request = _make_request(use_sysname=True, strip_domain=True)
result = view.get_librenms_device_info(obj, request)
assert result["librenms_device_details"]["resolved_name"] == "sw01"
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.resolve_naming_preferences")
def test_resolved_name_use_hostname(self, mock_prefs, mock_hw):
"""resolved_name uses hostname when use_sysname=False."""
mock_prefs.return_value = (False, False)
view = _make_view(42, {"sysName": "sw01.example.com", "hostname": "sw01-mgmt", "ip": "10.0.0.2"})
obj = _make_obj("sw01-mgmt", primary_ip="10.0.0.2")
request = _make_request(use_sysname=False, strip_domain=False)
result = view.get_librenms_device_info(obj, request)
assert result["librenms_device_details"]["resolved_name"] == "sw01-mgmt"
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.resolve_naming_preferences")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view._generate_vc_member_name")
def test_resolved_name_vc_member(self, mock_vc_name, mock_prefs, mock_hw):
"""resolved_name applies VC member naming pattern for VC members."""
mock_prefs.return_value = (True, True)
mock_vc_name.return_value = "sw01-M2"
vc = MagicMock()
view = _make_view(42, {"sysName": "sw01.example.com", "hostname": "10.0.0.2", "ip": "10.0.0.2"})
obj = _make_obj("sw01-M2", primary_ip="10.0.0.2", virtual_chassis=vc, vc_position=2, serial="ABC123")
request = _make_request(use_sysname=True, strip_domain=True)
result = view.get_librenms_device_info(obj, request)
assert result["librenms_device_details"]["resolved_name"] == "sw01-M2"
mock_vc_name.assert_called_once_with("sw01", 2, serial="ABC123")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
def test_resolved_name_falls_back_to_sysname_without_request(self, mock_hw):
"""Without request, resolved_name falls back to raw sysName."""
view = _make_view(42, {"sysName": "sw01.example.com", "ip": "10.0.0.2"})
obj = _make_obj("sw01", primary_ip="10.0.0.2")
result = view.get_librenms_device_info(obj)
assert result["librenms_device_details"]["resolved_name"] == "sw01.example.com"
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type")
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.resolve_naming_preferences")
def test_resolved_name_non_vc_device_no_vc_pattern(self, mock_prefs, mock_hw):
"""Non-VC device does not apply VC member naming."""
mock_prefs.return_value = (True, True)
view = _make_view(42, {"sysName": "sw01.example.com", "hostname": "10.0.0.2", "ip": "10.0.0.2"})
obj = _make_obj("sw01", primary_ip="10.0.0.2")
obj.virtual_chassis = None
obj.vc_position = None
request = _make_request(use_sysname=True, strip_domain=True)
result = view.get_librenms_device_info(obj, request)
assert result["librenms_device_details"]["resolved_name"] == "sw01"

View File

@@ -0,0 +1,818 @@
"""
Tests for netbox_librenms_plugin.utils module.
Phase 2 tests covering device type matching, site matching,
platform matching, and conversion helper functions.
"""
import json
from unittest.mock import MagicMock, patch
# =============================================================================
# TestDeviceTypeMatching - 5 tests
# =============================================================================
class TestDeviceTypeMatching:
"""Test device type matching logic."""
@patch("netbox_librenms_plugin.models.DeviceTypeMapping", create=True)
@patch("dcim.models.DeviceType")
def test_match_device_type_exact_match_by_part_number(self, mock_device_type, mock_dtm):
"""Exact part_number string should match."""
mock_dtm.DoesNotExist = Exception
mock_dtm.objects.get.side_effect = Exception
mock_dt = MagicMock(id=1, model="C9300-48P")
mock_device_type.objects.get.return_value = mock_dt
from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type
result = match_librenms_hardware_to_device_type("C9300-48P")
assert result["matched"] is True
assert result["device_type"] == mock_dt
assert result["match_type"] == "exact"
@patch("netbox_librenms_plugin.models.DeviceTypeMapping", create=True)
@patch("dcim.models.DeviceType")
def test_match_device_type_exact_match_by_model(self, mock_device_type, mock_dtm):
"""Exact model string should match when part_number fails."""
mock_dtm.DoesNotExist = Exception
mock_dtm.objects.get.side_effect = Exception
mock_dt = MagicMock(id=1, model="WS-C3750X-48P")
# Part number lookup fails, model lookup succeeds
mock_device_type.DoesNotExist = Exception
mock_device_type.objects.get.side_effect = [
mock_device_type.DoesNotExist, # part_number lookup fails
mock_dt, # model lookup succeeds
]
from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type
result = match_librenms_hardware_to_device_type("WS-C3750X-48P")
assert result["matched"] is True
assert result["device_type"] == mock_dt
assert result["match_type"] == "exact"
@patch("netbox_librenms_plugin.models.DeviceTypeMapping", create=True)
@patch("dcim.models.DeviceType")
def test_match_device_type_not_found(self, mock_device_type, mock_dtm):
"""Returns not-found dict when no match found."""
mock_dtm.DoesNotExist = Exception
mock_dtm.objects.get.side_effect = Exception
mock_device_type.DoesNotExist = Exception
mock_device_type.objects.get.side_effect = mock_device_type.DoesNotExist
from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type
result = match_librenms_hardware_to_device_type("NonexistentHardware")
assert result["matched"] is False
assert result["device_type"] is None
assert result["match_type"] is None
def test_match_device_type_empty_hardware(self):
"""Empty string returns None."""
from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type
result = match_librenms_hardware_to_device_type("")
assert result["matched"] is False
assert result["device_type"] is None
def test_match_device_type_dash_hardware(self):
"""Dash placeholder returns None."""
from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type
result = match_librenms_hardware_to_device_type("-")
assert result["matched"] is False
assert result["device_type"] is None
@patch("netbox_librenms_plugin.models.DeviceTypeMapping", create=True)
@patch("dcim.models.DeviceType")
def test_match_device_type_ambiguous_part_number_returns_none(self, mock_device_type, mock_dtm):
"""MultipleObjectsReturned on part_number should return None, not pick .first()."""
DoesNotExist = type("DoesNotExist", (Exception,), {})
MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {})
mock_dtm.DoesNotExist = DoesNotExist
mock_dtm.MultipleObjectsReturned = type("DTMMult", (Exception,), {})
mock_dtm.objects.get.side_effect = DoesNotExist # DTM not found
mock_device_type.DoesNotExist = DoesNotExist
mock_device_type.MultipleObjectsReturned = MultipleObjectsReturned
mock_device_type.objects.get.side_effect = MultipleObjectsReturned
from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type
result = match_librenms_hardware_to_device_type("DUPLICATE-PARTNUM")
assert result is None
@patch("netbox_librenms_plugin.models.DeviceTypeMapping", create=True)
@patch("dcim.models.DeviceType")
def test_match_device_type_ambiguous_model_returns_none(self, mock_device_type, mock_dtm):
"""MultipleObjectsReturned on model should return None, not pick .first()."""
DoesNotExist = type("DoesNotExist", (Exception,), {})
MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {})
mock_dtm.DoesNotExist = DoesNotExist
mock_dtm.MultipleObjectsReturned = type("DTMMult", (Exception,), {})
mock_dtm.objects.get.side_effect = DoesNotExist # DTM not found
mock_device_type.DoesNotExist = DoesNotExist
mock_device_type.MultipleObjectsReturned = MultipleObjectsReturned
# part_number raises DoesNotExist, model raises MultipleObjectsReturned
mock_device_type.objects.get.side_effect = [
DoesNotExist("not found"),
MultipleObjectsReturned("ambiguous"),
]
from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type
result = match_librenms_hardware_to_device_type("DUPLICATE-MODEL")
assert result is None
# =============================================================================
# TestSiteMatching - 4 tests
# =============================================================================
class TestSiteMatching:
"""Test site matching logic."""
@patch("dcim.models.Site")
def test_find_site_for_location_exact_match(self, mock_site_model):
"""Location name matched to site."""
mock_site = MagicMock(id=1, name="DC1")
mock_site_model.objects.get.return_value = mock_site
from netbox_librenms_plugin.utils import find_matching_site
result = find_matching_site("DC1")
assert result["found"] is True
assert result["site"] == mock_site
assert result["match_type"] == "exact"
assert result["confidence"] == 1.0
@patch("dcim.models.Site")
def test_find_site_for_location_not_found(self, mock_site_model):
"""Returns None when no match."""
mock_site_model.DoesNotExist = Exception
mock_site_model.objects.get.side_effect = mock_site_model.DoesNotExist
from netbox_librenms_plugin.utils import find_matching_site
result = find_matching_site("Unknown Location")
assert result["found"] is False
assert result["site"] is None
assert result["confidence"] == 0.0
def test_find_site_for_location_empty(self):
"""Empty location returns None."""
from netbox_librenms_plugin.utils import find_matching_site
result = find_matching_site("")
assert result["found"] is False
assert result["site"] is None
def test_find_site_for_location_dash(self):
"""Dash placeholder returns None."""
from netbox_librenms_plugin.utils import find_matching_site
result = find_matching_site("-")
assert result["found"] is False
assert result["site"] is None
# =============================================================================
# TestPlatformMatching - 4 tests
# =============================================================================
class TestPlatformMatching:
"""Test platform matching logic."""
@patch("dcim.models.Platform")
def test_find_platform_for_os_exact_match(self, mock_platform_model):
"""OS string matched to platform."""
mock_platform = MagicMock(id=1, name="ios")
mock_platform_model.objects.get.return_value = mock_platform
from netbox_librenms_plugin.utils import find_matching_platform
result = find_matching_platform("ios")
assert result["found"] is True
assert result["platform"] == mock_platform
assert result["match_type"] == "exact"
@patch("dcim.models.Platform")
def test_find_platform_for_os_not_found(self, mock_platform_model):
"""Returns None when no match."""
mock_platform_model.DoesNotExist = Exception
mock_platform_model.objects.get.side_effect = mock_platform_model.DoesNotExist
from netbox_librenms_plugin.utils import find_matching_platform
result = find_matching_platform("unknown_os")
assert result["found"] is False
assert result["platform"] is None
def test_find_platform_for_os_empty(self):
"""Empty OS returns None."""
from netbox_librenms_plugin.utils import find_matching_platform
result = find_matching_platform("")
assert result["found"] is False
assert result["platform"] is None
def test_find_platform_for_os_dash(self):
"""Dash placeholder returns None."""
from netbox_librenms_plugin.utils import find_matching_platform
result = find_matching_platform("-")
assert result["found"] is False
assert result["platform"] is None
# =============================================================================
# TestConversionHelpers - 4 tests
# =============================================================================
class TestConversionHelpers:
"""Test data conversion helper functions."""
def test_convert_speed_to_kbps_basic(self):
"""Convert bps to kbps."""
from netbox_librenms_plugin.utils import convert_speed_to_kbps
# 1 Gbps = 1,000,000,000 bps = 1,000,000 kbps
result = convert_speed_to_kbps(1000000000)
assert result == 1000000
def test_convert_speed_to_kbps_megabit(self):
"""Convert megabit speed to kbps."""
from netbox_librenms_plugin.utils import convert_speed_to_kbps
# 100 Mbps = 100,000,000 bps = 100,000 kbps
result = convert_speed_to_kbps(100000000)
assert result == 100000
def test_convert_speed_to_kbps_zero(self):
"""Zero handled correctly."""
from netbox_librenms_plugin.utils import convert_speed_to_kbps
result = convert_speed_to_kbps(0)
assert result == 0
def test_convert_speed_to_kbps_none(self):
"""None returns None."""
from netbox_librenms_plugin.utils import convert_speed_to_kbps
result = convert_speed_to_kbps(None)
assert result is None
def test_format_mac_address_valid(self):
"""Format valid MAC address."""
from netbox_librenms_plugin.utils import format_mac_address
result = format_mac_address("aabbccddeeff")
assert result == "AA:BB:CC:DD:EE:FF"
def test_format_mac_address_with_colons(self):
"""Format MAC address that already has colons."""
from netbox_librenms_plugin.utils import format_mac_address
result = format_mac_address("aa:bb:cc:dd:ee:ff")
assert result == "AA:BB:CC:DD:EE:FF"
def test_format_mac_address_with_dashes(self):
"""Format MAC address with dashes."""
from netbox_librenms_plugin.utils import format_mac_address
result = format_mac_address("aa-bb-cc-dd-ee-ff")
assert result == "AA:BB:CC:DD:EE:FF"
def test_format_mac_address_invalid(self):
"""Returns error message for invalid MAC."""
from netbox_librenms_plugin.utils import format_mac_address
result = format_mac_address("invalid")
assert result == "Invalid MAC Address"
def test_format_mac_address_empty(self):
"""Empty string returns empty string."""
from netbox_librenms_plugin.utils import format_mac_address
result = format_mac_address("")
assert result == ""
def test_format_mac_address_none(self):
"""None returns empty string."""
from netbox_librenms_plugin.utils import format_mac_address
result = format_mac_address(None)
assert result == ""
# =============================================================================
# TestVirtualChassisHelpers - 4 tests
# =============================================================================
class TestVirtualChassisHelpers:
"""Test virtual chassis helper functions."""
def test_get_virtual_chassis_member_no_vc(self, mock_netbox_device):
"""Device without VC returns original device."""
from netbox_librenms_plugin.utils import get_virtual_chassis_member
mock_netbox_device.virtual_chassis = None
result = get_virtual_chassis_member(mock_netbox_device, "Ethernet1")
assert result == mock_netbox_device
def test_get_virtual_chassis_member_with_vc(self):
"""Device with VC returns correct member."""
from netbox_librenms_plugin.utils import get_virtual_chassis_member
mock_device = MagicMock()
mock_member = MagicMock(name="member-1")
mock_device.virtual_chassis = MagicMock()
mock_device.virtual_chassis.members.get.return_value = mock_member
result = get_virtual_chassis_member(mock_device, "Ethernet1")
# Should try to get VC member with position 1
mock_device.virtual_chassis.members.get.assert_called_once_with(vc_position=1)
assert result == mock_member
def test_get_virtual_chassis_member_invalid_port(self):
"""Invalid port name returns original device."""
from netbox_librenms_plugin.utils import get_virtual_chassis_member
mock_device = MagicMock()
mock_device.virtual_chassis = MagicMock()
result = get_virtual_chassis_member(mock_device, "InvalidPort")
assert result == mock_device
def test_get_librenms_sync_device_no_vc(self, mock_netbox_device):
"""Device without VC returns itself."""
from netbox_librenms_plugin.utils import get_librenms_sync_device
mock_netbox_device.virtual_chassis = None
result = get_librenms_sync_device(mock_netbox_device)
assert result == mock_netbox_device
def test_get_librenms_sync_device_with_librenms_id(self):
"""VC member with librenms_id is returned."""
from netbox_librenms_plugin.utils import get_librenms_sync_device
mock_device = MagicMock()
mock_member_with_id = MagicMock()
mock_member_with_id.cf = {"librenms_id": 123}
mock_member_without_id = MagicMock()
mock_member_without_id.cf = {}
mock_device.virtual_chassis = MagicMock()
mock_device.virtual_chassis.members.all.return_value = [
mock_member_without_id,
mock_member_with_id,
]
result = get_librenms_sync_device(mock_device)
assert result == mock_member_with_id
def test_get_librenms_sync_device_dict_preferred_over_legacy_bare_int(self):
"""
In a partially migrated VC, a member with per-server dict format
is preferred over a member with legacy bare-int format."""
from netbox_librenms_plugin.utils import get_librenms_sync_device
# Member A: legacy bare-int librenms_id (not yet migrated)
member_a = MagicMock()
member_a.cf = {"librenms_id": 42}
# Member B: migrated per-server dict format
member_b = MagicMock()
member_b.cf = {"librenms_id": {"default": 42}}
mock_device = MagicMock()
mock_device.virtual_chassis = MagicMock()
# member_a listed first — the function should still prefer member_b
mock_device.virtual_chassis.members.all.return_value = [member_a, member_b]
result = get_librenms_sync_device(mock_device, server_key="default")
assert result == member_b
def test_get_librenms_sync_device_legacy_fallback_when_no_dict(self):
"""When no member has a per-server dict, fall back to legacy bare-int."""
from netbox_librenms_plugin.utils import get_librenms_sync_device
member_a = MagicMock()
member_a.cf = {"librenms_id": 42}
member_b = MagicMock()
member_b.cf = {}
mock_device = MagicMock()
mock_device.virtual_chassis = MagicMock()
mock_device.virtual_chassis.members.all.return_value = [member_b, member_a]
result = get_librenms_sync_device(mock_device, server_key="default")
assert result == member_a
def test_get_librenms_sync_device_dict_for_different_server_falls_through(self):
"""Per-server dict with a different key does not match; legacy bare-int resolves instead."""
from netbox_librenms_plugin.utils import get_librenms_sync_device
# Member A: legacy bare-int (universal fallback)
member_a = MagicMock()
member_a.cf = {"librenms_id": 42}
# Member B: dict but only for "production", not "default"
member_b = MagicMock()
member_b.cf = {"librenms_id": {"production": 99}}
mock_device = MagicMock()
mock_device.virtual_chassis = MagicMock()
mock_device.virtual_chassis.members.all.return_value = [member_a, member_b]
result = get_librenms_sync_device(mock_device, server_key="default")
assert result == member_a
def test_zero_id_is_not_a_valid_librenms_id(self):
"""LibreNMS uses MySQL auto-increment IDs starting at 1; device_id=0 cannot exist.
A member whose resolved ID is 0 must be skipped so a real ID is preferred."""
from netbox_librenms_plugin.utils import get_librenms_sync_device
device = MagicMock()
vc = MagicMock()
device.virtual_chassis = vc
vc.master = None
member_zero = MagicMock()
member_zero.primary_ip = None
member_real = MagicMock()
member_real.primary_ip = None
def _id_side_effect(obj, server_key, **kwargs):
if obj is member_zero:
return 0
if obj is member_real:
return 5
return None
# member_zero comes first but has id=0; member_real has id=5 — real ID wins
with patch("netbox_librenms_plugin.utils.get_librenms_device_id") as mock_get_id:
mock_get_id.side_effect = _id_side_effect
vc.members.all.return_value = [member_zero, member_real]
result = get_librenms_sync_device(device, server_key="default")
assert result is member_real
# =============================================================================
# TestSafeDisabled - tests for _safe_disabled in bulk_import.py and filters.py
# =============================================================================
class TestSafeDisabledBulkImport:
"""Tests for _safe_disabled in import_utils/bulk_import.py."""
def _call(self, val):
from netbox_librenms_plugin.import_utils.bulk_import import _safe_disabled
return _safe_disabled({"disabled": val})
def test_bool_true(self):
assert self._call(True) == 1
def test_bool_false(self):
assert self._call(False) == 0
def test_string_true_lowercase(self):
assert self._call("true") == 1
def test_string_yes(self):
assert self._call("yes") == 1
def test_string_on(self):
assert self._call("on") == 1
def test_string_false_lowercase(self):
assert self._call("false") == 0
def test_string_no(self):
assert self._call("no") == 0
def test_string_off(self):
assert self._call("off") == 0
def test_numeric_one(self):
assert self._call(1) == 1
def test_numeric_zero(self):
assert self._call(0) == 0
def test_none_defaults_to_zero(self):
assert self._call(None) == 0
def test_missing_key_defaults_to_zero(self):
from netbox_librenms_plugin.import_utils.bulk_import import _safe_disabled
assert _safe_disabled({}) == 0
def test_string_true_uppercase(self):
assert self._call("TRUE") == 1
def test_non_zero_int_is_disabled(self):
assert self._call(2) == 1
def test_negative_int_is_disabled(self):
assert self._call(-1) == 1
class TestSafeDisabledFilters:
"""Tests for _safe_disabled in import_utils/filters.py (same contract)."""
def _call(self, val):
from netbox_librenms_plugin.import_utils.filters import _safe_disabled
return _safe_disabled({"disabled": val})
def test_bool_true(self):
assert self._call(True) == 1
def test_bool_false(self):
assert self._call(False) == 0
def test_string_true(self):
assert self._call("true") == 1
def test_string_yes(self):
assert self._call("yes") == 1
def test_string_on(self):
assert self._call("on") == 1
def test_string_false(self):
assert self._call("false") == 0
def test_string_off(self):
assert self._call("off") == 0
def test_string_uppercase_true(self):
assert self._call("TRUE") == 1
def test_string_no(self):
assert self._call("no") == 0
def test_numeric_one(self):
assert self._call(1) == 1
def test_none_defaults_to_zero(self):
assert self._call(None) == 0
def test_non_zero_int_is_disabled(self):
assert self._call(2) == 1
def test_negative_int_is_disabled(self):
assert self._call(-1) == 1
def test_missing_key_defaults_to_zero(self):
from netbox_librenms_plugin.import_utils.filters import _safe_disabled
assert _safe_disabled({}) == 0
class TestPaginationHelpers:
"""Test pagination helper functions."""
@patch("netbox_librenms_plugin.utils.get_config")
@patch("netbox_librenms_plugin.utils.netbox_get_paginate_count")
def test_get_table_paginate_count_from_request(self, mock_netbox_paginate, mock_config):
"""Custom per_page from request is used."""
from netbox_librenms_plugin.utils import get_table_paginate_count
mock_config.return_value.MAX_PAGE_SIZE = 1000
mock_request = MagicMock()
mock_request.GET = {"table1_per_page": "50"}
result = get_table_paginate_count(mock_request, "table1_")
assert result == 50
@patch("netbox_librenms_plugin.utils.get_config")
@patch("netbox_librenms_plugin.utils.netbox_get_paginate_count")
def test_get_table_paginate_count_default(self, mock_netbox_paginate, mock_config):
"""Default pagination used when no override."""
from netbox_librenms_plugin.utils import get_table_paginate_count
mock_netbox_paginate.return_value = 25
mock_request = MagicMock()
mock_request.GET = {}
result = get_table_paginate_count(mock_request, "table1_")
assert result == 25
mock_netbox_paginate.assert_called_once()
# =============================================================================
# TestInterfaceNameField - 3 tests
# =============================================================================
class TestInterfaceNameField:
"""Test interface name field retrieval."""
@patch("netbox_librenms_plugin.utils.get_plugin_config")
def test_get_interface_name_field_from_get(self, mock_plugin_config):
"""Override from GET request parameter."""
from netbox_librenms_plugin.utils import get_interface_name_field
mock_request = MagicMock()
mock_request.GET = {"interface_name_field": "ifDescr"}
mock_request.POST = {}
result = get_interface_name_field(mock_request)
assert result == "ifDescr"
@patch("netbox_librenms_plugin.utils.get_plugin_config")
def test_get_interface_name_field_from_post(self, mock_plugin_config):
"""Override from POST request parameter."""
from netbox_librenms_plugin.utils import get_interface_name_field
mock_request = MagicMock()
mock_request.GET = {}
mock_request.POST = {"interface_name_field": "ifName"}
result = get_interface_name_field(mock_request)
assert result == "ifName"
@patch("netbox_librenms_plugin.utils.get_plugin_config")
def test_get_interface_name_field_from_config(self, mock_plugin_config):
"""Falls back to plugin config."""
from netbox_librenms_plugin.utils import get_interface_name_field
mock_plugin_config.return_value = "ifAlias"
mock_request = MagicMock()
mock_request.GET = {}
mock_request.POST = {}
mock_request.user.config.get.return_value = None
result = get_interface_name_field(mock_request)
assert result == "ifAlias"
mock_plugin_config.assert_called_with("netbox_librenms_plugin", "interface_name_field")
@patch("netbox_librenms_plugin.utils.get_plugin_config")
def test_get_interface_name_field_from_user_pref(self, mock_plugin_config):
"""Falls back to user preference before plugin config."""
from netbox_librenms_plugin.utils import get_interface_name_field
mock_request = MagicMock()
mock_request.GET = {}
mock_request.POST = {}
mock_request.user.config.get.return_value = "ifName"
result = get_interface_name_field(mock_request)
assert result == "ifName"
mock_plugin_config.assert_not_called()
@patch("netbox_librenms_plugin.utils.get_plugin_config")
def test_get_interface_name_field_persists_to_user_pref(self, mock_plugin_config):
"""Explicit GET param should be persisted to user preferences."""
from netbox_librenms_plugin.utils import get_interface_name_field
mock_request = MagicMock()
mock_request.GET = {"interface_name_field": "ifDescr"}
mock_request.POST = {}
result = get_interface_name_field(mock_request)
assert result == "ifDescr"
mock_request.user.config.set.assert_called_once_with(
"plugins.netbox_librenms_plugin.interface_name_field", "ifDescr", commit=True
)
# =============================================================================
# TestSaveUserPrefView - 6 tests
# =============================================================================
class TestSaveUserPrefView:
"""Test SaveUserPrefView endpoint for JS-driven preference persistence."""
def _make_request(self, body, has_perm=True):
"""Create a mock POST request with JSON body."""
request = MagicMock()
request.body = json.dumps(body).encode()
request.user.has_perm.return_value = has_perm
request.user.config = MagicMock()
request.method = "POST"
return request
def test_save_valid_boolean_pref(self):
"""Saving a valid boolean preference returns ok."""
from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView
view = SaveUserPrefView()
request = self._make_request({"key": "use_sysname", "value": True})
view.request = request
response = view.post(request)
assert response.status_code == 200
data = json.loads(response.content)
assert data["status"] == "ok"
request.user.config.set.assert_called_once_with("plugins.netbox_librenms_plugin.use_sysname", True, commit=True)
def test_save_string_pref(self):
"""Saving interface_name_field string value works."""
from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView
view = SaveUserPrefView()
request = self._make_request({"key": "interface_name_field", "value": "ifDescr"})
view.request = request
response = view.post(request)
assert response.status_code == 200
request.user.config.set.assert_called_once_with(
"plugins.netbox_librenms_plugin.interface_name_field", "ifDescr", commit=True
)
def test_reject_invalid_key(self):
"""Invalid preference key returns 400."""
from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView
view = SaveUserPrefView()
request = self._make_request({"key": "malicious_key", "value": True})
view.request = request
response = view.post(request)
assert response.status_code == 400
data = json.loads(response.content)
assert "Invalid preference key" in data["error"]
request.user.config.set.assert_not_called()
def test_reject_invalid_json(self):
"""Invalid JSON body returns 400."""
from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView
view = SaveUserPrefView()
request = MagicMock()
request.body = b"not valid json"
view.request = request
response = view.post(request)
assert response.status_code == 400
data = json.loads(response.content)
assert "Invalid JSON" in data["error"]
def test_save_false_value(self):
"""Saving False for a toggle works correctly."""
from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView
view = SaveUserPrefView()
request = self._make_request({"key": "strip_domain", "value": False})
view.request = request
response = view.post(request)
assert response.status_code == 200
request.user.config.set.assert_called_once_with(
"plugins.netbox_librenms_plugin.strip_domain", False, commit=True
)
def test_uses_permission_mixin(self):
"""SaveUserPrefView inherits from LibreNMSPermissionMixin."""
from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView
from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin
assert issubclass(SaveUserPrefView, LibreNMSPermissionMixin)

View File

@@ -0,0 +1,205 @@
"""Tests for SingleCableVerifyView and SingleInterfaceVerifyView VC resolution.
Verifies that both views delegate VC device resolution to
get_librenms_sync_device() and handle the None return gracefully
(e.g. empty VC members or vc_position type errors).
"""
import json
from unittest.mock import MagicMock, patch
def _make_request(body: dict) -> MagicMock:
"""Create a mock POST request with JSON body."""
request = MagicMock()
request.body = json.dumps(body).encode()
request.user.has_perm.return_value = True
return request
def _make_vc_device(pk=1, name="vc-device"):
"""Create a mock Device that belongs to a virtual chassis."""
device = MagicMock()
device.pk = pk
device.id = pk
device.name = name
device._meta.model_name = "device"
device.virtual_chassis = MagicMock()
device.interfaces.filter.return_value.first.return_value = None
return device
# ---------------------------------------------------------------------------
# SingleCableVerifyView
# ---------------------------------------------------------------------------
class TestSingleCableVerifyView:
"""SingleCableVerifyView.post() VC resolution and None guard."""
def _make_view(self):
from netbox_librenms_plugin.views.base.cables_view import SingleCableVerifyView
view = object.__new__(SingleCableVerifyView)
view._librenms_api = MagicMock()
return view
@patch("netbox_librenms_plugin.views.base.cables_view.get_object_or_404")
@patch("netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device")
@patch("netbox_librenms_plugin.views.base.cables_view.cache")
def test_vc_no_resolvable_sync_device_returns_empty_row(self, mock_cache, mock_sync, mock_get_obj):
"""VC where get_librenms_sync_device returns None → empty row, no crash."""
device = _make_vc_device(pk=1)
mock_get_obj.return_value = device
mock_sync.return_value = None
view = self._make_view()
request = _make_request({"device_id": 1, "local_port_id": "42"})
response = view.post(request)
data = json.loads(response.content)
assert data["status"] == "success"
assert data["formatted_row"]["cable_status"] == "Missing Ports"
mock_cache.get.assert_not_called()
@patch("netbox_librenms_plugin.views.base.cables_view.get_object_or_404")
@patch("netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device")
@patch("netbox_librenms_plugin.views.base.cables_view.cache")
def test_vc_resolved_sync_device_uses_cache(self, mock_cache, mock_sync, mock_get_obj):
"""VC with resolved sync device: cache is queried with that device's key."""
device = _make_vc_device(pk=1)
sync_device = _make_vc_device(pk=2, name="sync-device")
mock_get_obj.return_value = device
mock_sync.return_value = sync_device
mock_cache.get.return_value = None # No cached data
view = self._make_view()
request = _make_request({"device_id": 1, "local_port_id": "42"})
view.post(request)
mock_sync.assert_called_once_with(device, server_key=view._librenms_api.server_key)
mock_cache.get.assert_called_once()
cache_key = mock_cache.get.call_args[0][0]
assert "device" in cache_key
assert "2" in cache_key
@patch("netbox_librenms_plugin.views.base.cables_view.get_object_or_404")
@patch("netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device")
@patch("netbox_librenms_plugin.views.base.cables_view.cache")
def test_non_vc_device_skips_sync_device_lookup(self, mock_cache, mock_sync, mock_get_obj, mock_netbox_device):
"""Non-VC device: get_librenms_sync_device is NOT called."""
mock_netbox_device.virtual_chassis = None
mock_get_obj.return_value = mock_netbox_device
mock_cache.get.return_value = None
view = self._make_view()
request = _make_request({"device_id": 5, "local_port_id": "10"})
view.post(request)
mock_sync.assert_not_called()
mock_cache.get.assert_called_once()
def test_no_device_id_returns_empty_row(self):
"""Missing device_id: returns default empty formatted_row."""
view = self._make_view()
request = _make_request({"local_port_id": "42"})
response = view.post(request)
data = json.loads(response.content)
assert data["status"] == "success"
assert data["formatted_row"]["cable_status"] == "Missing Ports"
# ---------------------------------------------------------------------------
# SingleInterfaceVerifyView
# ---------------------------------------------------------------------------
class TestSingleInterfaceVerifyView:
"""SingleInterfaceVerifyView.post() VC resolution and None guard."""
def _make_view(self):
from netbox_librenms_plugin.views.object_sync.devices import SingleInterfaceVerifyView
view = object.__new__(SingleInterfaceVerifyView)
view._librenms_api = MagicMock()
return view
@patch("netbox_librenms_plugin.views.object_sync.devices.get_object_or_404")
@patch("netbox_librenms_plugin.views.object_sync.devices.get_librenms_sync_device")
@patch("netbox_librenms_plugin.views.object_sync.devices.cache")
def test_vc_no_resolvable_sync_device_returns_404(self, mock_cache, mock_sync, mock_get_obj):
"""VC where get_librenms_sync_device returns None → 404 JSON error, no crash."""
device = _make_vc_device(pk=1)
mock_get_obj.return_value = device
mock_sync.return_value = None
view = self._make_view()
request = _make_request(
{
"device_id": 1,
"interface_name": "eth0",
"interface_name_field": "ifName",
}
)
response = view.post(request)
assert response.status_code == 404
data = json.loads(response.content)
assert data["status"] == "error"
assert "sync device" in data["message"].lower()
mock_cache.get.assert_not_called()
@patch("netbox_librenms_plugin.views.object_sync.devices.get_object_or_404")
@patch("netbox_librenms_plugin.views.object_sync.devices.get_librenms_sync_device")
@patch("netbox_librenms_plugin.views.object_sync.devices.cache")
def test_vc_resolved_sync_device_uses_cache(self, mock_cache, mock_sync, mock_get_obj):
"""VC with resolved sync device: cache is queried with that device's key."""
device = _make_vc_device(pk=1)
sync_device = _make_vc_device(pk=3, name="sync-member")
mock_get_obj.return_value = device
mock_sync.return_value = sync_device
mock_cache.get.return_value = None
view = self._make_view()
request = _make_request(
{
"device_id": 1,
"interface_name": "eth0",
"interface_name_field": "ifName",
}
)
view.post(request)
mock_sync.assert_called_once_with(device, server_key=view._librenms_api.server_key)
mock_cache.get.assert_called_once()
cache_key = mock_cache.get.call_args[0][0]
assert "3" in cache_key
@patch("netbox_librenms_plugin.views.object_sync.devices.get_object_or_404")
@patch("netbox_librenms_plugin.views.object_sync.devices.get_librenms_sync_device")
@patch("netbox_librenms_plugin.views.object_sync.devices.cache")
def test_non_vc_device_skips_sync_device_lookup(self, mock_cache, mock_sync, mock_get_obj, mock_netbox_device):
"""Non-VC device: get_librenms_sync_device is NOT called."""
mock_netbox_device.virtual_chassis = None
mock_get_obj.return_value = mock_netbox_device
mock_cache.get.return_value = None
view = self._make_view()
request = _make_request(
{
"device_id": 5,
"interface_name": "eth0",
"interface_name_field": "ifName",
}
)
view.post(request)
mock_sync.assert_not_called()
mock_cache.get.assert_called_once()
def test_no_device_id_returns_400(self):
"""Missing device_id: returns 400 error."""
view = self._make_view()
request = _make_request({"interface_name": "eth0"})
response = view.post(request)
assert response.status_code == 400
data = json.loads(response.content)
assert data["status"] == "error"

View File

@@ -0,0 +1,443 @@
"""
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 = '<script>alert("xss")</script>'
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 "<script>" not in html
assert "&lt;script&gt;" in html
class TestAllServerMappingsDidValidation:
"""all_server_mappings must skip invalid device IDs in the cf_value dict."""
def _call(self, obj, active_server_key="default"):
from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView
return BaseLibreNMSSyncView._build_all_server_mappings(obj, active_server_key)
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings")
def test_skips_boolean_did(self, mock_settings):
mock_settings.PLUGINS_CONFIG = {"netbox_librenms_plugin": {"servers": {}}}
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"default": True, "prod": 42}}
result = self._call(obj)
# Only prod=42 should survive
assert len(result) == 1
assert result[0]["device_id"] == 42
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings")
def test_skips_none_did(self, mock_settings):
mock_settings.PLUGINS_CONFIG = {"netbox_librenms_plugin": {"servers": {}}}
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"default": None}}
result = self._call(obj)
assert result is None # empty list → returns None
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings")
def test_coerces_digit_string_did(self, mock_settings):
mock_settings.PLUGINS_CONFIG = {"netbox_librenms_plugin": {"servers": {}}}
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"prod": "99"}}
result = self._call(obj)
assert len(result) == 1
assert result[0]["device_id"] == 99
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings")
def test_skips_non_digit_string_did(self, mock_settings):
mock_settings.PLUGINS_CONFIG = {"netbox_librenms_plugin": {"servers": {}}}
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"default": "bogus"}}
result = self._call(obj)
assert result is None
@patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings")
def test_valid_int_passes_through(self, mock_settings):
mock_settings.PLUGINS_CONFIG = {"netbox_librenms_plugin": {"servers": {}}}
obj = MagicMock()
obj.custom_field_data = {"librenms_id": {"default": 5, "secondary": 10}}
result = self._call(obj)
assert len(result) == 2
ids = {e["device_id"] for e in result}
assert ids == {5, 10}
# ---------------------------------------------------------------------------
# render_device_selection — XSS escape
# ---------------------------------------------------------------------------
class TestSingleCableVerifyServerKey:
"""SingleCableVerifyView.post() must read server_key from POST body."""
def test_server_key_used_for_cache_lookup(self):
"""The server_key from POST body is passed to get_cache_key and get_librenms_sync_device."""
import json
from netbox_librenms_plugin.views.base.cables_view import SingleCableVerifyView
view = object.__new__(SingleCableVerifyView)
view._librenms_api = MagicMock()
view._librenms_api.server_key = "default-server"
request = MagicMock()
request.body = json.dumps(
{
"device_id": 1,
"local_port_id": "42",
"server_key": "production",
}
).encode()
with (
patch("netbox_librenms_plugin.views.base.cables_view.get_object_or_404") as mock_get_obj,
patch(
"netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device",
) as mock_sync_device,
patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache,
):
mock_device = MagicMock()
mock_sync_device.return_value = mock_device # return a device so code reaches cache.get
mock_get_obj.return_value = mock_device
mock_cache.get.return_value = None # No cached data
view.post(request)
# get_librenms_sync_device should be called with the posted server_key
mock_sync_device.assert_called_once_with(mock_device, server_key="production")
# cache lookup must also use the posted server_key (not the api default)
cache_key_arg = mock_cache.get.call_args[0][0]
assert "production" in cache_key_arg
def test_fallback_to_api_server_key(self):
"""When POST body has no server_key, falls back to self.librenms_api.server_key."""
import json
from netbox_librenms_plugin.views.base.cables_view import SingleCableVerifyView
view = object.__new__(SingleCableVerifyView)
view._librenms_api = MagicMock()
view._librenms_api.server_key = "fallback-server"
request = MagicMock()
request.body = json.dumps(
{
"device_id": 1,
"local_port_id": "42",
}
).encode()
with (
patch("netbox_librenms_plugin.views.base.cables_view.get_object_or_404") as mock_get_obj,
patch(
"netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device",
) as mock_sync_device,
patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache,
):
mock_device = MagicMock()
mock_sync_device.return_value = mock_device # return a device so code reaches cache.get
mock_get_obj.return_value = mock_device
mock_cache.get.return_value = None
view.post(request)
mock_sync_device.assert_called_once()
assert mock_sync_device.call_args[1]["server_key"] == "fallback-server"
# cache lookup must also use the fallback server_key
cache_key_arg = mock_cache.get.call_args[0][0]
assert "fallback-server" in cache_key_arg

View File

@@ -0,0 +1,103 @@
"""Tests for import_utils/virtual_chassis.py."""
from unittest.mock import MagicMock, patch
class TestLoadVcMemberNamePattern:
"""_load_vc_member_name_pattern must return valid string or default."""
DEFAULT = "-M{position}"
def _call(self):
from netbox_librenms_plugin.import_utils.virtual_chassis import _load_vc_member_name_pattern
return _load_vc_member_name_pattern()
def _patch_settings(self, settings_obj):
"""Patch the deferred import of LibreNMSSettings inside the function."""
return patch(
"netbox_librenms_plugin.models.LibreNMSSettings.objects",
**{"order_by.return_value.first.return_value": settings_obj},
)
def test_returns_valid_pattern(self):
settings = MagicMock()
settings.vc_member_name_pattern = "-SW{position}"
with self._patch_settings(settings):
assert self._call() == "-SW{position}"
def test_returns_default_for_none_pattern(self):
settings = MagicMock()
settings.vc_member_name_pattern = None
with self._patch_settings(settings):
assert self._call() == self.DEFAULT
def test_returns_default_for_empty_string(self):
settings = MagicMock()
settings.vc_member_name_pattern = ""
with self._patch_settings(settings):
assert self._call() == self.DEFAULT
def test_returns_default_for_whitespace_only(self):
settings = MagicMock()
settings.vc_member_name_pattern = " "
with self._patch_settings(settings):
assert self._call() == self.DEFAULT
def test_returns_default_for_boolean(self):
settings = MagicMock()
settings.vc_member_name_pattern = True
with self._patch_settings(settings):
assert self._call() == self.DEFAULT
def test_returns_default_when_no_settings(self):
with self._patch_settings(None):
assert self._call() == self.DEFAULT
def test_returns_default_on_exception(self):
with patch(
"netbox_librenms_plugin.models.LibreNMSSettings.objects",
) as mock_objs:
mock_objs.order_by.side_effect = RuntimeError("db error")
assert self._call() == self.DEFAULT
class TestGenerateVcMemberName:
"""_generate_vc_member_name must respect caller-supplied pattern and catch format errors."""
def _call(self, master_name, position, serial=None, pattern=None):
from netbox_librenms_plugin.import_utils.virtual_chassis import _generate_vc_member_name
return _generate_vc_member_name(master_name, position, serial=serial, pattern=pattern)
def test_explicit_pattern_used(self):
"""When pattern is passed, it should be used directly (no DB query)."""
result = self._call("switch01", 2, pattern="-SW{position}")
assert result == "switch01-SW2"
def test_serial_in_pattern(self):
result = self._call("switch01", 2, serial="ABC123", pattern=" [{serial}]")
assert result == "switch01 [ABC123]"
def test_none_pattern_loads_from_settings(self):
"""When pattern is None, _load_vc_member_name_pattern is called."""
with patch(
"netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
return_value="-STACK{position}",
):
result = self._call("core01", 3, pattern=None)
assert result == "core01-STACK3"
def test_malformed_pattern_falls_back_to_default(self):
"""Invalid format spec falls back to -M{position}."""
result = self._call("switch01", 2, pattern="{position!z}")
assert result == "switch01-M2"
def test_missing_key_falls_back_to_default(self):
"""Unknown placeholder falls back to -M{position}."""
result = self._call("switch01", 2, pattern="-{unknown_key}")
assert result == "switch01-M2"
def test_default_pattern(self):
result = self._call("switch01", 2, pattern="-M{position}")
assert result == "switch01-M2"

View File

@@ -0,0 +1,483 @@
"""
Tests for VLAN sync feature.
Tests cover:
- LibreNMS VLAN API methods
- VLAN mode detection logic
- VLAN comparison logic
- Port VLAN data parsing
"""
from unittest.mock import MagicMock, patch
# Import the autouse fixture from helpers
pytest_plugins = ["netbox_librenms_plugin.tests.test_librenms_api_helpers"]
# ============================================
# TEST DATA FIXTURES
# ============================================
# Sample LibreNMS VLAN response (from /resources/vlans endpoint)
# Note: This endpoint includes vlan_id and device_id, unlike /devices/{id}/vlans
MOCK_DEVICE_VLANS = {
"status": "ok",
"vlans": [
{
"vlan_id": 101,
"device_id": 123,
"vlan_vlan": 1,
"vlan_name": "default",
"vlan_type": "ethernet",
"vlan_state": 1,
"vlan_domain": 1,
},
{
"vlan_id": 102,
"device_id": 123,
"vlan_vlan": 50,
"vlan_name": "ORG_DATA",
"vlan_type": "ethernet",
"vlan_state": 1,
"vlan_domain": 1,
},
{
"vlan_id": 103,
"device_id": 123,
"vlan_vlan": 60,
"vlan_name": "ORG_VOICE",
"vlan_type": "ethernet",
"vlan_state": 1,
"vlan_domain": 1,
},
],
"count": 3,
}
# Sample port VLAN info response (bulk call)
MOCK_PORT_VLAN_INFO = {
"status": "ok",
"ports": [
{"port_id": 114184, "ifName": "Gi1/0/40", "ifVlan": "50", "ifTrunk": None},
{"port_id": 114326, "ifName": "Gi3/0/48", "ifVlan": "1", "ifTrunk": "dot1Q"},
{"port_id": 114327, "ifName": "Gi3/1/1", "ifVlan": "1", "ifTrunk": None},
{"port_id": 114145, "ifName": "Gi1/0/1", "ifVlan": "", "ifTrunk": None}, # No VLAN
],
}
# Sample port with vlans detail response (for trunk port)
MOCK_PORT_VLAN_DETAILS_TRUNK = {
"status": "ok",
"port": [
{
"port_id": 227011,
"ifName": "Te1/1/1",
"ifVlan": "90",
"ifTrunk": "dot1Q",
"vlans": [
{"vlan": 90, "untagged": 1, "state": "unknown", "port_vlan_id": 195164},
{"vlan": 50, "untagged": 0, "state": "forwarding", "port_vlan_id": 2165422},
],
}
],
}
# Sample port with vlans detail response (for access port)
MOCK_PORT_VLAN_DETAILS_ACCESS = {
"status": "ok",
"port": [
{
"port_id": 729403,
"ifName": "Gi0/2",
"ifVlan": "50",
"ifTrunk": None,
"vlans": [
{"vlan": 50, "untagged": 1, "state": "forwarding", "port_vlan_id": 3234550},
],
}
],
}
def create_mock_device():
"""Create a mock NetBox device."""
device = MagicMock()
device.pk = 123
device.name = "test-switch"
device._meta.model_name = "device"
device.site = MagicMock()
device.site.pk = 1
device.site.name = "Test Site"
return device
def create_mock_interface(name, mode=None, untagged_vlan=None, tagged_vlans=None):
"""Create a mock NetBox interface."""
interface = MagicMock()
interface.pk = hash(name)
interface.name = name
interface.mode = mode
interface.untagged_vlan = untagged_vlan
interface.tagged_vlans = MagicMock()
interface.tagged_vlans.all.return_value = tagged_vlans or []
return interface
def create_mock_vlan(vid, name, group=None):
"""Create a mock NetBox VLAN."""
vlan = MagicMock()
vlan.pk = vid * 100
vlan.vid = vid
vlan.name = name
vlan.group = group
return vlan
# ============================================
# API METHOD TESTS
# ============================================
class TestVLANAPIClient:
"""Tests for LibreNMS VLAN API methods."""
@patch("requests.get")
def test_get_device_vlans_success(self, mock_get, mock_librenms_config):
"""Test successful VLAN fetch from /resources/vlans endpoint."""
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = MOCK_DEVICE_VLANS
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
api = LibreNMSAPI(server_key="default")
success, data = api.get_device_vlans(123)
assert success is True
assert len(data) == 3
assert data[1]["vlan_vlan"] == 50
assert data[1]["vlan_name"] == "ORG_DATA"
# Verify vlan_id is present from /resources/vlans endpoint
assert data[1]["vlan_id"] == 102
@patch("requests.get")
def test_get_device_vlans_filters_by_device_id(self, mock_get, mock_librenms_config):
"""Test that VLANs are filtered by device_id."""
# Response includes VLANs from multiple devices
mock_response_data = {
"status": "ok",
"vlans": [
{"vlan_id": 101, "device_id": 123, "vlan_vlan": 1, "vlan_name": "default"},
{"vlan_id": 201, "device_id": 456, "vlan_vlan": 1, "vlan_name": "default"}, # Different device
{"vlan_id": 102, "device_id": 123, "vlan_vlan": 50, "vlan_name": "DATA"},
],
}
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = mock_response_data
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
api = LibreNMSAPI(server_key="default")
success, data = api.get_device_vlans(123)
assert success is True
assert len(data) == 2 # Only device 123's VLANs
assert all(str(v["device_id"]) == "123" for v in data)
@patch("requests.get")
def test_get_device_vlans_error(self, mock_get, mock_librenms_config):
"""Test VLAN fetch with error."""
from requests.exceptions import HTTPError
mock_response = MagicMock()
mock_response.status_code = 404
mock_response.raise_for_status.side_effect = HTTPError(response=mock_response)
mock_get.return_value = mock_response
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
api = LibreNMSAPI(server_key="default")
success, data = api.get_device_vlans(999)
assert success is False
assert "not found" in data.lower()
@patch("requests.get")
def test_get_port_vlan_details_trunk(self, mock_get, mock_librenms_config):
"""Test fetching trunk port VLAN details."""
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = MOCK_PORT_VLAN_DETAILS_TRUNK
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
api = LibreNMSAPI(server_key="default")
success, data = api.get_port_vlan_details(227011)
assert success is True
assert data["ifTrunk"] == "dot1Q"
assert len(data["vlans"]) == 2
@patch("requests.get")
def test_get_port_vlan_details_not_found(self, mock_get, mock_librenms_config):
"""Test fetching port details when port not found."""
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {"status": "ok", "port": []}
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
api = LibreNMSAPI(server_key="default")
success, data = api.get_port_vlan_details(999999)
assert success is False
assert "not found" in data.lower()
# ============================================
# MODE DETECTION TESTS
# ============================================
class TestVLANModeDetection:
"""Tests for 802.1Q mode detection logic."""
def test_parse_port_vlan_data_access_port(self, mock_librenms_config):
"""Access port: ifVlan set, ifTrunk null."""
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
api = LibreNMSAPI(server_key="default")
port_data = {"port_id": 1, "ifName": "Gi1/0/1", "ifVlan": "50", "ifTrunk": None}
result = api.parse_port_vlan_data(port_data)
assert result["mode"] == "access"
assert result["untagged_vlan"] == 50
assert result["tagged_vlans"] == []
def test_parse_port_vlan_data_trunk_port(self, mock_librenms_config):
"""Trunk port: ifTrunk = dot1Q."""
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
api = LibreNMSAPI(server_key="default")
port_data = {
"port_id": 2,
"ifName": "Te1/1/1",
"ifVlan": "90",
"ifTrunk": "dot1Q",
"vlans": [
{"vlan": 90, "untagged": 1},
{"vlan": 50, "untagged": 0},
{"vlan": 60, "untagged": 0},
],
}
result = api.parse_port_vlan_data(port_data)
assert result["mode"] == "tagged"
assert result["untagged_vlan"] == 90
assert result["tagged_vlans"] == [50, 60]
def test_parse_port_vlan_data_no_vlan(self, mock_librenms_config):
"""No VLAN: ifVlan empty."""
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
api = LibreNMSAPI(server_key="default")
port_data = {"port_id": 3, "ifName": "Gi1/0/48", "ifVlan": "", "ifTrunk": None}
result = api.parse_port_vlan_data(port_data)
assert result["mode"] is None
assert result["untagged_vlan"] is None
assert result["tagged_vlans"] == []
# ============================================
# VLAN COMPARISON TESTS
# ============================================
class TestVLANComparison:
"""Tests for VLAN comparison logic."""
def test_compare_vlans_exists_in_netbox(self):
"""Test VLAN exists in NetBox VLAN group."""
netbox_vlans = {50: create_mock_vlan(50, "ORG_DATA")}
librenms_vlan = {"vlan_vlan": 50, "vlan_name": "ORG_DATA"}
exists = librenms_vlan["vlan_vlan"] in netbox_vlans
assert exists is True
def test_compare_vlans_missing_from_netbox(self):
"""Test VLAN missing from NetBox."""
netbox_vlans = {50: create_mock_vlan(50, "ORG_DATA")}
librenms_vlan = {"vlan_vlan": 60, "vlan_name": "ORG_VOICE"}
exists = librenms_vlan["vlan_vlan"] in netbox_vlans
assert exists is False
def test_compare_vlans_name_matches(self):
"""Test VLAN name comparison when matching."""
netbox_vlan = create_mock_vlan(50, "ORG_DATA")
librenms_name = "ORG_DATA"
name_matches = netbox_vlan.name == librenms_name
assert name_matches is True
def test_compare_vlans_name_differs(self):
"""Test VLAN name comparison when different."""
netbox_vlan = create_mock_vlan(50, "DATA_VLAN")
librenms_name = "ORG_DATA"
name_matches = netbox_vlan.name == librenms_name
assert name_matches is False
# ============================================
# PORT VLAN PARSING TESTS
# ============================================
class TestPortVLANParsing:
"""Tests for parsing port VLAN data."""
def test_parse_trunk_port_vlans(self):
"""Parse trunk port into untagged and tagged lists."""
vlans_data = MOCK_PORT_VLAN_DETAILS_TRUNK["port"][0]["vlans"]
untagged = [v["vlan"] for v in vlans_data if v["untagged"] == 1]
tagged = [v["vlan"] for v in vlans_data if v["untagged"] == 0]
assert untagged == [90]
assert tagged == [50]
def test_parse_access_port_vlans(self):
"""Parse access port - single untagged VLAN."""
vlans_data = MOCK_PORT_VLAN_DETAILS_ACCESS["port"][0]["vlans"]
untagged = [v["vlan"] for v in vlans_data if v["untagged"] == 1]
tagged = [v["vlan"] for v in vlans_data if v["untagged"] == 0]
assert untagged == [50]
assert tagged == []
def test_parse_port_with_multiple_tagged(self):
"""Parse trunk port with multiple tagged VLANs."""
vlans_data = [
{"vlan": 1, "untagged": 1},
{"vlan": 10, "untagged": 0},
{"vlan": 20, "untagged": 0},
{"vlan": 30, "untagged": 0},
]
untagged = [v["vlan"] for v in vlans_data if v["untagged"] == 1]
tagged = [v["vlan"] for v in vlans_data if v["untagged"] == 0]
assert untagged == [1]
assert len(tagged) == 3
assert set(tagged) == {10, 20, 30}
# ============================================
# SYNC ACTION TESTS
# ============================================
class TestSyncVLANActions:
"""Tests for VLAN sync action logic."""
def test_mode_mapping_access(self):
"""Test mapping LibreNMS access mode to NetBox."""
librenms_mode = "access"
expected_netbox_mode = "access"
mode_map = {"access": "access", "tagged": "tagged"}
result = mode_map.get(librenms_mode)
assert result == expected_netbox_mode
def test_mode_mapping_tagged(self):
"""Test mapping LibreNMS tagged mode to NetBox."""
librenms_mode = "tagged"
expected_netbox_mode = "tagged"
mode_map = {"access": "access", "tagged": "tagged"}
result = mode_map.get(librenms_mode)
assert result == expected_netbox_mode
def test_vlan_state_mapping_active(self):
"""Test mapping active VLAN state."""
vlan_state = 1
status = "active" if vlan_state == 1 else "reserved"
assert status == "active"
def test_vlan_state_mapping_inactive(self):
"""Test mapping inactive VLAN state."""
vlan_state = 0
status = "active" if vlan_state == 1 else "reserved"
assert status == "reserved"
# ============================================
# VLAN SYNC CSS CLASS UTILITY
# ============================================
class TestGetVlanSyncCssClass:
"""Tests for the shared get_vlan_sync_css_class utility."""
def test_not_in_netbox(self):
"""VLAN not in NetBox should return text-danger."""
from netbox_librenms_plugin.utils import get_vlan_sync_css_class
assert get_vlan_sync_css_class(exists_in_netbox=False) == "text-danger"
def test_not_in_netbox_name_match_irrelevant(self):
"""Name match flag should be irrelevant when VLAN doesn't exist."""
from netbox_librenms_plugin.utils import get_vlan_sync_css_class
assert get_vlan_sync_css_class(exists_in_netbox=False, name_matches=True) == "text-danger"
def test_exists_name_matches(self):
"""VLAN exists with matching name should return text-success."""
from netbox_librenms_plugin.utils import get_vlan_sync_css_class
assert get_vlan_sync_css_class(exists_in_netbox=True, name_matches=True) == "text-success"
def test_exists_name_mismatch(self):
"""VLAN exists but name differs should return text-warning."""
from netbox_librenms_plugin.utils import get_vlan_sync_css_class
assert get_vlan_sync_css_class(exists_in_netbox=True, name_matches=False) == "text-warning"
def test_default_name_matches_is_true(self):
"""Default name_matches should be True (success when exists)."""
from netbox_librenms_plugin.utils import get_vlan_sync_css_class
assert get_vlan_sync_css_class(exists_in_netbox=True) == "text-success"
class TestVlanEntryDictGuardInSync:
"""Verify isinstance(vlan_entry, dict) guard works in parse_port_vlan_data."""
def test_mixed_vlans_data_only_dicts_parsed(self, mock_librenms_config):
"""vlans array with non-dict entries: only dict entries produce VIDs."""
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
api = LibreNMSAPI(server_key="default")
port_data = {
"port_id": 1,
"ifName": "GigabitEthernet0/0",
"ifDescr": "GigabitEthernet0/0",
"ifTrunk": "dot1Q",
"ifVlan": None,
"vlans": [{"vlan": 10, "untagged": 1}, "bad_entry", {"vlan": 20}],
}
result = api.parse_port_vlan_data(port_data)
assert result["untagged_vlan"] == 10
assert result["tagged_vlans"] == [20]

View File

@@ -0,0 +1,669 @@
"""
Tests for netbox_librenms_plugin.import_utils.vm_operations module.
Covers create_vm_from_librenms and bulk_import_vms.
All DB interactions are mocked — no @pytest.mark.django_db used.
"""
import pytest
from unittest.mock import MagicMock, patch
class TestCreateVmFromLibrenms:
"""Tests for create_vm_from_librenms function."""
def test_success_with_computed_name(self):
"""VM is created using pre-computed _computed_name when present."""
from netbox_librenms_plugin.import_utils.vm_operations import create_vm_from_librenms
libre_device = {
"device_id": 1,
"hostname": "vm01.example.com",
"_computed_name": "vm01-computed",
}
mock_cluster = MagicMock()
mock_platform = MagicMock()
validation = {
"can_import": True,
"cluster": {"cluster": mock_cluster},
"platform": {"platform": mock_platform},
}
mock_vm = MagicMock()
mock_vm.name = "vm01-computed"
mock_vm.pk = 10
with (
patch("django.db.transaction.atomic"),
patch("virtualization.models.VirtualMachine") as mock_vm_class,
):
mock_vm_class.objects.create.return_value = mock_vm
result = create_vm_from_librenms(libre_device, validation)
assert result == mock_vm
call_kwargs = mock_vm_class.objects.create.call_args[1]
assert call_kwargs["name"] == "vm01-computed"
assert call_kwargs["cluster"] == mock_cluster
assert call_kwargs["platform"] == mock_platform
def test_fallback_to_determine_device_name_when_no_computed_name(self):
"""Falls back to _determine_device_name when _computed_name is absent."""
from netbox_librenms_plugin.import_utils.vm_operations import create_vm_from_librenms
libre_device = {"device_id": 2, "hostname": "vm02.example.com"}
validation = {
"can_import": True,
"cluster": {"cluster": MagicMock()},
"platform": {"platform": None},
}
mock_vm = MagicMock()
mock_vm.name = "vm02-determined"
mock_vm.pk = 11
with (
patch(
"netbox_librenms_plugin.import_utils.vm_operations._determine_device_name",
return_value="vm02-determined",
) as mock_det,
patch("django.db.transaction.atomic"),
patch("virtualization.models.VirtualMachine") as mock_vm_class,
):
mock_vm_class.objects.create.return_value = mock_vm
result = create_vm_from_librenms(libre_device, validation)
mock_det.assert_called_once()
call_kwargs = mock_vm_class.objects.create.call_args[1]
assert call_kwargs["name"] == "vm02-determined"
assert result == mock_vm
def test_can_import_false_raises_value_error(self):
"""Raises ValueError immediately when validation['can_import'] is False."""
from netbox_librenms_plugin.import_utils.vm_operations import create_vm_from_librenms
libre_device = {"device_id": 3, "hostname": "vm03"}
validation = {
"can_import": False,
"issues": ["No cluster assigned", "Missing role"],
}
with pytest.raises(ValueError, match="VM cannot be imported"):
create_vm_from_librenms(libre_device, validation)
def test_server_key_stored_in_custom_field(self):
"""librenms_id custom field uses the provided server_key via set_librenms_device_id."""
from unittest.mock import patch
from netbox_librenms_plugin.import_utils.vm_operations import create_vm_from_librenms
libre_device = {"device_id": 5, "hostname": "vm05", "_computed_name": "vm05"}
validation = {
"can_import": True,
"cluster": {"cluster": MagicMock()},
"platform": {"platform": None},
}
mock_vm = MagicMock()
mock_vm.name = "vm05"
mock_vm.pk = 50
mock_vm.custom_field_data = {}
with patch("virtualization.models.VirtualMachine") as mock_vm_class:
with patch("django.db.transaction.atomic"):
with patch("netbox_librenms_plugin.utils.set_librenms_device_id") as mock_setter:
mock_vm_class.objects.create.return_value = mock_vm
create_vm_from_librenms(libre_device, validation, server_key="secondary")
mock_setter.assert_called_once_with(mock_vm, 5, "secondary")
mock_vm.save.assert_called_once()
def test_role_is_passed_to_create(self):
"""Optional role parameter is forwarded to VirtualMachine.objects.create."""
from netbox_librenms_plugin.import_utils.vm_operations import create_vm_from_librenms
libre_device = {"device_id": 6, "hostname": "vm06", "_computed_name": "vm06"}
mock_role = MagicMock()
validation = {
"can_import": True,
"cluster": {"cluster": MagicMock()},
"platform": {"platform": None},
}
mock_vm = MagicMock()
mock_vm.name = "vm06"
mock_vm.pk = 60
with (
patch("django.db.transaction.atomic"),
patch("virtualization.models.VirtualMachine") as mock_vm_class,
):
mock_vm_class.objects.create.return_value = mock_vm
create_vm_from_librenms(libre_device, validation, role=mock_role)
call_kwargs = mock_vm_class.objects.create.call_args[1]
assert call_kwargs["role"] == mock_role
def test_platform_none_when_not_in_validation(self):
"""Platform is None when validation['platform'] has no 'platform' key."""
from netbox_librenms_plugin.import_utils.vm_operations import create_vm_from_librenms
libre_device = {"device_id": 7, "hostname": "vm07", "_computed_name": "vm07"}
validation = {
"can_import": True,
"cluster": {"cluster": MagicMock()},
"platform": {}, # no 'platform' key — .get() returns None
}
mock_vm = MagicMock()
mock_vm.name = "vm07"
mock_vm.pk = 70
with (
patch("django.db.transaction.atomic"),
patch("virtualization.models.VirtualMachine") as mock_vm_class,
):
mock_vm_class.objects.create.return_value = mock_vm
create_vm_from_librenms(libre_device, validation)
call_kwargs = mock_vm_class.objects.create.call_args[1]
assert call_kwargs["platform"] is None
def test_import_comment_contains_device_id(self):
"""The comments field contains a reference to LibreNMS and device_id."""
from netbox_librenms_plugin.import_utils.vm_operations import create_vm_from_librenms
libre_device = {"device_id": 8, "hostname": "vm08", "_computed_name": "vm08"}
validation = {
"can_import": True,
"cluster": {"cluster": MagicMock()},
"platform": {"platform": None},
}
mock_vm = MagicMock()
mock_vm.name = "vm08"
mock_vm.pk = 80
with (
patch("django.db.transaction.atomic"),
patch("virtualization.models.VirtualMachine") as mock_vm_class,
):
mock_vm_class.objects.create.return_value = mock_vm
create_vm_from_librenms(libre_device, validation)
call_kwargs = mock_vm_class.objects.create.call_args[1]
assert "LibreNMS" in call_kwargs["comments"]
assert str(libre_device["device_id"]) in call_kwargs["comments"]
class TestBulkImportVms:
"""Tests for bulk_import_vms function."""
def test_empty_vm_imports_returns_empty_result(self):
"""Empty vm_imports dict returns empty success/failed/skipped lists."""
from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms
mock_api = MagicMock()
mock_api.server_key = "default"
with patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"):
result = bulk_import_vms({}, mock_api, user=MagicMock())
assert result == {"success": [], "failed": [], "skipped": []}
def test_permission_denied_propagates(self):
"""PermissionDenied from require_permissions propagates to the caller."""
from django.core.exceptions import PermissionDenied
from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms
mock_api = MagicMock()
mock_api.server_key = "default"
with patch(
"netbox_librenms_plugin.import_utils.vm_operations.require_permissions",
side_effect=PermissionDenied("No permission"),
):
with pytest.raises(PermissionDenied):
bulk_import_vms({1: {}}, mock_api, user=MagicMock())
def test_device_not_found_added_to_failed(self):
"""When fetch_device_with_cache returns None, device is appended to failed."""
from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms
mock_api = MagicMock()
mock_api.server_key = "default"
with (
patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"),
patch(
"netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache",
return_value=None,
),
):
result = bulk_import_vms({99: {}}, mock_api, user=MagicMock())
assert len(result["failed"]) == 1
assert result["failed"][0]["device_id"] == 99
assert "not found" in result["failed"][0]["error"].lower()
def test_existing_device_added_to_skipped(self):
"""When validation reports existing_device, device is appended to skipped."""
from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms
mock_api = MagicMock()
mock_api.server_key = "default"
mock_existing = MagicMock()
mock_existing.name = "existing-vm"
libre_device = {"device_id": 10, "hostname": "existing-vm"}
mock_validation = {
"existing_device": mock_existing,
"can_import": False,
"issues": [],
}
with (
patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"),
patch(
"netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache",
return_value=libre_device,
),
patch(
"netbox_librenms_plugin.import_utils.vm_operations.validate_device_for_import",
return_value=mock_validation,
),
):
result = bulk_import_vms({10: {}}, mock_api, user=MagicMock())
assert len(result["skipped"]) == 1
assert result["skipped"][0]["device_id"] == 10
assert "existing-vm" in result["skipped"][0]["reason"]
def test_success_path_vm_created(self):
"""Happy path: VM is created and appended to success list."""
from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms
mock_api = MagicMock()
mock_api.server_key = "default"
libre_device = {"device_id": 20, "hostname": "new-vm"}
mock_validation = {
"existing_device": None,
"can_import": True,
"cluster": {"cluster": MagicMock()},
"platform": {"platform": None},
"issues": [],
}
mock_vm = MagicMock()
mock_vm.name = "new-vm"
with (
patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"),
patch(
"netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache",
return_value=libre_device,
),
patch(
"netbox_librenms_plugin.import_utils.vm_operations.validate_device_for_import",
return_value=mock_validation,
),
patch(
"netbox_librenms_plugin.import_utils.vm_operations._determine_device_name",
return_value="new-vm",
),
patch(
"netbox_librenms_plugin.import_utils.vm_operations.create_vm_from_librenms",
return_value=mock_vm,
),
patch("netbox_librenms_plugin.import_utils.vm_operations.Cluster"),
patch("netbox_librenms_plugin.import_utils.vm_operations.DeviceRole"),
patch("netbox_librenms_plugin.import_validation_helpers.apply_cluster_to_validation"),
patch("netbox_librenms_plugin.import_validation_helpers.apply_role_to_validation"),
):
result = bulk_import_vms({20: {}}, mock_api, user=MagicMock())
assert len(result["success"]) == 1
assert result["success"][0]["device_id"] == 20
assert result["success"][0]["device"] == mock_vm
assert len(result["failed"]) == 0
assert len(result["skipped"]) == 0
def test_cluster_assignment_applied(self):
"""apply_cluster_to_validation is called when cluster_id is provided and found."""
from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms
mock_api = MagicMock()
mock_api.server_key = "default"
mock_cluster = MagicMock()
libre_device = {"device_id": 30, "hostname": "clustered-vm"}
mock_validation = {
"existing_device": None,
"can_import": True,
"cluster": {"cluster": mock_cluster},
"platform": {"platform": None},
"issues": [],
}
mock_vm = MagicMock()
mock_vm.name = "clustered-vm"
with (
patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"),
patch(
"netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache",
return_value=libre_device,
),
patch(
"netbox_librenms_plugin.import_utils.vm_operations.validate_device_for_import",
return_value=mock_validation,
),
patch(
"netbox_librenms_plugin.import_utils.vm_operations._determine_device_name",
return_value="clustered-vm",
),
patch(
"netbox_librenms_plugin.import_utils.vm_operations.create_vm_from_librenms",
return_value=mock_vm,
),
patch("netbox_librenms_plugin.import_utils.vm_operations.Cluster") as mock_cluster_cls,
patch("netbox_librenms_plugin.import_utils.vm_operations.DeviceRole"),
patch("netbox_librenms_plugin.import_validation_helpers.apply_cluster_to_validation") as mock_apply_cluster,
patch("netbox_librenms_plugin.import_validation_helpers.apply_role_to_validation"),
):
mock_cluster_cls.objects.filter.return_value.first.return_value = mock_cluster
bulk_import_vms({30: {"cluster_id": 5}}, mock_api, user=MagicMock())
mock_apply_cluster.assert_called_once_with(mock_validation, mock_cluster)
def test_role_assignment_applied(self):
"""apply_role_to_validation is called when role_id is provided and found."""
from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms
mock_api = MagicMock()
mock_api.server_key = "default"
mock_role = MagicMock()
libre_device = {"device_id": 40, "hostname": "role-vm"}
mock_validation = {
"existing_device": None,
"can_import": True,
"cluster": {"cluster": MagicMock()},
"platform": {"platform": None},
"issues": [],
}
mock_vm = MagicMock()
mock_vm.name = "role-vm"
with (
patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"),
patch(
"netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache",
return_value=libre_device,
),
patch(
"netbox_librenms_plugin.import_utils.vm_operations.validate_device_for_import",
return_value=mock_validation,
),
patch(
"netbox_librenms_plugin.import_utils.vm_operations._determine_device_name",
return_value="role-vm",
),
patch(
"netbox_librenms_plugin.import_utils.vm_operations.create_vm_from_librenms",
return_value=mock_vm,
),
patch("netbox_librenms_plugin.import_utils.vm_operations.Cluster"),
patch("netbox_librenms_plugin.import_utils.vm_operations.DeviceRole") as mock_role_cls,
patch("netbox_librenms_plugin.import_validation_helpers.apply_cluster_to_validation"),
patch("netbox_librenms_plugin.import_validation_helpers.apply_role_to_validation") as mock_apply_role,
):
mock_role_cls.objects.filter.return_value.first.return_value = mock_role
bulk_import_vms({40: {"device_role_id": 3}}, mock_api, user=MagicMock())
mock_apply_role.assert_called_once_with(mock_validation, mock_role, is_vm=True)
def test_exception_in_inner_loop_added_to_failed(self):
"""Exception during VM processing is caught and added to failed list."""
from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms
mock_api = MagicMock()
mock_api.server_key = "default"
with (
patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"),
patch(
"netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache",
side_effect=RuntimeError("Connection error"),
),
):
result = bulk_import_vms({50: {}}, mock_api, user=MagicMock())
assert len(result["failed"]) == 1
assert result["failed"][0]["device_id"] == 50
assert "Connection error" in result["failed"][0]["error"]
def test_job_cancellation_breaks_loop(self):
"""Loop exits early when _is_job_cancelled returns True at idx=1 check."""
from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms
mock_api = MagicMock()
mock_api.server_key = "default"
mock_job = MagicMock()
mock_job.logger = MagicMock()
# 5 VMs; _is_job_cancelled returns False for first check (idx=1), True for second (idx=5)
cancel_calls = [0]
def _cancelled(job):
cancel_calls[0] += 1
return cancel_calls[0] >= 2
vm_imports = {i: {} for i in range(1, 6)}
with (
patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"),
patch("netbox_librenms_plugin.import_utils.vm_operations._is_job_cancelled", side_effect=_cancelled),
patch(
"netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache",
return_value=None, # VMs 1-4 → failed; VM-5 never reached
),
):
result = bulk_import_vms(vm_imports, mock_api, job=mock_job)
# VMs 1-4 added to failed; 5th cancelled before processing
assert len(result["failed"]) == 4
def test_job_cancellation_with_errored_status(self):
"""Loop also exits when _is_job_cancelled returns True (rq_job.is_failed)."""
from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms
mock_api = MagicMock()
mock_api.server_key = "default"
mock_job = MagicMock()
mock_job.logger = MagicMock()
cancel_calls = [0]
def _cancelled(job):
cancel_calls[0] += 1
return cancel_calls[0] >= 2
vm_imports = {i: {} for i in range(1, 6)}
with (
patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"),
patch("netbox_librenms_plugin.import_utils.vm_operations._is_job_cancelled", side_effect=_cancelled),
patch(
"netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache",
return_value=None,
),
):
result = bulk_import_vms(vm_imports, mock_api, job=mock_job)
assert len(result["failed"]) == 4
def test_user_extracted_from_job_when_not_provided(self):
"""User is extracted from job.job.user when the user param is None."""
from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms
mock_user = MagicMock()
mock_job = MagicMock()
mock_job.job.user = mock_user
mock_job.logger = MagicMock()
mock_api = MagicMock()
mock_api.server_key = "default"
with patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions") as mock_require:
bulk_import_vms({}, mock_api, job=mock_job, user=None)
mock_require.assert_called_once_with(mock_user, ["virtualization.add_virtualmachine"], "import VMs")
def test_sync_options_use_sysname_and_strip_domain_forwarded(self):
"""sync_options use_sysname/strip_domain are passed to validate_device_for_import."""
from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms
mock_api = MagicMock()
mock_api.server_key = "default"
libre_device = {"device_id": 60, "hostname": "opts-vm"}
# existing_device set → triggers skipped path (avoids more mocking)
mock_validation = {
"existing_device": MagicMock(name="opts-vm"),
"can_import": False,
"issues": [],
}
with (
patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"),
patch(
"netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache",
return_value=libre_device,
),
patch(
"netbox_librenms_plugin.import_utils.vm_operations.validate_device_for_import",
return_value=mock_validation,
) as mock_validate,
):
bulk_import_vms(
{60: {}},
mock_api,
sync_options={"use_sysname": False, "strip_domain": True},
user=MagicMock(),
)
mock_validate.assert_called_once()
call_kwargs = mock_validate.call_args[1]
assert call_kwargs["use_sysname"] is False
assert call_kwargs["strip_domain"] is True
def test_no_cluster_id_skips_cluster_lookup(self):
"""Cluster lookup is skipped when cluster_id is absent from vm_mappings."""
from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms
mock_api = MagicMock()
mock_api.server_key = "default"
libre_device = {"device_id": 70, "hostname": "no-cluster-vm"}
mock_validation = {
"existing_device": None,
"can_import": True,
"cluster": {"cluster": MagicMock()},
"platform": {"platform": None},
"issues": [],
}
mock_vm = MagicMock()
mock_vm.name = "no-cluster-vm"
with (
patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"),
patch(
"netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache",
return_value=libre_device,
),
patch(
"netbox_librenms_plugin.import_utils.vm_operations.validate_device_for_import",
return_value=mock_validation,
),
patch(
"netbox_librenms_plugin.import_utils.vm_operations._determine_device_name",
return_value="no-cluster-vm",
),
patch(
"netbox_librenms_plugin.import_utils.vm_operations.create_vm_from_librenms",
return_value=mock_vm,
),
patch("netbox_librenms_plugin.import_utils.vm_operations.Cluster") as mock_cluster_cls,
patch("netbox_librenms_plugin.import_utils.vm_operations.DeviceRole"),
patch("netbox_librenms_plugin.import_validation_helpers.apply_cluster_to_validation") as mock_apply_cluster,
patch("netbox_librenms_plugin.import_validation_helpers.apply_role_to_validation"),
):
# No cluster_id in vm_mappings
bulk_import_vms({70: {}}, mock_api, user=MagicMock())
mock_cluster_cls.objects.filter.assert_not_called()
mock_apply_cluster.assert_not_called()
def test_is_job_cancelled_false_processes_all_vms(self):
"""_is_job_cancelled returning False lets loop process all VMs."""
from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms
mock_api = MagicMock()
mock_api.server_key = "default"
mock_job = MagicMock()
mock_job.logger = MagicMock()
vm_imports = {i: {} for i in range(1, 3)}
with (
patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"),
# Simulate Redis unavailable → _is_job_cancelled returns False (not cancelled)
patch(
"netbox_librenms_plugin.import_utils.vm_operations._is_job_cancelled",
return_value=False,
) as mock_is_job_cancelled,
patch(
"netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache",
return_value=None,
),
):
result = bulk_import_vms(vm_imports, mock_api, job=mock_job)
# The cancellation helper must be consulted at least once (idx==1 checkpoint);
# protects against accidental removal of the check.
mock_is_job_cancelled.assert_called_once_with(mock_job)
# Both VMs should be attempted (not cancelled) → both failed (fetch returned None)
assert len(result["failed"]) == 2
def test_job_log_info_when_not_cancelled_at_checkpoint(self):
"""log.info is called at a non-cancelling 5-iteration checkpoint."""
from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms
mock_api = MagicMock()
mock_api.server_key = "default"
# Status is "running" at first checkpoint (idx=5), "failed" at second (idx=10)
statuses = iter(["running", "running", "failed"])
mock_job = MagicMock()
mock_job.logger = MagicMock()
mock_job.job.status = "running"
def _refresh():
try:
mock_job.job.status = next(statuses)
except StopIteration:
mock_job.job.status = "failed"
mock_job.job.refresh_from_db.side_effect = _refresh
# 10 VMs: checkpoint at idx=5 (running → log.info) and idx=10 (failed → break)
vm_imports = {i: {} for i in range(1, 11)}
with (
patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"),
patch(
"netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache",
return_value=None,
),
):
bulk_import_vms(vm_imports, mock_api, job=mock_job)
# log.info called at idx=5 checkpoint
mock_job.logger.info.assert_called()