diff options
Diffstat (limited to 'tests/unit/modules/test_vyos_user.py')
| -rw-r--r-- | tests/unit/modules/test_vyos_user.py | 278 |
1 files changed, 174 insertions, 104 deletions
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() |
