diff options
| author | omnom62 <75066712+omnom62@users.noreply.github.com> | 2026-09-26 07:47:54 +1000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-26 07:47:54 +1000 |
| commit | 51e6ddb410d0ec7cd0845cf8bb382c9d0d0144eb (patch) | |
| tree | 2a4b8bf59c8d852e263d74a328e2f84a6dad379f /tests/unit/modules | |
| parent | 9fe93349225eb9b014e7a3e397da88fece40a0b2 (diff) | |
| parent | 7b4c77e8ee650314c8913e0565ffb93194007284 (diff) | |
| download | rest.vyos-51e6ddb410d0ec7cd0845cf8bb382c9d0d0144eb.tar.gz rest.vyos-51e6ddb410d0ec7cd0845cf8bb382c9d0d0144eb.zip | |
Merge pull request #28 from vyos/T8989_vyos_hostname_SIT_fix
T8989: vyos_hostname rework
Diffstat (limited to 'tests/unit/modules')
| -rw-r--r-- | tests/unit/modules/test_vyos_hostname.py | 309 |
1 files changed, 309 insertions, 0 deletions
diff --git a/tests/unit/modules/test_vyos_hostname.py b/tests/unit/modules/test_vyos_hostname.py new file mode 100644 index 0000000..3a7d99d --- /dev/null +++ b/tests/unit/modules/test_vyos_hostname.py @@ -0,0 +1,309 @@ +# -*- 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_hostname import ( + _BASE, + ARGUMENT_SPEC, + build_commands, + get_running_config, +) + + +class TestGetRunningConfig(unittest.TestCase): + def test_returns_current_hostname(self): + mock_vyos = MagicMock() + mock_vyos.get_value = MagicMock(return_value="vyos-core-01") + self.assertEqual(get_running_config(mock_vyos), "vyos-core-01") + + def test_uses_get_value_not_get_config(self): + """Regression test for the confirmed architectural bug: this + module was previously calling a "showConfig"-equivalent + operation directly via a bare VyOSRestClient, appropriate for + config subtrees, not the "returnValue" operation VyOS provides + specifically for a single scalar leaf like this one. Confirms + get_running_config goes through VyOSModule.get_value, not + get_config.""" + mock_vyos = MagicMock() + mock_vyos.get_value = MagicMock(return_value="vyos") + mock_vyos.get_config = MagicMock(return_value={}) + get_running_config(mock_vyos) + mock_vyos.get_value.assert_called_once_with(_BASE) + mock_vyos.get_config.assert_not_called() + + +class TestBuildCommands(unittest.TestCase): + def test_merged_sets_new_hostname(self): + cmds = build_commands({"hostname": "newhost"}, "vyos", "merged") + self.assertEqual(cmds, [("set", _BASE + ["newhost"])]) + + def test_merged_idempotent_when_already_correct(self): + cmds = build_commands({"hostname": "vyos"}, "vyos", "merged") + self.assertEqual(cmds, []) + + def test_merged_noop_when_hostname_not_specified(self): + cmds = build_commands({}, "vyos", "merged") + self.assertEqual(cmds, []) + + def test_deleted_with_existing_value(self): + cmds = build_commands({}, "somehost", "deleted") + self.assertEqual(cmds, [("delete", _BASE)]) + + def test_deleted_idempotent_when_already_empty(self): + cmds = build_commands({}, "", "deleted") + self.assertEqual(cmds, []) + + def test_config_none_does_not_crash(self): + cmds = build_commands(None, "vyos", "merged") + self.assertEqual(cmds, []) + + +class TestHostnameKeyAlwaysPresent(unittest.TestCase): + """Regression test for a real bug caught via integration testing: + "hostname" was conditionally omitted from the gathered/before/after + dict entirely when its value was empty (e.g. after deletion), + rather than being present with an empty value. This broke any + downstream access like gathered.gathered.hostname with an + AttributeError-equivalent ("object of type 'dict' has no attribute + 'hostname'"), rather than a clean value comparison.""" + + def test_gathered_includes_hostname_key_when_empty(self): + import json + import sys + + from unittest.mock import patch + + from ansible.module_utils import basic + + argv = ["x", json.dumps({"ANSIBLE_MODULE_ARGS": {"state": "gathered"}})] + captured = {} + + def fake_exit_json(self_mod, **kwargs): + captured.update(kwargs) + raise SystemExit(0) + + basic._ANSIBLE_ARGS = None + basic._PARSED_MODULE_ARGS = None + with patch.object(sys, "argv", argv), patch( + "ansible_collections.vyos.rest.plugins.module_utils.vyos.VyOSRestClient", + ) as mock_client, patch( + "ansible.module_utils.basic.AnsibleModule.exit_json", + fake_exit_json, + ): + mock_client.return_value.retrieve_return_value.return_value = {"data": ""} + from ansible_collections.vyos.rest.plugins.modules import vyos_hostname + + with self.assertRaises(SystemExit): + vyos_hostname.main() + + self.assertIn("hostname", captured["gathered"]) + self.assertEqual(captured["gathered"]["hostname"], "") + + +class TestGatheredReturnsCommands(unittest.TestCase): + """Regression test for a bug caught during rework: the gathered + branch initially dropped commands=[] entirely, contradicting the + RETURN doc's own "returned: always" claim and regressing from the + original module's behavior.""" + + def test_gathered_includes_empty_commands(self): + import json + import sys + + from unittest.mock import patch + + from ansible.module_utils import basic + + argv = [ + "x", + json.dumps({"ANSIBLE_MODULE_ARGS": {"state": "gathered"}}), + ] + captured = {} + + def fake_exit_json(self_mod, **kwargs): + captured.update(kwargs) + raise SystemExit(0) + + basic._ANSIBLE_ARGS = None + basic._PARSED_MODULE_ARGS = None + with patch.object(sys, "argv", argv), patch( + "ansible_collections.vyos.rest.plugins.module_utils.vyos.VyOSRestClient", + ) as mock_client, patch( + "ansible.module_utils.basic.AnsibleModule.exit_json", + fake_exit_json, + ): + mock_client.return_value.retrieve_return_value.return_value = {"data": "vyos"} + from ansible_collections.vyos.rest.plugins.modules import vyos_hostname + + with self.assertRaises(SystemExit): + vyos_hostname.main() + + self.assertIn("commands", captured) + self.assertEqual(captured["commands"], []) + self.assertEqual(captured["gathered"], {"hostname": "vyos"}) + + +class TestCollapsedStates(unittest.TestCase): + """replaced/overridden collapse onto merged for this single-value + resource -- there is nothing else to distinctly replace/override + when there is only one field. Confirmed via the argspec: no + separate branch exists for them in build_commands, matching the + module's own documented design decision.""" + + def test_argspec_declares_all_four_states(self): + self.assertEqual( + set(ARGUMENT_SPEC["state"]["choices"]), + {"merged", "replaced", "overridden", "deleted", "gathered"}, + ) + + def test_no_rendered_or_parsed_states(self): + """Confirmed architectural fix: rendered/parsed are CLI- + collection concepts (offline command/config-text rendering) + that don't correspond to anything meaningful for a REST + transport, where the actual payload is a structured API call, + not a CLI line.""" + self.assertNotIn("rendered", ARGUMENT_SPEC["state"]["choices"]) + self.assertNotIn("parsed", ARGUMENT_SPEC["state"]["choices"]) + + +class TestArgumentSpecNoConnectionParams(unittest.TestCase): + """Confirmed architectural fix: module-level hostname/port/api_key/ + timeout/verify_ssl params were a genuine outlier in this collection + -- every other module relies exclusively on the httpapi connection + plugin for transport/auth, never on module params.""" + + def test_no_connection_params_in_argspec(self): + for key in ("hostname", "port", "api_key", "timeout", "verify_ssl"): + self.assertNotIn(key, ARGUMENT_SPEC) + + +class TestEmptyHostnameFailsExplicitly(unittest.TestCase): + """Confirmed real bug (Copilot): with state=merged, an empty + string for config.hostname previously produced a silent no-op -- + build_commands' falsy check treated "" the same as "not + specified" -- even though hostname is declared required=True. + Ansible's own argspec validation only checks presence, not + non-emptiness, so this reached build_commands unnoticed. Confirmed + by direct reproduction before fixing; now fails explicitly instead. + + In-process pattern (patch.object for sys.argv, matching the + established tests elsewhere in this file) rather than subprocess + isolation: confirmed the subprocess approach breaks under + ansible-test --docker's custom collection-loading machinery, which + a freshly spawned subprocess does not inherit. + """ + + def _run_main(self, config, state="merged"): + import json + import sys + + from unittest.mock import patch + + from ansible.module_utils import basic + + args = {"state": state} + if config is not None: + args["config"] = config + argv = ["x", json.dumps({"ANSIBLE_MODULE_ARGS": args})] + captured = {} + + def fake_exit_json(self_mod, **kwargs): + captured.update(kwargs) + raise SystemExit(0) + + def fake_fail_json(self_mod, **kwargs): + captured.update(kwargs) + captured["failed"] = True + raise SystemExit(1) + + basic._ANSIBLE_ARGS = None + basic._PARSED_MODULE_ARGS = None + with patch.object(sys, "argv", argv), patch( + "ansible_collections.vyos.rest.plugins.module_utils.vyos.VyOSRestClient", + ) as mock_client, patch( + "ansible.module_utils.basic.AnsibleModule.exit_json", + fake_exit_json, + ), patch( + "ansible.module_utils.basic.AnsibleModule.fail_json", + fake_fail_json, + ): + mock_client.return_value.retrieve_return_value.return_value = { + "data": "existing-host", + } + from ansible_collections.vyos.rest.plugins.modules import vyos_hostname + + with self.assertRaises(SystemExit): + vyos_hostname.main() + return captured + + def test_empty_hostname_fails_explicitly(self): + result = self._run_main({"hostname": ""}) + self.assertTrue(result.get("failed")) + self.assertIn("non-empty", result.get("msg", "")) + + def test_non_empty_hostname_still_works(self): + result = self._run_main({"hostname": "newhost"}) + self.assertNotIn("failed", result) + + +class TestCheckModeOmitsAfter(unittest.TestCase): + """Confirmed real bug (Copilot): check_mode returned after=have + even when commands was non-empty, misrepresenting the pre-change + state as if it were the post-change result. Now matches the + established convention across the rest of the collection + (confirmed against vyos_nat, vyos_ha, vyos_snmp_server, + vyos_ntp_global): omit "after" entirely in check mode. + """ + + def test_check_mode_omits_after(self): + import json + import sys + + from unittest.mock import patch + + from ansible.module_utils import basic + + argv = [ + "x", + json.dumps( + { + "ANSIBLE_MODULE_ARGS": { + "config": {"hostname": "newhost"}, + "state": "merged", + "_ansible_check_mode": True, + }, + }, + ), + ] + captured = {} + + def fake_exit_json(self_mod, **kwargs): + captured.update(kwargs) + raise SystemExit(0) + + basic._ANSIBLE_ARGS = None + basic._PARSED_MODULE_ARGS = None + with patch.object(sys, "argv", argv), patch( + "ansible_collections.vyos.rest.plugins.module_utils.vyos.VyOSRestClient", + ) as mock_client, patch( + "ansible.module_utils.basic.AnsibleModule.exit_json", + fake_exit_json, + ): + mock_client.return_value.retrieve_return_value.return_value = {"data": "oldhost"} + from ansible_collections.vyos.rest.plugins.modules import vyos_hostname + + with self.assertRaises(SystemExit): + vyos_hostname.main() + + self.assertTrue(captured.get("changed")) + self.assertNotIn("after", captured) + + +if __name__ == "__main__": + unittest.main() |
