Files
napalm-opnsense/tests/unit/test_driver.py
T
Christian Manivong 26470676ce
CI / test (3.10) (push) Failing after 8s
CI / test (3.11) (push) Failing after 7s
CI / test (3.12) (push) Failing after 8s
CI / test (3.9) (push) Failing after 7s
feat(trust): add get_certificates() for Trust store certificate inventory
Reads certificates via POST /api/trust/cert/search, normalising each row
to {name, issuer, valid_from, valid_to, in_use_by}. Field mapping (Unix
timestamps for validity, %caref for the resolved issuer label) verified
against a live OPNsense 24.7 instance. Never surfaces crt_payload/
prv_payload/csr_payload -- those carry private key material.
2026-07-15 12:26:36 +02:00

1582 lines
59 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit tests for OPNsenseDriver — no real device required."""
import json
import pytest
from unittest.mock import MagicMock, patch
from napalm_opnsense.opnsense import OPNsenseDriver
from napalm.base.exceptions import ConnectionException, ConnectionClosedException
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def driver():
"""Return a driver instance with a mocked requests.Session."""
with patch("napalm_opnsense.opnsense.requests.Session"):
drv = OPNsenseDriver(
hostname="opnsense.example.com",
username="api_key",
password="api_secret",
optional_args={"verify": False},
)
drv.session = MagicMock()
yield drv
# ---------------------------------------------------------------------------
# Sample API responses
# ---------------------------------------------------------------------------
STATUS_RESPONSE = {
"hostname": "opnsense01",
"version": "24.7",
"model": "OPNsense",
"serial": "ABC123",
"uptime": 12345,
}
INTERFACES_RESPONSE = {
"interfaces": [
{
"name": "em0",
"up": True,
"enabled": True,
"descr": "LAN",
"mac": "AA:BB:CC:DD:EE:FF",
"speed_mbps": 1000,
"mtu": 1500,
},
{
"name": "em1",
"up": False,
"enabled": True,
"descr": "WAN",
"mac": "AA:BB:CC:DD:EE:00",
"speed_mbps": None,
"mtu": 0,
},
]
}
ADDRESSES_RESPONSE = {
"items": [
{"interface": "em0", "address": "192.0.2.10", "prefix": 24},
{"interface": "em0", "address": "2001:db8::1", "prefix": 64},
{"interface": "em1", "address": "203.0.113.5", "prefix": 30},
]
}
ARP_RESPONSE = {
"arp": [
{"intf": "em0", "mac": "AA:BB:CC:DD:EE:01", "ip": "192.0.2.1", "expires": 900},
{"intf": "em0", "mac": "AA:BB:CC:DD:EE:02", "ip": "192.0.2.2", "expires": 600},
]
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_json_response(data):
mock_resp = MagicMock()
mock_resp.json.return_value = data
mock_resp.raise_for_status.return_value = None
return mock_resp
# ---------------------------------------------------------------------------
# open() / close() / is_alive()
# ---------------------------------------------------------------------------
class TestOpenClose:
def test_open_raises_connection_exception_on_error(self):
drv = OPNsenseDriver(
hostname="unreachable.invalid",
username="k",
password="s",
optional_args={"verify": False},
)
with pytest.raises(ConnectionException):
drv.open()
def test_close_clears_session(self, driver):
driver.close()
assert driver.session is None
def test_close_is_idempotent(self, driver):
driver.close()
driver.close() # second call must not raise
class TestIsAlive:
def test_returns_false_when_no_session(self):
drv = OPNsenseDriver("host", "u", "p")
assert drv.is_alive() == {"is_alive": False}
def test_returns_true_on_successful_connection(self, driver):
with patch("napalm_opnsense.opnsense.socket.create_connection") as mock_conn:
mock_conn.return_value.__enter__ = MagicMock(return_value=None)
mock_conn.return_value.__exit__ = MagicMock(return_value=False)
result = driver.is_alive()
assert result == {"is_alive": True}
def test_returns_false_on_socket_error(self, driver):
with patch(
"napalm_opnsense.opnsense.socket.create_connection",
side_effect=OSError("refused"),
):
result = driver.is_alive()
assert result == {"is_alive": False}
# ---------------------------------------------------------------------------
# _get()
# ---------------------------------------------------------------------------
class TestInternalGet:
def test_raises_when_no_session(self):
drv = OPNsenseDriver("host", "u", "p")
with pytest.raises(ConnectionClosedException):
drv._get("/api/core/system/status")
def test_calls_correct_url(self, driver):
driver.session.get.return_value = _make_json_response(STATUS_RESPONSE)
driver._get("/api/core/system/status")
driver.session.get.assert_called_once_with(
"https://opnsense.example.com/api/core/system/status",
timeout=60,
)
# ---------------------------------------------------------------------------
# get_facts()
# ---------------------------------------------------------------------------
class TestGetFacts:
def test_returns_required_keys(self, driver):
driver._get = lambda path: (
STATUS_RESPONSE if "status" in path else INTERFACES_RESPONSE
)
facts = driver.get_facts()
for key in ("vendor", "model", "hostname", "fqdn", "os_version",
"serial_number", "uptime", "interface_list"):
assert key in facts
def test_vendor_constant(self, driver):
driver._get = lambda path: (
STATUS_RESPONSE if "status" in path else INTERFACES_RESPONSE
)
facts = driver.get_facts()
assert facts["vendor"] == "OPNsense"
def test_hostname_parsed(self, driver):
driver._get = lambda path: (
STATUS_RESPONSE if "status" in path else INTERFACES_RESPONSE
)
facts = driver.get_facts()
assert facts["hostname"] == "opnsense01"
def test_os_version_parsed(self, driver):
driver._get = lambda path: (
STATUS_RESPONSE if "status" in path else INTERFACES_RESPONSE
)
facts = driver.get_facts()
assert facts["os_version"] == "24.7"
def test_interface_list_populated(self, driver):
driver._get = lambda path: (
STATUS_RESPONSE if "status" in path else INTERFACES_RESPONSE
)
facts = driver.get_facts()
assert "em0" in facts["interface_list"]
assert "em1" in facts["interface_list"]
def test_interface_list_empty_on_getter_failure(self, driver):
def fail_on_interfaces(path):
if "overview" in path:
raise RuntimeError("no endpoint")
return STATUS_RESPONSE
driver._get = fail_on_interfaces
facts = driver.get_facts()
assert facts["interface_list"] == []
def test_serial_number(self, driver):
driver._get = lambda path: (
STATUS_RESPONSE if "status" in path else INTERFACES_RESPONSE
)
facts = driver.get_facts()
assert facts["serial_number"] == "ABC123"
def test_uptime(self, driver):
driver._get = lambda path: (
STATUS_RESPONSE if "status" in path else INTERFACES_RESPONSE
)
facts = driver.get_facts()
assert facts["uptime"] == 12345
# ---------------------------------------------------------------------------
# get_interfaces()
# ---------------------------------------------------------------------------
class TestGetInterfaces:
def test_interface_count(self, driver):
driver._get = lambda path: INTERFACES_RESPONSE
ifaces = driver.get_interfaces()
assert len(ifaces) == 2
def test_is_up_and_enabled(self, driver):
driver._get = lambda path: INTERFACES_RESPONSE
ifaces = driver.get_interfaces()
assert ifaces["em0"]["is_up"] is True
assert ifaces["em0"]["is_enabled"] is True
assert ifaces["em1"]["is_up"] is False
def test_speed(self, driver):
driver._get = lambda path: INTERFACES_RESPONSE
ifaces = driver.get_interfaces()
assert ifaces["em0"]["speed"] == 1000.0
assert ifaces["em1"]["speed"] == 0.0
def test_mac_address_lowercase(self, driver):
driver._get = lambda path: INTERFACES_RESPONSE
ifaces = driver.get_interfaces()
assert ifaces["em0"]["mac_address"] == "aa:bb:cc:dd:ee:ff"
def test_description(self, driver):
driver._get = lambda path: INTERFACES_RESPONSE
ifaces = driver.get_interfaces()
assert ifaces["em0"]["description"] == "LAN"
assert ifaces["em1"]["description"] == "WAN"
def test_last_flapped_is_negative_one(self, driver):
driver._get = lambda path: INTERFACES_RESPONSE
ifaces = driver.get_interfaces()
assert ifaces["em0"]["last_flapped"] == -1.0
def test_empty_response(self, driver):
driver._get = lambda path: {"interfaces": []}
assert driver.get_interfaces() == {}
# ---------------------------------------------------------------------------
# get_interfaces_ip()
# ---------------------------------------------------------------------------
class TestGetInterfacesIp:
def test_entry_count(self, driver):
driver._get = lambda path: ADDRESSES_RESPONSE
result = driver.get_interfaces_ip()
# em0 has 2 addresses, em1 has 1
assert len(result) == 2
def test_ipv4_entry(self, driver):
driver._get = lambda path: ADDRESSES_RESPONSE
result = driver.get_interfaces_ip()
assert "192.0.2.10" in result["em0"]["ipv4"]
assert result["em0"]["ipv4"]["192.0.2.10"]["prefix_length"] == 24
def test_ipv6_entry(self, driver):
driver._get = lambda path: ADDRESSES_RESPONSE
result = driver.get_interfaces_ip()
assert "2001:db8::1" in result["em0"]["ipv6"]
assert result["em0"]["ipv6"]["2001:db8::1"]["prefix_length"] == 64
def test_second_interface(self, driver):
driver._get = lambda path: ADDRESSES_RESPONSE
result = driver.get_interfaces_ip()
assert "203.0.113.5" in result["em1"]["ipv4"]
# ---------------------------------------------------------------------------
# get_arp_table()
# ---------------------------------------------------------------------------
class TestGetArpTable:
def test_entry_count(self, driver):
driver._get = lambda path: ARP_RESPONSE
table = driver.get_arp_table()
assert len(table) == 2
def test_entry_structure(self, driver):
driver._get = lambda path: ARP_RESPONSE
entry = driver.get_arp_table()[0]
for key in ("interface", "mac", "ip", "age"):
assert key in entry
def test_ip_values(self, driver):
driver._get = lambda path: ARP_RESPONSE
ips = {e["ip"] for e in driver.get_arp_table()}
assert "192.0.2.1" in ips
assert "192.0.2.2" in ips
def test_mac_lowercase(self, driver):
driver._get = lambda path: ARP_RESPONSE
macs = {e["mac"] for e in driver.get_arp_table()}
assert all(m == m.lower() for m in macs)
def test_list_response_format(self, driver):
"""ARP endpoint may return a bare list instead of dict."""
bare_list = ARP_RESPONSE["arp"]
driver._get = lambda path: bare_list
table = driver.get_arp_table()
assert len(table) == 2
# ---------------------------------------------------------------------------
# get_interfaces_counters()
# ---------------------------------------------------------------------------
INTERFACE_STATISTICS_RESPONSE = {
"statistics": {
"em0": {
"input-packets": 10000,
"output-packets": 8000,
"input-bytes": 1000000,
"output-bytes": 800000,
"input-errors": 5,
"output-errors": 2,
"input-drops": 3,
"output-drops": 1,
"input-multicasts": 100,
"output-multicasts": 50,
"input-broadcasts": 20,
"output-broadcasts": 10,
},
"em1": {
"input-packets": 500,
"output-packets": 300,
"input-bytes": 50000,
"output-bytes": 30000,
"input-errors": 0,
"output-errors": 0,
"input-drops": 0,
"output-drops": 0,
"input-multicasts": 0,
"output-multicasts": 0,
"input-broadcasts": 0,
"output-broadcasts": 0,
},
}
}
class TestGetInterfacesCounters:
def test_interface_count(self, driver):
driver._get = lambda path: INTERFACE_STATISTICS_RESPONSE
counters = driver.get_interfaces_counters()
assert len(counters) == 2
def test_required_keys(self, driver):
driver._get = lambda path: INTERFACE_STATISTICS_RESPONSE
entry = driver.get_interfaces_counters()["em0"]
for key in (
"tx_errors", "rx_errors", "tx_discards", "rx_discards",
"tx_octets", "rx_octets",
"tx_unicast_packets", "rx_unicast_packets",
"tx_multicast_packets", "rx_multicast_packets",
"tx_broadcast_packets", "rx_broadcast_packets",
):
assert key in entry
def test_rx_octets(self, driver):
driver._get = lambda path: INTERFACE_STATISTICS_RESPONSE
assert driver.get_interfaces_counters()["em0"]["rx_octets"] == 1000000
def test_tx_errors(self, driver):
driver._get = lambda path: INTERFACE_STATISTICS_RESPONSE
assert driver.get_interfaces_counters()["em0"]["tx_errors"] == 2
def test_rx_errors(self, driver):
driver._get = lambda path: INTERFACE_STATISTICS_RESPONSE
assert driver.get_interfaces_counters()["em0"]["rx_errors"] == 5
def test_zero_counters(self, driver):
driver._get = lambda path: INTERFACE_STATISTICS_RESPONSE
em1 = driver.get_interfaces_counters()["em1"]
assert em1["tx_errors"] == 0
assert em1["rx_errors"] == 0
def test_empty_statistics(self, driver):
driver._get = lambda path: {"statistics": {}}
assert driver.get_interfaces_counters() == {}
# ---------------------------------------------------------------------------
# get_environment()
# ---------------------------------------------------------------------------
SYSTEM_RESOURCES_RESPONSE = {
"cpu": {"used": "25"},
"memory": {"total": "4096000000", "used": "2048000000"},
}
SYSTEM_TEMP_RESPONSE = {
"data": [
{"device": "cpu0", "temperature": "52.5"},
{"device": "cpu1", "temperature": "48.0"},
]
}
class TestGetEnvironment:
def test_required_top_keys(self, driver):
def fake_get(path):
if "temperature" in path:
return SYSTEM_TEMP_RESPONSE
return SYSTEM_RESOURCES_RESPONSE
driver._get = fake_get
env = driver.get_environment()
for key in ("fans", "temperature", "power", "cpu", "memory"):
assert key in env
def test_cpu_usage(self, driver):
driver._get = lambda path: (
SYSTEM_TEMP_RESPONSE if "temperature" in path else SYSTEM_RESOURCES_RESPONSE
)
env = driver.get_environment()
assert env["cpu"][0]["%usage"] == 25.0
def test_memory_values(self, driver):
driver._get = lambda path: (
SYSTEM_TEMP_RESPONSE if "temperature" in path else SYSTEM_RESOURCES_RESPONSE
)
env = driver.get_environment()
assert env["memory"]["used_ram"] == 2048000000
assert env["memory"]["available_ram"] == 2048000000
def test_temperature_sensors(self, driver):
driver._get = lambda path: (
SYSTEM_TEMP_RESPONSE if "temperature" in path else SYSTEM_RESOURCES_RESPONSE
)
env = driver.get_environment()
assert "cpu0" in env["temperature"]
assert env["temperature"]["cpu0"]["temperature"] == 52.5
def test_temperature_alert_thresholds(self, driver):
driver._get = lambda path: (
{"data": [{"device": "cpu0", "temperature": "85.0"}]}
if "temperature" in path else SYSTEM_RESOURCES_RESPONSE
)
env = driver.get_environment()
assert env["temperature"]["cpu0"]["is_alert"] is True
assert env["temperature"]["cpu0"]["is_critical"] is False
def test_temperature_endpoint_failing_gracefully(self, driver):
"""Driver must not raise if temperature endpoint is unavailable."""
def fake_get(path):
if "temperature" in path:
raise Exception("no sensor data")
return SYSTEM_RESOURCES_RESPONSE
driver._get = fake_get
env = driver.get_environment()
assert env["temperature"] == {}
# ---------------------------------------------------------------------------
# get_route_to()
# ---------------------------------------------------------------------------
ROUTES_RESPONSE = {
"route": [
{
"network": "0.0.0.0/0",
"gateway": "192.0.2.1",
"flags": "UGS",
"netif": "em1",
"proto": "static",
"priority": 1,
},
{
"network": "192.0.2.0/24",
"gateway": "",
"flags": "U",
"netif": "em0",
"proto": "kernel",
"priority": 0,
},
{
"network": "198.51.100.0/24",
"gateway": "192.0.2.5",
"flags": "UGS",
"netif": "em0",
"proto": "static",
"priority": 1,
},
]
}
class TestGetRouteTo:
def test_returns_all_routes_without_filter(self, driver):
driver._get = lambda path: ROUTES_RESPONSE
routes = driver.get_route_to()
assert len(routes) == 3
def test_default_route_present(self, driver):
driver._get = lambda path: ROUTES_RESPONSE
assert "0.0.0.0/0" in driver.get_route_to()
def test_next_hop(self, driver):
driver._get = lambda path: ROUTES_RESPONSE
entry = driver.get_route_to()["0.0.0.0/0"][0]
assert entry["next_hop"] == "192.0.2.1"
def test_outgoing_interface(self, driver):
driver._get = lambda path: ROUTES_RESPONSE
entry = driver.get_route_to()["0.0.0.0/0"][0]
assert entry["outgoing_interface"] == "em1"
def test_protocol_static(self, driver):
driver._get = lambda path: ROUTES_RESPONSE
assert driver.get_route_to()["0.0.0.0/0"][0]["protocol"] == "static"
def test_protocol_kernel_mapped_to_connected(self, driver):
driver._get = lambda path: ROUTES_RESPONSE
assert driver.get_route_to()["192.0.2.0/24"][0]["protocol"] == "connected"
def test_filter_by_destination(self, driver):
driver._get = lambda path: ROUTES_RESPONSE
routes = driver.get_route_to(destination="0.0.0.0/0")
assert "0.0.0.0/0" in routes
assert "192.0.2.0/24" not in routes
def test_filter_by_protocol(self, driver):
driver._get = lambda path: ROUTES_RESPONSE
routes = driver.get_route_to(protocol="static")
assert all(
e["protocol"] == "static"
for entries in routes.values()
for e in entries
)
def test_required_keys_in_entry(self, driver):
driver._get = lambda path: ROUTES_RESPONSE
entry = driver.get_route_to()["0.0.0.0/0"][0]
for key in (
"protocol", "current_active", "last_active", "age",
"next_hop", "outgoing_interface", "selected_next_hop",
"preference", "inactive_reason", "routing_table",
"protocol_attributes",
):
assert key in entry
# ---------------------------------------------------------------------------
# get_ipv6_neighbors_table()
# ---------------------------------------------------------------------------
NDP_RESPONSE = {
"rows": [
{
"intf": "em0",
"mac": "aa:bb:cc:dd:ee:01",
"ip": "fe80::1",
"expires": 120,
"state": "REACHABLE",
},
{
"intf": "em0",
"mac": "aa:bb:cc:dd:ee:02",
"ip": "2001:db8::1",
"expires": 60,
"state": "STALE",
},
]
}
class TestGetIpv6NeighborsTable:
def test_entry_count(self, driver):
driver._get = lambda path: NDP_RESPONSE
assert len(driver.get_ipv6_neighbors_table()) == 2
def test_required_keys(self, driver):
driver._get = lambda path: NDP_RESPONSE
entry = driver.get_ipv6_neighbors_table()[0]
for key in ("interface", "mac", "ip", "age", "state"):
assert key in entry
def test_ip_values(self, driver):
driver._get = lambda path: NDP_RESPONSE
ips = {e["ip"] for e in driver.get_ipv6_neighbors_table()}
assert "fe80::1" in ips
assert "2001:db8::1" in ips
def test_mac_lowercase(self, driver):
driver._get = lambda path: NDP_RESPONSE
macs = {e["mac"] for e in driver.get_ipv6_neighbors_table()}
assert all(m == m.lower() for m in macs)
def test_list_format_response(self, driver):
"""Endpoint may return a bare list."""
driver._get = lambda path: NDP_RESPONSE["rows"]
assert len(driver.get_ipv6_neighbors_table()) == 2
def test_empty_response(self, driver):
driver._get = lambda path: {"rows": []}
assert driver.get_ipv6_neighbors_table() == []
# ---------------------------------------------------------------------------
# get_lldp_neighbors() / get_lldp_neighbors_detail()
# ---------------------------------------------------------------------------
LLDP_RESPONSE = {
"rows": [
{
"local_port": "em0",
"port_id": "eth1",
"chassis_id": "aa:bb:cc:dd:ee:ff",
"system_name": "core-sw-01",
"port_description": "uplink",
"system_description": "Cisco IOS",
"system_capabilities": "bridge, router",
"enabled_capabilities": "bridge",
}
]
}
class TestGetLldpNeighbors:
def test_returns_neighbor(self, driver):
driver._get = lambda path: LLDP_RESPONSE
neighbors = driver.get_lldp_neighbors()
assert "em0" in neighbors
assert neighbors["em0"][0]["hostname"] == "core-sw-01"
assert neighbors["em0"][0]["port"] == "eth1"
def test_plugin_not_installed_returns_empty(self, driver):
driver._get = lambda path: (_ for _ in ()).throw(Exception("404"))
assert driver.get_lldp_neighbors() == {}
class TestGetLldpNeighborsDetail:
def test_required_keys(self, driver):
driver._get = lambda path: LLDP_RESPONSE
detail = driver.get_lldp_neighbors_detail()
entry = detail["em0"][0]
for key in (
"remote_chassis_id", "remote_system_name", "remote_port",
"remote_port_description", "remote_system_description",
"remote_system_capab", "remote_system_enable_capab",
):
assert key in entry
def test_chassis_id(self, driver):
driver._get = lambda path: LLDP_RESPONSE
assert driver.get_lldp_neighbors_detail()["em0"][0]["remote_chassis_id"] == "aa:bb:cc:dd:ee:ff"
def test_system_name(self, driver):
driver._get = lambda path: LLDP_RESPONSE
assert driver.get_lldp_neighbors_detail()["em0"][0]["remote_system_name"] == "core-sw-01"
def test_capabilities_parsed(self, driver):
driver._get = lambda path: LLDP_RESPONSE
capab = driver.get_lldp_neighbors_detail()["em0"][0]["remote_system_capab"]
assert "bridge" in capab
assert "router" in capab
def test_interface_filter(self, driver):
driver._get = lambda path: LLDP_RESPONSE
detail = driver.get_lldp_neighbors_detail(interface="em99")
assert detail == {}
def test_plugin_not_installed_returns_empty(self, driver):
driver._get = lambda path: (_ for _ in ()).throw(Exception("404"))
assert driver.get_lldp_neighbors_detail() == {}
# ---------------------------------------------------------------------------
# get_ntp_servers()
# ---------------------------------------------------------------------------
NTP_STATUS_RESPONSE = {
"peers": [
{"address": "pool.ntp.org", "state": "synced"},
{"address": "time.cloudflare.com", "state": "candidate"},
]
}
class TestGetNtpServers:
def test_entry_count(self, driver):
driver._get = lambda path: NTP_STATUS_RESPONSE
servers = driver.get_ntp_servers()
assert len(servers) == 2
def test_server_addresses(self, driver):
driver._get = lambda path: NTP_STATUS_RESPONSE
servers = driver.get_ntp_servers()
assert "pool.ntp.org" in servers
assert "time.cloudflare.com" in servers
def test_value_is_empty_dict(self, driver):
driver._get = lambda path: NTP_STATUS_RESPONSE
for v in driver.get_ntp_servers().values():
assert v == {}
def test_endpoint_failure_returns_empty(self, driver):
driver._get = lambda path: (_ for _ in ()).throw(Exception("service not running"))
assert driver.get_ntp_servers() == {}
def test_empty_peers(self, driver):
driver._get = lambda path: {"peers": []}
assert driver.get_ntp_servers() == {}
# ---------------------------------------------------------------------------
# get_vlans()
# ---------------------------------------------------------------------------
VLAN_SEARCH_RESPONSE = {
"rows": [
{"tag": "10", "vlanif": "em0_vlan10", "if": "em0", "descr": "Management", "pcp": "0"},
{"tag": "20", "vlanif": "em0_vlan20 [LAN]", "if": "em0", "descr": "", "pcp": "0"},
{"tag": "100", "vlanif": "em1_vlan100", "if": "em1", "descr": "Guest WiFi", "pcp": "0"},
],
"rowCount": 3,
"total": 3,
"current": 1,
}
class TestGetVlans:
def test_returns_all_vlans(self, driver):
driver._get = lambda path: VLAN_SEARCH_RESPONSE
assert len(driver.get_vlans()) == 3
def test_keyed_by_tag_string(self, driver):
driver._get = lambda path: VLAN_SEARCH_RESPONSE
vlans = driver.get_vlans()
assert "10" in vlans
assert "20" in vlans
assert "100" in vlans
def test_name_from_descr(self, driver):
driver._get = lambda path: VLAN_SEARCH_RESPONSE
assert driver.get_vlans()["10"]["name"] == "Management"
def test_name_falls_back_to_vlanif_when_no_descr(self, driver):
driver._get = lambda path: VLAN_SEARCH_RESPONSE
# tag 20 has no descr — should use stripped vlanif
assert driver.get_vlans()["20"]["name"] == "em0_vlan20"
def test_interface_in_list(self, driver):
driver._get = lambda path: VLAN_SEARCH_RESPONSE
assert driver.get_vlans()["10"]["interfaces"] == ["em0_vlan10"]
def test_bracket_annotation_stripped_from_vlanif(self, driver):
driver._get = lambda path: VLAN_SEARCH_RESPONSE
# "em0_vlan20 [LAN]" must be stored as "em0_vlan20"
assert driver.get_vlans()["20"]["interfaces"] == ["em0_vlan20"]
def test_required_keys_present(self, driver):
driver._get = lambda path: VLAN_SEARCH_RESPONSE
for vlan in driver.get_vlans().values():
assert "name" in vlan
assert "interfaces" in vlan
def test_empty_response_returns_empty_dict(self, driver):
driver._get = lambda path: {"rows": [], "rowCount": 0, "total": 0}
assert driver.get_vlans() == {}
def test_multiple_vlans_on_different_parents(self, driver):
driver._get = lambda path: VLAN_SEARCH_RESPONSE
vlans = driver.get_vlans()
assert vlans["100"]["interfaces"] == ["em1_vlan100"]
# ---------------------------------------------------------------------------
# get_bgp_neighbors()
# ---------------------------------------------------------------------------
BGP_CFG_RESPONSE = {
"bgp": {
"asnumber": "65000",
"routerid": "1.2.3.4",
"enabled": "1",
}
}
BGP_NEIGHBORS_RESPONSE = {
"response": {
"10.0.0.1": {
"remoteAs": 65001,
"localAs": 65000,
"nbrDesc": "upstream-peer",
"bgpState": "Established",
"bgpTimerUpMsec": 3723000,
"remoteRouterId": "10.0.0.1",
"adminShutdown": False,
"addressFamilyInfo": {
"ipv4Unicast": {
"sentPrefixCounter": 5,
"prefixReceivedCount": 20,
"acceptedPrefixCounter": 18,
}
},
},
"10.0.0.2": {
"remoteAs": 65002,
"localAs": 65000,
"nbrDesc": "",
"bgpState": "Active",
"bgpTimerUpMsec": 0,
"remoteRouterId": "",
"adminShutdown": True,
"addressFamilyInfo": {},
},
}
}
class TestGetBgpNeighbors:
def _fake_get(self, path):
if "diagnostics/bgpneighbors" in path:
return BGP_NEIGHBORS_RESPONSE
return BGP_CFG_RESPONSE
def test_returns_global_vrf(self, driver):
driver._get = self._fake_get
result = driver.get_bgp_neighbors()
assert "global" in result
def test_router_id(self, driver):
driver._get = self._fake_get
assert driver.get_bgp_neighbors()["global"]["router_id"] == "1.2.3.4"
def test_peer_count(self, driver):
driver._get = self._fake_get
peers = driver.get_bgp_neighbors()["global"]["peers"]
assert len(peers) == 2
def test_established_peer_is_up(self, driver):
driver._get = self._fake_get
peer = driver.get_bgp_neighbors()["global"]["peers"]["10.0.0.1"]
assert peer["is_up"] is True
def test_active_peer_is_not_up(self, driver):
driver._get = self._fake_get
peer = driver.get_bgp_neighbors()["global"]["peers"]["10.0.0.2"]
assert peer["is_up"] is False
def test_admin_shutdown_peer_is_disabled(self, driver):
driver._get = self._fake_get
peer = driver.get_bgp_neighbors()["global"]["peers"]["10.0.0.2"]
assert peer["is_enabled"] is False
def test_non_shutdown_peer_is_enabled(self, driver):
driver._get = self._fake_get
peer = driver.get_bgp_neighbors()["global"]["peers"]["10.0.0.1"]
assert peer["is_enabled"] is True
def test_uptime_established(self, driver):
driver._get = self._fake_get
peer = driver.get_bgp_neighbors()["global"]["peers"]["10.0.0.1"]
assert peer["uptime"] == 3723 # 3723000 ms → 3723 s
def test_uptime_not_established(self, driver):
driver._get = self._fake_get
peer = driver.get_bgp_neighbors()["global"]["peers"]["10.0.0.2"]
assert peer["uptime"] == -1
def test_remote_as(self, driver):
driver._get = self._fake_get
peer = driver.get_bgp_neighbors()["global"]["peers"]["10.0.0.1"]
assert peer["remote_as"] == 65001
def test_local_as(self, driver):
driver._get = self._fake_get
peer = driver.get_bgp_neighbors()["global"]["peers"]["10.0.0.1"]
assert peer["local_as"] == 65000
def test_remote_id(self, driver):
driver._get = self._fake_get
peer = driver.get_bgp_neighbors()["global"]["peers"]["10.0.0.1"]
assert peer["remote_id"] == "10.0.0.1"
def test_description(self, driver):
driver._get = self._fake_get
peer = driver.get_bgp_neighbors()["global"]["peers"]["10.0.0.1"]
assert peer["description"] == "upstream-peer"
def test_ipv4_prefix_counters(self, driver):
driver._get = self._fake_get
af = driver.get_bgp_neighbors()["global"]["peers"]["10.0.0.1"]["address_family"]["ipv4"]
assert af["sent_prefixes"] == 5
assert af["received_prefixes"] == 20
assert af["accepted_prefixes"] == 18
def test_no_af_info_falls_back_to_ipv4_minus_one(self, driver):
driver._get = self._fake_get
af = driver.get_bgp_neighbors()["global"]["peers"]["10.0.0.2"]["address_family"]
assert "ipv4" in af
assert af["ipv4"]["sent_prefixes"] == -1
def test_ipv6_af_populated_when_present(self, driver):
def fake_get(path):
if "diagnostics/bgpneighbors" in path:
return {
"response": {
"2001:db8::1": {
"remoteAs": 65010,
"localAs": 65000,
"nbrDesc": "",
"bgpState": "Established",
"bgpTimerUpMsec": 1000,
"remoteRouterId": "2001:db8::1",
"adminShutdown": False,
"addressFamilyInfo": {
"ipv6Unicast": {
"sentPrefixCounter": 3,
"prefixReceivedCount": 7,
"acceptedPrefixCounter": 7,
}
},
}
}
}
return BGP_CFG_RESPONSE
driver._get = fake_get
af = driver.get_bgp_neighbors()["global"]["peers"]["2001:db8::1"]["address_family"]
assert "ipv6" in af
assert af["ipv6"]["sent_prefixes"] == 3
def test_plugin_absent_returns_empty(self, driver):
driver._get = lambda path: (_ for _ in ()).throw(Exception("404"))
assert driver.get_bgp_neighbors() == {}
def test_frr_not_running_returns_empty(self, driver):
def fake_get(path):
if "diagnostics" in path:
return {"response": "error"} # non-dict response
return BGP_CFG_RESPONSE
driver._get = fake_get
assert driver.get_bgp_neighbors() == {}
def test_required_peer_keys(self, driver):
driver._get = self._fake_get
peer = driver.get_bgp_neighbors()["global"]["peers"]["10.0.0.1"]
for key in ("local_as", "remote_as", "remote_id", "is_up", "is_enabled",
"description", "uptime", "address_family"):
assert key in peer
# ---------------------------------------------------------------------------
# get_certificates()
#
# Field names verified 2026-07-15 against a live OPNsense 24.7 instance's
# actual POST /api/trust/cert/search response. Confirmed: valid_from/valid_to
# are Unix timestamps as strings (not a formatted date string), issuer comes
# from the resolved "%caref" label (not the raw "caref" ref-id), and rows
# also carry crt_payload/prv_payload/csr_payload -- get_certificates() must
# never surface those (private key material).
# ---------------------------------------------------------------------------
CERT_SEARCH_RESPONSE = {
"rows": [
{
"refid": "69d55db78963e",
"descr": "gw.home.example.com (ACME Client)",
"commonname": "gw.home.example.com",
"caref": "6a24988658ae6",
"%caref": "YR1 (ACME Client)",
"cert_type": "server_cert",
"in_use": "1",
"valid_from": "1780779726",
"valid_to": "1788555725",
"crt_payload": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n",
"prv_payload": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----\n",
},
{
"refid": "def456",
"descr": "Internal cert",
"commonname": "",
"caref": "",
"%caref": "",
"cert_type": "server_cert",
"in_use": "0",
"valid_from": "1780000000",
"valid_to": "1790000000",
},
]
}
class TestGetCertificates:
def test_returns_one_entry_per_row(self, driver):
driver._post = lambda path, data=None: CERT_SEARCH_RESPONSE
certs = driver.get_certificates()
assert len(certs) == 2
def test_calls_trust_cert_search(self, driver):
calls = []
driver._post = lambda path, data=None: calls.append(path) or CERT_SEARCH_RESPONSE
driver.get_certificates()
assert calls == ["/api/trust/cert/search"]
def test_maps_expected_fields(self, driver):
driver._post = lambda path, data=None: CERT_SEARCH_RESPONSE
cert = driver.get_certificates()[0]
assert cert["name"] == "gw.home.example.com"
assert cert["issuer"] == "YR1 (ACME Client)"
assert cert["valid_from"] == 1780779726
assert cert["valid_to"] == 1788555725
assert cert["in_use_by"] == 1
def test_falls_back_to_descr_when_commonname_empty(self, driver):
driver._post = lambda path, data=None: CERT_SEARCH_RESPONSE
cert = driver.get_certificates()[1]
assert cert["name"] == "Internal cert"
def test_missing_caref_defaults_to_empty_string(self, driver):
driver._post = lambda path, data=None: CERT_SEARCH_RESPONSE
cert = driver.get_certificates()[1]
assert cert["issuer"] == ""
def test_never_includes_private_key_or_cert_payload(self, driver):
driver._post = lambda path, data=None: CERT_SEARCH_RESPONSE
for cert in driver.get_certificates():
assert "prv_payload" not in cert
assert "crt_payload" not in cert
assert "csr_payload" not in cert
def test_no_rows_returns_empty_list(self, driver):
driver._post = lambda path, data=None: {"rows": []}
assert driver.get_certificates() == []
def test_api_error_returns_empty_list(self, driver):
driver._post = lambda path, data=None: (_ for _ in ()).throw(Exception("404"))
assert driver.get_certificates() == []
# ---------------------------------------------------------------------------
# _post()
# ---------------------------------------------------------------------------
class TestInternalPost:
def test_raises_when_no_session(self):
drv = OPNsenseDriver("host", "u", "p")
with pytest.raises(ConnectionClosedException):
drv._post("/api/routes/routes/reconfigure")
def test_calls_correct_url(self, driver):
driver.session.post.return_value = _make_json_response({"result": "ok"})
driver._post("/api/routes/routes/reconfigure")
driver.session.post.assert_called_once_with(
"https://opnsense.example.com/api/routes/routes/reconfigure",
json={},
timeout=60,
)
def test_sends_json_payload(self, driver):
driver.session.post.return_value = _make_json_response({"uuid": "abc-123"})
payload = {"route": {"network": "10.0.0.0/8", "gateway": "WAN_GW"}}
driver._post("/api/routes/routes/addroute", payload)
driver.session.post.assert_called_once_with(
"https://opnsense.example.com/api/routes/routes/addroute",
json=payload,
timeout=60,
)
def test_returns_parsed_json(self, driver):
driver.session.post.return_value = _make_json_response({"result": "saved"})
result = driver._post("/api/routes/routes/reconfigure")
assert result == {"result": "saved"}
# ---------------------------------------------------------------------------
# send_wake_on_lan()
# ---------------------------------------------------------------------------
class TestSendWakeOnLan:
def test_raises_value_error_without_interface(self, driver):
with pytest.raises(ValueError):
driver.send_wake_on_lan("AA:BB:CC:DD:EE:FF")
driver.session.post.assert_not_called()
def test_sends_correct_request(self, driver):
driver.session.post.return_value = _make_json_response({"status": ""})
driver.send_wake_on_lan("AA:BB:CC:DD:EE:FF", interface="lan")
driver.session.post.assert_called_once_with(
"https://opnsense.example.com/api/wol/wol/set",
json={"wake": {"interface": "lan", "mac": "AA:BB:CC:DD:EE:FF"}},
timeout=60,
)
def test_success_on_empty_status(self, driver):
# OPNsense's WolController returns {"status": trim(configd output)} on
# success — the underlying `wol` CLI tool typically prints nothing.
driver.session.post.return_value = _make_json_response({"status": ""})
result = driver.send_wake_on_lan("AA:BB:CC:DD:EE:FF", interface="lan")
assert result == {"success": True, "output": "Magic packet sent to AA:BB:CC:DD:EE:FF via lan"}
def test_returns_failure_on_error_status(self, driver):
driver.session.post.return_value = _make_json_response(
{"status": "error", "error_msg": "Incorrect IPv4 configuration on interface"}
)
result = driver.send_wake_on_lan("AA:BB:CC:DD:EE:FF", interface="opt1")
assert result == {"success": False, "output": "Incorrect IPv4 configuration on interface"}
def test_returns_failure_on_empty_response(self, driver):
# OPNsense's model validation (bad MAC/interface) silently returns {}
# with HTTP 200 rather than an error status.
driver.session.post.return_value = _make_json_response({})
result = driver.send_wake_on_lan("AA:BB:CC:DD:EE:FF", interface="lan")
assert result["success"] is False
def test_returns_failure_on_request_exception(self, driver):
# e.g. HTTP 404 — the os-wol plugin is not installed on this firewall.
driver.session.post.side_effect = Exception("404 Client Error: Not Found")
result = driver.send_wake_on_lan("AA:BB:CC:DD:EE:FF", interface="lan")
assert result["success"] is False
assert "404" in result["output"]
# ---------------------------------------------------------------------------
# load_merge_candidate()
# ---------------------------------------------------------------------------
VALID_ROUTES_CONFIG = json.dumps([
{"network": "10.0.0.0/8", "gateway": "WAN_GW", "descr": "internal"},
{"network": "0.0.0.0/0", "gateway": "WAN_GW", "descr": "default"},
])
class TestLoadMergeCandidate:
def test_accepts_valid_json_string(self, driver):
driver.load_merge_candidate(config=VALID_ROUTES_CONFIG)
assert driver._candidate_config is not None
assert len(driver._candidate_config) == 2
def test_parses_required_keys(self, driver):
driver.load_merge_candidate(config=VALID_ROUTES_CONFIG)
first = driver._candidate_config[0]
assert first["network"] == "10.0.0.0/8"
assert first["gateway"] == "WAN_GW"
def test_reads_from_file(self, driver, tmp_path):
cfg_file = tmp_path / "routes.json"
cfg_file.write_text(VALID_ROUTES_CONFIG)
driver.load_merge_candidate(filename=str(cfg_file))
assert len(driver._candidate_config) == 2
def test_raises_if_both_args_given(self, driver):
from napalm.base.exceptions import MergeConfigException
with pytest.raises(MergeConfigException):
driver.load_merge_candidate(filename="f.json", config="{}")
def test_raises_if_no_args_given(self, driver):
from napalm.base.exceptions import MergeConfigException
with pytest.raises(MergeConfigException):
driver.load_merge_candidate()
def test_raises_on_invalid_json(self, driver):
from napalm.base.exceptions import MergeConfigException
with pytest.raises(MergeConfigException, match="Invalid JSON"):
driver.load_merge_candidate(config="not json {{{")
def test_raises_if_not_a_list(self, driver):
from napalm.base.exceptions import MergeConfigException
with pytest.raises(MergeConfigException, match="array"):
driver.load_merge_candidate(config='{"network": "1.0.0.0/8"}')
def test_raises_if_route_missing_network(self, driver):
from napalm.base.exceptions import MergeConfigException
bad = json.dumps([{"gateway": "GW1"}])
with pytest.raises(MergeConfigException, match="'network'"):
driver.load_merge_candidate(config=bad)
def test_raises_if_route_missing_gateway(self, driver):
from napalm.base.exceptions import MergeConfigException
bad = json.dumps([{"network": "10.0.0.0/8"}])
with pytest.raises(MergeConfigException, match="'gateway'"):
driver.load_merge_candidate(config=bad)
def test_raises_on_missing_file(self, driver):
from napalm.base.exceptions import MergeConfigException
with pytest.raises(MergeConfigException, match="Cannot read"):
driver.load_merge_candidate(filename="/nonexistent/path/routes.json")
# ---------------------------------------------------------------------------
# compare_config()
# ---------------------------------------------------------------------------
SEARCH_ROUTE_RESPONSE = {
"rows": [
{"network": "192.168.1.0/24", "gateway": "LAN_GW", "descr": "lan", "disabled": "0"},
]
}
class TestCompareConfig:
def test_returns_empty_string_without_candidate(self, driver):
assert driver.compare_config() == ""
def test_returns_diff_string(self, driver):
driver._get = lambda path: SEARCH_ROUTE_RESPONSE
driver.load_merge_candidate(config=VALID_ROUTES_CONFIG)
diff = driver.compare_config()
assert "---" in diff
assert "+++" in diff
def test_diff_shows_added_routes(self, driver):
driver._get = lambda path: SEARCH_ROUTE_RESPONSE
driver.load_merge_candidate(config=VALID_ROUTES_CONFIG)
diff = driver.compare_config()
assert "WAN_GW" in diff
def test_empty_diff_when_config_matches(self, driver):
same_config = json.dumps([
{"network": "192.168.1.0/24", "gateway": "LAN_GW", "descr": "lan", "disabled": "0"}
])
driver._get = lambda path: SEARCH_ROUTE_RESPONSE
driver.load_merge_candidate(config=same_config)
diff = driver.compare_config()
assert diff == ""
# ---------------------------------------------------------------------------
# commit_config()
# ---------------------------------------------------------------------------
BACKUPS_RESPONSE = {
"items": [
{"id": "config-opnsense01-1234567890.xml", "time": "1234567890", "description": "before change"},
{"id": "config-opnsense01-1234567800.xml", "time": "1234567800", "description": "initial"},
]
}
class TestCommitConfig:
def test_raises_without_candidate(self, driver):
from napalm.base.exceptions import MergeConfigException
with pytest.raises(MergeConfigException, match="No candidate"):
driver.commit_config()
def test_posts_each_route_and_reconfigure(self, driver):
# _get for backup list + POST for 2 routes + POST reconfigure
driver._get = lambda path: BACKUPS_RESPONSE
driver.load_merge_candidate(config=VALID_ROUTES_CONFIG)
driver.session.post.return_value = _make_json_response({})
driver.commit_config()
assert driver.session.post.call_count == 3 # addroute×2 + reconfigure
def test_records_pre_commit_backup_id(self, driver):
driver._get = lambda path: BACKUPS_RESPONSE
driver.load_merge_candidate(config=VALID_ROUTES_CONFIG)
driver.session.post.return_value = _make_json_response({})
driver.commit_config()
assert driver._pre_commit_backup_id == "config-opnsense01-1234567890.xml"
def test_clears_candidate_after_commit(self, driver):
driver._get = lambda path: BACKUPS_RESPONSE
driver.load_merge_candidate(config=VALID_ROUTES_CONFIG)
driver.session.post.return_value = _make_json_response({})
driver.commit_config()
assert driver._candidate_config is None
def test_records_none_backup_when_no_backups_exist(self, driver):
driver._get = lambda path: {"items": []}
driver.load_merge_candidate(config=VALID_ROUTES_CONFIG)
driver.session.post.return_value = _make_json_response({})
driver.commit_config()
assert driver._pre_commit_backup_id is None
def test_raises_on_api_error(self, driver):
from napalm.base.exceptions import MergeConfigException
import requests as _req
driver._get = lambda path: BACKUPS_RESPONSE
driver.load_merge_candidate(config=VALID_ROUTES_CONFIG)
driver.session.post.side_effect = _req.exceptions.RequestException("timeout")
with pytest.raises(MergeConfigException, match="Failed to apply"):
driver.commit_config()
# ---------------------------------------------------------------------------
# discard_config()
# ---------------------------------------------------------------------------
class TestDiscardConfig:
def test_clears_candidate(self, driver):
driver.load_merge_candidate(config=VALID_ROUTES_CONFIG)
driver.discard_config()
assert driver._candidate_config is None
def test_idempotent_when_no_candidate(self, driver):
driver.discard_config() # must not raise
assert driver._candidate_config is None
# ---------------------------------------------------------------------------
# rollback()
# ---------------------------------------------------------------------------
class TestRollback:
def test_noop_when_no_backups_and_no_commit(self, driver):
driver._get = lambda path: {"items": []}
driver.rollback() # must not raise
driver.session.post.assert_not_called()
def test_uses_pre_commit_backup_id(self, driver):
driver._pre_commit_backup_id = "config-opnsense01-1234567890.xml"
driver.session.post.return_value = _make_json_response({"status": "ok"})
driver.rollback()
url = driver.session.post.call_args[0][0]
assert "config-opnsense01-1234567890.xml" in url
assert "revert_backup" in url
def test_falls_back_to_latest_backup_without_commit(self, driver):
driver._get = lambda path: BACKUPS_RESPONSE
driver.session.post.return_value = _make_json_response({"status": "ok"})
driver.rollback()
url = driver.session.post.call_args[0][0]
assert "config-opnsense01-1234567890.xml" in url
def test_clears_pre_commit_backup_id_after_rollback(self, driver):
driver._pre_commit_backup_id = "config-opnsense01-1234567890.xml"
driver.session.post.return_value = _make_json_response({})
driver.rollback()
assert driver._pre_commit_backup_id is None
def test_calls_revert_backup_exactly_once(self, driver):
driver._pre_commit_backup_id = "config-opnsense01-1234567890.xml"
driver.session.post.return_value = _make_json_response({})
driver.rollback()
assert driver.session.post.call_count == 1
# ---------------------------------------------------------------------------
# get_config() — candidate slot
# ---------------------------------------------------------------------------
class TestGetConfigCandidate:
def test_candidate_empty_without_staged_config(self, driver):
driver._get = lambda path: "<opnsense/>"
result = driver.get_config()
assert result["candidate"] == ""
def test_candidate_contains_staged_routes(self, driver):
driver._get = lambda path: "<opnsense/>"
driver.load_merge_candidate(config=VALID_ROUTES_CONFIG)
result = driver.get_config()
assert "WAN_GW" in result["candidate"]
def test_candidate_is_valid_json(self, driver):
driver._get = lambda path: "<opnsense/>"
driver.load_merge_candidate(config=VALID_ROUTES_CONFIG)
result = driver.get_config()
parsed = json.loads(result["candidate"])
assert isinstance(parsed, list)
# ---------------------------------------------------------------------------
# create_dhcp_reservation()
#
# Payload/response shapes below are taken verbatim from a live probe against
# a real OPNsense box running the Kea DHCPv4 (os-kea) plugin — searchSubnet,
# searchReservation, addReservation, delReservation, and service/reconfigure
# were all exercised live (including a real add + verify + delete cycle) to
# confirm the exact request/response schema before writing this driver
# method and these tests against it.
# ---------------------------------------------------------------------------
KEA_SUBNETS_RESPONSE = {
"rows": [
{"uuid": "82766878-c5ac-41f3-b7b0-e24d2419beb3", "subnet": "172.22.0.0/24"},
{"uuid": "6854cab3-ebb5-4031-b987-0edcc6723546", "subnet": "172.22.8.0/24"},
]
}
class TestCreateDhcpReservation:
def test_adds_new_reservation_when_none_exists(self, driver):
driver.session.get.return_value = _make_json_response(KEA_SUBNETS_RESPONSE)
driver.session.post.side_effect = [
_make_json_response({"rows": []}), # searchReservation — no match
_make_json_response({"result": "saved", "uuid": "new-uuid-123"}), # addReservation
_make_json_response({"status": "ok"}), # service/reconfigure
]
driver.create_dhcp_reservation(
mac="02:aa:bb:cc:dd:ee", ip="172.22.8.253", hostname="new-vm"
)
add_call = driver.session.post.call_args_list[1]
assert add_call.args[0] == "https://opnsense.example.com/api/kea/dhcpv4/addReservation"
payload = add_call.kwargs["json"]["reservation"]
assert payload["subnet"] == "6854cab3-ebb5-4031-b987-0edcc6723546"
assert payload["ip_address"] == "172.22.8.253"
assert payload["hw_address"] == "02:aa:bb:cc:dd:ee"
assert payload["hostname"] == "new-vm"
assert payload["description"] == "[netork]"
reconfigure_call = driver.session.post.call_args_list[2]
assert reconfigure_call.args[0] == "https://opnsense.example.com/api/kea/service/reconfigure"
def test_updates_existing_reservation_for_same_ip(self, driver):
driver.session.get.return_value = _make_json_response(KEA_SUBNETS_RESPONSE)
driver.session.post.side_effect = [
_make_json_response(
{"rows": [{"uuid": "existing-uuid-456", "ip_address": "172.22.8.253"}]}
),
_make_json_response({"result": "saved", "uuid": "existing-uuid-456"}),
_make_json_response({"status": "ok"}),
]
driver.create_dhcp_reservation(mac="02:aa:bb:cc:dd:ee", ip="172.22.8.253")
set_call = driver.session.post.call_args_list[1]
assert (
set_call.args[0]
== "https://opnsense.example.com/api/kea/dhcpv4/setReservation/existing-uuid-456"
)
def test_raises_when_ip_not_in_any_kea_subnet(self, driver):
driver.session.get.return_value = _make_json_response(KEA_SUBNETS_RESPONSE)
with pytest.raises(ValueError, match="No Kea-managed subnet"):
driver.create_dhcp_reservation(mac="02:aa:bb:cc:dd:ee", ip="10.99.99.99")
driver.session.post.assert_not_called()
def test_raises_when_kea_rejects_reservation(self, driver):
driver.session.get.return_value = _make_json_response(KEA_SUBNETS_RESPONSE)
driver.session.post.side_effect = [
_make_json_response({"rows": []}),
_make_json_response(
{
"result": "failed",
"validations": {"reservation.ip_address": "Address not in specified subnet"},
}
),
]
with pytest.raises(RuntimeError, match="Kea rejected"):
driver.create_dhcp_reservation(mac="02:aa:bb:cc:dd:ee", ip="172.22.8.253")
# reconfigure must NOT be called after a rejected reservation
assert driver.session.post.call_count == 2
def test_raises_when_kea_plugin_unavailable(self, driver):
driver.session.get.side_effect = Exception("404 Not Found")
with pytest.raises(RuntimeError, match="Kea DHCPv4 plugin unavailable"):
driver.create_dhcp_reservation(mac="02:aa:bb:cc:dd:ee", ip="172.22.8.253")
driver.session.post.assert_not_called()
# ---------------------------------------------------------------------------
# delete_dhcp_reservation_and_lease()
# ---------------------------------------------------------------------------
class TestDeleteDhcpReservationAndLease:
def test_deletes_existing_reservation_and_lease(self, driver):
driver.session.post.side_effect = [
_make_json_response(
{"rows": [{"uuid": "existing-uuid-456", "ip_address": "172.22.8.253"}]}
), # searchReservation — match
_make_json_response({"result": "deleted"}), # delReservation
_make_json_response({"status": "ok"}), # service/reconfigure
_make_json_response({"result": "deleted"}), # delLease
]
driver.session.get.return_value = _make_json_response(
{"rows": [{"address": "172.22.8.253", "hwaddr": "02:aa:bb:cc:dd:ee"}]}
)
result = driver.delete_dhcp_reservation_and_lease(
mac="02:aa:bb:cc:dd:ee", ip="172.22.8.253"
)
assert result == {
"reservation_found": True,
"reservation_deleted": True,
"lease_found": True,
"lease_deleted": True,
}
del_res_call = driver.session.post.call_args_list[1]
assert (
del_res_call.args[0]
== "https://opnsense.example.com/api/kea/dhcpv4/delReservation/existing-uuid-456"
)
reconfigure_call = driver.session.post.call_args_list[2]
assert reconfigure_call.args[0] == "https://opnsense.example.com/api/kea/service/reconfigure"
del_lease_call = driver.session.post.call_args_list[3]
# IP is a URL path segment, not a JSON body field — verified live
# against a real OPNsense instance; a {"ips": [ip]} body (the
# natural reading of the documented del_lease($ips=null) signature)
# returns {"status": "error", "message": "Missing lease IP parameter"}.
assert (
del_lease_call.args[0]
== "https://opnsense.example.com/api/kea/leases4/del_lease/172.22.8.253"
)
def test_noop_when_no_reservation_and_no_lease_found(self, driver):
driver.session.post.return_value = _make_json_response({"rows": []})
driver.session.get.return_value = _make_json_response({"rows": []})
result = driver.delete_dhcp_reservation_and_lease(
mac="02:aa:bb:cc:dd:ee", ip="172.22.8.253"
)
assert result == {
"reservation_found": False,
"reservation_deleted": False,
"lease_found": False,
"lease_deleted": False,
}
# Only searchReservation was called — no delReservation/reconfigure/delLease.
assert driver.session.post.call_count == 1
def test_reservation_delete_raises_when_kea_rejects(self, driver):
driver.session.post.side_effect = [
_make_json_response(
{"rows": [{"uuid": "existing-uuid-456", "ip_address": "172.22.8.253"}]}
),
_make_json_response({"result": "not found"}), # delReservation rejected
]
with pytest.raises(RuntimeError, match="Kea rejected reservation delete"):
driver.delete_dhcp_reservation_and_lease(mac="02:aa:bb:cc:dd:ee", ip="172.22.8.253")
# reconfigure must NOT be called after a rejected delete
assert driver.session.post.call_count == 2
def test_lease_delete_failure_is_non_fatal(self, driver):
driver.session.post.side_effect = [
_make_json_response(
{"rows": [{"uuid": "existing-uuid-456", "ip_address": "172.22.8.253"}]}
),
_make_json_response({"result": "deleted"}),
_make_json_response({"status": "ok"}),
]
driver.session.get.side_effect = Exception("connection reset")
result = driver.delete_dhcp_reservation_and_lease(
mac="02:aa:bb:cc:dd:ee", ip="172.22.8.253"
)
assert result["reservation_found"] is True
assert result["reservation_deleted"] is True
assert result["lease_found"] is False
assert result["lease_deleted"] is False
def test_raises_when_kea_plugin_unavailable(self, driver):
driver.session.post.side_effect = Exception("404 Not Found")
with pytest.raises(RuntimeError, match="Kea DHCPv4 plugin unavailable"):
driver.delete_dhcp_reservation_and_lease(mac="02:aa:bb:cc:dd:ee", ip="172.22.8.253")