1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
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()
|