first commit
This commit is contained in:
1
netbox_librenms_plugin/tests/__init__.py
Normal file
1
netbox_librenms_plugin/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Unit test package for netbox_librenms_plugin."""
|
||||
336
netbox_librenms_plugin/tests/conftest.py
Normal file
336
netbox_librenms_plugin/tests/conftest.py
Normal 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",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
262
netbox_librenms_plugin/tests/mock_librenms_server.py
Normal file
262
netbox_librenms_plugin/tests/mock_librenms_server.py
Normal 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()
|
||||
967
netbox_librenms_plugin/tests/test_background_jobs.py
Normal file
967
netbox_librenms_plugin/tests/test_background_jobs.py
Normal 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
|
||||
311
netbox_librenms_plugin/tests/test_cable_verify.py
Normal file
311
netbox_librenms_plugin/tests/test_cable_verify.py
Normal 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 "<script>" 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 "<img" in remote_device_html
|
||||
3955
netbox_librenms_plugin/tests/test_coverage_actions.py
Normal file
3955
netbox_librenms_plugin/tests/test_coverage_actions.py
Normal file
File diff suppressed because it is too large
Load Diff
1217
netbox_librenms_plugin/tests/test_coverage_api.py
Normal file
1217
netbox_librenms_plugin/tests/test_coverage_api.py
Normal file
File diff suppressed because it is too large
Load Diff
707
netbox_librenms_plugin/tests/test_coverage_api2.py
Normal file
707
netbox_librenms_plugin/tests/test_coverage_api2.py
Normal 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/"
|
||||
2196
netbox_librenms_plugin/tests/test_coverage_base_views.py
Normal file
2196
netbox_librenms_plugin/tests/test_coverage_base_views.py
Normal file
File diff suppressed because it is too large
Load Diff
2037
netbox_librenms_plugin/tests/test_coverage_base_views2.py
Normal file
2037
netbox_librenms_plugin/tests/test_coverage_base_views2.py
Normal file
File diff suppressed because it is too large
Load Diff
320
netbox_librenms_plugin/tests/test_coverage_cache.py
Normal file
320
netbox_librenms_plugin/tests/test_coverage_cache.py
Normal 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
|
||||
2039
netbox_librenms_plugin/tests/test_coverage_device_fields.py
Normal file
2039
netbox_librenms_plugin/tests/test_coverage_device_fields.py
Normal file
File diff suppressed because it is too large
Load Diff
1667
netbox_librenms_plugin/tests/test_coverage_device_operations.py
Normal file
1667
netbox_librenms_plugin/tests/test_coverage_device_operations.py
Normal file
File diff suppressed because it is too large
Load Diff
768
netbox_librenms_plugin/tests/test_coverage_filters.py
Normal file
768
netbox_librenms_plugin/tests/test_coverage_filters.py
Normal 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]
|
||||
61
netbox_librenms_plugin/tests/test_coverage_forms.py
Normal file
61
netbox_librenms_plugin/tests/test_coverage_forms.py
Normal 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
|
||||
1406
netbox_librenms_plugin/tests/test_coverage_list.py
Normal file
1406
netbox_librenms_plugin/tests/test_coverage_list.py
Normal file
File diff suppressed because it is too large
Load Diff
1022
netbox_librenms_plugin/tests/test_coverage_mixins.py
Normal file
1022
netbox_librenms_plugin/tests/test_coverage_mixins.py
Normal file
File diff suppressed because it is too large
Load Diff
1193
netbox_librenms_plugin/tests/test_coverage_sync_interfaces.py
Normal file
1193
netbox_librenms_plugin/tests/test_coverage_sync_interfaces.py
Normal file
File diff suppressed because it is too large
Load Diff
692
netbox_librenms_plugin/tests/test_coverage_sync_view.py
Normal file
692
netbox_librenms_plugin/tests/test_coverage_sync_view.py
Normal 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
|
||||
2577
netbox_librenms_plugin/tests/test_coverage_sync_views.py
Normal file
2577
netbox_librenms_plugin/tests/test_coverage_sync_views.py
Normal file
File diff suppressed because it is too large
Load Diff
2240
netbox_librenms_plugin/tests/test_coverage_sync_views2.py
Normal file
2240
netbox_librenms_plugin/tests/test_coverage_sync_views2.py
Normal file
File diff suppressed because it is too large
Load Diff
980
netbox_librenms_plugin/tests/test_coverage_sync_views3.py
Normal file
980
netbox_librenms_plugin/tests/test_coverage_sync_views3.py
Normal 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)
|
||||
2649
netbox_librenms_plugin/tests/test_coverage_tables.py
Normal file
2649
netbox_librenms_plugin/tests/test_coverage_tables.py
Normal file
File diff suppressed because it is too large
Load Diff
551
netbox_librenms_plugin/tests/test_coverage_utils.py
Normal file
551
netbox_librenms_plugin/tests/test_coverage_utils.py
Normal 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()
|
||||
294
netbox_librenms_plugin/tests/test_coverage_virtual_chassis.py
Normal file
294
netbox_librenms_plugin/tests/test_coverage_virtual_chassis.py
Normal 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
|
||||
367
netbox_librenms_plugin/tests/test_coverage_vlans_table.py
Normal file
367
netbox_librenms_plugin/tests/test_coverage_vlans_table.py
Normal 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_")
|
||||
5894
netbox_librenms_plugin/tests/test_import_utils.py
Normal file
5894
netbox_librenms_plugin/tests/test_import_utils.py
Normal file
File diff suppressed because it is too large
Load Diff
368
netbox_librenms_plugin/tests/test_import_validation_helpers.py
Normal file
368
netbox_librenms_plugin/tests/test_import_validation_helpers.py
Normal 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
|
||||
216
netbox_librenms_plugin/tests/test_init.py
Normal file
216
netbox_librenms_plugin/tests/test_init.py
Normal 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
|
||||
399
netbox_librenms_plugin/tests/test_integration_sync.py
Normal file
399
netbox_librenms_plugin/tests/test_integration_sync.py
Normal 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
|
||||
854
netbox_librenms_plugin/tests/test_integration_virtual_chassis.py
Normal file
854
netbox_librenms_plugin/tests/test_integration_virtual_chassis.py
Normal 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
|
||||
563
netbox_librenms_plugin/tests/test_interface_vlan_sync.py
Normal file
563
netbox_librenms_plugin/tests/test_interface_vlan_sync.py
Normal 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
|
||||
137
netbox_librenms_plugin/tests/test_ip_verify.py
Normal file
137
netbox_librenms_plugin/tests/test_ip_verify.py
Normal 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"
|
||||
1347
netbox_librenms_plugin/tests/test_librenms_api.py
Normal file
1347
netbox_librenms_plugin/tests/test_librenms_api.py
Normal file
File diff suppressed because it is too large
Load Diff
26
netbox_librenms_plugin/tests/test_librenms_api_helpers.py
Normal file
26
netbox_librenms_plugin/tests/test_librenms_api_helpers.py
Normal 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}
|
||||
367
netbox_librenms_plugin/tests/test_librenms_id.py
Normal file
367
netbox_librenms_plugin/tests/test_librenms_id.py
Normal 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}
|
||||
207
netbox_librenms_plugin/tests/test_mixins.py
Normal file
207
netbox_librenms_plugin/tests/test_mixins.py
Normal 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
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""Tests for `netbox_librenms_plugin` package."""
|
||||
1083
netbox_librenms_plugin/tests/test_permissions.py
Normal file
1083
netbox_librenms_plugin/tests/test_permissions.py
Normal file
File diff suppressed because it is too large
Load Diff
426
netbox_librenms_plugin/tests/test_reviewer_fixes.py
Normal file
426
netbox_librenms_plugin/tests/test_reviewer_fixes.py
Normal 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 "<script>" 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
|
||||
314
netbox_librenms_plugin/tests/test_sync_devices.py
Normal file
314
netbox_librenms_plugin/tests/test_sync_devices.py
Normal 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)
|
||||
302
netbox_librenms_plugin/tests/test_sync_interfaces.py
Normal file
302
netbox_librenms_plugin/tests/test_sync_interfaces.py
Normal 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()
|
||||
589
netbox_librenms_plugin/tests/test_sync_view_mismatch.py
Normal file
589
netbox_librenms_plugin/tests/test_sync_view_mismatch.py
Normal 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"
|
||||
818
netbox_librenms_plugin/tests/test_utils.py
Normal file
818
netbox_librenms_plugin/tests/test_utils.py
Normal 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)
|
||||
205
netbox_librenms_plugin/tests/test_verify_views.py
Normal file
205
netbox_librenms_plugin/tests/test_verify_views.py
Normal 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"
|
||||
443
netbox_librenms_plugin/tests/test_view_wiring.py
Normal file
443
netbox_librenms_plugin/tests/test_view_wiring.py
Normal 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 "<script>" 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
|
||||
103
netbox_librenms_plugin/tests/test_virtual_chassis.py
Normal file
103
netbox_librenms_plugin/tests/test_virtual_chassis.py
Normal 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"
|
||||
483
netbox_librenms_plugin/tests/test_vlan_sync.py
Normal file
483
netbox_librenms_plugin/tests/test_vlan_sync.py
Normal 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]
|
||||
669
netbox_librenms_plugin/tests/test_vm_operations.py
Normal file
669
netbox_librenms_plugin/tests/test_vm_operations.py
Normal 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()
|
||||
Reference in New Issue
Block a user