fix: get_dhcp_leases raises when no lease source answered (#27)
CI / test (3.10) (push) Successful in 32s
CI / test (3.11) (push) Successful in 34s
CI / test (3.12) (push) Successful in 33s
CI / test (3.10) (pull_request) Successful in 32s
CI / test (3.11) (pull_request) Successful in 29s
CI / test (3.12) (pull_request) Successful in 34s

When Kea, ISC DHCP and the ARP fallback all failed, get_dhcp_leases()
returned [], which a caller cannot tell from a server with no leases. It now
raises a RuntimeError naming each failure, and returns [] only when a source
answered with nothing. An empty ARP table after both lease sources failed
counts as unread too, since ARP rows are no lease data.

Closes #27
This commit is contained in:
Christian Manivong committed 2026-10-21 01:01:52 +02:00
1 parent 6a91f43fc3
commit 5a6e573300
2 files changed
+135 -5

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
"""``get_dhcp_leases()``: an empty answer is only ever a real one (#27).
The getter tries Kea, then ISC DHCP, then falls back to the ARP table (rows
with ``state: "arp"``). A caller stores the answer as the server's lease set,
so ``[]`` must mean "a lease source answered and has no leases". When no lease
source answered and the ARP fallback gives nothing either, the getter raises:
an empty list there would wipe the leases the caller knows.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from napalm_opnsense.opnsense import OPNsenseDriver
KEA = "/api/kea/leases4/search"
ISC = "/api/dhcpv4/leases/searchlease"
ARP = "/api/diagnostics/interface/get_arp"
KEA_ROW = {"hwaddr": "AA:BB:CC:DD:EE:01", "address": "10.0.0.11", "hostname": "nas", "state": "0"}
ISC_ROW = {"mac": "aa:bb:cc:dd:ee:02", "address": "10.0.0.12", "ends": "1700000000"}
ARP_ROW = {"intf": "igb0", "mac": "AA:BB:CC:DD:EE:03", "ip": "10.0.0.13", "expires": "1200"}
def _driver(kea=None, isc=None, arp=None) -> OPNsenseDriver:
"""Each source answers its value, or raises it when it is an exception."""
def answer(value):
if isinstance(value, Exception):
raise value
return value
driver = OPNsenseDriver("gw-01", "user", "secret")
driver._get = MagicMock(side_effect=lambda path: answer({KEA: kea, ARP: arp}[path]))
driver._post = MagicMock(side_effect=lambda path, data=None: answer({ISC: isc}[path]))
return driver
def _macs(leases: list[dict]) -> list[str]:
return [lease["mac"] for lease in leases]
class TestALeaseSourceAnswers:
def test_kea_leases_come_first(self):
driver = _driver(kea={"rows": [KEA_ROW]}, isc=RuntimeError("404"), arp=RuntimeError("x"))
assert _macs(driver.get_dhcp_leases()) == ["aa:bb:cc:dd:ee:01"]
def test_isc_leases_when_kea_has_none(self):
driver = _driver(kea={"rows": []}, isc={"rows": [ISC_ROW]}, arp=RuntimeError("x"))
assert _macs(driver.get_dhcp_leases()) == ["aa:bb:cc:dd:ee:02"]
def test_isc_leases_when_kea_fails(self):
driver = _driver(kea=RuntimeError("404"), isc={"rows": [ISC_ROW]}, arp=RuntimeError("x"))
assert _macs(driver.get_dhcp_leases()) == ["aa:bb:cc:dd:ee:02"]
class TestTheArpFallback:
"""No source has leases: the ARP table stands in, marked ``state: "arp"`` (#1168)."""
def test_when_both_lease_sources_fail(self):
driver = _driver(kea=RuntimeError("404"), isc=RuntimeError("404"), arp=[ARP_ROW])
leases = driver.get_dhcp_leases()
assert leases == [
{
"mac": "aa:bb:cc:dd:ee:03",
"ip": "10.0.0.13",
"hostname": "",
"lease_end": 0,
"state": "arp",
}
]
def test_when_a_lease_source_answers_empty(self):
driver = _driver(kea={"rows": []}, isc=RuntimeError("404"), arp=[ARP_ROW])
assert [lease["state"] for lease in driver.get_dhcp_leases()] == ["arp"]
class TestAnEmptyAnswer:
"""[] only when Kea or ISC answered and has no leases."""
def test_kea_empty_and_no_arp_fallback(self):
driver = _driver(kea={"rows": []}, isc=RuntimeError("404"), arp=RuntimeError("timeout"))
assert driver.get_dhcp_leases() == []
def test_isc_empty_and_an_empty_arp_table(self):
driver = _driver(kea=RuntimeError("404"), isc={"rows": []}, arp=[])
assert driver.get_dhcp_leases() == []
class TestNothingAnswered:
"""No lease source answered and the fallback gave no rows: that is no answer."""
def test_all_three_fail(self):
driver = _driver(
kea=RuntimeError("kea down"), isc=RuntimeError("isc down"), arp=RuntimeError("arp down")
)
with pytest.raises(RuntimeError) as excinfo:
driver.get_dhcp_leases()
message = str(excinfo.value)
assert "kea down" in message
assert "isc down" in message
assert "arp down" in message
def test_lease_sources_fail_and_the_arp_table_is_empty(self):
driver = _driver(kea=RuntimeError("kea down"), isc=RuntimeError("isc down"), arp=[])
with pytest.raises(RuntimeError, match="kea down"):
driver.get_dhcp_leases()