first commit
Some checks failed
ci / deploy (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled

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

View File

@@ -0,0 +1,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()