summaryrefslogtreecommitdiff
path: root/tests/unit/modules/test_vyos_snmp_server.py
diff options
context:
space:
mode:
Diffstat (limited to 'tests/unit/modules/test_vyos_snmp_server.py')
-rw-r--r--tests/unit/modules/test_vyos_snmp_server.py644
1 files changed, 644 insertions, 0 deletions
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()