commit 676215d9fa4b46f659ca497da2cd560ef91000fd
parent 164cdb54102eab1a1137e11a9b88fdd1e564f331
Author: Asher Morgan <asher@ashermorgan.net>
Date: Sat, 12 Sep 2026 19:00:03 -0700
Add basic unit tests
Diffstat:
3 files changed, 304 insertions(+), 0 deletions(-)
diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py
@@ -0,0 +1,7 @@
+import unittest
+
+from .test_playbook import *
+from .test_security import *
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/unit/test_playbook.py b/tests/unit/test_playbook.py
@@ -0,0 +1,86 @@
+import os
+import unittest
+from unittest.mock import mock_open, patch
+
+import aoh.playbook
+from aoh.playbook import PlaybookFileError, get_playbook
+
+
+class TestPlaybookGetPlaybook(unittest.TestCase):
+ @patch.object(aoh.playbook, 'open', mock_open(read_data='main.yml:'))
+ def test_default_values(self):
+ pb = get_playbook('main.yml')
+
+ self.assertEqual(pb.name, 'main.yml')
+ self.assertEqual(pb.path, os.path.abspath('main.yml'))
+ self.assertEqual(pb.config, None)
+ self.assertEqual(pb.host, None)
+ self.assertEqual(pb.groups, [])
+ self.assertEqual(pb.limit, True)
+ self.assertEqual(pb.password, False)
+ self.assertEqual(pb.output, True)
+ self.assertEqual(pb.allow_opts, [])
+ self.assertEqual(pb.block_opts, [])
+ self.assertEqual(pb.jinja_args, False)
+ self.assertEqual(pb.extra_args, [])
+ self.assertEqual(pb.web_description, None)
+
+
+ @patch.object(aoh.playbook, 'open', mock_open(read_data='x: ['))
+ def test_invalid_yaml(self):
+ with self.assertRaisesRegex(PlaybookFileError, 'Failed to parse'):
+ get_playbook('main.yml')
+
+
+ @patch.object(aoh.playbook, 'open', mock_open(read_data='other.yml:'))
+ def test_missing_playbook(self):
+ self.assertEqual(get_playbook('main.yml'), None)
+
+
+ @patch.object(aoh.playbook, 'open',
+ mock_open(read_data='main.yml:\n allow_opts: [--step]'))
+ def test_unsupported_opts(self):
+ with self.assertRaisesRegex(PlaybookFileError, 'not supported'):
+ get_playbook('main.yml')
+
+
+ @patch.object(aoh.playbook, 'open',
+ mock_open(read_data='main.yml:\n allow_opts: [--limit]'))
+ def test_limit_opt_enabled(self):
+ with self.assertRaisesRegex(PlaybookFileError, 'if limit is true'):
+ get_playbook('main.yml')
+
+
+ @patch.object(aoh.playbook, 'open',
+ mock_open(read_data='main.yml:\n limit: false\n'
+ ' allow_opts: [--limit]'))
+ def test_limit_opt_disabled(self):
+ pb = get_playbook('main.yml')
+ self.assertEqual(pb.limit, False)
+ self.assertEqual(pb.allow_opts, ['--limit'])
+
+
+ @patch.object(aoh.playbook, 'open',
+ mock_open(read_data='main.yml:\n limit: asdf'))
+ def test_bad_bool_type(self):
+ with self.assertRaisesRegex(PlaybookFileError, 'must be of type'):
+ get_playbook('main.yml')
+
+
+ @patch.object(aoh.playbook, 'open',
+ mock_open(read_data='main.yml:\n limit: false'))
+ def test_good_bool_type(self):
+ self.assertEqual(get_playbook('main.yml').limit, False)
+
+
+ @patch.object(aoh.playbook, 'open',
+ mock_open(read_data='main.yml:\n groups: asdf'))
+ def test_bad_array_type(self):
+ with self.assertRaisesRegex(PlaybookFileError, 'must be of type'):
+ get_playbook('main.yml')
+
+
+ @patch.object(aoh.playbook, 'open',
+ mock_open(read_data='main.yml:\n groups: [one, two]'))
+ def test_good_array_type(self):
+ self.assertEqual(get_playbook('main.yml').groups, ['one', 'two'])
diff --git a/tests/unit/test_security.py b/tests/unit/test_security.py
@@ -0,0 +1,211 @@
+import unittest
+from unittest.mock import mock_open, patch
+
+import bcrypt
+
+import aoh.security
+from aoh.playbook import Playbook
+from aoh.security import (
+ PasswordFileError,
+ get_required_passwords,
+ validate_aoh_password,
+ validate_args,
+)
+
+
+class TestSecurityGetRequiredPasswords(unittest.TestCase):
+ def test_default_options(self):
+ pb = Playbook('main.yml', {})
+
+ self.assertEqual(
+ set(get_required_passwords(pb, [])),
+ set(),
+ )
+ self.assertEqual(
+ set(get_required_passwords(pb, ['--ask-pass'])),
+ {'connection_password'},
+ )
+ self.assertEqual(
+ set(get_required_passwords(pb, ['--ask-pass', '-K'])),
+ {'connection_password', 'become_password'},
+ )
+
+
+ def test_aoh_password(self):
+ pb = Playbook('main.yml', {'password': True})
+
+ self.assertEqual(
+ set(get_required_passwords(pb, [])),
+ {'aoh_password'},
+ )
+ self.assertEqual(
+ set(get_required_passwords(pb, ['--ask-pass'])),
+ {'aoh_password', 'connection_password'},
+ )
+ self.assertEqual(
+ set(get_required_passwords(pb, ['--ask-pass', '-K'])),
+ {'aoh_password', 'connection_password', 'become_password'},
+ )
+
+
+ @patch.object(aoh.security, 'get_config_values', return_value={
+ 'DEFAULT_ASK_PASS': False,
+ 'DEFAULT_BECOME_ASK_PASS': True,
+ 'DEFAULT_ASK_VAULT_PASS': False,
+ })
+ def test_config_file(self, _):
+ pb = Playbook('main.yml', {})
+
+ self.assertEqual(
+ set(get_required_passwords(pb, [])),
+ {'become_password'},
+ )
+ self.assertEqual(
+ set(get_required_passwords(pb, ['--ask-pass'])),
+ {'become_password', 'connection_password'},
+ )
+ self.assertEqual(
+ set(get_required_passwords(pb, ['--ask-pass', '-K'])),
+ {'become_password', 'connection_password'},
+ )
+
+
+ @patch.object(aoh.security, 'get_config_values', return_value={
+ 'DEFAULT_ASK_PASS': False,
+ 'DEFAULT_BECOME_ASK_PASS': True,
+ 'DEFAULT_ASK_VAULT_PASS': False,
+ })
+ def test_aoh_password_and_config_file(self, _):
+ pb = Playbook('main.yml', {'password': True})
+
+ self.assertEqual(
+ set(get_required_passwords(pb, [])),
+ {'aoh_password', 'become_password'},
+ )
+ self.assertEqual(
+ set(get_required_passwords(pb, ['--ask-pass'])),
+ {'aoh_password', 'become_password', 'connection_password'},
+ )
+ self.assertEqual(
+ set(get_required_passwords(pb, ['--ask-pass', '-K'])),
+ {'aoh_password', 'become_password', 'connection_password'},
+ )
+
+
+class TestSecurityValidateAohPassword(unittest.TestCase):
+ hash = bcrypt.hashpw(b'hunter2', bcrypt.gensalt()).decode()
+
+ def test_no_password_required(self):
+ pb = Playbook('main.yml', {'password': False})
+
+ self.assertTrue(validate_aoh_password(pb, 'asdf'))
+ self.assertTrue(validate_aoh_password(pb, None))
+
+
+ def test_null_password(self):
+ pb = Playbook('main.yml', {'password': True})
+
+ self.assertFalse(validate_aoh_password(pb, None))
+
+
+ @patch.object(aoh.security, 'open', mock_open(read_data='other.yml: foo'))
+ def test_missing_hash(self):
+ pb = Playbook('main.yml', {'password': True})
+
+ with self.assertRaisesRegex(PasswordFileError, 'not found'):
+ validate_aoh_password(pb, 'asdf')
+
+
+ @patch.object(aoh.security, 'open', mock_open(read_data='main.yml: foo'))
+ def test_bad_hash(self):
+ pb = Playbook('main.yml', {'password': True})
+
+ with self.assertRaisesRegex(PasswordFileError, 'Invalid bcrypt hash'):
+ validate_aoh_password(pb, 'asdf')
+
+
+ @patch.object(aoh.security, 'open',
+ mock_open(read_data=f'main.yml: {hash}'))
+ def test_bad_password(self):
+ pb = Playbook('main.yml', {'password': True})
+
+ self.assertFalse(validate_aoh_password(pb, 'asdf'))
+
+
+ @patch.object(aoh.security, 'open',
+ mock_open(read_data=f'main.yml: {hash}'))
+ def test_good_password(self):
+ pb = Playbook('main.yml', {'password': True})
+
+ self.assertTrue(validate_aoh_password(pb, 'hunter2'))
+
+
+class TestSecurityValidateArgs(unittest.TestCase):
+ def test_default_options(self):
+ pb = Playbook('main.yml', {})
+
+ self.assertTrue(validate_args(pb, ['--check']))
+ self.assertTrue(validate_args(pb, ['-t', 'foo']))
+ self.assertTrue(validate_args(pb, ['--tags=bar']))
+
+ self.assertFalse(validate_args(pb, ['--verbose', '--version']))
+ self.assertFalse(validate_args(pb, ['-e', 'foo=bar']))
+ self.assertFalse(validate_args(pb, ['--check', '-v']))
+
+ pb.allow_opts = ['--version']
+
+ self.assertTrue(validate_args(pb, ['--check']))
+ self.assertTrue(validate_args(pb, ['-t', 'foo']))
+ self.assertTrue(validate_args(pb, ['--tags=bar']))
+
+ self.assertFalse(validate_args(pb, ['--verbose', '--version']))
+ self.assertFalse(validate_args(pb, ['-e', 'foo=bar']))
+ self.assertFalse(validate_args(pb, ['--check', '-v']))
+
+
+ def test_allowed_options(self):
+ pb = Playbook('main.yml', {})
+
+ self.assertFalse(validate_args(pb, ['--verbose', '--check']))
+
+ pb.allow_opts = ['--verbose']
+
+ self.assertTrue(validate_args(pb, ['--verbose', '--check']))
+
+
+ def test_blocked_options(self):
+ pb = Playbook('main.yml', {})
+
+ self.assertTrue(validate_args(pb, ['--diff', '--check']))
+
+ pb.block_opts = ['--check']
+
+ self.assertFalse(validate_args(pb, ['--diff', '--check']))
+
+
+ def test_jinja(self):
+ pb = Playbook('main.yml', {})
+
+ self.assertFalse(validate_args(pb, ['-t', '{{x}}']))
+ self.assertFalse(validate_args(pb, ['-t', '{% x %}']))
+ self.assertFalse(validate_args(pb, ['-t', '\u007b\x25 x %}']))
+
+ pb.jinja_args = True
+
+ self.assertTrue(validate_args(pb, ['-t', '{{x}}']))
+ self.assertTrue(validate_args(pb, ['-t', '{% x %}']))
+ self.assertTrue(validate_args(pb, ['-t', '\u007b\x25 x %}']))
+
+ self.assertFalse(validate_args(pb, ['--verbose', '--version']))
+ self.assertFalse(validate_args(pb, ['-e', 'foo=bar']))
+ self.assertFalse(validate_args(pb, ['--check', '-v']))
+
+
+ def test_unsupported_options(self):
+ pb = Playbook('main.yml', {})
+
+ self.assertFalse(validate_args(pb, ['--step']))
+
+ pb.allow_opts = ['--step']
+
+ self.assertFalse(validate_args(pb, ['--step']))