"""Kea's HA peers and the firewall's configuration sync, as facts (netOrk #1125). The fixture is what gw-01 and gw-02 answered on 2026-10-16: Kea in hot standby (gw primary, gw-02 standby) and gw-01 pushing its configuration, Kea and Unbound included, to gw-02. """ from __future__ import annotations import json from pathlib import Path from unittest.mock import MagicMock import pytest from napalm_opnsense.ha import config_sync, dhcp_ha from napalm_opnsense.opnsense import OPNsenseDriver PAIR = json.loads((Path(__file__).parent / "fixtures" / "ha_pair.json").read_text()) @pytest.fixture def driver() -> OPNsenseDriver: d = OPNsenseDriver("gw-01", "user", "secret") d._session = MagicMock() return d def _answers(node: str): def _get(path: str): return { "/api/kea/dhcpv4/get": PAIR[node]["kea_dhcpv4_get"], "/api/kea/dhcpv4/searchPeer": PAIR[node]["kea_dhcpv4_searchPeer"], "/api/core/hasync/get": PAIR[node]["core_hasync_get"], }[path] return _get class TestDhcpHa: def test_reads_the_peers_with_their_roles(self, driver): driver._get = _answers("gw-01") ha = driver.get_dhcp_ha() assert ha["service"] == "kea-dhcp" assert ha["enabled"] is True assert ha["peers"] == [ {"name": "gw", "role": "primary", "url": "http://10.5.100.2:8001/"}, {"name": "gw-02", "role": "standby", "url": "http://10.5.100.3:8001/"}, ] def test_names_this_server_from_the_effective_value(self, driver): """`this_server_name` is empty when left at its default -- the hostname; the effective name is in its `%` twin.""" driver._get = _answers("gw-02") assert driver.get_dhcp_ha()["this_server"] == "gw-02" def test_an_explicit_server_name_wins(self): ha = dhcp_ha( { "dhcpv4": { "ha": {"enabled": "1", "this_server_name": "left", "%this_server_name": "x"} } }, {"rows": []}, ) assert ha["this_server"] == "left" def test_ha_switched_off(self): ha = dhcp_ha({"dhcpv4": {"ha": {"enabled": "0", "this_server_name": ""}}}, {"rows": []}) assert ha["enabled"] is False assert ha["peers"] == [] assert "this_server" not in ha def test_a_peer_without_url_has_no_url_key(self): ha = dhcp_ha( {"dhcpv4": {"ha": {"enabled": "1"}}}, {"rows": [{"name": "a", "role": "primary", "url": ""}]}, ) assert ha["peers"] == [{"name": "a", "role": "primary"}] def test_a_failed_read_raises_and_never_says_off(self, driver): driver._get = MagicMock(side_effect=RuntimeError("timeout")) with pytest.raises(RuntimeError): driver.get_dhcp_ha() def test_an_answer_without_the_ha_section_raises(self): with pytest.raises(ValueError): dhcp_ha({"dhcpv4": {}}, {"rows": []}) class TestConfigSync: def test_the_pushing_side_names_its_peer_and_services(self, driver): driver._get = _answers("gw-01") sync = driver.get_config_sync() assert sync["peer"] == "10.5.100.3" assert {"kea-dhcp", "unbound", "ntpd", "openssh", "wireguard"} <= set(sync["services"]) assert sync["services"] == sorted(sync["services"]) def test_items_without_a_service_stay_visible(self, driver): driver._get = _answers("gw-01") unmapped = driver.get_config_sync()["unmapped"] assert {"aliases", "rules", "virtualip"} <= set(unmapped) assert "kea" not in unmapped def test_only_selected_items_count(self, driver): driver._get = _answers("gw-01") sync = driver.get_config_sync() assert "dnsmasq" not in sync["services"] # dnsforwarder: not selected assert "cron" not in sync["unmapped"] and "cron" not in sync["services"] def test_the_receiving_side_pushes_to_no_one(self, driver): driver._get = _answers("gw-02") sync = driver.get_config_sync() assert sync == {"peer": None, "services": [], "unmapped": []} def test_without_a_peer_selected_items_are_not_pushed(self): """Ticked items without a target address push nothing.""" answer = { "hasync": {"synchronizetoip": "", "syncitems": {"kea": {"value": "Kea", "selected": 1}}} } assert config_sync(answer) == {"peer": None, "services": [], "unmapped": []} def test_a_failed_read_raises(self, driver): driver._get = MagicMock(side_effect=RuntimeError("timeout")) with pytest.raises(RuntimeError): driver.get_config_sync() def test_an_answer_without_the_section_raises(self): with pytest.raises(ValueError): config_sync({"something": {}})