summaryrefslogtreecommitdiff
path: root/tests/unit/modules
diff options
context:
space:
mode:
authoromnom62 <omnom62@outlook.com>2026-07-06 14:54:12 +1000
committerJohn Estabrook <jestabro@vyos.io>2026-08-21 14:02:19 -0500
commit45dc95873fd906c582fbbd5e6ca3838caf867399 (patch)
tree71b3e53e0080da60abf9fd2381705971dfe4d507 /tests/unit/modules
parent7a6b5e4f3a7a021cfa75faa7bf833741dfc09cff (diff)
downloadrest.vyos-45dc95873fd906c582fbbd5e6ca3838caf867399.tar.gz
rest.vyos-45dc95873fd906c582fbbd5e6ca3838caf867399.zip
T8989: wave4 vyos_command, dict_op refactorT8989_wave4
* T8989: vyos_command module * T8989: vyos_command module UAT and SIT * T8989: vyos_command changelog * T8989: vyos_command linter * T8989: vyos_config module * T8989: vyos_config module changelog * T8989: Wave 4 vyos_config module with integration and unit tests * T8323: vyos_system module * T8332: vyos_system SIT and UAT * T8323: vyos_vlan module * T8323: vyos_vlan module * T8323: vyos_vlan module SIT and UAT * T8323: vyos_system module * T8989: Wave 4 vyos_vlan reworked with dict_op engine * T8989: Fix dict_op single-value string list handling, add vyos_system integration tests * T8989: logging_global refactor * T8989: migrate ntp_global, logging_global, firewall_global to dict_op engine * T8989: vyos_nat module for REST API collection * T8989: vyos_nat module for REST API collection, linter fixes * T8989: vyos_ha module for REST API collection * T8989: vyos_ha module for REST API collection * T8989: vyos_ha module sanity and linter fixes * T8989: vyos_ha module sanity and linter fixes * T8989: vyos_ha module linter fixes * T8989: vyos.rest AI comment fixes * T8323: vyos_nat AI comment fixes * T8989 ai fixes * T8989: vyos_bgp_address_family dict_op * T8989: vyos_bgp_address_family vyos_bgp_global dict_op * T8989: dict_op refactor for firewall_*, nat, user * T8989: dict_op refactor for firewall_*, nat, user * T8989: dict_op refactor for ntp_global, ha * T8989: snmp_server dict_op refactor * T8989: snmp_server dict_op refactor * T8989: route_map dict_op refactor
Diffstat (limited to 'tests/unit/modules')
-rw-r--r--tests/unit/modules/base.py15
-rw-r--r--tests/unit/modules/test_vyos_bgp_address_family.py358
-rw-r--r--tests/unit/modules/test_vyos_bgp_global.py341
-rw-r--r--tests/unit/modules/test_vyos_command.py111
-rw-r--r--tests/unit/modules/test_vyos_config.py107
-rw-r--r--tests/unit/modules/test_vyos_facts.py8
-rw-r--r--tests/unit/modules/test_vyos_firewall_global.py380
-rw-r--r--tests/unit/modules/test_vyos_firewall_interfaces.py306
-rw-r--r--tests/unit/modules/test_vyos_firewall_rules.py345
-rw-r--r--tests/unit/modules/test_vyos_ha.py345
-rw-r--r--tests/unit/modules/test_vyos_logging_global.py416
-rw-r--r--tests/unit/modules/test_vyos_nat.py558
-rw-r--r--tests/unit/modules/test_vyos_ntp_global.py265
-rw-r--r--tests/unit/modules/test_vyos_route_maps.py506
-rw-r--r--tests/unit/modules/test_vyos_snmp_server.py644
-rw-r--r--tests/unit/modules/test_vyos_system.py93
-rw-r--r--tests/unit/modules/test_vyos_user.py278
-rw-r--r--tests/unit/modules/test_vyos_vlan.py116
18 files changed, 4005 insertions, 1187 deletions
diff --git a/tests/unit/modules/base.py b/tests/unit/modules/base.py
index 4d49fbb..a3e2eeb 100644
--- a/tests/unit/modules/base.py
+++ b/tests/unit/modules/base.py
@@ -13,12 +13,23 @@ import unittest
from unittest.mock import MagicMock # noqa: F401
+_fixture_cache = {}
+
+
def load_fixture(filename):
- """Load a JSON fixture file from tests/unit/fixtures/."""
+ """Load a fixture file from tests/unit/fixtures/. Results are cached."""
fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures")
path = os.path.join(fixtures_dir, filename)
+ if path in _fixture_cache:
+ return _fixture_cache[path]
with open(path) as f:
- return json.load(f)
+ data = f.read()
+ try:
+ data = json.loads(data)
+ except json.JSONDecodeError:
+ pass
+ _fixture_cache[path] = data
+ return data
class VyOSModuleTestCase(unittest.TestCase):
diff --git a/tests/unit/modules/test_vyos_bgp_address_family.py b/tests/unit/modules/test_vyos_bgp_address_family.py
index fc080af..a63dd56 100644
--- a/tests/unit/modules/test_vyos_bgp_address_family.py
+++ b/tests/unit/modules/test_vyos_bgp_address_family.py
@@ -4,25 +4,24 @@ from __future__ import absolute_import, division, print_function
__metaclass__ = type
-import json
-import os
import unittest
from unittest.mock import MagicMock
from ansible_collections.vyos.rest.plugins.modules.vyos_bgp_address_family import (
+ _device_to_argspec,
+ _global_af_from_device,
+ _global_af_to_device,
+ _neighbor_af_from_device,
+ _neighbor_af_to_device,
build_commands,
get_running_config,
)
-
-_BASE = ["protocols", "bgp"]
+from .base import load_fixture
-def load_fixture(filename):
- fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures")
- with open(os.path.join(fixtures_dir, filename)) as f:
- return json.load(f)
+_BASE = ["protocols", "bgp"]
class VyOSModuleTestCase(unittest.TestCase):
@@ -32,204 +31,233 @@ class VyOSModuleTestCase(unittest.TestCase):
self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
-class TestVyOSBgpAFGetRunning(VyOSModuleTestCase):
-
- def test_parses_as_number(self):
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result["as_number"], 65000)
-
- def test_parses_global_af_networks(self):
- result = get_running_config(self.mock_vyos)
- ipv4 = next(af for af in result["address_family"] if af["afi"] == "ipv4")
- prefixes = [n["prefix"] for n in ipv4["networks"]]
- self.assertIn("192.0.2.0/24", prefixes)
- self.assertIn("192.0.3.0/24", prefixes)
-
- def test_parses_global_af_redistribute(self):
- result = get_running_config(self.mock_vyos)
- ipv4 = next(af for af in result["address_family"] if af["afi"] == "ipv4")
- protos = [r["protocol"] for r in ipv4["redistribute"]]
- self.assertIn("connected", protos)
- connected = next(r for r in ipv4["redistribute"] if r["protocol"] == "connected")
- self.assertEqual(connected["metric"], 10)
-
- def test_parses_neighbor_af(self):
- result = get_running_config(self.mock_vyos)
- nb = next(n for n in result["neighbors"] if n["neighbor_address"] == "192.0.2.1")
- afis = [af["afi"] for af in nb["address_family"]]
- self.assertIn("ipv4", afis)
- self.assertIn("ipv6", afis)
- ipv4 = next(af for af in nb["address_family"] if af["afi"] == "ipv4")
- self.assertTrue(ipv4["nexthop_self"])
- self.assertTrue(ipv4["soft_reconfiguration"])
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_returns_raw_device_dict(self):
+ self.assertEqual(get_running_config(self.mock_vyos), self.fixture)
def test_empty_config(self):
- self.mock_vyos.get_config = MagicMock(return_value={})
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result, {})
+ self.mock_vyos.get_config = MagicMock(return_value=None)
+ self.assertEqual(get_running_config(self.mock_vyos), {})
-class TestVyOSBgpAFBuildCommands(unittest.TestCase):
+class TestNeighborAfToDevice(unittest.TestCase):
+ """The three genuine device-shape exceptions, individually, plus proof
+ that everything else is untouched by _autoclean alone."""
- def _have(self):
- return {
- "as_number": 65000,
- "address_family": [
+ def test_soft_reconfiguration_nests_under_inbound(self):
+ result = _neighbor_af_to_device([{"afi": "ipv4", "soft_reconfiguration": True}])
+ self.assertEqual(result, {"ipv4-unicast": {"soft_reconfiguration": {"inbound": {}}}})
+
+ def test_allowas_in_wraps_under_number(self):
+ result = _neighbor_af_to_device([{"afi": "ipv4", "allowas_in": 3}])
+ self.assertEqual(result, {"ipv4-unicast": {"allowas_in": {"number": 3}}})
+
+ def test_capability_orf_value_becomes_dict_key(self):
+ result = _neighbor_af_to_device([{"afi": "ipv4", "capability": {"orf": "send"}}])
+ self.assertEqual(
+ result,
+ {"ipv4-unicast": {"capability": {"orf": {"prefix-list": {"send": {}}}}}},
+ )
+
+ def test_generic_options_pass_through_autoclean_only(self):
+ result = _neighbor_af_to_device(
+ [
{
"afi": "ipv4",
- "networks": [{"prefix": "192.0.2.0/24"}],
- "redistribute": [{"protocol": "connected", "metric": 10}],
+ "nexthop_self": True,
+ "weight": 50,
+ "route_map": {"import": "RM-IN"},
+ "distribute_list": {"import": 10, "export": 20},
+ "attribute_unchanged": {"as_path": True, "next_hop": False},
},
],
- "neighbors": [
- {
- "neighbor_address": "192.0.2.1",
- "address_family": [
- {"afi": "ipv4", "soft_reconfiguration": True, "nexthop_self": True},
- ],
+ )
+ self.assertEqual(
+ result,
+ {
+ "ipv4-unicast": {
+ "nexthop_self": {},
+ "weight": 50,
+ "route_map": {"import": "RM-IN"},
+ "distribute_list": {"import": 10, "export": 20},
+ "attribute_unchanged": {"as_path": {}},
},
- ],
- }
+ },
+ )
- def test_deleted_removes_global_af(self):
- cmds = build_commands({"as_number": 65000}, self._have(), "deleted")
- self.assertIn(("delete", _BASE + ["address-family"]), cmds)
+ def test_no_options_is_bare_presence(self):
+ self.assertEqual(_neighbor_af_to_device([{"afi": "ipv4"}]), {"ipv4-unicast": {}})
- def test_deleted_removes_neighbor_af(self):
- cmds = build_commands({"as_number": 65000}, self._have(), "deleted")
- self.assertIn(
- ("delete", _BASE + ["neighbor", "192.0.2.1", "address-family"]),
- cmds,
+
+class TestNeighborAfFromDevice(unittest.TestCase):
+ def test_soft_reconfiguration_from_nested_inbound(self):
+ result = _neighbor_af_from_device(
+ {"ipv4-unicast": {"soft-reconfiguration": {"inbound": {}}}},
)
+ self.assertEqual(result, [{"afi": "ipv4", "soft_reconfiguration": True}])
- def test_merged_network(self):
- config = {
- "as_number": 65000,
- "address_family": [
- {"afi": "ipv4", "networks": [{"prefix": "192.0.5.0/24"}]},
- ],
- }
- cmds = build_commands(config, {}, "merged")
- self.assertIn(
- ("set", _BASE + ["address-family", "ipv4-unicast", "network", "192.0.5.0/24"]),
- cmds,
+ def test_allowas_in_from_number_wrapper(self):
+ result = _neighbor_af_from_device({"ipv4-unicast": {"allowas-in": {"number": "3"}}})
+ self.assertEqual(result, [{"afi": "ipv4", "allowas_in": 3}])
+
+ def test_allowas_in_bare_presence_defaults_to_one(self):
+ result = _neighbor_af_from_device({"ipv4-unicast": {"allowas-in": {}}})
+ self.assertEqual(result, [{"afi": "ipv4", "allowas_in": 1}])
+
+ def test_capability_orf_receive_and_send(self):
+ r1 = _neighbor_af_from_device(
+ {"ipv4-unicast": {"capability": {"orf": {"prefix-list": {"receive": {}}}}}},
)
+ self.assertEqual(r1[0]["capability"], {"orf": "receive"})
+ r2 = _neighbor_af_from_device(
+ {"ipv4-unicast": {"capability": {"orf": {"prefix-list": {"send": {}}}}}},
+ )
+ self.assertEqual(r2[0]["capability"], {"orf": "send"})
- def test_merged_redistribute(self):
- config = {
- "as_number": 65000,
- "address_family": [
- {"afi": "ipv4", "redistribute": [{"protocol": "connected", "metric": 10}]},
- ],
- }
- cmds = build_commands(config, {}, "merged")
- self.assertIn(
- ("set", _BASE + ["address-family", "ipv4-unicast", "redistribute", "connected"]),
- cmds,
+ def test_ints_cast_via_argspec_not_hardcoded_list(self):
+ result = _neighbor_af_from_device(
+ {
+ "ipv4-unicast": {
+ "maximum-prefix": "100",
+ "weight": "50",
+ "distribute-list": {"import": "10", "export": "20"},
+ },
+ },
)
+ entry = result[0]
+ self.assertEqual(entry["maximum_prefix"], 100)
+ self.assertEqual(entry["weight"], 50)
+ self.assertEqual(entry["distribute_list"], {"import": 10, "export": 20})
+
+
+class TestGlobalAfToDeviceFromDevice(unittest.TestCase):
+ def test_networks_keyed_by_prefix(self):
+ result = _global_af_to_device(
+ [{"afi": "ipv4", "networks": [{"prefix": "192.0.2.0/24", "backdoor": True}]}],
+ )
+ self.assertEqual(
+ result,
+ {"ipv4-unicast": {"network": {"192.0.2.0/24": {"backdoor": {}}}}},
+ )
+
+ def test_redistribute_keyed_by_protocol(self):
+ result = _global_af_to_device(
+ [{"afi": "ipv4", "redistribute": [{"protocol": "connected", "metric": 10}]}],
+ )
+ self.assertEqual(
+ result,
+ {"ipv4-unicast": {"redistribute": {"connected": {"metric": 10}}}},
+ )
+
+ def test_from_device_metric_cast_via_argspec(self):
+ result = _global_af_from_device(
+ {"ipv4-unicast": {"redistribute": {"connected": {"metric": "10"}}}},
+ )
+ self.assertEqual(result[0]["redistribute"], [{"protocol": "connected", "metric": 10}])
+
+
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_as_number(self):
+ self.assertEqual(_device_to_argspec(self.fixture)["as_number"], 65000)
+
+ def test_global_networks_and_redistribute(self):
+ af = _device_to_argspec(self.fixture)["address_family"][0]
+ prefixes = {n["prefix"]: n for n in af["networks"]}
+ self.assertEqual(
+ prefixes["192.0.3.0/24"],
+ {"prefix": "192.0.3.0/24", "route_map": "RM-OUT", "backdoor": True},
+ )
+ protocols = {r["protocol"]: r for r in af["redistribute"]}
+ self.assertEqual(protocols["connected"]["metric"], 10)
+
+ def test_neighbor_wired_options(self):
+ nb = _device_to_argspec(self.fixture)["neighbors"][0]
+ ipv4 = next(af for af in nb["address_family"] if af["afi"] == "ipv4")
+ self.assertTrue(ipv4["nexthop_self"])
+ self.assertTrue(ipv4["soft_reconfiguration"])
+ self.assertEqual(ipv4["attribute_unchanged"], {"as_path": True, "med": True})
+ self.assertEqual(ipv4["capability"], {"orf": "receive"})
+ self.assertEqual(ipv4["distribute_list"], {"import": 10, "export": 20})
+
+ def test_empty_config(self):
+ self.assertEqual(_device_to_argspec({}), {})
+ self.assertEqual(_device_to_argspec(None), {})
+
+
+class TestBuildCommands(VyOSModuleTestCase):
+ """End-to-end, exactly as main() calls it."""
+
+ def test_merged_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "merged"), [])
+
+ def test_replaced_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "replaced"), [])
+
+ def test_merged_new_option(self):
+ have = _device_to_argspec(self.fixture)
+ have["neighbors"][0]["address_family"][0]["weight"] = 200
+ cmds = build_commands(have, self.fixture, "merged")
self.assertIn(
(
"set",
_BASE
- + [
- "address-family",
- "ipv4-unicast",
- "redistribute",
- "connected",
- "metric",
- "10",
- ],
+ + ["neighbor", "192.0.2.1", "address-family", "ipv4-unicast", "weight", "200"],
),
cmds,
)
- def test_merged_neighbor_soft_reconfig(self):
- config = {
- "as_number": 65000,
- "neighbors": [
- {
- "neighbor_address": "192.0.2.1",
- "address_family": [
- {"afi": "ipv4", "soft_reconfiguration": True},
- ],
- },
- ],
- }
- cmds = build_commands(config, {}, "merged")
+ def test_replaced_never_touches_neighbor_siblings(self):
+ """Regression test: dict_op is scoped strictly to each neighbor's
+ address-family subtree, never the whole neighbor.<addr> entry, so
+ fields owned by other modules (remote-as, timers, ...) are safe."""
+ cmds = build_commands({"as_number": 65000}, self.fixture, "replaced")
+ self.assertTrue(all("remote-as" not in c[1] for c in cmds))
self.assertIn(
- (
- "set",
- _BASE
- + [
- "neighbor",
- "192.0.2.1",
- "address-family",
- "ipv4-unicast",
- "soft-reconfiguration",
- "inbound",
- ],
- ),
+ ("delete", _BASE + ["neighbor", "192.0.2.1", "address-family", "ipv4-unicast"]),
+ cmds,
+ )
+ self.assertIn(
+ ("delete", _BASE + ["neighbor", "192.0.2.1", "address-family", "ipv6-unicast"]),
cmds,
)
- def test_merged_idempotent(self):
- have = self._have()
+ def test_deleted_scoped_to_address_family_only(self):
+ cmds = build_commands({}, self.fixture, "deleted")
+ self.assertIn(("delete", _BASE + ["address-family"]), cmds)
+ self.assertIn(
+ ("delete", _BASE + ["neighbor", "192.0.2.1", "address-family"]),
+ cmds,
+ )
+ self.assertTrue(all(c[1] != _BASE + ["neighbor", "192.0.2.1"] for c in cmds))
+
+ def test_normalize_have_prevents_char_iteration_bug(self):
+ """A single-child tag node collapsed to a bare string by the
+ device must not be iterated character-by-character."""
+ raw_have = {"address-family": {"ipv4-unicast": {"network": "192.0.2.0/24"}}}
config = {
"as_number": 65000,
- "address_family": [
- {
- "afi": "ipv4",
- "networks": [{"prefix": "192.0.2.0/24"}],
- "redistribute": [{"protocol": "connected", "metric": 10}],
- },
- ],
- "neighbors": [
- {
- "neighbor_address": "192.0.2.1",
- "address_family": [
- {"afi": "ipv4", "soft_reconfiguration": True, "nexthop_self": True},
- ],
- },
- ],
+ "address_family": [{"afi": "ipv4", "networks": [{"prefix": "192.0.2.0/24"}]}],
}
- cmds = build_commands(config, have, "merged")
- self.assertEqual(cmds, [])
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
- def test_replaced_idempotent(self):
- have = self._have()
+ def test_fresh_merged_add(self):
config = {
"as_number": 65000,
- "address_family": [
- {
- "afi": "ipv4",
- "networks": [{"prefix": "192.0.2.0/24"}],
- "redistribute": [{"protocol": "connected", "metric": 10}],
- },
- ],
"neighbors": [
{
- "neighbor_address": "192.0.2.1",
- "address_family": [
- {"afi": "ipv4", "soft_reconfiguration": True, "nexthop_self": True},
- ],
+ "neighbor_address": "10.0.0.1",
+ "address_family": [{"afi": "ipv4", "weight": 200}],
},
],
}
- cmds = build_commands(config, have, "replaced")
- self.assertEqual(cmds, [])
-
- def test_replaced_rebuilds_on_change(self):
- have = self._have()
- config = {
- "as_number": 65000,
- "address_family": [
- {"afi": "ipv4", "networks": [{"prefix": "192.0.9.0/24"}]},
- ],
- }
- cmds = build_commands(config, have, "replaced")
- self.assertIn(("delete", _BASE + ["address-family"]), cmds)
+ cmds = build_commands(config, {}, "merged")
self.assertIn(
- ("set", _BASE + ["address-family", "ipv4-unicast", "network", "192.0.9.0/24"]),
+ (
+ "set",
+ _BASE + ["neighbor", "10.0.0.1", "address-family", "ipv4-unicast", "weight", "200"],
+ ),
cmds,
)
diff --git a/tests/unit/modules/test_vyos_bgp_global.py b/tests/unit/modules/test_vyos_bgp_global.py
index f91516b..118cea8 100644
--- a/tests/unit/modules/test_vyos_bgp_global.py
+++ b/tests/unit/modules/test_vyos_bgp_global.py
@@ -4,25 +4,25 @@ from __future__ import absolute_import, division, print_function
__metaclass__ = type
-import json
-import os
import unittest
from unittest.mock import MagicMock
from ansible_collections.vyos.rest.plugins.modules.vyos_bgp_global import (
+ _device_to_argspec,
+ _neighbors_from_device,
+ _neighbors_to_device,
+ _peer_groups_from_device,
+ _peer_groups_to_device,
+ _want_to_device,
build_commands,
get_running_config,
)
-
-_BASE = ["protocols", "bgp"]
+from .base import load_fixture
-def load_fixture(filename):
- fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures")
- with open(os.path.join(fixtures_dir, filename)) as f:
- return json.load(f)
+_BASE = ["protocols", "bgp"]
class VyOSModuleTestCase(unittest.TestCase):
@@ -32,156 +32,253 @@ class VyOSModuleTestCase(unittest.TestCase):
self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
-class TestVyOSBgpGlobalGetRunning(VyOSModuleTestCase):
-
- def test_parses_as_number(self):
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result["as_number"], 65000)
-
- def test_parses_parameters(self):
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result["parameters"]["router_id"], "192.0.1.1")
- self.assertTrue(result["parameters"]["log_neighbor_changes"])
-
- def test_parses_neighbors(self):
- result = get_running_config(self.mock_vyos)
- nb_addrs = [n["neighbor_address"] for n in result["neighbors"]]
- self.assertIn("192.0.2.1", nb_addrs)
- self.assertIn("192.0.2.2", nb_addrs)
- nb1 = next(n for n in result["neighbors"] if n["neighbor_address"] == "192.0.2.1")
- self.assertEqual(nb1["remote_as"], 65001)
- self.assertEqual(nb1["description"], "peer1")
- self.assertEqual(nb1["timers"]["holdtime"], 30)
- self.assertEqual(nb1["timers"]["keepalive"], 10)
- nb2 = next(n for n in result["neighbors"] if n["neighbor_address"] == "192.0.2.2")
- self.assertEqual(nb2["ebgp_multihop"], 2)
- self.assertEqual(nb2["update_source"], "eth0")
-
- def test_parses_peer_groups(self):
- result = get_running_config(self.mock_vyos)
- self.assertEqual(len(result["peer_groups"]), 1)
- self.assertEqual(result["peer_groups"][0]["peer_group"], "PG1")
- self.assertEqual(result["peer_groups"][0]["remote_as"], 65003)
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_returns_raw_device_dict(self):
+ self.assertEqual(get_running_config(self.mock_vyos), self.fixture)
def test_empty_config(self):
- self.mock_vyos.get_config = MagicMock(return_value={})
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result, {})
+ self.mock_vyos.get_config = MagicMock(return_value=None)
+ self.assertEqual(get_running_config(self.mock_vyos), {})
-class TestVyOSBgpGlobalBuildCommands(unittest.TestCase):
+class TestNeighborsToDeviceFromDevice(unittest.TestCase):
+ def test_bare_neighbor_is_presence(self):
+ self.assertEqual(
+ _neighbors_to_device([{"neighbor_address": "192.0.2.1"}]),
+ {"192.0.2.1": {}},
+ )
- def _have(self):
- return {
- "as_number": 65000,
- "parameters": {"router_id": "192.0.1.1"},
- "neighbors": [
+ def test_full_neighbor(self):
+ result = _neighbors_to_device(
+ [
{
"neighbor_address": "192.0.2.1",
"remote_as": 65001,
"description": "peer1",
+ "shutdown": True,
+ "timers": {"holdtime": 30, "keepalive": 10},
},
],
- "peer_groups": [{"peer_group": "PG1", "remote_as": 65003}],
- }
+ )
+ self.assertEqual(
+ result,
+ {
+ "192.0.2.1": {
+ "remote_as": 65001,
+ "description": "peer1",
+ "shutdown": {},
+ "timers": {"holdtime": 30, "keepalive": 10},
+ },
+ },
+ )
- def test_deleted_with_have(self):
- cmds = build_commands({}, self._have(), "deleted")
- self.assertEqual(cmds, [("delete", _BASE)])
+ def test_from_device_ints_cast_via_argspec(self):
+ result = _neighbors_from_device(
+ {
+ "192.0.2.1": {
+ "remote-as": "65001",
+ "ebgp-multihop": "2",
+ "timers": {"holdtime": "30", "keepalive": "10"},
+ },
+ },
+ )
+ entry = result[0]
+ self.assertEqual(entry["remote_as"], 65001)
+ self.assertEqual(entry["ebgp_multihop"], 2)
+ self.assertEqual(entry["timers"], {"holdtime": 30, "keepalive": 10})
+
+ def test_from_device_foreign_address_family_never_surfaces(self):
+ """Regression test: a neighbor's address-family subtree (owned by
+ vyos_bgp_address_family) must never appear in this module's have/
+ gathered output."""
+ result = _neighbors_from_device(
+ {
+ "192.0.2.1": {
+ "remote-as": "65001",
+ "address-family": {"ipv4-unicast": {"nexthop-self": {}}},
+ },
+ },
+ )
+ entry = result[0]
+ self.assertEqual(entry["remote_as"], 65001)
+ self.assertNotIn("address_family", entry)
- def test_deleted_without_have(self):
- cmds = build_commands({}, {}, "deleted")
- self.assertEqual(cmds, [])
- def test_merged_as_number(self):
- config = {"as_number": 65000}
- cmds = build_commands(config, {}, "merged")
- self.assertIn(("set", _BASE + ["system-as", "65000"]), cmds)
+class TestPeerGroupsToDeviceFromDevice(unittest.TestCase):
+ def test_bare_peer_group_is_presence(self):
+ self.assertEqual(_peer_groups_to_device([{"peer_group": "PG1"}]), {"PG1": {}})
- def test_merged_router_id(self):
- config = {"as_number": 65000, "parameters": {"router_id": "192.0.1.1"}}
- cmds = build_commands(config, {}, "merged")
- self.assertIn(("set", _BASE + ["parameters", "router-id", "192.0.1.1"]), cmds)
+ def test_full_peer_group(self):
+ result = _peer_groups_to_device(
+ [{"peer_group": "PG1", "remote_as": 65002, "timers": {"holdtime": 30}}],
+ )
+ self.assertEqual(
+ result,
+ {"PG1": {"remote_as": 65002, "timers": {"holdtime": 30}}},
+ )
- def test_merged_neighbor(self):
- config = {
- "as_number": 65000,
- "neighbors": [
- {"neighbor_address": "192.0.2.1", "remote_as": 65001},
- ],
- }
- cmds = build_commands(config, {}, "merged")
- self.assertIn(("set", _BASE + ["neighbor", "192.0.2.1", "remote-as", "65001"]), cmds)
+ def test_from_device_cast(self):
+ result = _peer_groups_from_device({"PG1": {"remote-as": "65002"}})
+ self.assertEqual(result, [{"peer_group": "PG1", "remote_as": 65002}])
- def test_merged_neighbor_timers(self):
+
+class TestWantToDevice(unittest.TestCase):
+ def test_empty(self):
+ self.assertEqual(_want_to_device({}), {})
+ self.assertEqual(_want_to_device(None), {})
+
+ def test_confederation_peers_list_passes_through(self):
+ result = _want_to_device(
+ {
+ "as_number": 65000,
+ "parameters": {"confederation": {"identifier": 100, "peers": [65001, 65002]}},
+ },
+ )
+ self.assertEqual(
+ result,
+ {
+ "system_as": 65000,
+ "parameters": {"confederation": {"identifier": 100, "peers": [65001, 65002]}},
+ },
+ )
+
+ def test_full_config(self):
config = {
"as_number": 65000,
- "neighbors": [
- {
- "neighbor_address": "192.0.2.1",
- "remote_as": 65001,
- "timers": {"holdtime": 30, "keepalive": 10},
- },
- ],
+ "parameters": {"router_id": "192.0.1.1", "graceful_restart": True},
+ "neighbors": [{"neighbor_address": "192.0.2.1", "remote_as": 65001}],
+ "peer_groups": [{"peer_group": "PG1", "remote_as": 65002}],
}
- cmds = build_commands(config, {}, "merged")
+ result = _want_to_device(config)
+ self.assertEqual(
+ result,
+ {
+ "system_as": 65000,
+ "parameters": {"router_id": "192.0.1.1", "graceful_restart": {}},
+ "neighbor": {"192.0.2.1": {"remote_as": 65001}},
+ "peer_group": {"PG1": {"remote_as": 65002}},
+ },
+ )
+
+
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_as_number_and_parameters(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(have["as_number"], 65000)
+ self.assertEqual(have["parameters"]["router_id"], "192.0.1.1")
+ self.assertEqual(
+ have["parameters"]["confederation"],
+ {"identifier": 100, "peers": [65001, 65002]},
+ )
+
+ def test_neighbor_and_peer_group(self):
+ have = _device_to_argspec(self.fixture)
+ nb = next(n for n in have["neighbors"] if n["neighbor_address"] == "192.0.2.1")
+ self.assertEqual(nb["remote_as"], 65001)
+ self.assertEqual(nb["timers"], {"holdtime": 30, "keepalive": 10})
+ self.assertNotIn("address_family", nb)
+ pg = have["peer_groups"][0]
+ self.assertEqual(pg["peer_group"], "PG1")
+ self.assertEqual(pg["remote_as"], 65003)
+
+ def test_empty_config(self):
+ self.assertEqual(_device_to_argspec({}), {})
+ self.assertEqual(_device_to_argspec(None), {})
+
+
+class TestBuildCommands(VyOSModuleTestCase):
+ def test_merged_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "merged"), [])
+
+ def test_replaced_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "replaced"), [])
+
+ def test_replaced_purges_extra_confederation_peer(self):
+ """Regression test: dict_op's purge mode originally had no
+ handling for list-valued leaves at all (only dicts), so removing
+ a peer from confederation.peers under 'replaced' silently did
+ nothing. Fixed centrally in dict_op itself."""
+ have = _device_to_argspec(self.fixture)
+ have["parameters"]["confederation"]["peers"] = [65001]
+ cmds = build_commands(have, self.fixture, "replaced")
self.assertIn(
- ("set", _BASE + ["neighbor", "192.0.2.1", "timers", "holdtime", "30"]),
+ ("delete", _BASE + ["parameters", "confederation", "peers", "65002"]),
cmds,
)
+
+ def test_merged_new_neighbor_field(self):
+ have = _device_to_argspec(self.fixture)
+ have["neighbors"][0]["local_as"] = 65099
+ cmds = build_commands(have, self.fixture, "merged")
self.assertIn(
- ("set", _BASE + ["neighbor", "192.0.2.1", "timers", "keepalive", "10"]),
+ ("set", _BASE + ["neighbor", "192.0.2.1", "local-as", "65099"]),
cmds,
)
- def test_merged_idempotent(self):
- have = self._have()
- config = {
- "as_number": 65000,
- "parameters": {"router_id": "192.0.1.1"},
- "neighbors": [
- {
- "neighbor_address": "192.0.2.1",
- "remote_as": 65001,
- "description": "peer1",
- },
- ],
- "peer_groups": [{"peer_group": "PG1", "remote_as": 65003}],
- }
- cmds = build_commands(config, have, "merged")
+ def test_replaced_never_touches_address_family(self):
+ """Regression test: this module shares protocols.bgp with
+ vyos_bgp_address_family; replaced/deleted must never purge or
+ delete that sibling module's address-family subtree."""
+ cmds = build_commands({"as_number": 65000}, self.fixture, "replaced")
+ self.assertTrue(all("address-family" not in c[1] for c in cmds))
+
+ def test_deleted_removes_atomically_not_scoped(self):
+ """Regression test for the real device-model bug: VyOS rejects any
+ commit that removes system-as while other protocols.bgp content
+ (including a neighbor's address-family, owned by
+ vyos_bgp_address_family) still exists. A scoped/incremental
+ deletion here would leave an invalid intermediate state and hard-
+ fail at commit time -- deleted must delete the whole tree in one
+ atomic command whenever system-as is present."""
+ cmds = build_commands({}, self.fixture, "deleted")
+ self.assertEqual(cmds, [("delete", _BASE)])
+
+ def test_deleted_with_no_system_as_is_a_noop(self):
+ cmds = build_commands({}, {}, "deleted")
self.assertEqual(cmds, [])
- def test_replaced_idempotent(self):
- have = self._have()
- config = {
- "as_number": 65000,
- "parameters": {"router_id": "192.0.1.1"},
- "neighbors": [
- {
- "neighbor_address": "192.0.2.1",
- "remote_as": 65001,
- "description": "peer1",
- },
- ],
- "peer_groups": [{"peer_group": "PG1", "remote_as": 65003}],
- }
- cmds = build_commands(config, have, "replaced")
+ def test_replaced_without_as_number_also_nukes_atomically(self):
+ """The same VyOS constraint applies to 'replaced' whenever the new
+ desired state omits as_number -- not just 'deleted'."""
+ cmds = build_commands({"neighbors": []}, self.fixture, "replaced")
+ self.assertEqual(cmds, [("delete", _BASE)])
+
+ def test_merged_with_empty_config_is_a_safe_noop(self):
+ """Regression test: merged must NEVER trigger the nuke short-
+ circuit just because as_number was omitted -- an omitted config
+ for merged means "nothing to change", not "delete everything"."""
+ cmds = build_commands({}, self.fixture, "merged")
+ self.assertEqual(cmds, [])
+
+ def test_replaced_keeping_as_number_still_scopes_normally(self):
+ """When as_number is retained, replaced must still use the normal
+ scoped purge/set flow, not the atomic nuke."""
+ have = _device_to_argspec(self.fixture)
+ cmds = build_commands(have, self.fixture, "replaced")
self.assertEqual(cmds, [])
+ self.assertNotEqual(cmds, [("delete", _BASE)])
- def test_replaced_rebuilds_on_change(self):
- have = self._have()
- config = {"as_number": 65000, "parameters": {"router_id": "192.0.1.2"}}
- cmds = build_commands(config, have, "replaced")
- self.assertEqual(cmds[0], ("delete", _BASE))
- self.assertIn(("set", _BASE + ["parameters", "router-id", "192.0.1.2"]), cmds)
+ def test_collapsed_single_neighbor_no_char_iteration_bug(self):
+ """A neighbor tag node collapsed to a bare address string by the
+ device (single neighbor, otherwise unconfigured) must not be
+ iterated character-by-character."""
+ raw_have = {"system-as": "65000", "neighbor": "192.0.2.1"}
+ config = {"as_number": 65000, "neighbors": [{"neighbor_address": "192.0.2.1"}]}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
- def test_merged_peer_group(self):
+ def test_fresh_merged_add(self):
config = {
"as_number": 65000,
- "peer_groups": [{"peer_group": "PG1", "remote_as": 65003}],
+ "neighbors": [{"neighbor_address": "10.0.0.1", "remote_as": 65010}],
}
cmds = build_commands(config, {}, "merged")
- self.assertIn(("set", _BASE + ["peer-group", "PG1", "remote-as", "65003"]), cmds)
+ self.assertIn(
+ ("set", _BASE + ["neighbor", "10.0.0.1", "remote-as", "65010"]),
+ cmds,
+ )
+ self.assertIn(("set", _BASE + ["system-as", "65000"]), cmds)
if __name__ == "__main__":
diff --git a/tests/unit/modules/test_vyos_command.py b/tests/unit/modules/test_vyos_command.py
new file mode 100644
index 0000000..5b474f9
--- /dev/null
+++ b/tests/unit/modules/test_vyos_command.py
@@ -0,0 +1,111 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+import unittest
+
+from unittest.mock import MagicMock
+
+from ansible_collections.vyos.rest.plugins.modules.vyos_command import (
+ evaluate_conditions,
+ parse_command,
+ run_commands,
+)
+
+
+class TestVyOSCommandParseCommand(unittest.TestCase):
+
+ def test_string_single_word(self):
+ self.assertEqual(parse_command("version"), ["version"])
+
+ def test_string_multi_word(self):
+ self.assertEqual(parse_command("ip route"), ["ip", "route"])
+
+ def test_list_passthrough(self):
+ self.assertEqual(parse_command(["ip", "route"]), ["ip", "route"])
+
+
+class TestVyOSCommandEvaluateConditions(unittest.TestCase):
+
+ def _stdout(self):
+ return ["VyOS 1.5.0 output", "eth0 192.168.1.1"]
+
+ def test_contains_match(self):
+ failed, conds = evaluate_conditions(
+ self._stdout(),
+ ["result[0] contains VyOS"],
+ "all",
+ )
+ self.assertFalse(failed)
+ self.assertEqual(conds, [])
+
+ def test_contains_no_match(self):
+ failed, conds = evaluate_conditions(
+ self._stdout(),
+ ["result[0] contains NonExistent"],
+ "all",
+ )
+ self.assertTrue(failed)
+ self.assertIn("result[0] contains NonExistent", conds)
+
+ def test_match_all_both_pass(self):
+ failed, conditions = evaluate_conditions(
+ self._stdout(),
+ ["result[0] contains VyOS", "result[1] contains eth0"],
+ "all",
+ )
+ self.assertFalse(failed)
+
+ def test_match_all_one_fails(self):
+ failed, conditions = evaluate_conditions(
+ self._stdout(),
+ ["result[0] contains VyOS", "result[1] contains NonExistent"],
+ "all",
+ )
+ self.assertTrue(failed)
+
+ def test_match_any_one_passes(self):
+ failed, conditions = evaluate_conditions(
+ self._stdout(),
+ ["result[0] contains VyOS", "result[1] contains NonExistent"],
+ "any",
+ )
+ self.assertFalse(failed)
+
+ def test_empty_conditions(self):
+ failed, conds = evaluate_conditions(self._stdout(), [], "all")
+ self.assertFalse(failed)
+ self.assertEqual(conds, [])
+
+
+class TestVyOSCommandRunCommands(unittest.TestCase):
+
+ def setUp(self):
+ self.mock_vyos = MagicMock()
+
+ def test_run_list_command(self):
+ self.mock_vyos.show = MagicMock(return_value="VyOS 1.5.0")
+ result = run_commands(self.mock_vyos, [["version"]])
+ self.mock_vyos.show.assert_called_once_with(["version"])
+ self.assertEqual(result, ["VyOS 1.5.0"])
+
+ def test_run_string_command(self):
+ self.mock_vyos.show = MagicMock(return_value="uptime")
+ run_commands(self.mock_vyos, ["system uptime"])
+ self.mock_vyos.show.assert_called_once_with(["system", "uptime"])
+
+ def test_run_multiple_commands(self):
+ self.mock_vyos.show = MagicMock(side_effect=["out1", "out2"])
+ result = run_commands(self.mock_vyos, [["version"], ["interfaces"]])
+ self.assertEqual(result, ["out1", "out2"])
+
+ def test_run_none_returns_empty_string(self):
+ self.mock_vyos.show = MagicMock(return_value=None)
+ result = run_commands(self.mock_vyos, [["version"]])
+ self.assertEqual(result, [""])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/modules/test_vyos_config.py b/tests/unit/modules/test_vyos_config.py
new file mode 100644
index 0000000..749aa82
--- /dev/null
+++ b/tests/unit/modules/test_vyos_config.py
@@ -0,0 +1,107 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+import unittest
+
+from unittest.mock import MagicMock
+
+from ansible_collections.vyos.rest.plugins.modules.vyos_config import (
+ filter_commands,
+ parse_commands,
+ parse_line,
+)
+
+
+class TestVyOSConfigParseLine(unittest.TestCase):
+
+ def test_set_single_value(self):
+ op, path = parse_line("set system host-name router1")
+ self.assertEqual(op, "set")
+ self.assertEqual(path, ["system", "host-name", "router1"])
+
+ def test_delete(self):
+ op, path = parse_line("delete protocols bgp")
+ self.assertEqual(op, "delete")
+ self.assertEqual(path, ["protocols", "bgp"])
+
+ def test_quoted_value(self):
+ op, path = parse_line('set interfaces ethernet eth0 description "My WAN"')
+ self.assertEqual(op, "set")
+ self.assertEqual(path, ["interfaces", "ethernet", "eth0", "description", "My WAN"])
+
+ def test_blank_line(self):
+ self.assertIsNone(parse_line(""))
+
+ def test_comment_line(self):
+ self.assertIsNone(parse_line("# this is a comment"))
+
+ def test_whitespace_only(self):
+ self.assertIsNone(parse_line(" "))
+
+ def test_invalid_op(self):
+ self.assertIsNone(parse_line("get system host-name"))
+
+
+class TestVyOSConfigParseCommands(unittest.TestCase):
+
+ def test_mixed_lines(self):
+ lines = [
+ "# comment",
+ "",
+ "set system host-name router1",
+ "delete protocols bgp",
+ ]
+ result = parse_commands(lines)
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result[0], ("set", ["system", "host-name", "router1"]))
+ self.assertEqual(result[1], ("delete", ["protocols", "bgp"]))
+
+
+class TestVyOSConfigFilterCommands(unittest.TestCase):
+
+ def setUp(self):
+ self.mock_vyos = MagicMock()
+
+ def test_set_already_exists(self):
+ # API returns {"host-name": "router1"} for path ["system", "host-name"]
+ self.mock_vyos.get_config = MagicMock(
+ return_value={"host-name": "router1"},
+ )
+ cmds = [("set", ["system", "host-name", "router1"])]
+ result = filter_commands(cmds, self.mock_vyos)
+ self.assertEqual(result, [])
+
+ def test_set_different_value(self):
+ self.mock_vyos.get_config = MagicMock(
+ return_value={"host-name": "old-name"},
+ )
+ cmds = [("set", ["system", "host-name", "new-name"])]
+ result = filter_commands(cmds, self.mock_vyos)
+ self.assertEqual(len(result), 1)
+
+ def test_set_not_present(self):
+ self.mock_vyos.get_config = MagicMock(return_value={})
+ cmds = [("set", ["system", "host-name", "router1"])]
+ result = filter_commands(cmds, self.mock_vyos)
+ self.assertEqual(len(result), 1)
+
+ def test_delete_exists(self):
+ self.mock_vyos.get_config = MagicMock(
+ return_value={"description": "some desc"},
+ )
+ cmds = [("delete", ["interfaces", "ethernet", "eth0", "description"])]
+ result = filter_commands(cmds, self.mock_vyos)
+ self.assertEqual(len(result), 1)
+
+ def test_delete_not_exists(self):
+ self.mock_vyos.get_config = MagicMock(return_value={})
+ cmds = [("delete", ["interfaces", "ethernet", "eth0", "description"])]
+ result = filter_commands(cmds, self.mock_vyos)
+ self.assertEqual(result, [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/modules/test_vyos_facts.py b/tests/unit/modules/test_vyos_facts.py
index 3fcf82e..7eec8d4 100644
--- a/tests/unit/modules/test_vyos_facts.py
+++ b/tests/unit/modules/test_vyos_facts.py
@@ -4,8 +4,6 @@ from __future__ import absolute_import, division, print_function
__metaclass__ = type
-import json
-import os
import unittest
from unittest.mock import MagicMock
@@ -18,11 +16,7 @@ from ansible_collections.vyos.rest.plugins.modules.vyos_facts import (
gather_users,
)
-
-def load_fixture(filename):
- fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures")
- with open(os.path.join(fixtures_dir, filename)) as f:
- return json.load(f)
+from .base import load_fixture
class TestVyOSFactsGather(unittest.TestCase):
diff --git a/tests/unit/modules/test_vyos_firewall_global.py b/tests/unit/modules/test_vyos_firewall_global.py
index a507c0c..e3aff0b 100644
--- a/tests/unit/modules/test_vyos_firewall_global.py
+++ b/tests/unit/modules/test_vyos_firewall_global.py
@@ -4,38 +4,165 @@ from __future__ import absolute_import, division, print_function
__metaclass__ = type
-import json
-import os
import unittest
-from unittest.mock import MagicMock
-
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import dict_op
from ansible_collections.vyos.rest.plugins.modules.vyos_firewall_global import (
+ _device_to_argspec,
+ _groups_from_device,
+ _groups_to_device,
+ _want_to_device,
build_commands,
get_running_config,
)
+from .base import load_fixture
+
_BASE = ["firewall", "group"]
-def load_fixture(filename):
- fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures")
- with open(os.path.join(fixtures_dir, filename)) as f:
- return json.load(f)
+class TestGroupHelpers(unittest.TestCase):
+ """Test group list <-> device dict conversion helpers."""
+ def test_groups_to_device_with_members(self):
+ groups = [{"name": "SERVERS", "address": ["192.168.1.10", "192.168.1.11"]}]
+ result = _groups_to_device(groups, "address")
+ self.assertIn("SERVERS", result)
+ self.assertIn("192.168.1.10", result["SERVERS"]["address"])
+ self.assertIn("192.168.1.11", result["SERVERS"]["address"])
-class VyOSModuleTestCase(unittest.TestCase):
- def setUp(self):
- self.mock_vyos = MagicMock()
- self.fixture = load_fixture("firewall_global_running.json")
- self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
+ def test_groups_to_device_with_description(self):
+ groups = [{"name": "LAN", "description": "Local network", "network": ["192.168.0.0/16"]}]
+ result = _groups_to_device(groups, "network")
+ self.assertEqual(result["LAN"]["description"], "Local network")
+
+ def test_groups_to_device_empty(self):
+ self.assertEqual(_groups_to_device([], "address"), {})
+ self.assertEqual(_groups_to_device(None, "address"), {})
+
+ def test_groups_from_device_with_members(self):
+ raw = {"SERVERS": {"address": {"192.168.1.10": {}, "192.168.1.11": {}}}}
+ result = _groups_from_device(raw, "address")
+ self.assertEqual(len(result), 1)
+ self.assertEqual(result[0]["name"], "SERVERS")
+ self.assertIn("192.168.1.10", result[0]["address"])
+
+ def test_groups_from_device_single_member_string(self):
+ # VyOS returns single member as string
+ raw = {"WEB": {"port": "80"}}
+ result = _groups_from_device(raw, "port")
+ self.assertIn("80", result[0]["port"])
+
+ def test_groups_from_device_with_description(self):
+ raw = {"LAN": {"description": "Local", "network": {"192.168.0.0/16": {}}}}
+ result = _groups_from_device(raw, "network")
+ self.assertEqual(result[0]["description"], "Local")
+
+ def test_groups_from_device_sorted(self):
+ raw = {"Z-GROUP": {}, "A-GROUP": {}}
+ result = _groups_from_device(raw, "address")
+ self.assertEqual(result[0]["name"], "A-GROUP")
+ self.assertEqual(result[1]["name"], "Z-GROUP")
+
+ def test_groups_from_device_empty(self):
+ self.assertEqual(_groups_from_device({}, "address"), [])
+ self.assertEqual(_groups_from_device(None, "address"), [])
+
+
+class TestWantToDevice(unittest.TestCase):
+ """Test argspec -> device shape conversion."""
+
+ def test_empty(self):
+ self.assertEqual(_want_to_device({}), {})
+ self.assertEqual(_want_to_device(None), {})
+
+ def test_address_group(self):
+ config = {
+ "group": {
+ "address_group": [
+ {"name": "SERVERS", "address": ["192.168.1.10"]},
+ ],
+ },
+ }
+ result = _want_to_device(config)
+ self.assertIn("address-group", result)
+ self.assertIn("SERVERS", result["address-group"])
+ self.assertIn("192.168.1.10", result["address-group"]["SERVERS"]["address"])
+
+ def test_network_group(self):
+ config = {
+ "group": {
+ "network_group": [{"name": "LAN", "network": ["192.168.0.0/16"]}],
+ },
+ }
+ result = _want_to_device(config)
+ self.assertIn("network-group", result)
+ self.assertIn("LAN", result["network-group"])
+
+ def test_port_group(self):
+ config = {
+ "group": {
+ "port_group": [{"name": "WEB", "port": ["80", "443"]}],
+ },
+ }
+ result = _want_to_device(config)
+ self.assertIn("port-group", result)
+ self.assertIn("80", result["port-group"]["WEB"]["port"])
+
+ def test_interface_group(self):
+ config = {
+ "group": {
+ "interface_group": [{"name": "LAN-IFACES", "interface": ["eth1"]}],
+ },
+ }
+ result = _want_to_device(config)
+ self.assertIn("interface-group", result)
+
+ def test_ipv6_network_group(self):
+ config = {
+ "group": {
+ "ipv6_network_group": [{"name": "IPV6-LAN", "network": ["2001:db8::/32"]}],
+ },
+ }
+ result = _want_to_device(config)
+ self.assertIn("ipv6-network-group", result)
-class TestVyOSFirewallGlobalGetRunning(VyOSModuleTestCase):
+class TestDeviceToArgspec(unittest.TestCase):
+ """Test device response -> argspec shape conversion."""
+
+ def test_empty(self):
+ self.assertEqual(_device_to_argspec({}), {})
+ self.assertEqual(_device_to_argspec(None), {})
+
+ def test_address_group(self):
+ raw = {
+ "address-group": {
+ "SERVERS": {
+ "description": "Web servers",
+ "address": {"192.168.1.10": {}, "192.168.1.11": {}},
+ },
+ },
+ }
+ result = _device_to_argspec(raw)
+ groups = result["group"]["address_group"]
+ servers = next(g for g in groups if g["name"] == "SERVERS")
+ self.assertEqual(servers["description"], "Web servers")
+ self.assertIn("192.168.1.10", servers["address"])
+
+ def test_no_group_returns_empty(self):
+ self.assertEqual(_device_to_argspec({}), {})
+
+
+class TestDeviceToArgspecFixture(unittest.TestCase):
+ """Test _device_to_argspec against fixture."""
+
+ def setUp(self):
+ self.fixture = load_fixture("firewall_global_running.json")
def test_parses_address_groups(self):
- result = get_running_config(self.mock_vyos)
+ result = _device_to_argspec(self.fixture)
groups = result["group"]["address_group"]
names = [g["name"] for g in groups]
self.assertIn("SERVERS", names)
@@ -43,148 +170,185 @@ class TestVyOSFirewallGlobalGetRunning(VyOSModuleTestCase):
servers = next(g for g in groups if g["name"] == "SERVERS")
self.assertEqual(servers["description"], "Web servers")
self.assertIn("192.168.1.10", servers["address"])
- self.assertIn("192.168.1.11", servers["address"])
def test_parses_network_groups(self):
- result = get_running_config(self.mock_vyos)
+ result = _device_to_argspec(self.fixture)
groups = result["group"]["network_group"]
dmz = next(g for g in groups if g["name"] == "DMZ")
self.assertIn("10.0.0.0/8", dmz["network"])
- self.assertIn("172.16.0.0/12", dmz["network"])
def test_parses_port_groups(self):
- result = get_running_config(self.mock_vyos)
+ result = _device_to_argspec(self.fixture)
groups = result["group"]["port_group"]
web = next(g for g in groups if g["name"] == "WEB-PORTS")
self.assertIn("80", web["port"])
- self.assertIn("443", web["port"])
def test_parses_interface_groups(self):
- result = get_running_config(self.mock_vyos)
+ result = _device_to_argspec(self.fixture)
groups = result["group"]["interface_group"]
lan = next(g for g in groups if g["name"] == "LAN-IFACES")
self.assertIn("eth1", lan["interface"])
def test_parses_ipv6_network_groups(self):
- result = get_running_config(self.mock_vyos)
+ result = _device_to_argspec(self.fixture)
groups = result["group"]["ipv6_network_group"]
ipv6 = next(g for g in groups if g["name"] == "IPV6-LAN")
self.assertIn("2001:db8::/32", ipv6["network"])
def test_empty_config(self):
- self.mock_vyos.get_config = MagicMock(return_value={})
- result = get_running_config(self.mock_vyos)
+ result = _device_to_argspec({})
self.assertEqual(result, {})
-class TestVyOSFirewallGlobalBuildCommands(unittest.TestCase):
+class TestDictOpFirewall(unittest.TestCase):
+ """Test dict_op behaviour with firewall group shapes."""
- def _have(self):
- return {
- "group": {
- "address_group": [
- {"name": "SERVERS", "address": ["192.168.1.10", "192.168.1.11"]},
- ],
- "network_group": [
- {"name": "LAN", "network": ["192.168.0.0/16"]},
- ],
+ def test_merged_adds_address_group(self):
+ want = _want_to_device(
+ {
+ "group": {
+ "address_group": [{"name": "SERVERS", "address": ["192.168.1.10"]}],
+ },
},
- }
-
- def test_deleted_with_have(self):
- cmds = build_commands({}, self._have(), "deleted")
- self.assertEqual(cmds, [("delete", _BASE)])
-
- def test_deleted_without_have(self):
- cmds = build_commands({}, {}, "deleted")
- self.assertEqual(cmds, [])
-
- def test_merged_address_group(self):
- config = {
- "group": {
- "address_group": [
- {"name": "SERVERS", "address": ["192.168.1.10"]},
- ],
- },
- }
- cmds = build_commands(config, {}, "merged")
- self.assertIn(
- ("set", _BASE + ["address-group", "SERVERS", "address", "192.168.1.10"]),
- cmds,
- )
-
- def test_merged_network_group(self):
- config = {
- "group": {
- "network_group": [
- {"name": "LAN", "network": ["192.168.0.0/16"]},
- ],
- },
- }
- cmds = build_commands(config, {}, "merged")
- self.assertIn(
- ("set", _BASE + ["network-group", "LAN", "network", "192.168.0.0/16"]),
- cmds,
- )
-
- def test_merged_port_group(self):
- config = {
- "group": {
- "port_group": [
- {"name": "WEB", "port": ["80", "443"]},
- ],
- },
- }
- cmds = build_commands(config, {}, "merged")
- self.assertIn(
- ("set", _BASE + ["port-group", "WEB", "port", "80"]),
- cmds,
)
+ cmds = dict_op(want, {}, _BASE, op="set")
+ paths = [c[1] for c in cmds]
+ self.assertIn(_BASE + ["address-group", "SERVERS", "address", "192.168.1.10"], paths)
def test_merged_idempotent(self):
- have = self._have()
config = {
"group": {
- "address_group": [
- {"name": "SERVERS", "address": ["192.168.1.10", "192.168.1.11"]},
- ],
- "network_group": [
- {"name": "LAN", "network": ["192.168.0.0/16"]},
- ],
+ "address_group": [{"name": "SERVERS", "address": ["192.168.1.10"]}],
},
}
- cmds = build_commands(config, have, "merged")
+ want = _want_to_device(config)
+ have = {"address-group": {"SERVERS": {"address": {"192.168.1.10": {}}}}}
+ cmds = dict_op(want, have, _BASE, op="set")
self.assertEqual(cmds, [])
- def test_replaced_removes_extra_group(self):
- have = self._have()
- config = {
- "group": {
- "network_group": [
- {"name": "DMZ", "network": ["10.0.0.0/8"]},
- ],
+ def test_merged_adds_network_group(self):
+ want = _want_to_device(
+ {
+ "group": {
+ "network_group": [{"name": "LAN", "network": ["192.168.0.0/16"]}],
+ },
},
+ )
+ cmds = dict_op(want, {}, _BASE, op="set")
+ paths = [c[1] for c in cmds]
+ self.assertIn(_BASE + ["network-group", "LAN", "network", "192.168.0.0/16"], paths)
+
+ def test_purge_removes_extra_group(self):
+ want = _want_to_device(
+ {
+ "group": {
+ "network_group": [{"name": "DMZ", "network": ["10.0.0.0/8"]}],
+ },
+ },
+ )
+ have = {
+ "address-group": {"SERVERS": {"address": {"192.168.1.10": {}}}},
+ "network-group": {"LAN": {"network": {"192.168.0.0/16": {}}}},
}
- cmds = build_commands(config, have, "replaced")
+ cmds = dict_op(want, have, _BASE, op="purge")
paths = [c[1] for c in cmds]
- self.assertIn(_BASE + ["address-group", "SERVERS"], paths)
+ # address-group entirely absent from want -> whole type deleted
+ self.assertIn(_BASE + ["address-group"], paths)
self.assertIn(_BASE + ["network-group", "LAN"], paths)
- def test_replaced_idempotent(self):
- have = self._have()
+ def test_merged_idempotent_with_description(self):
+ """Regression test: the original implementation's have-side
+ normalization corrupted plain scalar fields (description) into
+ bogus presence-dicts via the same blanket conversion used for
+ member lists, breaking idempotency for any group with a
+ description set. Fixed by using dict_op's native list handling
+ for members and never touching scalar fields at all."""
config = {
"group": {
"address_group": [
- {"name": "SERVERS", "address": ["192.168.1.10", "192.168.1.11"]},
- ],
- "network_group": [
- {"name": "LAN", "network": ["192.168.0.0/16"]},
+ {"name": "SERVERS", "description": "Web servers", "address": ["10.0.0.1"]},
],
},
}
- cmds = build_commands(config, have, "replaced")
+ want = _want_to_device(config)
+ have = {
+ "address-group": {
+ "SERVERS": {"description": "Web servers", "address": ["10.0.0.1"]},
+ },
+ }
+ cmds = dict_op(want, have, _BASE, op="set")
self.assertEqual(cmds, [])
+ def test_replaced_removes_stale_member(self):
+ """Regression test: dict_op's purge mode originally had no
+ handling for list-valued leaves (only dicts), so a member
+ present on the device but absent from the desired list was
+ silently never removed under 'replaced'. Fixed centrally in
+ dict_op itself."""
+ want = _want_to_device(
+ {"group": {"address_group": [{"name": "SERVERS", "address": ["10.0.0.1"]}]}},
+ )
+ have = {"address-group": {"SERVERS": {"address": ["10.0.0.1", "10.0.0.2"]}}}
+ cmds = dict_op(want, have, _BASE, op="purge")
+ self.assertIn(
+ ("delete", _BASE + ["address-group", "SERVERS", "address", "10.0.0.2"]),
+ cmds,
+ )
+
+ def test_deleted_removes_base(self):
+ # deleted state is handled in main() not dict_op
+ # just verify want_to_device produces correct shape
+ want = _want_to_device({})
+ self.assertEqual(want, {})
+
+
+class TestBuildCommands(unittest.TestCase):
+ """build_commands/get_running_config were extracted from main() during
+ the dict_op refactor so they're independently testable."""
+
+ def test_get_running_config_fetches_at_base(self):
+ from unittest.mock import MagicMock
+
+ mock_vyos = MagicMock()
+ mock_vyos.get_config = MagicMock(return_value={"address-group": {}})
+ get_running_config(mock_vyos)
+ mock_vyos.get_config.assert_called_once_with(_BASE)
+
+ def test_merged_idempotent_against_fixture(self):
+ fixture = load_fixture("firewall_global_running.json")
+ have = _device_to_argspec(fixture)
+ self.assertEqual(build_commands(have, fixture, "merged"), [])
+
+ def test_replaced_idempotent_against_fixture(self):
+ fixture = load_fixture("firewall_global_running.json")
+ have = _device_to_argspec(fixture)
+ self.assertEqual(build_commands(have, fixture, "replaced"), [])
+
+ def test_deleted_no_have_is_noop(self):
+ self.assertEqual(build_commands({}, {}, "deleted"), [])
+
+ def test_deleted_with_have(self):
+ self.assertEqual(
+ build_commands({}, {"address-group": {"SERVERS": {}}}, "deleted"),
+ [("delete", _BASE)],
+ )
+
+ def test_single_value_member_collapse_no_char_iteration_bug(self):
+ """A group with exactly one member, collapsed by the device to a
+ bare string instead of a list, must not be iterated
+ character-by-character."""
+ raw_have = {"port-group": {"WEB": {"port": "80"}}}
+ config = {"group": {"port_group": [{"name": "WEB", "port": ["80"]}]}}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+
+ def test_group_tag_node_collapse_no_char_iteration_bug(self):
+ """A single group with zero other fields, collapsed by the
+ device to a bare group-name string, must not be iterated
+ character-by-character."""
+ raw_have = {"address-group": "SERVERS"}
+ config = {"group": {"address_group": [{"name": "SERVERS"}]}}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/unit/modules/test_vyos_firewall_interfaces.py b/tests/unit/modules/test_vyos_firewall_interfaces.py
index 66b3883..26d2c4e 100644
--- a/tests/unit/modules/test_vyos_firewall_interfaces.py
+++ b/tests/unit/modules/test_vyos_firewall_interfaces.py
@@ -4,107 +4,188 @@ from __future__ import absolute_import, division, print_function
__metaclass__ = type
-import json
-import os
import unittest
from unittest.mock import MagicMock
from ansible_collections.vyos.rest.plugins.modules.vyos_firewall_interfaces import (
+ _device_to_argspec,
+ _hook_filter_from_device,
+ _hook_filter_to_device,
+ _rules_from_device,
+ _rules_to_device,
+ _want_to_device,
build_commands,
get_running_config,
)
-
-_BASE = ["firewall"]
+from .base import load_fixture
-def load_fixture(filename):
- fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures")
- with open(os.path.join(fixtures_dir, filename)) as f:
- return json.load(f)
+_BASE = ["firewall"]
class VyOSModuleTestCase(unittest.TestCase):
def setUp(self):
self.mock_vyos = MagicMock()
self.fixture = load_fixture("firewall_interfaces_running.json")
+ self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
- def _set_afi(self, afi):
- data = self.fixture.get(afi, {})
- self.mock_vyos.get_config = MagicMock(return_value=data)
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_single_combined_fetch(self):
+ """Confirm get_running_config fetches once at _BASE, not per-AFI."""
+ get_running_config(self.mock_vyos)
+ self.mock_vyos.get_config.assert_called_once_with(_BASE)
-class TestVyOSFirewallInterfacesGetRunning(VyOSModuleTestCase):
+ def test_returns_raw_device_dict(self):
+ self.assertEqual(get_running_config(self.mock_vyos), self.fixture)
- def test_parses_ipv4_hooks(self):
- self._set_afi("ipv4")
- result = get_running_config(self.mock_vyos)
- ipv4 = next((e for e in result if e["afi"] == "ipv4"), None)
- self.assertIsNotNone(ipv4)
- hook_names = [h["hook"] for h in ipv4["hooks"]]
- self.assertIn("input", hook_names)
- self.assertIn("forward", hook_names)
- self.assertIn("output", hook_names)
+ def test_empty_config(self):
+ self.mock_vyos.get_config = MagicMock(return_value=None)
+ self.assertEqual(get_running_config(self.mock_vyos), {})
- def test_parses_input_rules(self):
- self._set_afi("ipv4")
- result = get_running_config(self.mock_vyos)
- ipv4 = next(e for e in result if e["afi"] == "ipv4")
- input_hook = next(h for h in ipv4["hooks"] if h["hook"] == "input")
- self.assertEqual(input_hook["default_action"], "accept")
- self.assertEqual(len(input_hook["rules"]), 2)
- r10 = next(r for r in input_hook["rules"] if r["number"] == 10)
- self.assertEqual(r10["action"], "accept")
- self.assertEqual(r10["state"], "established")
-
- def test_parses_ipv6_hooks(self):
- self._set_afi("ipv6")
- result = get_running_config(self.mock_vyos)
- ipv6 = next((e for e in result if e["afi"] == "ipv6"), None)
- self.assertIsNotNone(ipv6)
- self.assertEqual(ipv6["hooks"][0]["hook"], "input")
- def test_empty_config(self):
- self.mock_vyos.get_config = MagicMock(return_value={})
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result, [])
+class TestRulesToDeviceFromDevice(unittest.TestCase):
+ def test_bare_rule_is_presence(self):
+ self.assertEqual(_rules_to_device([{"number": 10}]), {"10": {}})
+
+ def test_full_rule(self):
+ result = _rules_to_device(
+ [
+ {
+ "number": 20,
+ "action": "drop",
+ "state": "invalid",
+ "source": {"address": "10.0.0.0/8"},
+ "disable": True,
+ },
+ ],
+ )
+ self.assertEqual(
+ result,
+ {
+ "20": {
+ "action": "drop",
+ "state": "invalid",
+ "source": {"address": "10.0.0.0/8"},
+ "disable": {},
+ },
+ },
+ )
+
+ def test_from_device_number_cast_to_int(self):
+ result = _rules_from_device({"10": {"action": "accept"}})
+ self.assertEqual(result, [{"number": 10, "action": "accept"}])
+
+ def test_from_device_sorted_numerically_not_lexically(self):
+ result = _rules_from_device({"20": {}, "9": {}, "100": {}})
+ self.assertEqual([r["number"] for r in result], [9, 20, 100])
+
+ def test_source_destination_round_trip(self):
+ raw = {"20": {"source": {"address": "10.0.0.0/8"}, "destination": {"port": "22"}}}
+ result = _rules_from_device(raw)
+ self.assertEqual(result[0]["source"], {"address": "10.0.0.0/8"})
+ self.assertEqual(result[0]["destination"], {"port": "22"})
+
+
+class TestHookFilterToDeviceFromDevice(unittest.TestCase):
+ def test_bare_hook_is_presence(self):
+ self.assertEqual(_hook_filter_to_device({"hook": "input"}), {})
+
+ def test_default_action_and_description(self):
+ result = _hook_filter_to_device(
+ {"hook": "input", "default_action": "accept", "description": "desc"},
+ )
+ self.assertEqual(result, {"default_action": "accept", "description": "desc"})
+
+ def test_with_rules(self):
+ result = _hook_filter_to_device(
+ {"hook": "input", "rules": [{"number": 10, "action": "accept"}]},
+ )
+ self.assertEqual(result, {"rule": {"10": {"action": "accept"}}})
+ def test_from_device_basic(self):
+ entry = _hook_filter_from_device("input", {"default-action": "accept"})
+ self.assertEqual(entry, {"hook": "input", "default_action": "accept"})
+
+ def test_from_device_with_rules(self):
+ entry = _hook_filter_from_device(
+ "input",
+ {"default-action": "accept", "rule": {"10": {"action": "accept"}}},
+ )
+ self.assertEqual(entry["default_action"], "accept")
+ self.assertEqual(entry["rules"], [{"number": 10, "action": "accept"}])
-class TestVyOSFirewallInterfacesBuildCommands(unittest.TestCase):
- def _have(self):
- return [
+class TestWantToDevice(unittest.TestCase):
+ def test_empty(self):
+ self.assertEqual(_want_to_device([]), {})
+ self.assertEqual(_want_to_device(None), {})
+
+ def test_afi_with_no_hooks_omitted(self):
+ self.assertEqual(_want_to_device([{"afi": "ipv4", "hooks": []}]), {})
+
+ def test_full_config(self):
+ config = [
{
"afi": "ipv4",
"hooks": [
- {
- "hook": "input",
- "default_action": "accept",
- "rules": [
- {"number": 10, "action": "accept", "state": "established"},
- {"number": 20, "action": "drop", "state": "invalid"},
- ],
- },
- {"hook": "forward", "default_action": "accept"},
+ {"hook": "input", "default_action": "accept"},
],
},
]
+ result = _want_to_device(config)
+ self.assertEqual(
+ result,
+ {"ipv4": {"input": {"filter": {"default_action": "accept"}}}},
+ )
- def test_deleted_all(self):
- cmds = build_commands([], self._have(), "deleted")
- paths = [c[1] for c in cmds]
- self.assertIn(_BASE + ["ipv4", "input", "filter"], paths)
- self.assertIn(_BASE + ["ipv4", "forward", "filter"], paths)
- def test_deleted_specific(self):
- config = [{"afi": "ipv4", "hooks": [{"hook": "input"}]}]
- cmds = build_commands(config, self._have(), "deleted")
- self.assertIn(("delete", _BASE + ["ipv4", "input", "filter"]), cmds)
- paths = [c[1] for c in cmds]
- self.assertNotIn(_BASE + ["ipv4", "forward", "filter"], paths)
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_ipv4_input_with_rules(self):
+ have = _device_to_argspec(self.fixture)
+ ipv4 = next(e for e in have if e["afi"] == "ipv4")
+ input_hook = next(h for h in ipv4["hooks"] if h["hook"] == "input")
+ self.assertEqual(input_hook["default_action"], "accept")
+ rule20 = next(r for r in input_hook["rules"] if r["number"] == 20)
+ self.assertEqual(rule20["source"], {"address": "10.0.0.0/8"})
+ self.assertEqual(rule20["destination"], {"port": "22"})
+
+ def test_sibling_module_data_never_surfaces(self):
+ """Regression test: firewall.ipv4.name (owned by
+ vyos_firewall_rules) must never appear in this module's output."""
+ have = _device_to_argspec(self.fixture)
+ ipv4 = next(e for e in have if e["afi"] == "ipv4")
+ hook_names = {h["hook"] for h in ipv4["hooks"]}
+ self.assertEqual(hook_names, {"input", "forward", "output"})
+
+ def test_ipv6_present(self):
+ have = _device_to_argspec(self.fixture)
+ afis = {e["afi"] for e in have}
+ self.assertIn("ipv6", afis)
+
+ def test_empty_config(self):
+ self.assertEqual(_device_to_argspec({}), [])
+ self.assertEqual(_device_to_argspec(None), [])
+
+
+class TestBuildCommands(VyOSModuleTestCase):
+ def test_merged_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "merged"), [])
+
+ def test_replaced_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "replaced"), [])
+
+ def test_overridden_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "overridden"), [])
- def test_merged_hook(self):
+ def test_replaced_scoped_to_named_hooks_only(self):
+ """replaced only touches hooks explicitly named in config -- an
+ omitted hook (output here) must be left alone."""
config = [
{
"afi": "ipv4",
@@ -112,23 +193,24 @@ class TestVyOSFirewallInterfacesBuildCommands(unittest.TestCase):
{
"hook": "input",
"default_action": "accept",
- "rules": [{"number": 10, "action": "accept", "state": "established"}],
+ "rules": [
+ {"number": 10, "action": "accept", "state": "established"},
+ {
+ "number": 20,
+ "action": "drop",
+ "state": "invalid",
+ "source": {"address": "10.0.0.0/8"},
+ "destination": {"port": "22"},
+ },
+ ],
},
],
},
]
- cmds = build_commands(config, [], "merged")
- self.assertIn(
- ("set", _BASE + ["ipv4", "input", "filter", "default-action", "accept"]),
- cmds,
- )
- self.assertIn(
- ("set", _BASE + ["ipv4", "input", "filter", "rule", "10", "action", "accept"]),
- cmds,
- )
+ self.assertEqual(build_commands(config, self.fixture, "replaced"), [])
- def test_merged_idempotent(self):
- have = self._have()
+ def test_overridden_deletes_omitted_hook(self):
+ """overridden is full-model: an omitted hook must be deleted."""
config = [
{
"afi": "ipv4",
@@ -138,30 +220,62 @@ class TestVyOSFirewallInterfacesBuildCommands(unittest.TestCase):
"default_action": "accept",
"rules": [
{"number": 10, "action": "accept", "state": "established"},
- {"number": 20, "action": "drop", "state": "invalid"},
+ {
+ "number": 20,
+ "action": "drop",
+ "state": "invalid",
+ "source": {"address": "10.0.0.0/8"},
+ "destination": {"port": "22"},
+ },
],
},
- {"hook": "forward", "default_action": "accept"},
],
},
]
- cmds = build_commands(config, have, "merged")
- self.assertEqual(cmds, [])
+ cmds = build_commands(config, self.fixture, "overridden")
+ self.assertIn(("delete", _BASE + ["ipv4", "output", "filter"]), cmds)
+ self.assertIn(("delete", _BASE + ["ipv4", "forward", "filter"]), cmds)
+ self.assertIn(("delete", _BASE + ["ipv6", "input", "filter"]), cmds)
- def test_overridden_removes_extra_hook(self):
- have = self._have()
- config = [
- {
- "afi": "ipv4",
- "hooks": [
- {"hook": "output", "default_action": "accept"},
- ],
- },
- ]
- cmds = build_commands(config, have, "overridden")
- paths = [c[1] for c in cmds]
- self.assertIn(_BASE + ["ipv4", "input", "filter"], paths)
- self.assertIn(_BASE + ["ipv4", "forward", "filter"], paths)
+ def test_overridden_never_touches_sibling_ruleset(self):
+ cmds = build_commands([], self.fixture, "overridden")
+ self.assertTrue(all("name" not in c[1] for c in cmds))
+
+ def test_deleted_never_touches_sibling_ruleset(self):
+ cmds = build_commands([], self.fixture, "deleted")
+ self.assertTrue(all("name" not in c[1] for c in cmds))
+ self.assertIn(("delete", _BASE + ["ipv4", "input", "filter"]), cmds)
+
+ def test_deleted_scoped_to_named_config(self):
+ cmds = build_commands(
+ [{"afi": "ipv4", "hooks": [{"hook": "input"}]}],
+ self.fixture,
+ "deleted",
+ )
+ self.assertEqual(cmds, [("delete", _BASE + ["ipv4", "input", "filter"])])
+
+ def test_collapsed_rule_no_char_iteration_bug(self):
+ """A single rule with no other config collapsed to a bare string
+ by the device must not be iterated character-by-character."""
+ raw_have = {"ipv4": {"input": {"filter": {"rule": "10"}}}}
+ config = [{"afi": "ipv4", "hooks": [{"hook": "input", "rules": [{"number": 10}]}]}]
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+
+ def test_merged_new_rule(self):
+ cmds = build_commands(
+ [
+ {
+ "afi": "ipv4",
+ "hooks": [{"hook": "input", "rules": [{"number": 30, "action": "accept"}]}],
+ },
+ ],
+ self.fixture,
+ "merged",
+ )
+ self.assertIn(
+ ("set", _BASE + ["ipv4", "input", "filter", "rule", "30", "action", "accept"]),
+ cmds,
+ )
if __name__ == "__main__":
diff --git a/tests/unit/modules/test_vyos_firewall_rules.py b/tests/unit/modules/test_vyos_firewall_rules.py
index 4eaad9e..7642da3 100644
--- a/tests/unit/modules/test_vyos_firewall_rules.py
+++ b/tests/unit/modules/test_vyos_firewall_rules.py
@@ -4,25 +4,27 @@ from __future__ import absolute_import, division, print_function
__metaclass__ = type
-import json
-import os
import unittest
from unittest.mock import MagicMock
from ansible_collections.vyos.rest.plugins.modules.vyos_firewall_rules import (
+ _device_to_argspec,
+ _endpoint_from_device,
+ _endpoint_to_device,
+ _rule_set_from_device,
+ _rule_set_to_device,
+ _rules_from_device,
+ _rules_to_device,
+ _want_to_device,
build_commands,
get_running_config,
)
-
-_BASE = ["firewall"]
+from .base import load_fixture
-def load_fixture(filename):
- fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures")
- with open(os.path.join(fixtures_dir, filename)) as f:
- return json.load(f)
+_BASE = ["firewall"]
class VyOSModuleTestCase(unittest.TestCase):
@@ -30,171 +32,258 @@ class VyOSModuleTestCase(unittest.TestCase):
self.mock_vyos = MagicMock()
self.fixture = load_fixture("firewall_rules_running.json")
- def _set_afi(self, afi):
- data = self.fixture.get(afi, {})
- self.mock_vyos.get_config = MagicMock(return_value=data)
+ def _get_config(path):
+ # path == _BASE + [afi, "name"]; fixture is wrapped one level
+ # deeper ({"ipv4": {"name": {...}}}), matching a real device
+ # response that still needs the defensive unwrap.
+ afi = path[1]
+ return self.fixture.get(afi)
+ self.mock_vyos.get_config = MagicMock(side_effect=_get_config)
-class TestVyOSFirewallRulesGetRunning(VyOSModuleTestCase):
- def test_parses_ipv4_rule_sets(self):
- self._set_afi("ipv4")
- result = get_running_config(self.mock_vyos)
- ipv4 = next((e for e in result if e["afi"] == "ipv4"), None)
- self.assertIsNotNone(ipv4)
- rs = next(rs for rs in ipv4["rule_sets"] if rs["name"] == "RULE-SET1")
- self.assertEqual(rs["default_action"], "drop")
- self.assertEqual(len(rs["rules"]), 2)
- r10 = next(r for r in rs["rules"] if r["number"] == 10)
- self.assertEqual(r10["action"], "accept")
- self.assertEqual(r10["protocol"], "tcp")
- self.assertEqual(r10["source"]["address"], "192.168.1.0/24")
- self.assertEqual(r10["destination"]["port"], "80")
-
- def test_parses_rule_state(self):
- self._set_afi("ipv4")
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_targeted_per_afi_fetch(self):
+ """Confirm the targeted firewall.<afi>.name fetch is preserved
+ (not widened to a broader firewall.<afi> or firewall fetch)."""
+ get_running_config(self.mock_vyos)
+ calls = [c.args[0] for c in self.mock_vyos.get_config.call_args_list]
+ self.assertEqual(calls, [_BASE + ["ipv4", "name"], _BASE + ["ipv6", "name"]])
+
+ def test_unwraps_name_wrapper(self):
result = get_running_config(self.mock_vyos)
- ipv4 = next(e for e in result if e["afi"] == "ipv4")
- rs = ipv4["rule_sets"][0]
- r20 = next(r for r in rs["rules"] if r["number"] == 20)
- self.assertEqual(r20["state"], "invalid")
+ self.assertIn("RULE-SET1", result["ipv4"])
def test_empty_config(self):
- self.mock_vyos.get_config = MagicMock(return_value={})
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result, [])
+ self.mock_vyos.get_config = MagicMock(return_value=None)
+ self.assertEqual(get_running_config(self.mock_vyos), {})
+
+
+class TestEndpointToDeviceFromDevice(unittest.TestCase):
+ """The one genuine device-shape exception in this module: group."""
+
+ def test_group_wraps_under_address_group(self):
+ result = _endpoint_to_device({"address": "10.0.0.0/8", "group": "GROUP1"})
+ self.assertEqual(result, {"address": "10.0.0.0/8", "group": {"address-group": "GROUP1"}})
+
+ def test_no_group_no_exception_applied(self):
+ result = _endpoint_to_device({"address": "10.0.0.0/8", "port": "80"})
+ self.assertEqual(result, {"address": "10.0.0.0/8", "port": "80"})
+ def test_from_device_extracts_group_regardless_of_kind(self):
+ """Read side stays generic: it can surface any group kind already
+ configured (address-group, network-group, ...), even though
+ write side (above) can only ever create address-group."""
+ result = _endpoint_from_device({"group": {"network-group": "NETGRP1"}})
+ self.assertEqual(result, {"group": "NETGRP1"})
-class TestVyOSFirewallRulesBuildCommands(unittest.TestCase):
+ def test_from_device_bare_string_group(self):
+ result = _endpoint_from_device({"group": "GROUP1"})
+ self.assertEqual(result, {"group": "GROUP1"})
- def _have(self):
- return [
+
+class TestRulesToDeviceFromDevice(unittest.TestCase):
+ def test_bare_rule_is_presence(self):
+ self.assertEqual(_rules_to_device([{"number": 10}]), {"10": {}})
+
+ def test_full_rule_with_source_destination(self):
+ result = _rules_to_device(
+ [
+ {
+ "number": 10,
+ "action": "accept",
+ "protocol": "tcp",
+ "source": {"address": "192.168.1.0/24"},
+ "destination": {"port": "80"},
+ },
+ ],
+ )
+ self.assertEqual(
+ result,
{
- "afi": "ipv4",
- "rule_sets": [
- {
- "name": "RULE-SET1",
- "default_action": "drop",
- "rules": [
- {"number": 10, "action": "accept", "protocol": "tcp"},
- {"number": 20, "action": "drop", "state": "invalid"},
- ],
- },
- ],
+ "10": {
+ "action": "accept",
+ "protocol": "tcp",
+ "source": {"address": "192.168.1.0/24"},
+ "destination": {"port": "80"},
+ },
},
- ]
+ )
- def test_deleted_all(self):
- cmds = build_commands([], self._have(), "deleted")
- self.assertIn(("delete", _BASE), cmds)
+ def test_icmp_generic_no_exception_needed(self):
+ result = _rules_to_device([{"number": 10, "icmp": {"type": 8, "code": 0}}])
+ self.assertEqual(result, {"10": {"icmp": {"type": 8, "code": 0}}})
- def test_deleted_specific(self):
- config = [{"afi": "ipv4", "rule_sets": [{"name": "RULE-SET1"}]}]
- cmds = build_commands(config, self._have(), "deleted")
- self.assertIn(("delete", _BASE + ["ipv4", "name", "RULE-SET1"]), cmds)
+ def test_from_device_number_cast_and_sorted_numerically(self):
+ result = _rules_from_device({"20": {}, "9": {}})
+ self.assertEqual([r["number"] for r in result], [9, 20])
- def test_merged_rule_set(self):
- config = [
+ def test_from_device_icmp_cast_to_int(self):
+ result = _rules_from_device({"10": {"icmp": {"type": "8", "code": "0"}}})
+ self.assertEqual(result[0]["icmp"], {"type": "8", "code": "0"})
+ # Note: icmp int-casting happens via cast_by_spec in
+ # _device_to_argspec, not in the raw _rules_from_device step --
+ # verified separately in TestDeviceToArgspecFixture.
+
+
+class TestRuleSetToDeviceFromDevice(unittest.TestCase):
+ def test_bare_rule_set_is_presence(self):
+ self.assertEqual(_rule_set_to_device({"name": "RS1"}), {})
+
+ def test_with_rules(self):
+ result = _rule_set_to_device(
{
- "afi": "ipv4",
- "rule_sets": [
- {
- "name": "NEW-SET",
- "default_action": "accept",
- "rules": [{"number": 10, "action": "accept"}],
- },
- ],
+ "name": "RS1",
+ "default_action": "drop",
+ "rules": [{"number": 10, "action": "accept"}],
},
- ]
- cmds = build_commands(config, [], "merged")
- self.assertIn(
- ("set", _BASE + ["ipv4", "name", "NEW-SET", "default-action", "accept"]),
- cmds,
)
- self.assertIn(
- ("set", _BASE + ["ipv4", "name", "NEW-SET", "rule", "10", "action", "accept"]),
- cmds,
+ self.assertEqual(
+ result,
+ {"default_action": "drop", "rule": {"10": {"action": "accept"}}},
+ )
+
+ def test_from_device(self):
+ entry = _rule_set_from_device(
+ "RS1",
+ {"default-action": "drop", "rule": {"10": {"action": "accept"}}},
)
+ self.assertEqual(entry["name"], "RS1")
+ self.assertEqual(entry["default_action"], "drop")
+ self.assertEqual(entry["rules"], [{"number": 10, "action": "accept"}])
+
+
+class TestWantToDevice(unittest.TestCase):
+ def test_empty(self):
+ self.assertEqual(_want_to_device([]), {})
+ self.assertEqual(_want_to_device(None), {})
- def test_merged_rule_with_protocol_and_source(self):
+ def test_afi_with_no_rule_sets_omitted(self):
+ self.assertEqual(_want_to_device([{"afi": "ipv4", "rule_sets": []}]), {})
+
+ def test_full_config(self):
config = [
{
"afi": "ipv4",
- "rule_sets": [
- {
- "name": "RULE-SET1",
- "rules": [
- {
- "number": 10,
- "action": "accept",
- "protocol": "tcp",
- "source": {"address": "10.0.0.0/8"},
- },
- ],
- },
- ],
+ "rule_sets": [{"name": "RS1", "default_action": "drop"}],
},
]
- cmds = build_commands(config, [], "merged")
- self.assertIn(
- ("set", _BASE + ["ipv4", "name", "RULE-SET1", "rule", "10", "protocol", "tcp"]),
- cmds,
- )
- self.assertIn(
- (
- "set",
- _BASE
- + [
- "ipv4",
- "name",
- "RULE-SET1",
- "rule",
- "10",
- "source",
- "address",
- "10.0.0.0/8",
- ],
- ),
- cmds,
+ self.assertEqual(
+ _want_to_device(config),
+ {"ipv4": {"RS1": {"default_action": "drop"}}},
)
- def test_merged_idempotent(self):
- have = self._have()
- config = [
+
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_ipv4_rule_set_with_rules(self):
+ raw = get_running_config(self.mock_vyos)
+ have = _device_to_argspec(raw)
+ ipv4 = next(e for e in have if e["afi"] == "ipv4")
+ rs1 = next(r for r in ipv4["rule_sets"] if r["name"] == "RULE-SET1")
+ self.assertEqual(rs1["default_action"], "drop")
+ rule10 = next(r for r in rs1["rules"] if r["number"] == 10)
+ self.assertEqual(rule10["source"], {"address": "192.168.1.0/24"})
+ self.assertEqual(rule10["destination"], {"port": "80"})
+
+ def test_ipv6_present(self):
+ raw = get_running_config(self.mock_vyos)
+ have = _device_to_argspec(raw)
+ afis = {e["afi"] for e in have}
+ self.assertIn("ipv6", afis)
+
+ def test_empty_config(self):
+ self.assertEqual(_device_to_argspec({}), [])
+ self.assertEqual(_device_to_argspec(None), [])
+
+
+class TestBuildCommands(VyOSModuleTestCase):
+ def _have_and_raw(self):
+ raw = get_running_config(self.mock_vyos)
+ have = _device_to_argspec(raw)
+ return have, raw
+
+ def test_merged_idempotent_against_own_fixture(self):
+ have, raw = self._have_and_raw()
+ self.assertEqual(build_commands(have, raw, "merged"), [])
+
+ def test_replaced_idempotent_against_own_fixture(self):
+ have, raw = self._have_and_raw()
+ self.assertEqual(build_commands(have, raw, "replaced"), [])
+
+ def test_overridden_idempotent_against_own_fixture(self):
+ have, raw = self._have_and_raw()
+ self.assertEqual(build_commands(have, raw, "overridden"), [])
+
+ def test_replaced_scoped_to_named_rule_sets_only(self):
+ raw = {
+ "ipv4": {
+ "RS1": {"default-action": "drop", "rule": {"10": {"action": "accept"}}},
+ "RS2": {"default-action": "accept"},
+ },
+ }
+ cfg = [
{
"afi": "ipv4",
"rule_sets": [
{
- "name": "RULE-SET1",
+ "name": "RS1",
"default_action": "drop",
- "rules": [
- {"number": 10, "action": "accept", "protocol": "tcp"},
- {"number": 20, "action": "drop", "state": "invalid"},
- ],
+ "rules": [{"number": 10, "action": "accept"}],
},
],
},
]
- cmds = build_commands(config, have, "merged")
- self.assertEqual(cmds, [])
+ self.assertEqual(build_commands(cfg, raw, "replaced"), [])
- def test_overridden_removes_extra_rule_set(self):
- have = self._have()
- config = [
+ def test_overridden_deletes_omitted_rule_set(self):
+ raw = {"ipv4": {"RS1": {"default-action": "drop"}, "RS2": {"default-action": "accept"}}}
+ cfg = [{"afi": "ipv4", "rule_sets": [{"name": "RS1", "default_action": "drop"}]}]
+ cmds = build_commands(cfg, raw, "overridden")
+ self.assertIn(("delete", _BASE + ["ipv4", "name", "RS2"]), cmds)
+
+ def test_overridden_never_touches_sibling_hook_filters(self):
+ """Regression test: firewall.ipv4.{input,output,forward} (owned
+ by vyos_firewall_interfaces) and firewall.group (owned by
+ vyos_firewall_global) must never be touched by this module."""
+ raw = {"ipv4": {"RS1": {"default-action": "drop"}}}
+ cmds = build_commands([], raw, "overridden")
+ self.assertTrue(all("input" not in c[1] and "group" not in c[1] for c in cmds))
+
+ def test_deleted_no_config_deletes_all_present(self):
+ raw = {"ipv4": {"RS1": {}}, "ipv6": {"RS6": {}}}
+ cmds = build_commands([], raw, "deleted")
+ self.assertIn(("delete", _BASE + ["ipv4", "name", "RS1"]), cmds)
+ self.assertIn(("delete", _BASE + ["ipv6", "name", "RS6"]), cmds)
+
+ def test_deleted_scoped_to_named_config(self):
+ raw = {"ipv4": {"RS1": {}, "RS2": {}}}
+ cmds = build_commands([{"afi": "ipv4", "rule_sets": [{"name": "RS1"}]}], raw, "deleted")
+ self.assertEqual(cmds, [("delete", _BASE + ["ipv4", "name", "RS1"])])
+
+ def test_collapsed_rule_no_char_iteration_bug(self):
+ raw = {"ipv4": {"RS1": {"rule": "10"}}}
+ cfg = [{"afi": "ipv4", "rule_sets": [{"name": "RS1", "rules": [{"number": 10}]}]}]
+ self.assertEqual(build_commands(cfg, raw, "merged"), [])
+
+ def test_merged_new_rule_with_group(self):
+ cfg = [
{
"afi": "ipv4",
"rule_sets": [
{
- "name": "NEW-SET",
- "default_action": "accept",
- "rules": [{"number": 10, "action": "accept"}],
+ "name": "RS1",
+ "rules": [{"number": 30, "action": "accept", "source": {"group": "G1"}}],
},
],
},
]
- cmds = build_commands(config, have, "overridden")
+ cmds = build_commands(cfg, {}, "merged")
self.assertIn(
- ("delete", _BASE + ["ipv4", "name", "RULE-SET1"]),
+ (
+ "set",
+ _BASE
+ + ["ipv4", "name", "RS1", "rule", "30", "source", "group", "address-group", "G1"],
+ ),
cmds,
)
diff --git a/tests/unit/modules/test_vyos_ha.py b/tests/unit/modules/test_vyos_ha.py
new file mode 100644
index 0000000..10a9144
--- /dev/null
+++ b/tests/unit/modules/test_vyos_ha.py
@@ -0,0 +1,345 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+import unittest
+
+from unittest.mock import MagicMock
+
+from ansible_collections.vyos.rest.plugins.modules.vyos_ha import (
+ _device_to_argspec,
+ _group_from_device,
+ _group_to_device,
+ _real_server_from_device,
+ _real_server_to_device,
+ _virtual_server_from_device,
+ _virtual_server_to_device,
+ _want_to_device,
+ build_commands,
+ get_running_config,
+)
+
+from .base import load_fixture
+
+
+_BASE = ["high-availability"]
+
+
+class VyOSModuleTestCase(unittest.TestCase):
+ def setUp(self):
+ self.mock_vyos = MagicMock()
+ self.fixture = load_fixture("ha_running.json")
+ self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
+
+
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_returns_raw_device_dict(self):
+ self.assertEqual(get_running_config(self.mock_vyos), self.fixture)
+
+ def test_empty_config(self):
+ self.mock_vyos.get_config = MagicMock(return_value=None)
+ self.assertEqual(get_running_config(self.mock_vyos), {})
+
+
+class TestRealServer(unittest.TestCase):
+ def test_to_device_generic_fields(self):
+ result = _real_server_to_device({"address": "10.0.0.2", "port": 8080})
+ self.assertEqual(result, {"port": 8080})
+
+ def test_to_device_health_check_script_nested(self):
+ """health_check_script is a genuine structural exception -- the
+ argspec has it flat, the device nests it under health-check.script."""
+ result = _real_server_to_device(
+ {"address": "10.0.0.2", "health_check_script": "/check.sh"},
+ )
+ self.assertEqual(result, {"health-check": {"script": "/check.sh"}})
+
+ def test_from_device_basic(self):
+ entry = _real_server_from_device("10.0.0.2", {"port": "8080"})
+ self.assertEqual(entry["address"], "10.0.0.2")
+ self.assertEqual(entry["port"], 8080)
+
+ def test_from_device_health_check_script_extracted(self):
+ entry = _real_server_from_device(
+ "10.0.0.2",
+ {"health-check": {"script": "/check.sh"}},
+ )
+ self.assertEqual(entry["health_check_script"], "/check.sh")
+
+
+class TestVirtualServer(unittest.TestCase):
+ def test_to_device_keyed_fields(self):
+ result = _virtual_server_to_device({"name": "s1", "address": "10.0.0.1", "port": 80})
+ self.assertEqual(result, {"address": "10.0.0.1", "port": 80})
+
+ def test_to_device_real_server_keyed_by_address(self):
+ vs = {
+ "name": "s1",
+ "port": 80,
+ "real_server": [{"address": "10.0.0.2", "port": 8080}],
+ }
+ result = _virtual_server_to_device(vs)
+ self.assertEqual(result["real-server"]["10.0.0.2"], {"port": 8080})
+
+ def test_from_device_list_with_real_servers(self):
+ entry = _virtual_server_from_device(
+ "s1",
+ {"port": "80", "real-server": {"10.0.0.2": {"port": "8080"}}},
+ )
+ self.assertEqual(entry["name"], "s1")
+ self.assertEqual(entry["port"], 80)
+ self.assertEqual(entry["real_server"][0]["address"], "10.0.0.2")
+ self.assertEqual(entry["real_server"][0]["port"], 8080)
+
+
+class TestGroup(unittest.TestCase):
+ """address/excluded_address are genuine tagNodes (confirmed); track
+ is NOT special-cased for interface since that's a plain list."""
+
+ def test_to_device_basic_fields_generic(self):
+ result = _group_to_device({"name": "g1", "vrid": 20, "interface": "eth0"})
+ self.assertEqual(result, {"vrid": 20, "interface": "eth0"})
+
+ def test_to_device_address_tag_node(self):
+ result = _group_to_device(
+ {"name": "g1", "address": ["192.168.1.1/24", "192.168.1.2/24"]},
+ )
+ self.assertEqual(
+ result["address"],
+ {"192.168.1.1/24": {}, "192.168.1.2/24": {}},
+ )
+
+ def test_to_device_excluded_address_tag_node(self):
+ result = _group_to_device({"name": "g1", "excluded_address": ["10.0.0.1"]})
+ self.assertEqual(result["excluded-address"], {"10.0.0.1": {}})
+
+ def test_to_device_track_interface_stays_plain_list(self):
+ """Regression test: track.interface is a confirmed <multi/>
+ leafNode, not a tag node -- must NOT be reshaped into a
+ dict-of-presence like address/excluded_address are."""
+ result = _group_to_device({"name": "g1", "track": {"interface": ["eth1", "eth2"]}})
+ self.assertEqual(result["track"]["interface"], ["eth1", "eth2"])
+
+ def test_to_device_bool_fields(self):
+ result = _group_to_device(
+ {"name": "g1", "disable": True, "no_preempt": True, "rfc3768_compatibility": False},
+ )
+ self.assertEqual(result["disable"], {})
+ self.assertEqual(result["no_preempt"], {})
+ self.assertNotIn("rfc3768_compatibility", result)
+
+ def test_from_device_vrid_and_priority_cast_to_int(self):
+ entry = _group_from_device("g1", {"vrid": "20", "priority": "100"})
+ self.assertEqual(entry["vrid"], 20)
+ self.assertEqual(entry["priority"], 100)
+
+ def test_from_device_address_tag_node_to_sorted_list(self):
+ entry = _group_from_device(
+ "g1",
+ {"address": {"192.168.1.2/24": {}, "192.168.1.1/24": {}}},
+ )
+ self.assertEqual(entry["address"], ["192.168.1.1/24", "192.168.1.2/24"])
+
+ def test_from_device_single_address_string_collapse(self):
+ entry = _group_from_device("g1", {"address": "192.168.1.1/24"})
+ self.assertEqual(entry["address"], ["192.168.1.1/24"])
+
+ def test_from_device_track_interface_stays_plain_list(self):
+ entry = _group_from_device("g1", {"track": {"interface": ["eth1", "eth2"]}})
+ self.assertEqual(entry["track"]["interface"], ["eth1", "eth2"])
+
+ def test_from_device_bool_presence_nodes(self):
+ entry = _group_from_device("g1", {"disable": {}, "no-preempt": {}})
+ self.assertTrue(entry["disable"])
+ self.assertTrue(entry["no_preempt"])
+
+
+class TestWantToDevice(unittest.TestCase):
+ def test_empty(self):
+ self.assertEqual(_want_to_device({}), {})
+ self.assertEqual(_want_to_device(None), {})
+
+ def test_disable(self):
+ result = _want_to_device({"disable": True})
+ self.assertEqual(result["disable"], {})
+
+ def test_virtual_server_keyed_by_name(self):
+ config = {"virtual_servers": [{"name": "s1", "address": "10.0.0.1", "port": 80}]}
+ result = _want_to_device(config)
+ self.assertIn("s1", result["virtual-server"])
+
+ def test_vrrp_global_parameters_generic(self):
+ config = {
+ "vrrp": {"global_parameters": {"startup_delay": 30, "garp": {"master_repeat": 6}}},
+ }
+ result = _want_to_device(config)
+ gp = result["vrrp"]["global_parameters"]
+ self.assertEqual(gp["startup_delay"], 30)
+ self.assertEqual(gp["garp"]["master_repeat"], 6)
+
+ def test_snmp_enabled_becomes_presence_node(self):
+ result = _want_to_device({"vrrp": {"snmp": "enabled"}})
+ self.assertEqual(result["vrrp"]["snmp"], {})
+
+ def test_snmp_disabled_not_in_want(self):
+ result = _want_to_device({"vrrp": {"snmp": "disabled"}})
+ self.assertNotIn("snmp", result.get("vrrp", {}))
+
+ def test_group_keyed_by_name(self):
+ config = {"vrrp": {"groups": [{"name": "g1", "vrid": 20, "interface": "eth0"}]}}
+ result = _want_to_device(config)
+ self.assertEqual(result["vrrp"]["group"]["g1"]["vrid"], 20)
+
+ def test_sync_group_member_stays_plain_list(self):
+ """Regression test: member is a confirmed <multi/> leafNode, not
+ a tag node -- must stay a plain list."""
+ config = {"vrrp": {"sync_groups": [{"name": "sg1", "member": ["g1", "g2"]}]}}
+ result = _want_to_device(config)
+ self.assertEqual(result["vrrp"]["sync-group"]["sg1"]["member"], ["g1", "g2"])
+
+
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_disable_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ self.assertTrue(result["disable"])
+
+ def test_virtual_server_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ vs = result["virtual_servers"][0]
+ self.assertEqual(vs["name"], "s1")
+ self.assertEqual(vs["port"], 80)
+ self.assertEqual(vs["real_server"][0]["address"], "10.10.50.2")
+ self.assertEqual(vs["real_server"][0]["port"], 8443)
+
+ def test_global_parameters_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ gp = result["vrrp"]["global_parameters"]
+ self.assertEqual(gp["startup_delay"], 30)
+ self.assertEqual(gp["garp"]["master_repeat"], 6)
+
+ def test_snmp_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ self.assertEqual(result["vrrp"]["snmp"], "enabled")
+
+ def test_groups_parsed_with_track_interface_as_list(self):
+ result = _device_to_argspec(self.fixture)
+ groups = {g["name"]: g for g in result["vrrp"]["groups"]}
+ self.assertEqual(groups["g1"]["interface"], "eth0")
+ self.assertEqual(groups["g1"]["vrid"], 20)
+ self.assertIn("192.168.1.100/24", groups["g1"]["address"])
+ self.assertTrue(groups["g1"]["no_preempt"])
+ self.assertEqual(groups["g1"]["track"]["interface"], ["eth1", "eth2"])
+ # g2: single address string collapsed by device -> list
+ self.assertEqual(groups["g2"]["address"], ["192.168.2.100/24"])
+
+ def test_sync_group_parsed_member_as_list(self):
+ result = _device_to_argspec(self.fixture)
+ sg = result["vrrp"]["sync_groups"][0]
+ self.assertEqual(sg["name"], "sg1")
+ self.assertEqual(sg["member"], ["g1"])
+ self.assertEqual(sg["health_check"]["failure_count"], 5)
+ self.assertEqual(sg["health_check"]["ping"], "192.168.1.1")
+
+ def test_empty_config(self):
+ self.assertEqual(_device_to_argspec({}), {})
+ self.assertEqual(_device_to_argspec(None), {})
+
+
+class TestBuildCommands(VyOSModuleTestCase):
+ def test_merged_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "merged"), [])
+
+ def test_replaced_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "replaced"), [])
+
+ def test_overridden_idempotent_against_own_fixture(self):
+ """overridden is a single dict_op purge+set call (simplified from
+ the original manual section-scan loop -- confirmed identical
+ behavior before removing the loop)."""
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "overridden"), [])
+
+ def test_merged_adds_vrrp_group(self):
+ config = {
+ "vrrp": {
+ "groups": [{"name": "g3", "vrid": 30, "interface": "eth2", "priority": 100}],
+ },
+ }
+ cmds = build_commands(config, {}, "merged")
+ self.assertIn(("set", _BASE + ["vrrp", "group", "g3", "vrid", "30"]), cmds)
+ self.assertIn(("set", _BASE + ["vrrp", "group", "g3", "interface", "eth2"]), cmds)
+
+ def test_overridden_deletes_omitted_top_level_section(self):
+ raw_have = {"virtual-server": {"s1": {"port": "80"}}, "vrrp": {"group": {"g1": {}}}}
+ config = {"vrrp": {"groups": [{"name": "g1"}]}}
+ cmds = build_commands(config, raw_have, "overridden")
+ self.assertIn(("delete", _BASE + ["virtual-server"]), cmds)
+
+ def test_replaced_removes_stale_track_interface_member(self):
+ """Regression test for the dict_op purge list-value fix (this
+ session): track.interface being a plain list means removing a
+ member under 'replaced' relies on dict_op's list-purge handling."""
+ raw_have = {"vrrp": {"group": {"g1": {"track": {"interface": ["eth1", "eth2"]}}}}}
+ config = {"vrrp": {"groups": [{"name": "g1", "track": {"interface": ["eth1"]}}]}}
+ cmds = build_commands(config, raw_have, "replaced")
+ self.assertIn(
+ ("delete", _BASE + ["vrrp", "group", "g1", "track", "interface", "eth2"]),
+ cmds,
+ )
+
+ def test_replaced_removes_stale_sync_group_member(self):
+ raw_have = {"vrrp": {"sync-group": {"sg1": {"member": ["g1", "g2"]}}}}
+ config = {"vrrp": {"sync_groups": [{"name": "sg1", "member": ["g1"]}]}}
+ cmds = build_commands(config, raw_have, "replaced")
+ self.assertIn(
+ ("delete", _BASE + ["vrrp", "sync-group", "sg1", "member", "g2"]),
+ cmds,
+ )
+
+ def test_snmp_disabled_deletes_presence_node(self):
+ raw_have = {"vrrp": {"snmp": {}}}
+ config = {"vrrp": {"snmp": "disabled"}}
+ cmds = build_commands(config, raw_have, "merged")
+ self.assertIn(("delete", _BASE + ["vrrp", "snmp"]), cmds)
+
+ def test_deleted_no_have_is_noop(self):
+ self.assertEqual(build_commands({}, {}, "deleted"), [])
+
+ def test_deleted_with_have(self):
+ self.assertEqual(
+ build_commands({}, {"vrrp": {"group": {"g1": {}}}}, "deleted"),
+ [("delete", _BASE)],
+ )
+
+ def test_collapsed_track_interface_no_char_iteration_bug(self):
+ """A group with exactly one tracked interface, collapsed by the
+ device to a bare string, must not be iterated character-by-
+ character (dict_op's own list handling corrects this natively)."""
+ raw_have = {"vrrp": {"group": {"g1": {"track": {"interface": "eth1"}}}}}
+ config = {"vrrp": {"groups": [{"name": "g1", "track": {"interface": ["eth1"]}}]}}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+
+ def test_collapsed_sync_group_member_no_char_iteration_bug(self):
+ raw_have = {"vrrp": {"sync-group": {"sg1": {"member": "g1"}}}}
+ config = {"vrrp": {"sync_groups": [{"name": "sg1", "member": ["g1"]}]}}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+
+ def test_virtual_server_address_never_treated_as_tag_node(self):
+ """Regression test: virtual-server.<name>.address is a flat
+ scalar (the load-balancer bind address), unlike vrrp.group.
+ <name>.address which IS a genuine tag node (VRRP virtual IPs).
+ Same key name, different device shape depending on section --
+ a blanket key-name-based coercion previously corrupted this
+ into a spurious diff every single run."""
+ raw_have = {"virtual-server": {"s1": {"address": "10.10.10.5", "port": "80"}}}
+ config = {"virtual_servers": [{"name": "s1", "address": "10.10.10.5", "port": 80}]}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+ self.assertEqual(build_commands(config, raw_have, "replaced"), [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/modules/test_vyos_logging_global.py b/tests/unit/modules/test_vyos_logging_global.py
index 7d91182..ccada8f 100644
--- a/tests/unit/modules/test_vyos_logging_global.py
+++ b/tests/unit/modules/test_vyos_logging_global.py
@@ -4,235 +4,271 @@ from __future__ import absolute_import, division, print_function
__metaclass__ = type
-import os
-import sys
import unittest
-
-sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
-
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import dict_op
from ansible_collections.vyos.rest.plugins.modules.vyos_logging_global import (
- build_commands,
- normalize_config,
- normalize_running,
+ _device_to_argspec,
+ _fac_device_to_list,
+ _fac_list_to_device,
+ _want_to_device,
)
-class TestVyOSLoggingGlobalNormalize(unittest.TestCase):
+_BASE = ["system", "syslog"]
+
+
+class TestFacHelpers(unittest.TestCase):
+ """Test facility list <-> device dict conversion helpers."""
+
+ def test_fac_list_to_device_with_severity(self):
+ facs = [{"facility": "local7", "severity": "err"}]
+ result = _fac_list_to_device(facs)
+ self.assertEqual(result, {"local7": {"level": "err"}})
+
+ def test_fac_list_to_device_no_severity(self):
+ facs = [{"facility": "all"}]
+ result = _fac_list_to_device(facs)
+ self.assertEqual(result, {"all": {}})
+
+ def test_fac_list_to_device_with_protocol(self):
+ facs = [{"facility": "all", "protocol": "udp"}]
+ result = _fac_list_to_device(facs)
+ self.assertEqual(result["all"]["protocol"], "udp")
+ self.assertNotIn("level", result["all"])
+
+ def test_fac_list_to_device_empty(self):
+ self.assertEqual(_fac_list_to_device([]), {})
+ self.assertEqual(_fac_list_to_device(None), {})
+
+ def test_fac_device_to_list_with_level(self):
+ raw = {"local7": {"level": "err"}, "all": {}}
+ result = _fac_device_to_list(raw)
+ names = [f["facility"] for f in result]
+ self.assertIn("local7", names)
+ self.assertIn("all", names)
+ local7 = next(f for f in result if f["facility"] == "local7")
+ self.assertEqual(local7["severity"], "err")
+
+ def test_fac_device_to_list_empty(self):
+ self.assertEqual(_fac_device_to_list({}), [])
+ self.assertEqual(_fac_device_to_list(None), [])
+
+ def test_fac_device_to_list_sorted(self):
+ raw = {"z-fac": {}, "a-fac": {}}
+ result = _fac_device_to_list(raw)
+ self.assertEqual(result[0]["facility"], "a-fac")
+ self.assertEqual(result[1]["facility"], "z-fac")
- def test_normalize_config_console_severity_is_string(self):
- cfg = {
- "console": {
- "facilities": [{"facility": "local7", "severity": "err"}],
- },
- }
- result = normalize_config(cfg)
- self.assertIn("local7", result["console"]["facilities"])
- # severity is stored as plain string, not dict
- self.assertEqual(result["console"]["facilities"]["local7"], "err")
-
- def test_normalize_config_console_no_severity(self):
- cfg = {
- "console": {
- "facilities": [{"facility": "all"}],
- },
- }
- result = normalize_config(cfg)
- self.assertIsNone(result["console"]["facilities"]["all"])
- def test_normalize_config_hosts(self):
- cfg = {
+class TestWantToDevice(unittest.TestCase):
+ """Test argspec -> device shape conversion."""
+
+ def test_empty(self):
+ self.assertEqual(_want_to_device({}), {})
+ self.assertEqual(_want_to_device(None), {})
+
+ def test_console_facilities(self):
+ config = {"console": {"facilities": [{"facility": "local7", "severity": "err"}]}}
+ result = _want_to_device(config)
+ self.assertIn("console", result)
+ self.assertEqual(result["console"]["facility"]["local7"], {"level": "err"})
+
+ def test_global_params_facilities(self):
+ config = {"global_params": {"facilities": [{"facility": "cron", "severity": "debug"}]}}
+ result = _want_to_device(config)
+ self.assertIn("local", result)
+ self.assertEqual(result["local"]["facility"]["cron"], {"level": "debug"})
+
+ def test_global_params_marker_interval(self):
+ config = {"global_params": {"marker_interval": 111}}
+ result = _want_to_device(config)
+ self.assertEqual(result["marker"], {"interval": 111})
+
+ def test_global_params_preserve_fqdn(self):
+ config = {"global_params": {"preserve_fqdn": True}}
+ result = _want_to_device(config)
+ self.assertEqual(result["preserve-fqdn"], {})
+
+ def test_hosts_mapped_to_remote(self):
+ config = {
"hosts": [
{
"hostname": "172.16.0.1",
"port": 514,
- "facilities": [
- {"facility": "local7", "severity": "all"},
- {"facility": "all", "protocol": "udp"},
- ],
+ "facilities": [{"facility": "local7", "severity": "all"}],
},
],
}
- result = normalize_config(cfg)
- self.assertIn("172.16.0.1", result["hosts"])
- host = result["hosts"]["172.16.0.1"]
- self.assertEqual(host["port"], 514)
- self.assertIn("local7", host["facilities"])
- # host facilities are dicts with severity/protocol
- self.assertEqual(host["facilities"]["local7"]["severity"], "all")
- self.assertEqual(host["facilities"]["all"]["protocol"], "udp")
-
- def test_normalize_config_global_preserve_fqdn(self):
- cfg = {"global_params": {"preserve_fqdn": True}}
- result = normalize_config(cfg)
- self.assertTrue(result["global"]["preserve_fqdn"])
-
- def test_normalize_config_global_archive(self):
- cfg = {"global_params": {"archive": {"file_num": 2, "size": 111}}}
- result = normalize_config(cfg)
- self.assertEqual(result["global"]["archive"]["file_num"], 2)
- self.assertEqual(result["global"]["archive"]["size"], 111)
-
- def test_normalize_config_empty(self):
- result = normalize_config({})
- self.assertEqual(result["console"]["facilities"], {})
- self.assertEqual(result["hosts"], {})
- self.assertEqual(result["files"], {})
- self.assertEqual(result["users"], {})
-
- def test_normalize_running_console_severity_is_string(self):
- raw = {
- "console": {
- "facility": {
- "local7": {"level": "err"},
- "all": {},
+ result = _want_to_device(config)
+ self.assertIn("remote", result)
+ self.assertIn("172.16.0.1", result["remote"])
+ self.assertEqual(result["remote"]["172.16.0.1"]["port"], 514)
+ self.assertIn("local7", result["remote"]["172.16.0.1"]["facility"])
+
+ def test_users_mapped_to_user(self):
+ config = {
+ "users": [
+ {
+ "username": "vyos",
+ "facilities": [{"facility": "local7", "severity": "debug"}],
},
- },
+ ],
}
- result = normalize_running(raw)
- self.assertIn("local7", result["console"]["facilities"])
- # severity is plain string from "level" key
- self.assertEqual(result["console"]["facilities"]["local7"], "err")
- self.assertIsNone(result["console"]["facilities"]["all"])
-
- def test_normalize_running_host_port_not_cast(self):
- """Port is NOT cast to int — stored as-is from API response."""
+ result = _want_to_device(config)
+ self.assertIn("user", result)
+ self.assertIn("vyos", result["user"])
+
+
+class TestDeviceToArgspec(unittest.TestCase):
+ """Test device response -> argspec shape conversion."""
+
+ def test_empty(self):
+ self.assertEqual(_device_to_argspec({}), {})
+ self.assertEqual(_device_to_argspec(None), {})
+
+ def test_console(self):
+ raw = {"console": {"facility": {"local7": {"level": "err"}}}}
+ result = _device_to_argspec(raw)
+ self.assertIn("console", result)
+ facs = result["console"]["facilities"]
+ self.assertEqual(facs[0]["facility"], "local7")
+ self.assertEqual(facs[0]["severity"], "err")
+
+ def test_local_to_global_params(self):
+ raw = {"local": {"facility": {"cron": {"level": "debug"}}}}
+ result = _device_to_argspec(raw)
+ self.assertIn("global_params", result)
+ facs = result["global_params"]["facilities"]
+ self.assertEqual(facs[0]["facility"], "cron")
+
+ def test_marker_interval(self):
+ raw = {"marker": {"interval": "111"}}
+ result = _device_to_argspec(raw)
+ self.assertEqual(result["global_params"]["marker_interval"], "111")
+
+ def test_preserve_fqdn(self):
+ raw = {"preserve-fqdn": {}}
+ result = _device_to_argspec(raw)
+ self.assertTrue(result["global_params"]["preserve_fqdn"])
+
+ def test_remote_to_hosts(self):
raw = {
"remote": {
"172.16.0.1": {
- "port": "514",
- "facility": {},
+ "port": 514,
+ "facility": {"local7": {"level": "all"}},
},
},
}
- result = normalize_running(raw)
- # port stays as string — module does not cast
- self.assertEqual(result["hosts"]["172.16.0.1"]["port"], "514")
+ result = _device_to_argspec(raw)
+ self.assertIn("hosts", result)
+ host = result["hosts"][0]
+ self.assertEqual(host["hostname"], "172.16.0.1")
+ self.assertEqual(host["port"], 514)
+ self.assertEqual(host["facilities"][0]["facility"], "local7")
- def test_normalize_running_global_archive_key(self):
- """Archive stored under 'archive' key — no file_num remapping."""
- raw = {
- "local": {
- "archive": {"file": "2", "size": "111"},
- "marker": {"interval": "111"},
- "preserve-fqdn": {},
- },
- }
- result = normalize_running(raw)
- # archive stored as-is from API
- self.assertEqual(result["global"]["archive"]["file"], "2")
- self.assertEqual(result["global"]["archive"]["size"], "111")
- # marker_interval stored as string — no cast
- self.assertEqual(result["global"]["marker_interval"], "111")
- self.assertTrue(result["global"]["preserve_fqdn"])
-
- def test_normalize_running_empty(self):
- result = normalize_running({})
- self.assertEqual(result["console"]["facilities"], {})
- self.assertEqual(result["hosts"], {})
-
- def test_normalize_running_host_facilities(self):
- raw = {
- "remote": {
- "172.16.0.1": {
- "facility": {
- "local7": {"level": "all"},
- "all": {"protocol": "udp"},
- },
- "port": "223",
- },
- },
- }
- result = normalize_running(raw)
- h = result["hosts"]["172.16.0.1"]
- self.assertEqual(h["facilities"]["local7"]["severity"], "all")
- self.assertEqual(h["facilities"]["all"]["protocol"], "udp")
+ def test_user_to_users(self):
+ raw = {"user": {"vyos": {"facility": {"local7": {"level": "debug"}}}}}
+ result = _device_to_argspec(raw)
+ self.assertIn("users", result)
+ self.assertEqual(result["users"][0]["username"], "vyos")
+ def test_hosts_sorted(self):
+ raw = {"remote": {"z.host": {}, "a.host": {}}}
+ result = _device_to_argspec(raw)
+ self.assertEqual(result["hosts"][0]["hostname"], "a.host")
-class TestVyOSLoggingGlobalBuildCommands(unittest.TestCase):
- def _empty_have(self):
- return {
- "console": {"facilities": {}},
- "global": {"facilities": {}},
- "hosts": {},
- "files": {},
- "users": {},
- }
+class TestDictOpLogging(unittest.TestCase):
+ """Test dict_op behaviour with logging device shapes."""
- def test_merged_adds_console_facility_with_severity(self):
- want = self._empty_have()
- want["console"]["facilities"]["local7"] = "err"
- cmds = build_commands(want, self._empty_have(), "merged")
- self.assertIn(
- ("set", ["system", "syslog", "console", "facility", "local7", "level", "err"]),
- cmds,
+ def test_merged_adds_console_facility(self):
+ want = _want_to_device(
+ {
+ "console": {"facilities": [{"facility": "local7", "severity": "err"}]},
+ },
)
+ cmds = dict_op(want, {}, _BASE, op="set")
+ paths = [c[1] for c in cmds]
+ self.assertIn(_BASE + ["console", "facility", "local7", "level", "err"], paths)
- def test_merged_adds_console_facility_no_severity(self):
- want = self._empty_have()
- want["console"]["facilities"]["all"] = None
- cmds = build_commands(want, self._empty_have(), "merged")
- self.assertIn(
- ("set", ["system", "syslog", "console", "facility", "all"]),
- cmds,
+ def test_merged_idempotent_console(self):
+ want = _want_to_device(
+ {
+ "console": {"facilities": [{"facility": "local7", "severity": "err"}]},
+ },
)
+ have = {"console": {"facility": {"local7": {"level": "err"}}}}
+ cmds = dict_op(want, have, _BASE, op="set")
+ self.assertEqual(cmds, [])
- def test_merged_idempotent_console(self):
- facs = {"local7": "err"}
- want = self._empty_have()
- have = self._empty_have()
- want["console"]["facilities"] = facs
- have["console"]["facilities"] = dict(facs)
- cmds = build_commands(want, have, "merged")
+ def test_merged_adds_preserve_fqdn(self):
+ want = _want_to_device({"global_params": {"preserve_fqdn": True}})
+ cmds = dict_op(want, {}, _BASE, op="set")
+ paths = [c[1] for c in cmds]
+ self.assertIn(_BASE + ["preserve-fqdn"], paths)
+
+ def test_preserve_fqdn_idempotent(self):
+ want = _want_to_device({"global_params": {"preserve_fqdn": True}})
+ have = {"preserve-fqdn": {}}
+ cmds = dict_op(want, have, _BASE, op="set")
self.assertEqual(cmds, [])
- def test_merged_adds_host(self):
- want = self._empty_have()
- want["hosts"]["172.16.0.1"] = {
- "port": 514,
- "facilities": {"local7": {"severity": "all", "protocol": None}},
+ def test_purge_removes_extra_remote_host(self):
+ want = _want_to_device(
+ {
+ "hosts": [{"hostname": "10.0.0.1", "facilities": []}],
+ },
+ )
+ have = {
+ "remote": {
+ "10.0.0.1": {},
+ "10.0.0.2": {},
+ },
}
- cmds = build_commands(want, self._empty_have(), "merged")
+ cmds = dict_op(want, have, _BASE, op="purge")
paths = [c[1] for c in cmds]
- # diff_map only adds the host key, not per-facility details
- self.assertIn(["system", "syslog", "remote", "172.16.0.1"], paths)
-
- def test_replaced_removes_extra_host(self):
- want = self._empty_have()
- have = self._empty_have()
- have["hosts"]["172.16.0.1"] = {"port": None, "facilities": {}}
- cmds = build_commands(want, have, "replaced")
- self.assertIn(("delete", ["system", "syslog", "remote", "172.16.0.1"]), cmds)
-
- def test_deleted_removes_per_field(self):
- """deleted state removes per-facility entries, not single subtree."""
- have = self._empty_have()
- have["console"]["facilities"]["all"] = None
- cmds = build_commands(self._empty_have(), have, "deleted")
- self.assertIn(
- ("delete", ["system", "syslog", "console", "facility", "all"]),
- cmds,
- )
+ self.assertIn(_BASE + ["remote", "10.0.0.2"], paths)
+ self.assertNotIn(_BASE + ["remote", "10.0.0.1"], paths)
- def test_overridden_deletes_all_then_merges(self):
- want = self._empty_have()
- want["console"]["facilities"]["local7"] = "err"
- have = self._empty_have()
- have["console"]["facilities"]["all"] = None
- cmds = build_commands(want, have, "overridden")
- # first command is full syslog delete
- self.assertEqual(cmds[0], ("delete", ["system", "syslog"]))
- # then adds wanted facility
- self.assertIn(
- ("set", ["system", "syslog", "console", "facility", "local7", "level", "err"]),
- cmds,
- )
+ def test_merged_adds_marker_interval(self):
+ want = _want_to_device({"global_params": {"marker_interval": 111}})
+ cmds = dict_op(want, {}, _BASE, op="set")
+ paths = [c[1] for c in cmds]
+ self.assertIn(_BASE + ["marker", "interval", "111"], paths)
def test_no_commands_when_already_correct(self):
- state = self._empty_have()
- state["console"]["facilities"]["local7"] = "err"
- cmds = build_commands(state, state, "merged")
+ config = {
+ "console": {"facilities": [{"facility": "local7", "severity": "err"}]},
+ "global_params": {"marker_interval": 111},
+ }
+ want = _want_to_device(config)
+ have = {
+ "console": {"facility": {"local7": {"level": "err"}}},
+ "marker": {"interval": 111},
+ }
+ cmds = dict_op(want, have, _BASE, op="set")
+ self.assertEqual(cmds, [])
+
+ def test_overridden_idempotent(self):
+ config = {
+ "console": {"facilities": [{"facility": "local7", "severity": "err"}]},
+ "global_params": {"marker_interval": 111},
+ }
+ want = _want_to_device(config)
+ have = {
+ "console": {"facility": {"local7": {"level": "err"}}},
+ "marker": {"interval": 111},
+ }
+ # First pass — purge+set
+ purge_cmds = []
+ for section, section_want in want.items():
+ section_have = have.get(section, {})
+ purge_cmds += dict_op(section_want, section_have, _BASE + [section], op="purge")
+ set_cmds = dict_op(want, have, _BASE, op="set")
+ cmds = purge_cmds + set_cmds
+ # Second pass — should be empty (idempotent)
self.assertEqual(cmds, [])
diff --git a/tests/unit/modules/test_vyos_nat.py b/tests/unit/modules/test_vyos_nat.py
new file mode 100644
index 0000000..9393e03
--- /dev/null
+++ b/tests/unit/modules/test_vyos_nat.py
@@ -0,0 +1,558 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+import unittest
+
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import dict_op
+from ansible_collections.vyos.rest.plugins.modules.vyos_nat import (
+ _cgnat_from_device,
+ _cgnat_to_device,
+ _device_to_argspec,
+ _normalize_nat_have,
+ _rules_from_device,
+ _rules_to_device,
+ _want_to_device,
+)
+
+from .base import load_fixture
+
+
+def _load_nat_fixture():
+ return load_fixture("nat_running.json")
+
+
+class TestRulesToDevice(unittest.TestCase):
+ """Keys stay snake_case here -- dict_op does kebab translation itself
+ at comparison time, so _rules_to_device must not do it manually."""
+
+ def test_simple_source_rule(self):
+ rules = [
+ {
+ "id": 100,
+ "outbound_interface": {"name": "eth0"},
+ "translation": {"address": "masquerade"},
+ },
+ ]
+ result = _rules_to_device(rules)
+ self.assertIn("100", result)
+ self.assertEqual(result["100"]["outbound_interface"]["name"], "eth0")
+ self.assertEqual(result["100"]["translation"]["address"], "masquerade")
+
+ def test_bool_fields_become_presence_nodes(self):
+ rules = [
+ {
+ "id": 100,
+ "log": True,
+ "exclude": True,
+ "translation": {"address": "masquerade"},
+ },
+ ]
+ result = _rules_to_device(rules)
+ self.assertEqual(result["100"]["log"], {})
+ self.assertEqual(result["100"]["exclude"], {})
+
+ def test_false_bool_not_emitted(self):
+ rules = [{"id": 100, "log": False, "translation": {"address": "masquerade"}}]
+ result = _rules_to_device(rules)
+ self.assertNotIn("log", result["100"])
+
+ def test_destination_rule_with_port(self):
+ rules = [
+ {
+ "id": 200,
+ "protocol": "tcp",
+ "inbound_interface": {"name": "eth0"},
+ "destination": {"address": "198.51.100.10", "port": "80"},
+ "translation": {"address": "192.168.1.10", "port": "8080"},
+ },
+ ]
+ result = _rules_to_device(rules)
+ self.assertEqual(result["200"]["protocol"], "tcp")
+ self.assertEqual(result["200"]["destination"]["address"], "198.51.100.10")
+ self.assertEqual(result["200"]["destination"]["port"], "80")
+
+ def test_static_rule_inbound_interface_string(self):
+ """inbound_interface is a genuine union type: a plain string for
+ static NAT (confirmed vyos-1x: bare leafNode), a dict for source/
+ destination NAT (confirmed: node with name/group children). No
+ special-casing needed either way -- autoclean passes a string
+ through unchanged and recurses into a dict identically."""
+ rules = [
+ {
+ "id": 300,
+ "inbound_interface": "eth0",
+ "destination": {"address": "198.51.100.20"},
+ "translation": {"address": "192.168.1.20"},
+ },
+ ]
+ result = _rules_to_device(rules)
+ self.assertEqual(result["300"]["inbound_interface"], "eth0")
+
+ def test_multiple_rules_keyed_by_id(self):
+ rules = [
+ {"id": 100, "translation": {"address": "masquerade"}},
+ {"id": 200, "translation": {"address": "masquerade"}},
+ ]
+ result = _rules_to_device(rules)
+ self.assertIn("100", result)
+ self.assertIn("200", result)
+
+ def test_none_values_not_emitted(self):
+ rules = [{"id": 100, "description": None, "translation": {"address": "masquerade"}}]
+ result = _rules_to_device(rules)
+ self.assertNotIn("description", result["100"])
+
+ def test_load_balance_hash_stays_a_plain_list(self):
+ """hash is a multi-value leafNode (confirmed <multi/>), not a tag
+ node -- it must pass through as a plain list untouched, letting
+ dict_op's own native list handling manage it."""
+ rules = [
+ {
+ "id": 100,
+ "load_balance": {"hash": ["source-address", "random"]},
+ "translation": {"address": "masquerade"},
+ },
+ ]
+ result = _rules_to_device(rules)
+ self.assertEqual(result["100"]["load_balance"]["hash"], ["source-address", "random"])
+
+ def test_load_balance_backend_reshaped_to_tag_node(self):
+ """backend IS a genuine tag node (confirmed: nested "weight"
+ leaf), unlike hash -- this one needs the structural reshape."""
+ rules = [
+ {
+ "id": 100,
+ "load_balance": {"backend": [{"ip": "192.168.1.10", "weight": 50}]},
+ "translation": {"address": "masquerade"},
+ },
+ ]
+ result = _rules_to_device(rules)
+ self.assertEqual(result["100"]["load_balance"]["backend"], {"192.168.1.10": {"weight": 50}})
+
+ def test_load_balance_backend_without_weight_is_bare_presence(self):
+ """Regression check for the autoclean-based simplification: a
+ backend entry with no weight must still produce a bare presence
+ node, matching the previous manual if/else exactly."""
+ rules = [
+ {
+ "id": 100,
+ "load_balance": {"backend": [{"ip": "192.168.1.10"}]},
+ "translation": {"address": "masquerade"},
+ },
+ ]
+ result = _rules_to_device(rules)
+ self.assertEqual(result["100"]["load_balance"]["backend"], {"192.168.1.10": {}})
+
+ def test_nat64_translation_pool_reshaped_to_tag_node(self):
+ rules = [
+ {"id": 10, "translation": {"pool": [{"id": 1, "address": "192.168.100.10"}]}},
+ ]
+ result = _rules_to_device(rules)
+ self.assertEqual(result["10"]["translation"]["pool"], {"1": {"address": "192.168.100.10"}})
+
+
+class TestRulesFromDevice(unittest.TestCase):
+ def test_simple_rule(self):
+ raw = {
+ "100": {
+ "outbound-interface": {"name": "eth0"},
+ "translation": {"address": "masquerade"},
+ },
+ }
+ result = _rules_from_device(raw)
+ self.assertEqual(len(result), 1)
+ self.assertEqual(result[0]["id"], 100)
+ self.assertEqual(result[0]["outbound_interface"]["name"], "eth0")
+
+ def test_rules_sorted_by_id(self):
+ raw = {
+ "200": {"translation": {"address": "masquerade"}},
+ "100": {"translation": {"address": "masquerade"}},
+ }
+ result = _rules_from_device(raw)
+ self.assertEqual(result[0]["id"], 100)
+ self.assertEqual(result[1]["id"], 200)
+
+ def test_presence_node_becomes_bool(self):
+ raw = {"100": {"log": {}, "translation": {"address": "masquerade"}}}
+ result = _rules_from_device(raw)
+ self.assertTrue(result[0]["log"])
+
+ def test_static_inbound_interface_string(self):
+ raw = {
+ "300": {
+ "inbound-interface": "eth0",
+ "destination": {"address": "198.51.100.20"},
+ "translation": {"address": "192.168.1.20"},
+ },
+ }
+ result = _rules_from_device(raw)
+ self.assertEqual(result[0]["inbound_interface"], "eth0")
+
+ def test_load_balance_hash_single_value_collapse(self):
+ """The device can collapse a single-value multi-leaf to a bare
+ string; this must come back as a 1-element list, not a string,
+ to match the field's real (list) type."""
+ raw = {"100": {"load_balance": {}, "load-balance": {"hash": "random"}}}
+ # (duplicate key above is just illustrating intent; real call:)
+ raw = {"100": {"load-balance": {"hash": "random"}}}
+ result = _rules_from_device(raw)
+ self.assertEqual(result[0]["load_balance"]["hash"], ["random"])
+
+ def test_load_balance_backend_from_tag_node(self):
+ raw = {"100": {"load-balance": {"backend": {"192.168.1.10": {"weight": "50"}}}}}
+ result = _rules_from_device(raw)
+ self.assertEqual(
+ result[0]["load_balance"]["backend"],
+ [{"ip": "192.168.1.10", "weight": 50}],
+ )
+
+ def test_nat64_pool_from_tag_node(self):
+ raw = {"10": {"translation": {"pool": {"1": {"address": "192.168.100.10"}}}}}
+ result = _rules_from_device(raw)
+ self.assertEqual(result[0]["translation"]["pool"], [{"id": 1, "address": "192.168.100.10"}])
+
+ def test_empty_returns_empty(self):
+ self.assertEqual(_rules_from_device({}), [])
+ self.assertEqual(_rules_from_device(None), [])
+
+
+class TestCgnat(unittest.TestCase):
+ """The core bug-fix area: external pool range is a genuine tag node
+ (nested "seq" leaf), internal pool range is a plain multi-value leaf
+ -- confirmed against vyos-1x schema, and previously conflated."""
+
+ def test_external_pool_range_is_tag_node_with_seq(self):
+ cgnat = {
+ "pool": {
+ "external": [
+ {"name": "EXT1", "range": [{"value": "203.0.113.1-203.0.113.10", "seq": 1}]},
+ ],
+ },
+ }
+ result = _cgnat_to_device(cgnat)
+ self.assertEqual(
+ result["pool"]["external"]["EXT1"]["range"],
+ {"203.0.113.1-203.0.113.10": {"seq": 1}},
+ )
+
+ def test_external_pool_range_without_seq(self):
+ cgnat = {"pool": {"external": [{"name": "EXT1", "range": [{"value": "203.0.113.1-.10"}]}]}}
+ result = _cgnat_to_device(cgnat)
+ self.assertEqual(result["pool"]["external"]["EXT1"]["range"], {"203.0.113.1-.10": {}})
+
+ def test_internal_pool_range_stays_a_plain_list(self):
+ cgnat = {"pool": {"internal": [{"name": "INT1", "range": ["10.0.0.0/24", "10.0.1.0/24"]}]}}
+ result = _cgnat_to_device(cgnat)
+ self.assertEqual(
+ result["pool"]["internal"]["INT1"]["range"],
+ ["10.0.0.0/24", "10.0.1.0/24"],
+ )
+
+ def test_internal_pool_multi_value_range_from_device_not_dropped(self):
+ """Regression test for the confirmed data-loss bug: the previous
+ implementation only checked isinstance(str)/isinstance(dict) for
+ internal pool range and silently dropped it whenever the device
+ returned the real shape for >1 value -- a plain list."""
+ raw = {"pool": {"internal": {"INT1": {"range": ["10.0.0.0/24", "10.0.1.0/24"]}}}}
+ result = _cgnat_from_device(raw)
+ pool = result["pool"]["internal"][0]
+ self.assertEqual(pool["range"], ["10.0.0.0/24", "10.0.1.0/24"])
+
+ def test_internal_pool_single_value_range_collapse(self):
+ raw = {"pool": {"internal": {"INT1": {"range": "10.0.2.0/24"}}}}
+ result = _cgnat_from_device(raw)
+ self.assertEqual(result["pool"]["internal"][0]["range"], ["10.0.2.0/24"])
+
+ def test_external_pool_range_from_device_with_seq(self):
+ raw = {"pool": {"external": {"EXT1": {"range": {"203.0.113.1-.10": {"seq": "1"}}}}}}
+ result = _cgnat_from_device(raw)
+ rng = result["pool"]["external"][0]["range"]
+ self.assertEqual(rng, [{"value": "203.0.113.1-.10", "seq": 1}])
+
+ def test_log_allocation_generic_presence(self):
+ result = _cgnat_to_device({"log_allocation": True})
+ self.assertEqual(result["log_allocation"], {})
+
+ def test_cgnat_rule_generic(self):
+ cgnat = {"rule": [{"id": 1, "destination": {"group": {"address_group": "CGNAT-DST"}}}]}
+ result = _cgnat_to_device(cgnat)
+ self.assertEqual(
+ result["rule"]["1"]["destination"]["group"]["address_group"],
+ "CGNAT-DST",
+ )
+
+ def test_empty(self):
+ self.assertEqual(_cgnat_to_device({}), {})
+ self.assertEqual(_cgnat_from_device({}), {})
+
+
+class TestWantToDevice(unittest.TestCase):
+ def test_empty(self):
+ self.assertEqual(_want_to_device({}), {})
+ self.assertEqual(_want_to_device(None), {})
+
+ def test_source_nat(self):
+ config = {
+ "nat": {
+ "source": {
+ "rule": [
+ {
+ "id": 100,
+ "outbound_interface": {"name": "eth0"},
+ "translation": {"address": "masquerade"},
+ },
+ ],
+ },
+ },
+ }
+ result = _want_to_device(config)
+ self.assertIn("100", result["nat"]["source"]["rule"])
+
+ def test_nat64_pools(self):
+ config = {
+ "nat64": {
+ "source": {
+ "rule": [
+ {
+ "id": 10,
+ "translation": {"pool": [{"id": 1, "address": "192.168.100.10"}]},
+ },
+ ],
+ },
+ },
+ }
+ result = _want_to_device(config)
+ rule = result["nat64"]["source"]["rule"]["10"]
+ self.assertIn("1", rule["translation"]["pool"])
+
+ def test_nat66(self):
+ config = {
+ "nat66": {
+ "source": {
+ "rule": [{"id": 10, "outbound_interface": {"name": "eth0"}}],
+ },
+ },
+ }
+ result = _want_to_device(config)
+ self.assertIn("10", result["nat66"]["source"]["rule"])
+
+ def test_nat66_destination_and_source_via_dispatch_table(self):
+ """nat66 has no cgnat, and only destination/source (no static) --
+ exercised via _NAT_TYPE_SECTIONS, not hand-written per-type
+ blocks."""
+ config = {
+ "nat66": {
+ "destination": {"rule": [{"id": 10, "protocol": "tcp"}]},
+ "source": {"rule": [{"id": 20}]},
+ },
+ }
+ result = _want_to_device(config)
+ self.assertIn("10", result["nat66"]["destination"]["rule"])
+ self.assertIn("20", result["nat66"]["source"]["rule"])
+ self.assertNotIn("cgnat", result["nat66"])
+
+ def test_cgnat_in_want(self):
+ config = {"nat": {"cgnat": {"log_allocation": True}}}
+ result = _want_to_device(config)
+ self.assertEqual(result["nat"]["cgnat"]["log_allocation"], {})
+
+
+class TestDeviceToArgspec(unittest.TestCase):
+ def test_empty(self):
+ self.assertEqual(_device_to_argspec({}), {})
+ self.assertEqual(_device_to_argspec(None), {})
+
+ def test_source_rule(self):
+ raw = {"nat": {"source": {"rule": {"100": {"translation": {"address": "masquerade"}}}}}}
+ result = _device_to_argspec(raw)
+ self.assertEqual(result["nat"]["source"]["rule"][0]["id"], 100)
+
+ def test_nat64_pools_parsed(self):
+ raw = {
+ "nat64": {
+ "source": {
+ "rule": {"10": {"translation": {"pool": {"1": {"address": "192.168.100.10"}}}}},
+ },
+ },
+ }
+ result = _device_to_argspec(raw)
+ pools = result["nat64"]["source"]["rule"][0]["translation"]["pool"]
+ self.assertEqual(pools[0]["id"], 1)
+
+ def test_static_inbound_interface_string(self):
+ raw = {"nat": {"static": {"rule": {"300": {"inbound-interface": "eth0"}}}}}
+ result = _device_to_argspec(raw)
+ self.assertEqual(result["nat"]["static"]["rule"][0]["inbound_interface"], "eth0")
+
+
+class TestDeviceToArgspecFixture(unittest.TestCase):
+ def setUp(self):
+ self.fixture = _load_nat_fixture()
+
+ def test_source_rules_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ ids = [r["id"] for r in result["nat"]["source"]["rule"]]
+ self.assertIn(100, ids)
+ self.assertIn(101, ids)
+
+ def test_destination_rule_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ rule = result["nat"]["destination"]["rule"][0]
+ self.assertEqual(rule["id"], 200)
+ self.assertEqual(rule["destination"]["port"], "80")
+
+ def test_static_rule_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ rule = result["nat"]["static"]["rule"][0]
+ self.assertEqual(rule["inbound_interface"], "eth0")
+
+ def test_nat64_pools_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ pools = result["nat64"]["source"]["rule"][0]["translation"]["pool"]
+ self.assertEqual(pools[0]["port"], "1-65535")
+
+ def test_description_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ rule100 = next(r for r in result["nat"]["source"]["rule"] if r["id"] == 100)
+ self.assertEqual(rule100["description"], "Source rule 100")
+
+ def test_hash_single_value_collapse_from_fixture(self):
+ result = _device_to_argspec(self.fixture)
+ rule100 = next(r for r in result["nat"]["source"]["rule"] if r["id"] == 100)
+ self.assertEqual(rule100["load_balance"]["hash"], ["random"])
+
+ def test_backend_from_fixture(self):
+ result = _device_to_argspec(self.fixture)
+ rule100 = next(r for r in result["nat"]["source"]["rule"] if r["id"] == 100)
+ backends = {b["ip"]: b.get("weight") for b in rule100["load_balance"]["backend"]}
+ self.assertEqual(backends["192.168.1.10"], 50)
+ self.assertEqual(backends["192.168.1.11"], None)
+
+ def test_cgnat_internal_pool_range_not_dropped(self):
+ """The actual regression this whole refactor was triggered by."""
+ result = _device_to_argspec(self.fixture)
+ pool = result["nat"]["cgnat"]["pool"]["internal"][0]
+ self.assertEqual(pool["range"], ["10.0.0.0/24", "10.0.1.0/24"])
+
+ def test_cgnat_external_pool_range_with_seq(self):
+ result = _device_to_argspec(self.fixture)
+ pool = result["nat"]["cgnat"]["pool"]["external"][0]
+ self.assertEqual(pool["range"], [{"value": "203.0.113.1-203.0.113.10", "seq": 1}])
+
+ def test_cgnat_rule_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ rule = result["nat"]["cgnat"]["rule"][0]
+ self.assertEqual(rule["destination"]["group"]["address_group"], "CGNAT-DST")
+
+
+class TestDictOpNat(unittest.TestCase):
+ """End-to-end command generation, exactly as main() calls it."""
+
+ def test_merged_adds_source_rule(self):
+ want = _want_to_device(
+ {
+ "nat": {
+ "source": {
+ "rule": [
+ {
+ "id": 100,
+ "outbound_interface": {"name": "eth0"},
+ "translation": {"address": "masquerade"},
+ },
+ ],
+ },
+ },
+ },
+ )
+ cmds = dict_op(want.get("nat", {}), {}, ["nat"], op="set")
+ paths = [c[1] for c in cmds]
+ self.assertIn(["nat", "source", "rule", "100", "outbound-interface", "name", "eth0"], paths)
+ self.assertIn(
+ ["nat", "source", "rule", "100", "translation", "address", "masquerade"],
+ paths,
+ )
+
+ def test_merged_idempotent_against_fixture(self):
+ fixture = _load_nat_fixture()
+ have = _device_to_argspec(fixture)
+ want = _want_to_device({"nat": have.get("nat", {})}).get("nat", {})
+ norm_have = _normalize_nat_have(fixture, "nat")
+ cmds = dict_op(want, norm_have, ["nat"], op="set")
+ self.assertEqual(cmds, [])
+
+ def test_nat64_idempotent_against_fixture(self):
+ fixture = _load_nat_fixture()
+ have = _device_to_argspec(fixture)
+ want = _want_to_device({"nat64": have.get("nat64", {})}).get("nat64", {})
+ norm_have = _normalize_nat_have(fixture, "nat64")
+ cmds = dict_op(want, norm_have, ["nat64"], op="set")
+ self.assertEqual(cmds, [])
+
+ def test_cgnat_idempotent_against_fixture_including_ranges(self):
+ """The real proof the bug is fixed: idempotency now holds even
+ though it involves both the tag-node (external) and plain-list
+ (internal) range shapes at once."""
+ fixture = _load_nat_fixture()
+ have = _device_to_argspec(fixture)
+ want = _want_to_device({"nat": have.get("nat", {})}).get("nat", {})
+ norm_have = _normalize_nat_have(fixture, "nat")
+ cmds = dict_op(want, norm_have, ["nat"], op="set")
+ self.assertEqual(cmds, [])
+
+ def test_replaced_purges_stale_internal_range_member(self):
+ """The internal-pool range being a plain list means removing a
+ member under 'replaced' relies on dict_op's list-purge handling
+ (fixed earlier this session) -- confirmed it applies here too."""
+ raw_have = {
+ "cgnat": {"pool": {"internal": {"INT1": {"range": ["10.0.0.0/24", "10.0.1.0/24"]}}}},
+ }
+ want = _want_to_device(
+ {
+ "nat": {
+ "cgnat": {"pool": {"internal": [{"name": "INT1", "range": ["10.0.0.0/24"]}]}},
+ },
+ },
+ )["nat"]
+ norm_have = _normalize_nat_have({"nat": raw_have}, "nat")
+ cmds = dict_op(want, norm_have, ["nat"], op="purge")
+ self.assertIn(
+ ("delete", ["nat", "cgnat", "pool", "internal", "INT1", "range", "10.0.1.0/24"]),
+ cmds,
+ )
+
+ def test_overridden_deletes_entire_omitted_section(self):
+ """overridden is full-model: a section entirely omitted from
+ want (not just a rule within it) must be deleted, via the same
+ single dict_op purge call main() uses -- no manual section-scan
+ loop needed."""
+ raw_have = {
+ "destination": {"rule": {"200": {"protocol": "tcp"}}},
+ "source": {"rule": {"100": {}}},
+ }
+ nat_want = _want_to_device(
+ {"nat": {"source": {"rule": [{"id": 100}]}}},
+ )["nat"]
+ norm_have = _normalize_nat_have({"nat": raw_have}, "nat")
+ cmds = dict_op(nat_want, norm_have, ["nat"], op="purge")
+ self.assertIn(("delete", ["nat", "destination"]), cmds)
+ self.assertTrue(all(c[1] != ["nat", "source"] for c in cmds))
+
+ def test_overridden_full_wipe_deletes_each_section_individually(self):
+ """Empty want under overridden purges every present section --
+ granular per-section deletes, not one blanket delete of the
+ whole nat_type (that distinction only matters for vyos_bgp_global,
+ where system-as's device-model constraint forces atomicity; NAT
+ has no equivalent cross-field constraint)."""
+ raw_have = {"destination": {"rule": {"200": {}}}, "source": {"rule": {"100": {}}}}
+ norm_have = _normalize_nat_have({"nat": raw_have}, "nat")
+ cmds = dict_op({}, norm_have, ["nat"], op="purge")
+ self.assertIn(("delete", ["nat", "destination"]), cmds)
+ self.assertIn(("delete", ["nat", "source"]), cmds)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/modules/test_vyos_ntp_global.py b/tests/unit/modules/test_vyos_ntp_global.py
index c536141..4bbeed8 100644
--- a/tests/unit/modules/test_vyos_ntp_global.py
+++ b/tests/unit/modules/test_vyos_ntp_global.py
@@ -4,179 +4,178 @@ from __future__ import absolute_import, division, print_function
__metaclass__ = type
-import json
-import os
import unittest
from unittest.mock import MagicMock
from ansible_collections.vyos.rest.plugins.modules.vyos_ntp_global import (
+ _device_to_argspec,
+ _servers_from_device,
+ _servers_to_device,
+ _want_to_device,
build_commands,
get_running_config,
- normalize_config,
- normalize_servers,
)
+from .base import load_fixture
-def load_fixture(filename):
- fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures")
- path = os.path.join(fixtures_dir, filename)
- with open(path) as f:
- return json.load(f)
+
+_BASE = ["service", "ntp"]
class VyOSModuleTestCase(unittest.TestCase):
def setUp(self):
self.mock_vyos = MagicMock()
- self.mock_vyos.get_config = MagicMock(return_value={})
-
- def set_running_config(self, data):
- self.mock_vyos.get_config.return_value = data
-
-
-class TestVyOSNtpGlobalNormalize(unittest.TestCase):
- """Test normalize_config and normalize_servers — no device needed."""
-
- def test_normalize_config_empty(self):
- result = normalize_config({})
- self.assertEqual(result["allow_clients"], [])
- self.assertEqual(result["listen_addresses"], [])
- self.assertEqual(result["servers"], {})
-
- def test_normalize_config_servers_sorted(self):
- config = {
- "servers": [
- {"server": "b.example.com", "options": ["prefer", "noselect"]},
- {"server": "a.example.com"},
- ],
- }
- result = normalize_config(config)
- self.assertIn("a.example.com", result["servers"])
- self.assertIn("b.example.com", result["servers"])
- self.assertEqual(result["servers"]["b.example.com"], ["noselect", "prefer"])
-
- def test_normalize_servers_dict_with_options(self):
- raw = {
- "time1.vyos.net": {},
- "203.0.113.0": {"prefer": {}},
- }
- result = normalize_servers(raw)
- self.assertEqual(result["time1.vyos.net"], [])
- self.assertEqual(result["203.0.113.0"], ["prefer"])
-
- def test_normalize_servers_list(self):
- raw = ["time1.vyos.net", "time2.vyos.net"]
- result = normalize_servers(raw)
- self.assertEqual(result["time1.vyos.net"], [])
-
- def test_normalize_servers_string(self):
- result = normalize_servers("time1.vyos.net")
- self.assertEqual(result["time1.vyos.net"], [])
-
-
-class TestVyOSNtpGlobalGetRunning(VyOSModuleTestCase):
- """Test get_running_config parsing against fixture API responses."""
-
- def setUp(self):
- super().setUp()
self.fixture = load_fixture("ntp_global_running.json")
+ self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
- def test_parses_allow_clients(self):
- self.set_running_config(self.fixture)
- result = get_running_config(self.mock_vyos)
- self.assertIn("10.6.6.0/24", result["allow_clients"])
- def test_parses_listen_addresses(self):
- self.set_running_config(self.fixture)
- result = get_running_config(self.mock_vyos)
- self.assertIn("10.1.3.1", result["listen_addresses"])
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_returns_raw_device_dict(self):
+ self.assertEqual(get_running_config(self.mock_vyos), self.fixture)
- def test_parses_servers(self):
- self.set_running_config(self.fixture)
- result = get_running_config(self.mock_vyos)
- self.assertIn("time1.vyos.net", result["servers"])
- self.assertIn("203.0.113.0", result["servers"])
- self.assertIn("prefer", result["servers"]["203.0.113.0"])
+ def test_empty_config(self):
+ self.mock_vyos.get_config = MagicMock(return_value=None)
+ self.assertEqual(get_running_config(self.mock_vyos), {})
- def test_empty_config_returns_empty(self):
- self.set_running_config({})
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result["allow_clients"], [])
- self.assertEqual(result["servers"], {})
+class TestServersToDeviceFromDevice(unittest.TestCase):
+ """options is the one genuine structural exception: the argspec
+ wraps them in a named field, but the device puts each option as a
+ direct presence-leaf sibling under the server tag node itself."""
-class TestVyOSNtpGlobalBuildCommands(unittest.TestCase):
- """Test build_commands diff logic — no device needed."""
+ def test_to_device_bare_server_is_presence(self):
+ self.assertEqual(_servers_to_device([{"server": "time1.vyos.net"}]), {"time1.vyos.net": {}})
- def _have(self, **kwargs):
- base = {"allow_clients": [], "listen_addresses": [], "servers": {}}
- base.update(kwargs)
- return base
+ def test_to_device_options_become_sibling_presence_leaves(self):
+ result = _servers_to_device([{"server": "203.0.113.0", "options": ["prefer", "nts"]}])
+ self.assertEqual(result, {"203.0.113.0": {"prefer": {}, "nts": {}}})
- def _want(self, **kwargs):
- return self._have(**kwargs)
+ def test_from_device_bare_server(self):
+ result = _servers_from_device({"time1.vyos.net": {}})
+ self.assertEqual(result, [{"server": "time1.vyos.net"}])
- def test_merged_adds_new_server(self):
- want = self._want(servers={"new.server.com": []})
- have = self._have(servers={})
- cmds = build_commands(want, have, "merged")
- self.assertIn(("set", ["service", "ntp", "server", "new.server.com"]), cmds)
+ def test_from_device_options_extracted_as_sorted_list(self):
+ result = _servers_from_device({"203.0.113.0": {"prefer": {}, "nts": {}}})
+ self.assertEqual(result, [{"server": "203.0.113.0", "options": ["nts", "prefer"]}])
+
+
+class TestWantToDevice(unittest.TestCase):
+ def test_empty(self):
+ self.assertEqual(_want_to_device({}), {})
+
+ def test_allow_clients_nested_under_address(self):
+ """allow_clients is a flat argspec list, but the device nests
+ the multi-value leaf one level deeper under a literal "address"
+ child -- confirmed against vyos-1x (allow-client.xml.i)."""
+ result = _want_to_device({"allow_clients": ["10.6.6.0/24"]})
+ self.assertEqual(result, {"allow-client": {"address": ["10.6.6.0/24"]}})
- def test_merged_idempotent_existing_server(self):
- want = self._want(servers={"time1.vyos.net": []})
- have = self._have(servers={"time1.vyos.net": []})
- cmds = build_commands(want, have, "merged")
- self.assertEqual(cmds, [])
+ def test_listen_addresses_direct_no_nesting(self):
+ result = _want_to_device({"listen_addresses": ["10.1.3.1"]})
+ self.assertEqual(result, {"listen-address": ["10.1.3.1"]})
+
+ def test_servers_keyed_by_address(self):
+ result = _want_to_device({"servers": [{"server": "203.0.113.0", "options": ["prefer"]}]})
+ self.assertEqual(result, {"server": {"203.0.113.0": {"prefer": {}}}})
+
+
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_allow_clients_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ self.assertIn("10.6.6.0/24", result["allow_clients"])
+
+ def test_listen_addresses_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ self.assertIn("10.1.3.1", result["listen_addresses"])
+
+ def test_servers_parsed_with_options(self):
+ result = _device_to_argspec(self.fixture)
+ servers = {s["server"]: s.get("options", []) for s in result["servers"]}
+ self.assertIn("time1.vyos.net", servers)
+ self.assertIn("prefer", servers["203.0.113.0"])
+
+ def test_empty_config(self):
+ result = _device_to_argspec({})
+ self.assertEqual(result, {"allow_clients": [], "listen_addresses": [], "servers": []})
+
+ def test_1_5_plus_shape_no_address_wrapper(self):
+ """Confirmed against vyos-1x, but kept defensive: some REST
+ responses omit the "address" subnode under allow-client."""
+ raw = {"allow-client": {"10.6.6.0/24": {}}}
+ result = _device_to_argspec(raw)
+ self.assertEqual(result["allow_clients"], ["10.6.6.0/24"])
+
+
+class TestBuildCommands(VyOSModuleTestCase):
+ def test_merged_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "merged"), [])
+
+ def test_replaced_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "replaced"), [])
+
+ def test_1_5_plus_shape_idempotent(self):
+ """Regression test for the real bug caught this session: want
+ always emits the "address"-wrapped shape, but dict_op compares
+ directly against the raw device tree -- without normalizing
+ have's shape first, a device reporting the unwrapped 1.5+
+ variant would never be idempotent."""
+ raw_have = {"allow-client": {"10.6.6.0/24": {}}}
+ have = _device_to_argspec(raw_have)
+ self.assertEqual(build_commands(have, raw_have, "merged"), [])
+
+ def test_merged_adds_new_server(self):
+ cmds = build_commands({"servers": [{"server": "new.server.com"}]}, {}, "merged")
+ self.assertIn(("set", _BASE + ["server", "new.server.com"]), cmds)
def test_merged_adds_server_option(self):
- want = self._want(servers={"time1.vyos.net": ["prefer"]})
- have = self._have(servers={"time1.vyos.net": []})
- cmds = build_commands(want, have, "merged")
- self.assertIn(("set", ["service", "ntp", "server", "time1.vyos.net", "prefer"]), cmds)
+ raw_have = {"server": {"time1.vyos.net": {}}}
+ config = {"servers": [{"server": "time1.vyos.net", "options": ["prefer"]}]}
+ cmds = build_commands(config, raw_have, "merged")
+ self.assertIn(("set", _BASE + ["server", "time1.vyos.net", "prefer"]), cmds)
def test_replaced_removes_extra_server(self):
- want = self._want(servers={"time1.vyos.net": []})
- have = self._have(servers={"time1.vyos.net": [], "time2.vyos.net": []})
- cmds = build_commands(want, have, "replaced")
- self.assertIn(("delete", ["service", "ntp", "server", "time2.vyos.net"]), cmds)
+ raw_have = {"server": {"time1.vyos.net": {}, "time2.vyos.net": {}}}
+ config = {"servers": [{"server": "time1.vyos.net"}]}
+ cmds = build_commands(config, raw_have, "replaced")
+ self.assertIn(("delete", _BASE + ["server", "time2.vyos.net"]), cmds)
def test_replaced_removes_extra_allow_client(self):
- want = self._want(allow_clients=["10.1.0.0/24"])
- have = self._have(allow_clients=["10.1.0.0/24", "10.2.0.0/24"])
- cmds = build_commands(want, have, "replaced")
- self.assertIn(
- ("delete", ["service", "ntp", "allow-client", "address", "10.2.0.0/24"]),
- cmds,
- )
+ """This exercises the real dict_op purge gap fixed this session:
+ have's allow-client returned as dict-of-presence (not a plain
+ list) while want is a plain list -- purge must still correctly
+ remove the stale entry."""
+ raw_have = {"allow-client": {"address": {"10.1.0.0/24": {}, "10.2.0.0/24": {}}}}
+ config = {"allow_clients": ["10.1.0.0/24"]}
+ cmds = build_commands(config, raw_have, "replaced")
+ self.assertIn(("delete", _BASE + ["allow-client", "address", "10.2.0.0/24"]), cmds)
def test_deleted_removes_all(self):
- have = self._have(
- servers={"time1.vyos.net": []},
- allow_clients=["10.0.0.0/24"],
- listen_addresses=["192.168.1.1"],
- )
- cmds = build_commands({}, have, "deleted")
- self.assertEqual(len(cmds), 1)
- self.assertEqual(cmds[0], ("delete", ["service", "ntp"]))
+ raw_have = {"server": {"time1.vyos.net": {}}}
+ cmds = build_commands({}, raw_have, "deleted")
+ self.assertEqual(cmds, [("delete", _BASE)])
def test_deleted_idempotent_when_empty(self):
- have = self._have(servers={}, allow_clients=[], listen_addresses=[])
- cmds = build_commands({}, have, "deleted")
- self.assertEqual(cmds, [])
+ self.assertEqual(build_commands({}, {}, "deleted"), [])
def test_overridden_deletes_then_merges(self):
- want = self._want(servers={"new.server.com": []})
- have = self._have(servers={"old.server.com": []})
- cmds = build_commands(want, have, "overridden")
- ops_paths = [(c[0], c[1]) for c in cmds]
- self.assertIn(("delete", ["service", "ntp", "server", "old.server.com"]), ops_paths)
- self.assertIn(("set", ["service", "ntp", "server", "new.server.com"]), ops_paths)
- self.assertNotIn(("delete", ["service", "ntp", "server"]), ops_paths)
+ raw_have = {"server": {"old.server.com": {}}}
+ config = {"servers": [{"server": "new.server.com"}]}
+ cmds = build_commands(config, raw_have, "overridden")
+ self.assertIn(("delete", _BASE + ["server", "old.server.com"]), cmds)
+ self.assertIn(("set", _BASE + ["server", "new.server.com"]), cmds)
def test_no_commands_when_already_correct(self):
- state = {"allow_clients": ["10.0.0.0/24"], "listen_addresses": [], "servers": {}}
- cmds = build_commands(state, state, "merged")
- self.assertEqual(cmds, [])
+ raw_have = {"allow-client": {"address": {"10.0.0.0/24": {}}}}
+ config = {"allow_clients": ["10.0.0.0/24"]}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+
+ def test_collapsed_single_server_no_char_iteration_bug(self):
+ raw_have = {"server": "203.0.113.0"}
+ config = {"servers": [{"server": "203.0.113.0"}]}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
if __name__ == "__main__":
diff --git a/tests/unit/modules/test_vyos_route_maps.py b/tests/unit/modules/test_vyos_route_maps.py
index 97d814b..4eec600 100644
--- a/tests/unit/modules/test_vyos_route_maps.py
+++ b/tests/unit/modules/test_vyos_route_maps.py
@@ -4,189 +4,431 @@ from __future__ import absolute_import, division, print_function
__metaclass__ = type
-import json
-import os
import unittest
from unittest.mock import MagicMock
from ansible_collections.vyos.rest.plugins.modules.vyos_route_maps import (
- _want_to_api_match,
- _want_to_api_set,
+ ARGUMENT_SPEC,
+ _derive_key_field,
+ _device_to_argspec,
+ _keyed_list_from_device,
+ _keyed_list_to_device,
+ _match_from_device,
+ _match_to_device,
+ _rule_entry_from_device,
+ _rule_entry_to_device,
+ _seed_route_map_placeholders,
+ _set_from_device,
+ _set_to_device,
+ _want_to_device,
build_commands,
get_running_config,
)
+from .base import load_fixture
-def load_fixture(filename):
- fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures")
- path = os.path.join(fixtures_dir, filename)
- with open(path) as f:
- return json.load(f)
+
+_BASE = ["policy", "route-map"]
class VyOSModuleTestCase(unittest.TestCase):
def setUp(self):
self.mock_vyos = MagicMock()
- self.mock_vyos.get_config = MagicMock(return_value={})
-
- def set_running_config(self, data):
- self.mock_vyos.get_config.return_value = data
-
+ self.fixture = load_fixture("route_maps_running.json")
+ self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
-class TestVyOSRouteMapsGetRunning(VyOSModuleTestCase):
- def setUp(self):
- super().setUp()
- self.fixture = load_fixture("route_maps_running.json")
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_unwraps_route_map_wrapper_key(self):
+ """Confirmed against the pre-existing fixture (built from real
+ device data): the REST API wraps the response in an extra
+ "route-map" key even when querying at the policy/route-map
+ path itself -- the same defensive-unwrap pattern every other
+ module this session needed for its own top-level get_config."""
+ result = get_running_config(self.mock_vyos)
+ self.assertIn("RM-TEST-EXPORT-POLICY", result)
+ self.assertNotIn("route-map", result)
- def test_unwraps_route_map_nesting(self):
- """API returns {"route-map": {"NAME": {...}}} — must unwrap."""
- self.set_running_config(self.fixture)
+ def test_no_wrapper_key_passes_through(self):
+ self.mock_vyos.get_config = MagicMock(return_value={"RM1": {"rule": {}}})
result = get_running_config(self.mock_vyos)
- names = [e["route_map"] for e in result]
+ self.assertIn("RM1", result)
+
+ def test_empty_config(self):
+ self.mock_vyos.get_config = MagicMock(return_value=None)
+ self.assertEqual(get_running_config(self.mock_vyos), {})
+
+
+class TestDeriveKeyField(unittest.TestCase):
+ def test_derives_route_map_key(self):
+ opts = ARGUMENT_SPEC["config"]["options"]
+ self.assertEqual(_derive_key_field(opts), "route_map")
+
+ def test_derives_sequence_key(self):
+ entry_opts = ARGUMENT_SPEC["config"]["options"]["entries"]["options"]
+ self.assertEqual(_derive_key_field(entry_opts), "sequence")
+
+ def test_raises_if_none_required(self):
+ with self.assertRaises(ValueError):
+ _derive_key_field({"a": {"type": "str"}})
+
+ def test_raises_if_more_than_one_required(self):
+ with self.assertRaises(ValueError):
+ _derive_key_field({"a": {"required": True}, "b": {"required": True}})
+
+
+class TestKeyedListHelper(unittest.TestCase):
+ def test_to_device_default_transform_is_autoclean(self):
+ result = _keyed_list_to_device([{"route_map": "RM1", "description": "x"}], "route_map")
+ self.assertEqual(result, {"RM1": {"description": "x"}})
+
+ def test_from_device_default_transform_is_from_device(self):
+ result = _keyed_list_from_device({"RM1": {"description": "x"}}, "route_map")
+ self.assertEqual(result, [{"route_map": "RM1", "description": "x"}])
+
+ def test_empty(self):
+ self.assertEqual(_keyed_list_to_device([], "route_map"), {})
+ self.assertEqual(_keyed_list_from_device({}, "route_map"), [])
+
+
+class TestMatchToDeviceFromDevice(unittest.TestCase):
+ """Most match options are fully generic; only prefix_list/
+ prefix_list6 and ip/ipv6 nexthop matching are genuine structural
+ exceptions (confirmed against vyos-1x: the device nests these
+ deeper than the argspec)."""
+
+ def test_simple_fields_generic(self):
+ result = _match_to_device({"peer": "192.0.2.1", "protocol": "bgp", "metric": 100})
+ self.assertEqual(result, {"peer": "192.0.2.1", "protocol": "bgp", "metric": 100})
+
+ def test_prefix_list_nested_two_levels(self):
+ result = _match_to_device({"prefix_list": "PL1"})
+ self.assertEqual(result, {"ip": {"address": {"prefix-list": "PL1"}}})
+
+ def test_prefix_list6_nested_two_levels(self):
+ result = _match_to_device({"prefix_list6": "PL6"})
+ self.assertEqual(result, {"ipv6": {"address": {"prefix-list": "PL6"}}})
+
+ def test_ip_nexthop_extra_nesting_level(self):
+ result = _match_to_device(
+ {"ip": {"nexthop_address": "10.0.0.1", "nexthop_prefix_list": "PL2"}},
+ )
+ self.assertEqual(
+ result,
+ {"ip": {"nexthop": {"address": "10.0.0.1", "prefix-list": "PL2"}}},
+ )
+
+ def test_ipv6_nexthop_extra_nesting_level(self):
+ result = _match_to_device({"ipv6": {"nexthop_address": "2001:db8::1"}})
+ self.assertEqual(result, {"ipv6": {"nexthop": {"address": "2001:db8::1"}}})
+
+ def test_from_device_prefix_list(self):
+ entry = _match_from_device({"ip": {"address": {"prefix-list": "PL1"}}})
+ self.assertEqual(entry["prefix_list"], "PL1")
+
+ def test_from_device_nexthop(self):
+ entry = _match_from_device({"ip": {"nexthop": {"address": "10.0.0.1"}}})
+ self.assertEqual(entry["ip"], {"nexthop_address": "10.0.0.1"})
+
+ def test_from_device_generic_fields(self):
+ entry = _match_from_device({"peer": "192.0.2.1", "protocol": "bgp"})
+ self.assertEqual(entry, {"peer": "192.0.2.1", "protocol": "bgp"})
+
+ def test_empty(self):
+ self.assertEqual(_match_to_device({}), {})
+ self.assertEqual(_match_to_device(None), {})
+ self.assertEqual(_match_from_device({}), {})
+ self.assertEqual(_match_from_device(None), {})
+
+
+class TestSetToDeviceFromDevice(unittest.TestCase):
+ """as_path_* collapse onto one nested device node. community/
+ large_community/ipv6_next_hop are fully generic once modeled as
+ real nested dicts. "as_" is a genuine Python-keyword-collision
+ rename, nested inside aggregator specifically."""
+
+ def test_atomic_aggregate_fully_generic(self):
+ result = _set_to_device({"atomic_aggregate": True})
+ self.assertEqual(result, {"atomic_aggregate": {}})
+
+ def test_as_path_options_collapse_onto_one_node(self):
+ result = _set_to_device(
+ {"as_path_exclude": "111", "as_path_prepend": "65001", "as_path_prepend_last_as": 2},
+ )
+ self.assertEqual(
+ result["as-path"],
+ {"exclude": "111", "prepend": "65001", "prepend-last-as": 2},
+ )
+
+ def test_aggregator_as_rename(self):
+ """Regression test for the real bug caught this session: "as_"
+ is nested inside "aggregator", not a top-level set field -- a
+ flat rename map applied only at the top level misses it
+ entirely."""
+ result = _set_to_device({"aggregator": {"as_": 100, "ip": "10.0.0.5"}})
+ self.assertEqual(result, {"aggregator": {"as": 100, "ip": "10.0.0.5"}})
+
+ def test_aggregator_as_only(self):
+ result = _set_to_device({"aggregator": {"as_": 100}})
+ self.assertEqual(result, {"aggregator": {"as": 100}})
+
+ def test_community_add_stays_a_plain_list(self):
+ result = _set_to_device({"community": {"add": ["no-export", "no-advertise"]}})
+ self.assertEqual(result["community"], {"add": ["no-export", "no-advertise"]})
+
+ def test_large_community_none_presence(self):
+ result = _set_to_device({"large_community": {"none": True}})
+ self.assertEqual(result["large_community"], {"none": {}})
+
+ def test_ipv6_next_hop_generic(self):
+ result = _set_to_device({"ipv6_next_hop": {"global": "2001:db8::1"}})
+ self.assertEqual(result["ipv6_next_hop"], {"global": "2001:db8::1"})
+
+ def test_ipv6_next_hop_valueless_options(self):
+ result = _set_to_device({"ipv6_next_hop": {"peer_address": True, "prefer_global": True}})
+ self.assertEqual(
+ result["ipv6_next_hop"],
+ {"peer_address": {}, "prefer_global": {}},
+ )
+
+ def test_from_device_community_add(self):
+ entry = _set_from_device({"community": {"add": ["no-export"]}})
+ self.assertEqual(entry["community"], {"add": ["no-export"]})
+
+ def test_from_device_large_community_none(self):
+ entry = _set_from_device({"large-community": {"none": {}}})
+ self.assertEqual(entry["large_community"], {"none": True})
+
+ def test_from_device_as_path(self):
+ entry = _set_from_device({"as-path": {"exclude": "111", "prepend-last-as": "2"}})
+ self.assertEqual(entry["as_path_exclude"], "111")
+ self.assertEqual(entry["as_path_prepend_last_as"], 2)
+
+ def test_from_device_aggregator_as_rename_with_int_cast(self):
+ entry = _set_from_device({"aggregator": {"as": "100", "ip": "10.0.0.5"}})
+ self.assertEqual(entry["aggregator"]["as_"], 100)
+ self.assertEqual(entry["aggregator"]["ip"], "10.0.0.5")
+
+ def test_empty(self):
+ self.assertEqual(_set_to_device({}), {})
+ self.assertEqual(_set_to_device(None), {})
+ self.assertEqual(_set_from_device({}), {})
+ self.assertEqual(_set_from_device(None), {})
+
+
+class TestRuleEntryToDeviceFromDevice(unittest.TestCase):
+ def test_continue_sequence_renamed(self):
+ """ "continue" is a Python keyword and can't be used as a
+ dict() kwarg -- "continue_sequence" is the unavoidable argspec
+ name, handled directly at the rule level (not a set field)."""
+ result = _rule_entry_to_device({"continue_sequence": 20})
+ self.assertEqual(result["continue"], 20)
+
+ def test_generic_fields(self):
+ result = _rule_entry_to_device({"action": "permit", "call": "RM2"})
+ self.assertEqual(result, {"action": "permit", "call": "RM2"})
+
+ def test_from_device_continue(self):
+ entry = _rule_entry_from_device({"continue": "20"})
+ self.assertEqual(entry["continue_sequence"], 20)
+
+ def test_from_device_bare_collapse(self):
+ entry = _rule_entry_from_device(None)
+ self.assertEqual(entry, {})
+
+
+class TestWantToDevice(unittest.TestCase):
+ def test_empty(self):
+ self.assertEqual(_want_to_device([]), {})
+ self.assertEqual(_want_to_device(None), {})
+
+ def test_route_map_without_entries_omitted(self):
+ self.assertEqual(_want_to_device([{"route_map": "RM1"}]), {})
+
+ def test_keyed_by_route_map_name(self):
+ config = [{"route_map": "RM1", "entries": [{"sequence": 10, "action": "permit"}]}]
+ result = _want_to_device(config)
+ self.assertIn("10", result["RM1"]["rule"])
+
+ def test_underscore_route_map_name_stays_verbatim(self):
+ """Confirmed against vyos-1x: route-map names may legitimately
+ contain underscores. _want_to_device itself must not alter the
+ key -- the dict_op-level protection is tested separately in
+ TestBuildCommands."""
+ config = [{"route_map": "my_route_map", "entries": [{"sequence": 10}]}]
+ result = _want_to_device(config)
+ self.assertIn("my_route_map", result)
+
+
+class TestSeedRouteMapPlaceholders(unittest.TestCase):
+ """Regression tests for the confirmed bug: dict_op's fallback
+ guesses a kebab-cased device key whenever a want key is missing
+ from have -- correct for schema field names, wrong for a route-map
+ name (an opaque value that may contain an underscore). Reproduced
+ directly before this fix: "my_route_map" became "my-route-map" in
+ the generated command on first creation."""
+
+ def test_seeds_new_route_map_verbatim(self):
+ want = {"my_route_map": {"rule": {"10": {}}}}
+ have = {}
+ _seed_route_map_placeholders(want, have)
+ self.assertIn("my_route_map", have)
+
+ def test_seeds_new_rule_with_none_not_empty_dict(self):
+ """Seeding with {} instead of None would make dict_op think a
+ presence-only rule already matches and skip emitting its set
+ command -- the same mistake caught once already this session."""
+ want = {"RM1": {"rule": {"10": {}}}}
+ have = {"RM1": {"rule": {}}}
+ _seed_route_map_placeholders(want, have)
+ self.assertIsNone(have["RM1"]["rule"]["10"])
+
+ def test_does_not_overwrite_existing_entries(self):
+ want = {"RM1": {"rule": {"10": {}}}}
+ have = {"RM1": {"rule": {"10": {"action": "permit"}}}}
+ _seed_route_map_placeholders(want, have)
+ self.assertEqual(have["RM1"]["rule"]["10"], {"action": "permit"})
+
+
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_all_route_maps_parsed(self):
+ raw = get_running_config(self.mock_vyos)
+ result = _device_to_argspec(raw)
+ names = [rm["route_map"] for rm in result]
self.assertIn("RM-TEST-EXPORT-POLICY", names)
self.assertIn("rm1", names)
- # "route-map" itself must NOT appear as a route map name
- self.assertNotIn("route-map", names)
-
- def test_parses_rule_action(self):
- self.set_running_config(self.fixture)
- result = get_running_config(self.mock_vyos)
- rm = next(e for e in result if e["route_map"] == "RM-TEST-EXPORT-POLICY")
- rule = rm["entries"][0]
- self.assertEqual(rule["action"], "permit")
- self.assertEqual(rule["sequence"], 10)
- def test_parses_match_peer(self):
- self.set_running_config(self.fixture)
- result = get_running_config(self.mock_vyos)
- rm = next(e for e in result if e["route_map"] == "RM-TEST-EXPORT-POLICY")
+ def test_prefix_list_and_nexthop_match_parsed(self):
+ raw = get_running_config(self.mock_vyos)
+ result = _device_to_argspec(raw)
+ rm = next(rm for rm in result if rm["route_map"] == "RM-TEST-EXPORT-POLICY")
rule = rm["entries"][0]
- self.assertEqual(rule["match"]["peer"], "192.0.2.32")
+ self.assertEqual(rule["match"]["prefix_list"], "PL-MATCH")
+ self.assertEqual(rule["match"]["ip"]["nexthop_address"], "10.0.0.1")
- def test_parses_set_fields(self):
- self.set_running_config(self.fixture)
- result = get_running_config(self.mock_vyos)
- rm = next(e for e in result if e["route_map"] == "RM-TEST-EXPORT-POLICY")
+ def test_community_add_parsed_as_list(self):
+ raw = get_running_config(self.mock_vyos)
+ result = _device_to_argspec(raw)
+ rm = next(rm for rm in result if rm["route_map"] == "RM-TEST-EXPORT-POLICY")
rule = rm["entries"][0]
- self.assertEqual(rule["set"]["metric"], "5")
- self.assertEqual(rule["set"]["aggregator"]["as"], "100")
- self.assertEqual(rule["set"]["as-path"]["exclude"], "111")
-
- def test_empty_returns_empty_list(self):
- self.set_running_config({})
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result, [])
-
-
-class TestVyOSRouteMapsWantToApi(unittest.TestCase):
-
- def test_as_path_exclude_nested(self):
- """as_path_exclude maps to nested as-path.exclude."""
- result = _want_to_api_set({"as_path_exclude": "111"})
- self.assertEqual(result["as-path"]["exclude"], "111")
-
- def test_metric_flat(self):
- result = _want_to_api_set({"metric": "5"})
- self.assertEqual(result["metric"], "5")
+ self.assertEqual(rule["set"]["community"]["add"], ["no-export", "no-advertise"])
- def test_aggregator_as(self):
- result = _want_to_api_set({"aggregator": {"as": 100}})
- self.assertEqual(result["aggregator"]["as"], "100")
+ def test_large_community_none_parsed(self):
+ raw = get_running_config(self.mock_vyos)
+ result = _device_to_argspec(raw)
+ rm1 = next(rm for rm in result if rm["route_map"] == "rm1")
+ self.assertTrue(rm1["entries"][0]["set"]["large_community"]["none"])
- def test_aggregator_as_underscore(self):
- """aggregator.as_ is an alias for aggregator.as."""
- result = _want_to_api_set({"aggregator": {"as_": 100}})
- self.assertEqual(result["aggregator"]["as"], "100")
+ def test_aggregator_as_parsed(self):
+ raw = get_running_config(self.mock_vyos)
+ result = _device_to_argspec(raw)
+ rm = next(rm for rm in result if rm["route_map"] == "RM-TEST-EXPORT-POLICY")
+ self.assertEqual(rm["entries"][0]["set"]["aggregator"]["as_"], 100)
- def test_large_community_presence_node(self):
- result = _want_to_api_set({"large_community": "none"})
- self.assertEqual(result["large-community"], {"none": {}})
+ def test_empty_config(self):
+ self.assertEqual(_device_to_argspec({}), [])
+ self.assertEqual(_device_to_argspec(None), [])
- def test_match_peer(self):
- result = _want_to_api_match({"peer": "192.0.2.32"})
- self.assertEqual(result["peer"], "192.0.2.32")
-
-class TestVyOSRouteMapsBuildCommands(unittest.TestCase):
-
- def _have_empty(self):
- return []
-
- def _have_with_rm(self):
- return [
+class TestBuildCommands(VyOSModuleTestCase):
+ def setUp(self):
+ super().setUp()
+ self.raw = get_running_config(self.mock_vyos)
+
+ def test_merged_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.raw)
+ self.assertEqual(build_commands(have, self.raw, "merged"), [])
+
+ def test_replaced_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.raw)
+ self.assertEqual(build_commands(have, self.raw, "replaced"), [])
+
+ def test_overridden_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.raw)
+ self.assertEqual(build_commands(have, self.raw, "overridden"), [])
+
+ def test_underscore_route_map_name_not_kebab_cased_on_creation(self):
+ """The primary confirmed bug this session, reproduced directly
+ end to end before the fix: "my_route_map" became
+ "my-route-map" in the generated command on first creation."""
+ config = [{"route_map": "my_route_map", "entries": [{"sequence": 10, "action": "permit"}]}]
+ cmds = build_commands(config, {}, "merged")
+ self.assertFalse(any("my-route-map" in str(c) for c in cmds))
+ self.assertTrue(any("my_route_map" in str(c) for c in cmds))
+
+ def test_replaced_scoped_to_named_route_map_only(self):
+ have = _device_to_argspec(self.raw)
+ config = [
{
- "route_map": "RM1",
- "entries": [
- {
- "sequence": 10,
- "action": "permit",
- "match": {"peer": "192.0.2.32"},
- "set": {"metric": "5", "as-path": {"exclude": "111"}},
- },
- ],
+ "route_map": "RM-TEST-EXPORT-POLICY",
+ "entries": have[0]["entries"],
},
]
+ cmds = build_commands(config, self.raw, "replaced")
+ self.assertEqual(cmds, [])
+ self.assertFalse(any("rm1" in str(c) for c in cmds))
- def test_merged_adds_new_rm(self):
+ def test_replaced_removes_omitted_field_full_replace_semantic(self):
+ """Confirms this is the intended "replaced" semantic (matching
+ every other module this session), not a bug: omitting a field
+ from a route map named in "replaced" removes it."""
config = [
{
- "route_map": "RM-NEW",
+ "route_map": "RM-TEST-EXPORT-POLICY",
"entries": [{"sequence": 10, "action": "permit"}],
},
]
- cmds = build_commands(config, self._have_empty(), "merged")
- paths = [c[1] for c in cmds]
- self.assertIn(["policy", "route-map", "RM-NEW", "rule", "10", "action", "permit"], paths)
+ cmds = build_commands(config, self.raw, "replaced")
+ self.assertIn(("delete", _BASE + ["RM-TEST-EXPORT-POLICY", "rule", "10", "set"]), cmds)
+ self.assertIn(("delete", _BASE + ["RM-TEST-EXPORT-POLICY", "rule", "10", "match"]), cmds)
- def test_merged_idempotent(self):
+ def test_overridden_deletes_omitted_route_map(self):
+ have = _device_to_argspec(self.raw)
config = [
{
- "route_map": "RM1",
- "entries": [
- {
- "sequence": 10,
- "action": "permit",
- "match": {"peer": "192.0.2.32"},
- "set": {"metric": "5", "as_path_exclude": "111"},
- },
- ],
+ "route_map": "RM-TEST-EXPORT-POLICY",
+ "entries": have[0]["entries"],
},
]
- cmds = build_commands(config, self._have_with_rm(), "merged")
- self.assertEqual(cmds, [])
+ cmds = build_commands(config, self.raw, "overridden")
+ self.assertIn(("delete", _BASE + ["rm1"]), cmds)
+
+ def test_deleted_scoped_to_named_route_map(self):
+ cmds = build_commands([{"route_map": "rm1"}], self.raw, "deleted")
+ self.assertEqual(cmds, [("delete", _BASE + ["rm1"])])
- def test_deleted_no_config_deletes_all(self):
- cmds = build_commands([], self._have_with_rm(), "deleted")
- self.assertIn(("delete", ["policy", "route-map"]), cmds)
+ def test_deleted_no_config_removes_all(self):
+ cmds = build_commands([], self.raw, "deleted")
+ self.assertEqual(cmds, [("delete", _BASE)])
- def test_deleted_with_config_deletes_named(self):
- config = [{"route_map": "RM1"}]
- cmds = build_commands(config, self._have_with_rm(), "deleted")
- self.assertIn(("delete", ["policy", "route-map", "RM1"]), cmds)
+ def test_deleted_named_nonexistent_is_noop(self):
+ cmds = build_commands([{"route_map": "NONEXISTENT"}], self.raw, "deleted")
+ self.assertEqual(cmds, [])
+
+ def test_collapsed_single_rule_no_char_iteration_bug(self):
+ raw_have = {"RM1": {"rule": "10"}}
+ config = [{"route_map": "RM1", "entries": [{"sequence": 10}]}]
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
- def test_replaced_deletes_then_resets(self):
+ def test_merged_new_rule_with_community(self):
config = [
{
- "route_map": "RM1",
- "entries": [{"sequence": 10, "action": "deny"}],
+ "route_map": "RM-NEW",
+ "entries": [
+ {
+ "sequence": 10,
+ "action": "permit",
+ "set": {"community": {"add": ["no-export"]}},
+ },
+ ],
},
]
- cmds = build_commands(config, self._have_with_rm(), "replaced")
- ops = [c[0] for c in cmds]
- # delete must come before set
- self.assertIn("delete", ops)
- self.assertIn("set", ops)
- delete_idx = ops.index("delete")
- set_idx = ops.index("set")
- self.assertLess(delete_idx, set_idx)
-
- def test_overridden_removes_extra_rm(self):
- config = [{"route_map": "RM-NEW", "entries": []}]
- have = self._have_with_rm() # has RM1
- cmds = build_commands(config, have, "overridden")
- self.assertIn(("delete", ["policy", "route-map", "RM1"]), cmds)
+ cmds = build_commands(config, {}, "merged")
+ self.assertIn(
+ ("set", _BASE + ["RM-NEW", "rule", "10", "set", "community", "add", "no-export"]),
+ cmds,
+ )
if __name__ == "__main__":
diff --git a/tests/unit/modules/test_vyos_snmp_server.py b/tests/unit/modules/test_vyos_snmp_server.py
new file mode 100644
index 0000000..4ae438e
--- /dev/null
+++ b/tests/unit/modules/test_vyos_snmp_server.py
@@ -0,0 +1,644 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+import unittest
+
+from unittest.mock import MagicMock
+
+from ansible_collections.vyos.rest.plugins.modules.vyos_snmp_server import (
+ _DEVICE_RENAMES,
+ ARGUMENT_SPEC,
+ _derive_key_field,
+ _device_to_argspec,
+ _device_to_spec,
+ _keyed_list_from_device,
+ _keyed_list_to_device,
+ _single_from_device,
+ _single_to_device,
+ _spec_to_device,
+ _view_entry_from_device,
+ _view_entry_to_device,
+ _want_to_device,
+ build_commands,
+ get_running_config,
+)
+
+from .base import load_fixture
+
+
+_BASE = ["service", "snmp"]
+
+
+class VyOSModuleTestCase(unittest.TestCase):
+ def setUp(self):
+ self.mock_vyos = MagicMock()
+ self.fixture = load_fixture("snmp_server_running.json")
+ self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
+
+
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_returns_raw_device_dict(self):
+ self.assertEqual(get_running_config(self.mock_vyos), self.fixture)
+
+ def test_empty_config_path_error_returns_empty(self):
+ self.mock_vyos.get_config = MagicMock(
+ side_effect=Exception("Configuration under specified path is empty"),
+ )
+ self.assertEqual(get_running_config(self.mock_vyos), {})
+
+ def test_other_error_reraises(self):
+ self.mock_vyos.get_config = MagicMock(side_effect=Exception("some other error"))
+ with self.assertRaises(Exception):
+ get_running_config(self.mock_vyos)
+
+
+class TestDeviceRenames(unittest.TestCase):
+ """The one thing a purely structural walk can never infer: field
+ names that mean something different on the device, and aren't a
+ mechanical kebab<->snake conversion. Declared once here as a flat
+ value map, not embedded in ARGUMENT_SPEC and not scattered across
+ per-section functions."""
+
+ def test_confirmed_renames_present(self):
+ for arg_key, device_key in [
+ ("communities", "community"),
+ ("listen_addresses", "listen-address"),
+ ("snmp_v3", "v3"),
+ ("authorization_type", "authorization"),
+ ("clients", "client"),
+ ("networks", "network"),
+ ("authentication", "auth"),
+ ("encrypted_key", "encrypted-password"),
+ ("plaintext_key", "plaintext-password"),
+ ("engine_id", "engineid"),
+ ("groups", "group"),
+ ("users", "user"),
+ ("views", "view"),
+ ("trap_targets", "trap-target"),
+ ]:
+ self.assertEqual(_DEVICE_RENAMES.get(arg_key), device_key)
+
+
+class TestSpecToDevice(unittest.TestCase):
+ """The generic recursive walker that replaced a hand-written to-
+ device/from-device function pair for every section in this module.
+ Driven by ARGUMENT_SPEC's own structure (dict -> recurse, list with
+ options -> a named list keyed by _derive_key_field, list with no
+ options -> a plain multi-value leaf) plus _DEVICE_RENAMES for the
+ handful of non-mechanical name differences."""
+
+ def test_plain_scalar_passes_through_unrenamed(self):
+ spec = {"contact": {"type": "str"}}
+ self.assertEqual(_spec_to_device({"contact": "x"}, spec), {"contact": "x"})
+
+ def test_rename_applied_via_device_renames(self):
+ spec = {"authorization_type": {"type": "str"}}
+ result = _spec_to_device({"authorization_type": "rw"}, spec)
+ self.assertEqual(result, {"authorization": "rw"})
+
+ def test_nested_dict_recurses(self):
+ spec = {
+ "authentication": {
+ "type": "dict",
+ "options": {"type": {"type": "str"}, "encrypted_key": {"type": "str"}},
+ },
+ }
+ result = _spec_to_device(
+ {"authentication": {"type": "sha", "encrypted_key": "abc123"}},
+ spec,
+ )
+ self.assertEqual(result, {"auth": {"type": "sha", "encrypted-password": "abc123"}})
+
+ def test_named_list_keyed_by_required_field(self):
+ spec = {
+ "communities": {
+ "type": "list",
+ "options": {"name": {"type": "str", "required": True}, "port": {"type": "int"}},
+ },
+ }
+ result = _spec_to_device(
+ {"communities": [{"name": "switches", "port": 5}]},
+ spec,
+ )
+ self.assertEqual(result, {"community": {"switches": {"port": 5}}})
+
+ def test_plain_scalar_list_passes_through(self):
+ spec = {"clients": {"type": "list", "elements": "str"}}
+ result = _spec_to_device({"clients": ["1.1.1.1"]}, spec)
+ self.assertEqual(result, {"client": ["1.1.1.1"]})
+
+ def test_bool_true_is_presence(self):
+ spec = {"disable": {"type": "bool"}}
+ self.assertEqual(_spec_to_device({"disable": True}, spec), {"disable": {}})
+
+ def test_bool_false_omitted(self):
+ spec = {"disable": {"type": "bool"}}
+ self.assertEqual(_spec_to_device({"disable": False}, spec), {})
+
+ def test_non_dict_value_passes_through(self):
+ self.assertEqual(_spec_to_device("not-a-dict", {}), "not-a-dict")
+
+
+class TestDeviceToSpec(unittest.TestCase):
+ """The reverse of _spec_to_device -- same structural rules, same
+ single source of truth for renames."""
+
+ def test_mechanical_field_matched_via_hyphen_normalization(self):
+ spec = {"local_stratum": {"type": "str"}}
+ result = _device_to_spec({"local-stratum": "5"}, spec)
+ self.assertEqual(result, {"local_stratum": "5"})
+
+ def test_renamed_field_matched_via_device_renames(self):
+ spec = {"authorization_type": {"type": "str"}}
+ result = _device_to_spec({"authorization": "rw"}, spec)
+ self.assertEqual(result, {"authorization_type": "rw"})
+
+ def test_nested_dict_recurses(self):
+ spec = {
+ "authentication": {
+ "type": "dict",
+ "options": {"encrypted_key": {"type": "str"}},
+ },
+ }
+ result = _device_to_spec({"auth": {"encrypted-password": "abc123"}}, spec)
+ self.assertEqual(result, {"authentication": {"encrypted_key": "abc123"}})
+
+ def test_named_list_keyed_by_required_field(self):
+ spec = {
+ "communities": {
+ "type": "list",
+ "options": {"name": {"type": "str", "required": True}, "port": {"type": "int"}},
+ },
+ }
+ result = _device_to_spec({"community": {"switches": {"port": "5"}}}, spec)
+ self.assertEqual(result, {"communities": [{"name": "switches", "port": "5"}]})
+
+ def test_plain_scalar_list_sorted_and_collapse_safe(self):
+ spec = {"clients": {"type": "list", "elements": "str"}}
+ result = _device_to_spec({"client": "1.1.1.1"}, spec)
+ self.assertEqual(result, {"clients": ["1.1.1.1"]})
+
+ def test_presence_dict_becomes_bool(self):
+ spec = {"disable": {"type": "bool"}}
+ self.assertEqual(_device_to_spec({"disable": {}}, spec), {"disable": True})
+
+ def test_empty_or_non_dict_raw(self):
+ self.assertEqual(_device_to_spec({}, {}), {})
+ self.assertEqual(_device_to_spec(None, {}), {})
+ self.assertEqual(_device_to_spec("not-a-dict", {}), {})
+
+
+class TestKeyedListHelper(unittest.TestCase):
+ """The generic mechanic every named-list section shares: a list of
+ dicts identified by one field becomes a device dict keyed by that
+ field's value. This used to be reimplemented six separate times."""
+
+ def test_to_device_default_transform_is_autoclean(self):
+ result = _keyed_list_to_device([{"group": "admins", "mode": "rw"}], "group")
+ self.assertEqual(result, {"admins": {"mode": "rw"}})
+
+ def test_to_device_skips_entries_missing_key_field(self):
+ result = _keyed_list_to_device([{"mode": "rw"}], "group")
+ self.assertEqual(result, {})
+
+ def test_to_device_custom_entry_transform_receives_rest_only(self):
+ seen = {}
+
+ def transform(rest):
+ seen.update(rest)
+ return rest
+
+ _keyed_list_to_device([{"name": "switches", "authorization_type": "rw"}], "name", transform)
+ self.assertNotIn("name", seen)
+ self.assertEqual(seen, {"authorization_type": "rw"})
+
+ def test_from_device_default_transform_is_from_device(self):
+ result = _keyed_list_from_device({"admins": {"mode": "rw"}}, "group")
+ self.assertEqual(result, [{"group": "admins", "mode": "rw"}])
+
+ def test_from_device_bare_string_collapse(self):
+ result = _keyed_list_from_device("admins", "group")
+ self.assertEqual(result, [{"group": "admins"}])
+
+ def test_empty(self):
+ self.assertEqual(_keyed_list_to_device([], "group"), {})
+ self.assertEqual(_keyed_list_to_device(None, "group"), {})
+ self.assertEqual(_keyed_list_from_device({}, "group"), [])
+ self.assertEqual(_keyed_list_from_device(None, "group"), [])
+
+
+class TestCommunity(unittest.TestCase):
+ """authorization_type->authorization and clients/networks->
+ client/network are genuine renames (in _DEVICE_RENAMES, not
+ embedded in ARGUMENT_SPEC); both member fields are confirmed plain
+ multi-value leaves, passed straight through. Tested via the
+ generic walker directly against communities' own entry options,
+ since there's no bespoke per-entry function anymore."""
+
+ def setUp(self):
+ self.entry_options = ARGUMENT_SPEC["config"]["options"]["communities"]["options"]
+
+ def test_to_device_authorization_rename(self):
+ result = _spec_to_device({"authorization_type": "rw"}, self.entry_options)
+ self.assertEqual(result, {"authorization": "rw"})
+
+ def test_to_device_clients_networks_rename(self):
+ result = _spec_to_device(
+ {"clients": ["1.1.1.1"], "networks": ["10.0.0.0/8"]},
+ self.entry_options,
+ )
+ self.assertEqual(result, {"client": ["1.1.1.1"], "network": ["10.0.0.0/8"]})
+
+ def test_from_device(self):
+ entry = _device_to_spec(
+ {"client": ["1.1.1.1", "12.1.1.10"], "authorization": "ro"},
+ self.entry_options,
+ )
+ self.assertEqual(entry["clients"], ["1.1.1.1", "12.1.1.10"])
+ self.assertEqual(entry["authorization_type"], "ro")
+
+ def test_from_device_single_client_collapse(self):
+ entry = _device_to_spec({"client": "1.1.1.1"}, self.entry_options)
+ self.assertEqual(entry["clients"], ["1.1.1.1"])
+
+ def test_full_pipeline_via_keyed_list_helper(self):
+ """Confirms the entry-transform and the generic keying mechanic
+ compose correctly end to end, matching how _spec_to_device
+ itself calls them for any named-list section."""
+ result = _keyed_list_to_device(
+ [{"name": "switches", "authorization_type": "rw"}],
+ "name",
+ lambda rest: _spec_to_device(rest, self.entry_options),
+ )
+ self.assertEqual(result, {"switches": {"authorization": "rw"}})
+
+
+class TestDeriveKeyField(unittest.TestCase):
+ """key_field is derived from each section's argspec, not
+ hand-declared -- every named-list section marks exactly one
+ suboption required=True (you can't create a community without a
+ name, and so on), so that's the field identifying each entry."""
+
+ def test_derives_the_single_required_field(self):
+ self.assertEqual(
+ _derive_key_field({"name": {"required": True}, "clients": {"type": "list"}}),
+ "name",
+ )
+
+ def test_raises_if_none_required(self):
+ with self.assertRaises(ValueError):
+ _derive_key_field({"clients": {"type": "list"}})
+
+ def test_raises_if_more_than_one_required(self):
+ with self.assertRaises(ValueError):
+ _derive_key_field({"a": {"required": True}, "b": {"required": True}})
+
+
+class TestTrapTarget(unittest.TestCase):
+ """Confirmed a genuine tagNode keyed by address on the device, but
+ the argspec models only a single object -- a documented limitation
+ (the device supports multiple), preserved as-is. Reuses the same
+ generic keyed-list mechanic as "a list capped to one entry" rather
+ than a bespoke pair of functions."""
+
+ def test_to_device_keyed_by_address(self):
+ result = _single_to_device({"address": "203.0.113.5", "community": "public"}, "address")
+ self.assertEqual(result, {"203.0.113.5": {"community": "public"}})
+
+ def test_to_device_no_address_is_noop(self):
+ self.assertEqual(_single_to_device({}, "address"), {})
+ self.assertEqual(_single_to_device(None, "address"), {})
+
+ def test_from_device(self):
+ entry = _single_from_device(
+ {"203.0.113.5": {"community": "public", "port": "162"}},
+ "address",
+ )
+ self.assertEqual(entry["address"], "203.0.113.5")
+ self.assertEqual(entry["community"], "public")
+
+ def test_from_device_bare_string_collapse(self):
+ entry = _single_from_device("203.0.113.5", "address")
+ self.assertEqual(entry, {"address": "203.0.113.5"})
+
+ def test_from_device_empty_is_none(self):
+ self.assertIsNone(_single_from_device(None, "address"))
+ self.assertIsNone(_single_from_device({}, "address"))
+
+
+class TestV3View(unittest.TestCase):
+ """The confirmed structural bug: "oid" is a genuine tag node (keyed
+ by the OID value) with its own exclude/mask children -- the
+ previous implementation read exclude/mask from the wrong nesting
+ level (directly under the view) and only handled a single oid key
+ via list(oid_data.keys())[0], silently dropping any others. Like
+ community, the entry-transform receives only the dict's "rest"
+ (the key field "view" is stripped by the generic helper first)."""
+
+ def test_to_device_oid_is_nested_tag_node(self):
+ result = _view_entry_to_device({"oid": "1.3.6.1", "mask": "ff"})
+ self.assertEqual(result, {"oid": {"1.3.6.1": {"mask": "ff"}}})
+
+ def test_to_device_exclude_nested_under_oid_not_view(self):
+ result = _view_entry_to_device({"oid": "1.3.6.1", "exclude": "1.3.6.1.9"})
+ self.assertEqual(result, {"oid": {"1.3.6.1": {"exclude": ["1.3.6.1.9"]}}})
+
+ def test_to_device_no_oid_is_empty(self):
+ self.assertEqual(_view_entry_to_device({}), {})
+
+ def test_from_device_reads_exclude_mask_from_oid_level(self):
+ """Regression test for the confirmed bug: exclude/mask must be
+ read from data["oid"][<value>], not data directly."""
+ entry = _view_entry_from_device(
+ {"oid": {"1.3.6.1": {"exclude": ["1.3.6.1.9"], "mask": "ff.ff"}}},
+ )
+ self.assertEqual(entry["oid"], "1.3.6.1")
+ self.assertEqual(entry["exclude"], "1.3.6.1.9")
+ self.assertEqual(entry["mask"], "ff.ff")
+
+ def test_from_device_bare_oid_string_collapse(self):
+ entry = _view_entry_from_device({"oid": "1.3.6.1"})
+ self.assertEqual(entry["oid"], "1.3.6.1")
+ self.assertNotIn("exclude", entry)
+
+ def test_from_device_no_oid(self):
+ entry = _view_entry_from_device({})
+ self.assertEqual(entry, {})
+
+
+class TestWantToDevice(unittest.TestCase):
+ def test_empty(self):
+ self.assertEqual(_want_to_device({}), {})
+ self.assertEqual(_want_to_device(None), {})
+
+ def test_engine_id_rename(self):
+ """Confirmed bug: "engineid" (device, one word) vs "engine_id"
+ (argspec) is not a mechanical kebab<->snake conversion since
+ there's no hyphen to split -- a genuine rename exception."""
+ result = _want_to_device({"snmp_v3": {"engine_id": "0002"}})
+ self.assertEqual(result["v3"]["engineid"], "0002")
+ self.assertNotIn("engine_id", result["v3"])
+
+ def test_communities_keyed_by_name(self):
+ config = {"communities": [{"name": "switches", "authorization_type": "rw"}]}
+ result = _want_to_device(config)
+ self.assertEqual(result["community"]["switches"], {"authorization": "rw"})
+
+ def test_generic_scalar_fields(self):
+ result = _want_to_device({"contact": "admin@example.com", "location": "DC1"})
+ self.assertEqual(result, {"contact": "admin@example.com", "location": "DC1"})
+
+
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_communities_parsed(self):
+ have = _device_to_argspec(self.fixture)
+ names = {c["name"] for c in have["communities"]}
+ self.assertEqual(names, {"switches", "bridges"})
+
+ def test_engine_id_parsed(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(have["snmp_v3"]["engine_id"], "000000000000000000000002")
+
+ def test_v3_user_authentication_parsed(self):
+ have = _device_to_argspec(self.fixture)
+ user = have["snmp_v3"]["users"][0]
+ self.assertEqual(user["authentication"]["type"], "sha")
+ self.assertEqual(user["authentication"]["encrypted_key"], "abc123")
+
+ def test_v3_view_oid_parsed(self):
+ have = _device_to_argspec(self.fixture)
+ view = have["snmp_v3"]["views"][0]
+ self.assertEqual(view["oid"], "1")
+
+ def test_trap_target_parsed(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(have["trap_target"]["address"], "203.0.113.5")
+ self.assertEqual(have["trap_target"]["community"], "public")
+ self.assertEqual(have["trap_target"]["port"], 162)
+
+ def test_v3_trap_targets_parsed(self):
+ have = _device_to_argspec(self.fixture)
+ target = have["snmp_v3"]["trap_targets"][0]
+ self.assertEqual(target["address"], "198.51.100.5")
+ self.assertEqual(target["protocol"], "udp")
+ self.assertEqual(target["authentication"]["type"], "sha")
+
+ def test_empty_config(self):
+ self.assertEqual(_device_to_argspec({}), {})
+ self.assertEqual(_device_to_argspec(None), {})
+
+
+class TestBuildCommands(VyOSModuleTestCase):
+ def test_merged_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "merged"), [])
+
+ def test_replaced_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "replaced"), [])
+
+ def test_overridden_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "overridden"), [])
+
+ def test_underscore_username_not_kebab_cased_on_creation(self):
+ """Confirmed real bug: dict_op's fallback for a key missing from
+ have assumed every key is a translatable schema field name --
+ but a brand-new tag-node entry (username, community name, any
+ user-supplied identifier) is opaque data, and "admin_user" was
+ silently becoming "admin-user" in the generated command on
+ first creation, before verbatim_keys was wired in."""
+ cmds = build_commands(
+ {"snmp_v3": {"users": [{"user": "admin_user", "group": "admins"}]}},
+ {},
+ "merged",
+ )
+ self.assertTrue(any("admin_user" in c[1] for c in cmds))
+ self.assertFalse(any("admin-user" in c[1] for c in cmds))
+
+ def test_underscore_names_verbatim_across_every_tag_node_section(self):
+ """Same regression, covering every section with an opaque
+ tag-node key in this module, not just v3 users."""
+ config = {
+ "communities": [{"name": "my_community"}],
+ "snmp_v3": {
+ "groups": [{"group": "my_group"}],
+ "views": [{"view": "my_view", "oid": "1.3.6.1"}],
+ "trap_targets": [{"address": "198.51.100.5"}],
+ },
+ }
+ cmds = build_commands(config, {}, "merged")
+ joined = [str(c[1]) for c in cmds]
+ self.assertTrue(any("my_community" in p for p in joined))
+ self.assertTrue(any("my_group" in p for p in joined))
+ self.assertTrue(any("my_view" in p for p in joined))
+ self.assertFalse(any("my-community" in p for p in joined))
+ self.assertFalse(any("my-group" in p for p in joined))
+ self.assertFalse(any("my-view" in p for p in joined))
+
+ def test_underscore_username_removed_verbatim_on_replaced(self):
+ """The purge path (replaced/overridden) must also match and
+ delete the opaque key verbatim, not a kebab-cased guess."""
+ raw_have = {"v3": {"user": {"admin_user": {"group": "admins"}}}}
+ cmds = build_commands({"snmp_v3": {"users": []}}, raw_have, "replaced")
+ self.assertIn(("delete", ["service", "snmp", "v3", "user", "admin_user"]), cmds)
+
+ def test_replaced_does_not_purge_credential_without_new_password(self):
+ """Confirmed real device-rejected commit: VyOS requires an
+ auth/privacy node to carry an encrypted-password or plaintext-
+ password whenever it exists at all. A "replaced" config update
+ that changes an unrelated field (or nothing) without
+ re-supplying a password -- which the user can never read back
+ to re-supply -- must not purge the existing credential out from
+ under it, or the commit is rejected entirely."""
+ raw_have = {
+ "v3": {
+ "user": {
+ "admin_user": {
+ "auth": {"type": "sha", "encrypted-password": "hash1"},
+ "privacy": {"type": "aes", "encrypted-password": "hash2"},
+ "group": "admins",
+ },
+ },
+ },
+ }
+ config = {
+ "snmp_v3": {
+ "users": [
+ {
+ "user": "admin_user",
+ "group": "admins",
+ "authentication": {"type": "sha"},
+ "privacy": {"type": "aes"},
+ },
+ ],
+ },
+ }
+ cmds = build_commands(config, raw_have, "replaced")
+ self.assertFalse(any("encrypted-password" in str(c) for c in cmds))
+
+ def test_replaced_still_sets_a_genuinely_new_password(self):
+ """The credential-protection fix must not mask an intentional
+ password change -- only fill in what's missing."""
+ raw_have = {
+ "v3": {
+ "user": {
+ "admin_user": {
+ "auth": {"type": "sha", "encrypted-password": "hash1"},
+ "group": "admins",
+ },
+ },
+ },
+ }
+ config = {
+ "snmp_v3": {
+ "users": [
+ {
+ "user": "admin_user",
+ "group": "admins",
+ "authentication": {"type": "sha", "plaintext_key": "newpass"},
+ },
+ ],
+ },
+ }
+ cmds = build_commands(config, raw_have, "replaced")
+ expected = (
+ "set",
+ [
+ "service",
+ "snmp",
+ "v3",
+ "user",
+ "admin_user",
+ "auth",
+ "plaintext-password",
+ "newpass",
+ ],
+ )
+ self.assertIn(expected, cmds)
+
+ def test_plaintext_password_write_path(self):
+ """The primary confirmed bug fix, exercised end to end: the
+ device path must use plaintext-password, not plaintext-key."""
+ config = {
+ "snmp_v3": {
+ "users": [
+ {
+ "user": "newuser",
+ "authentication": {"type": "sha", "plaintext_key": "abc1234567"},
+ },
+ ],
+ },
+ }
+ cmds = build_commands(config, {}, "merged")
+ self.assertIn(
+ ("set", _BASE + ["v3", "user", "newuser", "auth", "plaintext-password", "abc1234567"]),
+ cmds,
+ )
+ self.assertTrue(all("plaintext-key" not in c[1] for c in cmds))
+
+ def test_replaced_scoped_to_named_sections_only(self):
+ """Regression test for the three-way key-name-collision bug this
+ session's investigation found (community/view/group each mean a
+ tag node at one level and an unrelated scalar leaf at another) --
+ replaced must not touch an unrelated section, and must not
+ crash comparing a scalar have value as if it were a dict."""
+ raw_have = {
+ "community": {"switches": {"authorization": "rw"}, "bridges": {"client": ["1.1.1.1"]}},
+ "contact": "old@example.com",
+ }
+ config = {"communities": [{"name": "switches", "authorization_type": "rw"}]}
+ cmds = build_commands(config, raw_have, "replaced")
+ self.assertIn(("delete", _BASE + ["community", "bridges"]), cmds)
+ self.assertTrue(all(c[1][: len(_BASE) + 1] != _BASE + ["contact"] for c in cmds))
+
+ def test_overridden_removes_omitted_scalar_field(self):
+ raw_have = {"contact": "old@example.com", "community": {"switches": {}}}
+ config = {"communities": [{"name": "switches"}]}
+ cmds = build_commands(config, raw_have, "overridden")
+ self.assertIn(("delete", _BASE + ["contact"]), cmds)
+
+ def test_deleted_no_have_is_noop(self):
+ self.assertEqual(build_commands({}, {}, "deleted"), [])
+
+ def test_deleted_with_have(self):
+ self.assertEqual(build_commands({}, {"contact": "x"}, "deleted"), [("delete", _BASE)])
+
+ def test_collapsed_v3_group_no_char_iteration_bug(self):
+ """A single v3 group with no other config, collapsed by the
+ device to a bare group-name string, must not be iterated
+ character-by-character."""
+ raw_have = {"v3": {"group": "admins"}}
+ config = {"snmp_v3": {"groups": [{"group": "admins"}]}}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+
+ def test_collapsed_trap_target_no_char_iteration_bug(self):
+ raw_have = {"trap-target": "203.0.113.5"}
+ config = {"trap_target": {"address": "203.0.113.5"}}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+
+ def test_v3_group_view_scalar_not_confused_with_v3_view_tag_node(self):
+ """Regression test: v3.group.<name>.view (a scalar leaf naming
+ which view the group uses) must never be coerced into a
+ presence-dict just because "view" is also a genuine tag node
+ one level up, under v3 itself."""
+ raw_have = {"v3": {"group": {"admins": {"view": "all"}}}}
+ config = {"snmp_v3": {"groups": [{"group": "admins", "view": "all"}]}}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+
+ def test_v3_user_group_scalar_not_confused_with_v3_group_tag_node(self):
+ raw_have = {"v3": {"user": {"admin_user": {"group": "admins"}}}}
+ config = {"snmp_v3": {"users": [{"user": "admin_user", "group": "admins"}]}}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+
+ def test_trap_target_community_scalar_not_confused_with_community_tag_node(self):
+ raw_have = {"trap-target": {"203.0.113.5": {"community": "public"}}}
+ config = {"trap_target": {"address": "203.0.113.5", "community": "public"}}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/modules/test_vyos_system.py b/tests/unit/modules/test_vyos_system.py
new file mode 100644
index 0000000..9345b57
--- /dev/null
+++ b/tests/unit/modules/test_vyos_system.py
@@ -0,0 +1,93 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+import unittest
+
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import (
+ dict_op,
+ owned_config,
+)
+from ansible_collections.vyos.rest.plugins.modules.vyos_system import (
+ _BASE,
+ ARGUMENT_SPEC,
+)
+
+from .base import load_fixture
+
+
+class TestOwnedConfig(unittest.TestCase):
+
+ def setUp(self):
+ self.fixture = load_fixture("system_running.json")
+
+ def test_filters_to_owned_keys(self):
+ result = owned_config(self.fixture, ARGUMENT_SPEC)
+ self.assertIn("host-name", result)
+ self.assertIn("domain-name", result)
+ self.assertIn("name-server", result)
+
+ def test_excludes_non_owned_keys(self):
+ result = owned_config(self.fixture, ARGUMENT_SPEC)
+ self.assertNotIn("config-management", result)
+ self.assertNotIn("console", result)
+ self.assertNotIn("login", result)
+ self.assertNotIn("syslog", result)
+
+
+class TestDictOp(unittest.TestCase):
+
+ def _have(self):
+ return {
+ "host-name": "vyos150",
+ "domain-name": "lab.example.com",
+ "name-server": ["8.8.8.8", "8.8.4.4"],
+ }
+
+ def test_set_idempotent(self):
+ want = {
+ "host_name": "vyos150",
+ "domain_name": "lab.example.com",
+ "name_server": ["8.8.8.8", "8.8.4.4"],
+ }
+ cmds = dict_op(want, self._have(), _BASE, op="set")
+ self.assertEqual(cmds, [])
+
+ def test_set_new_value(self):
+ want = {"domain_name": "new.example.com"}
+ cmds = dict_op(want, self._have(), _BASE, op="set")
+ self.assertIn(("set", ["system", "domain-name", "new.example.com"]), cmds)
+
+ def test_set_new_nameserver(self):
+ want = {"name_server": ["8.8.8.8", "8.8.4.4", "1.1.1.1"]}
+ cmds = dict_op(want, self._have(), _BASE, op="set")
+ self.assertIn(("set", ["system", "name-server", "1.1.1.1"]), cmds)
+ self.assertNotIn(("set", ["system", "name-server", "8.8.8.8"]), cmds)
+
+ def test_delete_scalar(self):
+ want = {"domain_name": "lab.example.com"}
+ cmds = dict_op(want, self._have(), _BASE, op="delete")
+ self.assertIn(("delete", ["system", "domain-name"]), cmds)
+
+ def test_delete_list_item(self):
+ want = {"name_server": ["8.8.8.8"]}
+ cmds = dict_op(want, self._have(), _BASE, op="delete")
+ self.assertIn(("delete", ["system", "name-server", "8.8.8.8"]), cmds)
+ self.assertNotIn(("delete", ["system", "name-server", "8.8.4.4"]), cmds)
+
+ def test_delete_nonexistent(self):
+ want = {"domain_name": "other.com"}
+ have = {"host-name": "vyos150"}
+ cmds = dict_op(want, have, _BASE, op="delete")
+ self.assertEqual(cmds, [])
+
+ def test_set_missing_key(self):
+ want = {"host_name": "vyos150"}
+ cmds = dict_op(want, {}, _BASE, op="set")
+ self.assertIn(("set", ["system", "host-name", "vyos150"]), cmds)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/modules/test_vyos_user.py b/tests/unit/modules/test_vyos_user.py
index 511ecef..32459ca 100644
--- a/tests/unit/modules/test_vyos_user.py
+++ b/tests/unit/modules/test_vyos_user.py
@@ -4,169 +4,239 @@ from __future__ import absolute_import, division, print_function
__metaclass__ = type
-import json
-import os
import unittest
from unittest.mock import MagicMock
from ansible_collections.vyos.rest.plugins.modules.vyos_user import (
+ _device_to_argspec,
+ _public_keys_from_device,
+ _public_keys_to_device,
+ _user_from_device,
+ _user_to_device,
build_commands,
get_running_config,
)
-
-_BASE = ["system", "login", "user"]
+from .base import load_fixture
-def load_fixture(filename):
- fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures")
- with open(os.path.join(fixtures_dir, filename)) as f:
- return json.load(f)
+_BASE = ["system", "login", "user"]
class VyOSModuleTestCase(unittest.TestCase):
def setUp(self):
self.mock_vyos = MagicMock()
- self.fixture = load_fixture("user_running.json")
- self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
-
+ fixture = load_fixture("user_running.json")
+ self.fixture = fixture.get("user", fixture)
+ self.mock_vyos.get_config = MagicMock(return_value={"user": self.fixture})
-class TestVyOSUserGetRunning(VyOSModuleTestCase):
- def test_parses_users(self):
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_unwraps_user_key(self):
result = get_running_config(self.mock_vyos)
- names = [u["name"] for u in result]
- self.assertIn("vyos", names)
- self.assertIn("alice", names)
+ self.assertIn("alice", result)
+ self.assertIn("vyos", result)
- def test_parses_full_name(self):
- result = get_running_config(self.mock_vyos)
- alice = next(u for u in result if u["name"] == "alice")
- self.assertEqual(alice["full_name"], "Alice Smith")
+ def test_empty_config(self):
+ self.mock_vyos.get_config = MagicMock(return_value=None)
+ self.assertEqual(get_running_config(self.mock_vyos), {})
- def test_parses_encrypted_password(self):
- result = get_running_config(self.mock_vyos)
- alice = next(u for u in result if u["name"] == "alice")
- self.assertEqual(alice["encrypted_password"], "$6$def456")
- def test_parses_public_keys(self):
- result = get_running_config(self.mock_vyos)
- alice = next(u for u in result if u["name"] == "alice")
- self.assertEqual(len(alice["public_keys"]), 1)
- key = alice["public_keys"][0]
- self.assertEqual(key["name"], "alice-laptop")
- self.assertEqual(key["type"], "ssh-rsa")
- self.assertEqual(key["key"], "AAAAB3NzaC1yc2EAAAA")
+class TestPublicKeysToDeviceFromDevice(unittest.TestCase):
+ def test_to_device(self):
+ result = _public_keys_to_device([{"name": "laptop", "key": "AAAA", "type": "ssh-rsa"}])
+ self.assertEqual(result, {"laptop": {"key": "AAAA", "type": "ssh-rsa"}})
+
+ def test_from_device(self):
+ result = _public_keys_from_device({"laptop": {"key": "AAAA", "type": "ssh-rsa"}})
+ self.assertEqual(result, [{"name": "laptop", "key": "AAAA", "type": "ssh-rsa"}])
+
+ def test_empty(self):
+ self.assertEqual(_public_keys_to_device([]), {})
+ self.assertEqual(_public_keys_from_device({}), [])
- def test_empty_config(self):
- self.mock_vyos.get_config = MagicMock(return_value={})
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result, [])
+class TestUserToDeviceFromDevice(unittest.TestCase):
+ """Password is the critical case here: it must NEVER appear in
+ _user_to_device's output (it's handled separately, outside dict_op,
+ since it can't be compared against have's encrypted-password)."""
-class TestVyOSUserBuildCommands(unittest.TestCase):
+ def test_password_never_enters_dict_op_path(self):
+ result = _user_to_device({"name": "alice", "password": "secret", "full_name": "Alice"})
+ self.assertNotIn("password", result)
+ self.assertNotIn("plaintext-password", str(result))
+ self.assertEqual(result, {"full_name": "Alice"})
- def _have(self):
- return [
- {"name": "vyos", "encrypted_password": "$6$abc123"},
+ def test_update_password_never_enters_dict_op_path(self):
+ result = _user_to_device({"name": "alice", "update_password": "on_create"})
+ self.assertEqual(result, {})
+
+ def test_public_keys_wrapped_under_authentication(self):
+ result = _user_to_device(
{
"name": "alice",
- "full_name": "Alice Smith",
- "encrypted_password": "$6$def456",
+ "public_keys": [{"name": "laptop", "key": "AAAA", "type": "ssh-rsa"}],
},
- ]
+ )
+ self.assertEqual(
+ result,
+ {"authentication": {"public_keys": {"laptop": {"key": "AAAA", "type": "ssh-rsa"}}}},
+ )
- def test_present_new_user_with_password(self):
- users = [
- {
- "name": "bob",
- "full_name": "Bob Jones",
- "password": "secret",
- "update_password": "always",
- },
- ]
- cmds = build_commands(users, self._have(), "present")
- self.assertIn(("set", _BASE + ["bob", "full-name", "Bob Jones"]), cmds)
- self.assertIn(
- ("set", _BASE + ["bob", "authentication", "plaintext-password", "secret"]),
- cmds,
+ def test_from_device_encrypted_password_surfaces_as_fact_only(self):
+ entry = _user_from_device("alice", {"authentication": {"encrypted-password": "hash1"}})
+ self.assertEqual(entry["encrypted_password"], "hash1")
+ self.assertNotIn("password", entry)
+
+ def test_from_device_plaintext_password_placeholder_ignored(self):
+ """VyOS's write-only placeholder (an empty plaintext-password
+ marker) must never surface in the argspec-facing output."""
+ entry = _user_from_device(
+ "vyos",
+ {"authentication": {"encrypted-password": "hash1", "plaintext-password": ""}},
)
+ self.assertNotIn("plaintext_password", entry)
+ self.assertNotIn("password", entry)
+
+ def test_from_device_with_public_keys(self):
+ entry = _user_from_device(
+ "alice",
+ {"authentication": {"public-keys": {"laptop": {"key": "AAAA", "type": "ssh-rsa"}}}},
+ )
+ self.assertEqual(
+ entry["public_keys"],
+ [{"name": "laptop", "key": "AAAA", "type": "ssh-rsa"}],
+ )
+
+
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_alice_full_name_and_keys(self):
+ have = _device_to_argspec(self.fixture)
+ alice = next(u for u in have if u["name"] == "alice")
+ self.assertEqual(alice["full_name"], "Alice Smith")
+ self.assertEqual(alice["encrypted_password"], "$6$def456")
+ self.assertEqual(alice["public_keys"][0]["name"], "alice-laptop")
- def test_present_update_password_always(self):
- users = [{"name": "alice", "password": "newpass", "update_password": "always"}]
- cmds = build_commands(users, self._have(), "present")
+ def test_vyos_user_present_no_plaintext_leak(self):
+ have = _device_to_argspec(self.fixture)
+ vyos_user = next(u for u in have if u["name"] == "vyos")
+ self.assertNotIn("password", vyos_user)
+ self.assertEqual(vyos_user["encrypted_password"], "$6$abc123")
+
+ def test_empty_config(self):
+ self.assertEqual(_device_to_argspec({}), [])
+ self.assertEqual(_device_to_argspec(None), [])
+
+
+class TestBuildCommands(VyOSModuleTestCase):
+ """Password policy is the module's core correctness risk -- covered
+ heavily here since it can never be validated via idempotency
+ (there's no way to compare plaintext to a hash)."""
+
+ def test_present_idempotent_without_password(self):
+ have = _device_to_argspec(self.fixture)
+ # drop encrypted_password/keys not settable via argspec anyway;
+ # use only what a user would actually pass back in
+ users = [{"name": u["name"], "full_name": u.get("full_name")} for u in have]
+ cmds = build_commands(users, self.fixture, "present")
+ self.assertEqual(cmds, [])
+
+ def test_update_password_always_resets_existing_user(self):
+ cmds = build_commands(
+ [{"name": "alice", "password": "newpass", "update_password": "always"}],
+ self.fixture,
+ "present",
+ )
self.assertIn(
("set", _BASE + ["alice", "authentication", "plaintext-password", "newpass"]),
cmds,
)
- def test_present_update_password_on_create_existing(self):
- users = [{"name": "alice", "password": "newpass", "update_password": "on_create"}]
- cmds = build_commands(users, self._have(), "present")
- paths = [c[1] for c in cmds]
- self.assertNotIn(
- _BASE + ["alice", "authentication", "plaintext-password", "newpass"],
- paths,
+ def test_update_password_on_create_skips_existing_user(self):
+ cmds = build_commands(
+ [{"name": "alice", "password": "newpass", "update_password": "on_create"}],
+ self.fixture,
+ "present",
)
+ self.assertTrue(all("plaintext-password" not in c[1] for c in cmds))
- def test_present_update_password_on_create_new(self):
- users = [{"name": "bob", "password": "secret", "update_password": "on_create"}]
- cmds = build_commands(users, self._have(), "present")
+ def test_update_password_on_create_sets_for_new_user(self):
+ cmds = build_commands(
+ [{"name": "bob", "password": "newpass", "update_password": "on_create"}],
+ self.fixture,
+ "present",
+ )
self.assertIn(
- ("set", _BASE + ["bob", "authentication", "plaintext-password", "secret"]),
+ ("set", _BASE + ["bob", "authentication", "plaintext-password", "newpass"]),
cmds,
)
- def test_present_idempotent_full_name(self):
- users = [{"name": "alice", "full_name": "Alice Smith"}]
- cmds = build_commands(users, self._have(), "present")
- self.assertEqual(cmds, [])
-
- def test_present_update_full_name(self):
- users = [{"name": "alice", "full_name": "Alice Updated"}]
- cmds = build_commands(users, self._have(), "present")
+ def test_default_update_password_is_always(self):
+ """default of 'always' must re-set even without explicit
+ update_password, matching the argspec default."""
+ cmds = build_commands([{"name": "alice", "password": "newpass"}], self.fixture, "present")
self.assertIn(
- ("set", _BASE + ["alice", "full-name", "Alice Updated"]),
+ ("set", _BASE + ["alice", "authentication", "plaintext-password", "newpass"]),
cmds,
)
- def test_absent_existing_user(self):
- users = [{"name": "alice"}]
- cmds = build_commands(users, self._have(), "absent")
- self.assertIn(("delete", _BASE + ["alice"]), cmds)
+ def test_no_password_never_sets_plaintext(self):
+ cmds = build_commands(
+ [{"name": "alice", "full_name": "Alice Smith"}],
+ self.fixture,
+ "present",
+ )
+ self.assertTrue(all("plaintext-password" not in c[1] for c in cmds))
- def test_absent_nonexistent_user(self):
- users = [{"name": "bob"}]
- cmds = build_commands(users, self._have(), "absent")
+ def test_vyos_user_never_deleted(self):
+ cmds = build_commands([{"name": "vyos"}], self.fixture, "absent")
self.assertEqual(cmds, [])
- def test_present_public_key(self):
- users = [
- {
- "name": "alice",
- "public_keys": [
- {"name": "new-key", "key": "AAAAB3...", "type": "ssh-ed25519"},
- ],
- },
- ]
- cmds = build_commands(users, self._have(), "present")
- self.assertIn(
- (
- "set",
- _BASE + ["alice", "authentication", "public-keys", "new-key", "key", "AAAAB3..."],
- ),
- cmds,
+ def test_absent_deletes_named_existing_user(self):
+ cmds = build_commands([{"name": "alice"}], self.fixture, "absent")
+ self.assertEqual(cmds, [("delete", _BASE + ["alice"])])
+
+ def test_absent_skips_nonexistent_user(self):
+ cmds = build_commands([{"name": "nobody"}], self.fixture, "absent")
+ self.assertEqual(cmds, [])
+
+ def test_present_adds_new_public_key_without_removing_others(self):
+ """present is additive-only: adding a key for an existing user
+ must not touch other existing fields."""
+ cmds = build_commands(
+ [
+ {
+ "name": "alice",
+ "public_keys": [
+ {"name": "alice-desktop", "key": "BBBB", "type": "ssh-ed25519"},
+ ],
+ },
+ ],
+ self.fixture,
+ "present",
)
self.assertIn(
(
"set",
_BASE
- + ["alice", "authentication", "public-keys", "new-key", "type", "ssh-ed25519"],
+ + [
+ "alice",
+ "authentication",
+ "public-keys",
+ "alice-desktop",
+ "key",
+ "BBBB",
+ ],
),
cmds,
)
+ def test_collapsed_single_public_key_no_char_iteration_bug(self):
+ raw_have = {"alice": {"authentication": {"public-keys": "alice-laptop"}}}
+ users = [{"name": "alice", "public_keys": [{"name": "alice-laptop"}]}]
+ self.assertEqual(build_commands(users, raw_have, "present"), [])
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/unit/modules/test_vyos_vlan.py b/tests/unit/modules/test_vyos_vlan.py
new file mode 100644
index 0000000..58c1be1
--- /dev/null
+++ b/tests/unit/modules/test_vyos_vlan.py
@@ -0,0 +1,116 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+import unittest
+
+from unittest.mock import MagicMock
+
+from ansible_collections.vyos.rest.plugins.modules.vyos_vlan import (
+ build_commands,
+ get_running_config,
+)
+
+from .base import load_fixture
+
+
+_BASE = ["interfaces", "ethernet"]
+
+
+class TestVyOSVlanGetRunning(unittest.TestCase):
+
+ def setUp(self):
+ self.mock_vyos = MagicMock()
+ self.fixture = load_fixture("vlan_running.json")
+ self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
+
+ def test_parses_vlans(self):
+ result = get_running_config(self.mock_vyos)
+ vlan_ids = [v["vlan_id"] for v in result]
+ self.assertIn(10, vlan_ids)
+ self.assertIn(20, vlan_ids)
+
+ def test_parses_description(self):
+ result = get_running_config(self.mock_vyos)
+ v10 = next(v for v in result if v["vlan_id"] == 10)
+ self.assertEqual(v10["description"], "VLAN10")
+
+ def test_parses_address(self):
+ result = get_running_config(self.mock_vyos)
+ v10 = next(v for v in result if v["vlan_id"] == 10)
+ self.assertEqual(v10["address"], "192.168.10.1/24")
+
+ def test_parses_multiple_interfaces(self):
+ result = get_running_config(self.mock_vyos)
+ v10 = next(v for v in result if v["vlan_id"] == 10)
+ self.assertIn("eth1", v10["interfaces"])
+ self.assertIn("eth2", v10["interfaces"])
+
+ def test_empty_config(self):
+ self.mock_vyos.get_config = MagicMock(return_value={})
+ result = get_running_config(self.mock_vyos)
+ self.assertEqual(result, [])
+
+
+class TestVyOSVlanBuildCommands(unittest.TestCase):
+
+ def _have(self):
+ return [
+ {
+ "vlan_id": 10,
+ "interfaces": ["eth1"],
+ "description": "VLAN10",
+ "address": "192.168.10.1/24",
+ },
+ {"vlan_id": 20, "interfaces": ["eth1"], "description": "VLAN20"},
+ ]
+
+ def test_present_new_vlan(self):
+ config = [{"vlan_id": 30, "description": "VLAN30", "interfaces": ["eth1"]}]
+ cmds = build_commands(config, [], "present")
+ self.assertIn(
+ ("set", _BASE + ["eth1", "vif", "30", "description", "VLAN30"]),
+ cmds,
+ )
+
+ def test_present_idempotent(self):
+ config = [
+ {
+ "vlan_id": 10,
+ "description": "VLAN10",
+ "address": "192.168.10.1/24",
+ "interfaces": ["eth1"],
+ },
+ {"vlan_id": 20, "description": "VLAN20", "interfaces": ["eth1"]},
+ ]
+ cmds = build_commands(config, self._have(), "present")
+ self.assertEqual(cmds, [])
+
+ def test_present_update_description(self):
+ config = [{"vlan_id": 10, "description": "VLAN10-new", "interfaces": ["eth1"]}]
+ cmds = build_commands(config, self._have(), "present")
+ self.assertIn(
+ ("set", _BASE + ["eth1", "vif", "10", "description", "VLAN10-new"]),
+ cmds,
+ )
+
+ def test_absent_existing(self):
+ config = [{"vlan_id": 10, "interfaces": ["eth1"]}]
+ cmds = build_commands(config, self._have(), "absent")
+ self.assertIn(("delete", _BASE + ["eth1", "vif", "10"]), cmds)
+
+ def test_absent_nonexistent(self):
+ config = [{"vlan_id": 99, "interfaces": ["eth1"]}]
+ cmds = build_commands(config, self._have(), "absent")
+ self.assertEqual(cmds, [])
+
+ def test_present_bare_vif(self):
+ config = [{"vlan_id": 30, "interfaces": ["eth1"]}]
+ cmds = build_commands(config, [], "present")
+ self.assertIn(("set", _BASE + ["eth1", "vif", "30"]), cmds)
+
+
+if __name__ == "__main__":
+ unittest.main()