first commit
This commit is contained in:
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()
|
||||
Reference in New Issue
Block a user