aoh

A tool for running Ansible playbooks with `curl <url> | python` commands
git clone https://git.ashermorgan.net/aoh/
Log | Files | Refs | README

commit 676215d9fa4b46f659ca497da2cd560ef91000fd
parent 164cdb54102eab1a1137e11a9b88fdd1e564f331
Author: Asher Morgan <asher@ashermorgan.net>
Date:   Sat, 12 Sep 2026 19:00:03 -0700

Add basic unit tests

Diffstat:
Atests/unit/__init__.py | 7+++++++
Atests/unit/test_playbook.py | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/test_security.py | 211+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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']))