From 2d472e04bc9c172591b363fd09f43b9dcb9d0a1f Mon Sep 17 00:00:00 2001 From: George Rawlinson Date: Fri, 4 Jun 2021 17:08:54 +1200 Subject: [PATCH] add module pacman_key (#778) * add module pacman_key * add symlink and fix documentation for pacman_key * documentation fix for pacman_key * improve logic around user input * Update plugins/modules/packaging/os/pacman_key.py Co-authored-by: Andrew Klychkov * Update plugins/modules/packaging/os/pacman_key.py Co-authored-by: Andrew Klychkov * Update plugins/modules/packaging/os/pacman_key.py Co-authored-by: Andrew Klychkov * Update plugins/modules/packaging/os/pacman_key.py Co-authored-by: Andrew Klychkov * Update plugins/modules/packaging/os/pacman_key.py Co-authored-by: Andrew Klychkov * Update plugins/modules/packaging/os/pacman_key.py Co-authored-by: Andrew Klychkov * Update plugins/modules/packaging/os/pacman_key.py Co-authored-by: Andrew Klychkov * Update plugins/modules/packaging/os/pacman_key.py Co-authored-by: Andrew Klychkov * Update plugins/modules/packaging/os/pacman_key.py Co-authored-by: Andrew Klychkov * Update plugins/modules/packaging/os/pacman_key.py Co-authored-by: Andrew Klychkov * Improve parameter checking required_one_of=[] is neat. Co-authored-by: Alexei Znamensky * Revert "Improve parameter checking" This reverts commit 044b0cbc854744480ad1e17753e33f0371c7d0eb. * Simplify a bunch of code. * fix typos pointed out by yan12125 * replaced manual checks with required-if invocation * added default keyring to documentation * some initial tests * updated metadata * refactored to make sanity tests pass * refactor to make sanity tests pass ... part deux * refactor: simplify run_command invocations * test: cover check-mode and some normal operation * docs: fix grammatical errors * rip out fingerprint code a full length (40 characters) key ID is equivalent to the fingerprint. * refactor tests, add a couple more * test: added testcase for method: data * Update plugins/modules/packaging/os/pacman_key.py Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> * docs: correct yaml boolean type Co-authored-by: Felix Fontein Co-authored-by: Andrew Klychkov Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> Co-authored-by: Felix Fontein (cherry picked from commit 5ddf0041ecc733ed6f1f6ab938af584683c6e862) --- plugins/modules/packaging/os/pacman_key.py | 314 ++++++++++ plugins/modules/pacman_key.py | 1 + .../modules/packaging/os/test_pacman_key.py | 576 ++++++++++++++++++ 3 files changed, 891 insertions(+) create mode 100644 plugins/modules/packaging/os/pacman_key.py create mode 120000 plugins/modules/pacman_key.py create mode 100644 tests/unit/plugins/modules/packaging/os/test_pacman_key.py diff --git a/plugins/modules/packaging/os/pacman_key.py b/plugins/modules/packaging/os/pacman_key.py new file mode 100644 index 00000000000..85896c211df --- /dev/null +++ b/plugins/modules/packaging/os/pacman_key.py @@ -0,0 +1,314 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2019, George Rawlinson +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: pacman_key +author: +- George Rawlinson (@grawlinson) +version_added: "3.2.0" +short_description: Manage pacman's list of trusted keys +description: +- Add or remove gpg keys from the pacman keyring. +notes: +- Use full-length key ID (40 characters). +- Keys will be verified when using I(data), I(file), or I(url) unless I(verify) is overridden. +- Keys will be locally signed after being imported into the keyring. +- If the key ID exists in the keyring, the key will not be added unless I(force_update) is specified. +- I(data), I(file), I(url), and I(keyserver) are mutually exclusive. +- Supports C(check_mode). +requirements: +- gpg +- pacman-key +options: + id: + description: + - The 40 character identifier of the key. + - Including this allows check mode to correctly report the changed state. + - Do not specify a subkey ID, instead specify the primary key ID. + required: true + type: str + data: + description: + - The keyfile contents to add to the keyring. + - Must be of C(PGP PUBLIC KEY BLOCK) type. + type: str + file: + description: + - The path to a keyfile on the remote server to add to the keyring. + - Remote file must be of C(PGP PUBLIC KEY BLOCK) type. + type: path + url: + description: + - The URL to retrieve keyfile from. + - Remote file must be of C(PGP PUBLIC KEY BLOCK) type. + type: str + keyserver: + description: + - The keyserver used to retrieve key from. + type: str + verify: + description: + - Whether or not to verify the keyfile's key ID against specified key ID. + type: bool + default: true + force_update: + description: + - This forces the key to be updated if it already exists in the keyring. + type: bool + default: false + keyring: + description: + - The full path to the keyring folder on the remote server. + - If not specified, module will use pacman's default (C(/etc/pacman.d/gnupg)). + - Useful if the remote system requires an alternative gnupg directory. + type: path + default: /etc/pacman.d/gnupg + state: + description: + - Ensures that the key is present (added) or absent (revoked). + default: present + choices: [ absent, present ] + type: str +''' + +EXAMPLES = ''' +- name: Import a key via local file + community.general.pacman_key: + data: "{{ lookup('file', 'keyfile.asc') }}" + state: present + +- name: Import a key via remote file + community.general.pacman_key: + file: /tmp/keyfile.asc + state: present + +- name: Import a key via url + community.general.pacman_key: + id: 01234567890ABCDE01234567890ABCDE12345678 + url: https://domain.tld/keys/keyfile.asc + state: present + +- name: Import a key via keyserver + community.general.pacman_key: + id: 01234567890ABCDE01234567890ABCDE12345678 + keyserver: keyserver.domain.tld + +- name: Import a key into an alternative keyring + community.general.pacman_key: + id: 01234567890ABCDE01234567890ABCDE12345678 + file: /tmp/keyfile.asc + keyring: /etc/pacman.d/gnupg-alternative + +- name: Remove a key from the keyring + community.general.pacman_key: + id: 01234567890ABCDE01234567890ABCDE12345678 + state: absent +''' + +RETURN = r''' # ''' + +import os.path +import tempfile +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.urls import fetch_url +from ansible.module_utils._text import to_native + + +class PacmanKey(object): + def __init__(self, module): + self.module = module + # obtain binary paths for gpg & pacman-key + self.gpg = module.get_bin_path('gpg', required=True) + self.pacman_key = module.get_bin_path('pacman-key', required=True) + + # obtain module parameters + keyid = module.params['id'] + url = module.params['url'] + data = module.params['data'] + file = module.params['file'] + keyserver = module.params['keyserver'] + verify = module.params['verify'] + force_update = module.params['force_update'] + keyring = module.params['keyring'] + state = module.params['state'] + self.keylength = 40 + + # sanitise key ID & check if key exists in the keyring + keyid = self.sanitise_keyid(keyid) + key_present = self.key_in_keyring(keyring, keyid) + + # check mode + if module.check_mode: + if state == "present": + changed = (key_present and force_update) or not key_present + module.exit_json(changed=changed) + elif state == "absent": + if key_present: + module.exit_json(changed=True) + module.exit_json(changed=False) + + if state == "present": + if key_present and not force_update: + module.exit_json(changed=False) + + if data: + file = self.save_key(data) + self.add_key(keyring, file, keyid, verify) + module.exit_json(changed=True) + elif file: + self.add_key(keyring, file, keyid, verify) + module.exit_json(changed=True) + elif url: + data = self.fetch_key(url) + file = self.save_key(data) + self.add_key(keyring, file, keyid, verify) + module.exit_json(changed=True) + elif keyserver: + self.recv_key(keyring, keyid, keyserver) + module.exit_json(changed=True) + elif state == "absent": + if key_present: + self.remove_key(keyring, keyid) + module.exit_json(changed=True) + module.exit_json(changed=False) + + def is_hexadecimal(self, string): + """Check if a given string is valid hexadecimal""" + try: + int(string, 16) + except ValueError: + return False + return True + + def sanitise_keyid(self, keyid): + """Sanitise given key ID. + + Strips whitespace, uppercases all characters, and strips leading `0X`. + """ + sanitised_keyid = keyid.strip().upper().replace(' ', '').replace('0X', '') + if len(sanitised_keyid) != self.keylength: + self.module.fail_json(msg="key ID is not full-length: %s" % sanitised_keyid) + if not self.is_hexadecimal(sanitised_keyid): + self.module.fail_json(msg="key ID is not hexadecimal: %s" % sanitised_keyid) + return sanitised_keyid + + def fetch_key(self, url): + """Downloads a key from url""" + response, info = fetch_url(self.module, url) + if info['status'] != 200: + self.module.fail_json(msg="failed to fetch key at %s, error was %s" % (url, info['msg'])) + return to_native(response.read()) + + def recv_key(self, keyring, keyid, keyserver): + """Receives key via keyserver""" + cmd = [self.pacman_key, '--gpgdir', keyring, '--keyserver', keyserver, '--recv-keys', keyid] + self.module.run_command(cmd, check_rc=True) + self.lsign_key(keyring, keyid) + + def lsign_key(self, keyring, keyid): + """Locally sign key""" + cmd = [self.pacman_key, '--gpgdir', keyring] + self.module.run_command(cmd + ['--lsign-key', keyid], check_rc=True) + + def save_key(self, data): + "Saves key data to a temporary file" + tmpfd, tmpname = tempfile.mkstemp() + self.module.add_cleanup_file(tmpname) + tmpfile = os.fdopen(tmpfd, "w") + tmpfile.write(data) + tmpfile.close() + return tmpname + + def add_key(self, keyring, keyfile, keyid, verify): + """Add key to pacman's keyring""" + if verify: + self.verify_keyfile(keyfile, keyid) + cmd = [self.pacman_key, '--gpgdir', keyring, '--add', keyfile] + self.module.run_command(cmd, check_rc=True) + self.lsign_key(keyring, keyid) + + def remove_key(self, keyring, keyid): + """Remove key from pacman's keyring""" + cmd = [self.pacman_key, '--gpgdir', keyring, '--delete', keyid] + self.module.run_command(cmd, check_rc=True) + + def verify_keyfile(self, keyfile, keyid): + """Verify that keyfile matches the specified key ID""" + if keyfile is None: + self.module.fail_json(msg="expected a key, got none") + elif keyid is None: + self.module.fail_json(msg="expected a key ID, got none") + + rc, stdout, stderr = self.module.run_command( + [ + self.gpg, + '--with-colons', + '--with-fingerprint', + '--batch', + '--no-tty', + '--show-keys', + keyfile + ], + check_rc=True, + ) + + extracted_keyid = None + for line in stdout.splitlines(): + if line.startswith('fpr:'): + extracted_keyid = line.split(':')[9] + break + + if extracted_keyid != keyid: + self.module.fail_json(msg="key ID does not match. expected %s, got %s" % (keyid, extracted_keyid)) + + def key_in_keyring(self, keyring, keyid): + "Check if the key ID is in pacman's keyring" + rc, stdout, stderr = self.module.run_command( + [ + self.gpg, + '--with-colons', + '--batch', + '--no-tty', + '--no-default-keyring', + '--keyring=%s/pubring.gpg' % keyring, + '--list-keys', keyid + ], + check_rc=False, + ) + if rc != 0: + if stderr.find("No public key") >= 0: + return False + else: + self.module.fail_json(msg="gpg returned an error: %s" % stderr) + return True + + +def main(): + module = AnsibleModule( + argument_spec=dict( + id=dict(type='str', required=True), + data=dict(type='str'), + file=dict(type='path'), + url=dict(type='str'), + keyserver=dict(type='str'), + verify=dict(type='bool', default=True), + force_update=dict(type='bool', default=False), + keyring=dict(type='path', default='/etc/pacman.d/gnupg'), + state=dict(type='str', default='present', choices=['absent', 'present']), + ), + supports_check_mode=True, + mutually_exclusive=(('data', 'file', 'url', 'keyserver'),), + required_if=[('state', 'present', ('data', 'file', 'url', 'keyserver'), True)], + ) + PacmanKey(module) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/pacman_key.py b/plugins/modules/pacman_key.py new file mode 120000 index 00000000000..ac0f4482322 --- /dev/null +++ b/plugins/modules/pacman_key.py @@ -0,0 +1 @@ +packaging/os/pacman_key.py \ No newline at end of file diff --git a/tests/unit/plugins/modules/packaging/os/test_pacman_key.py b/tests/unit/plugins/modules/packaging/os/test_pacman_key.py new file mode 100644 index 00000000000..757fee4e87a --- /dev/null +++ b/tests/unit/plugins/modules/packaging/os/test_pacman_key.py @@ -0,0 +1,576 @@ +# Copyright: (c) 2019, George Rawlinson +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.general.plugins.modules.packaging.os import pacman_key +import pytest +import json + +# path used for mocking get_bin_path() +MOCK_BIN_PATH = '/mocked/path' + +# Key ID used for tests +TESTING_KEYID = '14F26682D0916CDD81E37B6D61B7B526D98F0353' +TESTING_KEYFILE_PATH = '/tmp/pubkey.asc' + +# gpg --{show,list}-key output (key present) +GPG_SHOWKEY_OUTPUT = '''tru::1:1616373715:0:3:1:5 +pub:-:4096:1:61B7B526D98F0353:1437155332:::-:::scSC::::::23::0: +fpr:::::::::14F26682D0916CDD81E37B6D61B7B526D98F0353: +uid:-::::1437155332::E57D1F9BFF3B404F9F30333629369B08DF5E2161::Mozilla Software Releases ::::::::::0: +sub:e:4096:1:1C69C4E55E9905DB:1437155572:1500227572:::::s::::::23: +fpr:::::::::F2EF4E6E6AE75B95F11F1EB51C69C4E55E9905DB: +sub:e:4096:1:BBBEBDBB24C6F355:1498143157:1561215157:::::s::::::23: +fpr:::::::::DCEAC5D96135B91C4EA672ABBBBEBDBB24C6F355: +sub:e:4096:1:F1A6668FBB7D572E:1559247338:1622319338:::::s::::::23: +fpr:::::::::097B313077AE62A02F84DA4DF1A6668FBB7D572E:''' + +# gpg --{show,list}-key output (key absent) +GPG_NOKEY_OUTPUT = '''gpg: error reading key: No public key +tru::1:1616373715:0:3:1:5''' + +# pacman-key output (successful invocation) +PACMAN_KEY_SUCCESS = '''==> Updating trust database... +gpg: next trustdb check due at 2021-08-02''' + +# expected command for gpg --list-keys KEYID +RUN_CMD_LISTKEYS = [ + MOCK_BIN_PATH, + '--with-colons', + '--batch', + '--no-tty', + '--no-default-keyring', + '--keyring=/etc/pacman.d/gnupg/pubring.gpg', + '--list-keys', + TESTING_KEYID, +] + +# expected command for gpg --show-keys KEYFILE +RUN_CMD_SHOW_KEYFILE = [ + MOCK_BIN_PATH, + '--with-colons', + '--with-fingerprint', + '--batch', + '--no-tty', + '--show-keys', + TESTING_KEYFILE_PATH, +] + +# expected command for pacman-key --lsign-key KEYID +RUN_CMD_LSIGN_KEY = [ + MOCK_BIN_PATH, + '--gpgdir', + '/etc/pacman.d/gnupg', + '--lsign-key', + TESTING_KEYID, +] + + +TESTCASES = [ + # + # invalid user input + # + # state: present, id: absent + [ + { + 'state': 'present', + }, + { + 'id': 'param_missing_id', + 'msg': 'missing required arguments: id', + 'failed': True, + }, + ], + # state: present, required parameters: missing + [ + { + 'state': 'present', + 'id': '0xDOESNTMATTER', + }, + { + 'id': 'param_missing_method', + 'msg': 'state is present but any of the following are missing: data, file, url, keyserver', + 'failed': True, + }, + ], + # state: present, id: invalid (not full-length) + [ + { + 'id': '0xDOESNTMATTER', + 'data': 'FAKEDATA', + }, + { + 'id': 'param_id_not_full', + 'msg': 'key ID is not full-length: DOESNTMATTER', + 'failed': True, + }, + ], + # state: present, id: invalid (not hexadecimal) + [ + { + 'state': 'present', + 'id': '01234567890ABCDE01234567890ABCDE1234567M', + 'data': 'FAKEDATA', + }, + { + 'id': 'param_id_not_hex', + 'msg': 'key ID is not hexadecimal: 01234567890ABCDE01234567890ABCDE1234567M', + 'failed': True, + }, + ], + # state: absent, id: absent + [ + { + 'state': 'absent', + }, + { + 'id': 'param_absent_state_missing_id', + 'msg': 'missing required arguments: id', + 'failed': True, + }, + ], + # + # check mode + # + # state & key present + [ + { + 'state': 'present', + 'id': TESTING_KEYID, + 'data': 'FAKEDATA', + '_ansible_check_mode': True, + }, + { + 'id': 'checkmode_state_and_key_present', + 'run_command.calls': [ + ( + RUN_CMD_LISTKEYS, + {'check_rc': False}, + ( + 0, + GPG_SHOWKEY_OUTPUT, + '', + ), + ), + ], + 'changed': False, + }, + ], + # state present, key absent + [ + { + 'state': 'present', + 'id': TESTING_KEYID, + 'data': 'FAKEDATA', + '_ansible_check_mode': True, + }, + { + 'id': 'checkmode_state_present_key_absent', + 'run_command.calls': [ + ( + RUN_CMD_LISTKEYS, + {'check_rc': False}, + ( + 2, + '', + GPG_NOKEY_OUTPUT, + ), + ), + ], + 'changed': True, + }, + ], + # state & key absent + [ + { + 'state': 'absent', + 'id': TESTING_KEYID, + '_ansible_check_mode': True, + }, + { + 'id': 'checkmode_state_and_key_absent', + 'run_command.calls': [ + ( + RUN_CMD_LISTKEYS, + {'check_rc': False}, + ( + 2, + '', + GPG_NOKEY_OUTPUT, + ), + ), + ], + 'changed': False, + }, + ], + # state absent, key present + [ + { + 'state': 'absent', + 'id': TESTING_KEYID, + '_ansible_check_mode': True, + }, + { + 'id': 'check_mode_state_absent_key_present', + 'run_command.calls': [ + ( + RUN_CMD_LISTKEYS, + {'check_rc': False}, + ( + 0, + GPG_SHOWKEY_OUTPUT, + '', + ), + ), + ], + 'changed': True, + }, + ], + # + # normal operation + # + # state & key present + [ + { + 'state': 'present', + 'id': TESTING_KEYID, + 'data': 'FAKEDATA', + }, + { + 'id': 'state_and_key_present', + 'run_command.calls': [ + ( + RUN_CMD_LISTKEYS, + {'check_rc': False}, + ( + 0, + GPG_SHOWKEY_OUTPUT, + '', + ), + ), + ], + 'changed': False, + }, + ], + # state absent, key present + [ + { + 'state': 'absent', + 'id': TESTING_KEYID, + }, + { + 'id': 'state_absent_key_present', + 'run_command.calls': [ + ( + RUN_CMD_LISTKEYS, + {'check_rc': False}, + ( + 0, + GPG_SHOWKEY_OUTPUT, + '', + ), + ), + ( + [ + MOCK_BIN_PATH, + '--gpgdir', + '/etc/pacman.d/gnupg', + '--delete', + TESTING_KEYID, + ], + {'check_rc': True}, + ( + 0, + PACMAN_KEY_SUCCESS, + '', + ), + ), + ], + 'changed': True, + }, + ], + # state & key absent + [ + { + 'state': 'absent', + 'id': TESTING_KEYID, + }, + { + 'id': 'state_and_key_absent', + 'run_command.calls': [ + ( + RUN_CMD_LISTKEYS, + {'check_rc': False}, + ( + 2, + '', + GPG_NOKEY_OUTPUT, + ), + ), + ], + 'changed': False, + }, + ], + # state: present, key: absent, method: file + [ + { + 'state': 'present', + 'id': TESTING_KEYID, + 'file': TESTING_KEYFILE_PATH, + }, + { + 'id': 'state_present_key_absent_method_file', + 'run_command.calls': [ + ( + RUN_CMD_LISTKEYS, + {'check_rc': False}, + ( + 2, + '', + GPG_NOKEY_OUTPUT, + ), + ), + ( + RUN_CMD_SHOW_KEYFILE, + {'check_rc': True}, + ( + 0, + GPG_SHOWKEY_OUTPUT, + '', + ), + ), + ( + [ + MOCK_BIN_PATH, + '--gpgdir', + '/etc/pacman.d/gnupg', + '--add', + '/tmp/pubkey.asc', + ], + {'check_rc': True}, + ( + 0, + PACMAN_KEY_SUCCESS, + '', + ), + ), + ( + RUN_CMD_LSIGN_KEY, + {'check_rc': True}, + ( + 0, + PACMAN_KEY_SUCCESS, + '', + ), + ), + ], + 'changed': True, + }, + ], + # state: present, key: absent, method: file + # failure: keyid & keyfile don't match + [ + { + 'state': 'present', + 'id': TESTING_KEYID, + 'file': TESTING_KEYFILE_PATH, + }, + { + 'id': 'state_present_key_absent_verify_failed', + 'msg': 'key ID does not match. expected 14F26682D0916CDD81E37B6D61B7B526D98F0353, got 14F26682D0916CDD81E37B6D61B7B526D98F0354', + 'run_command.calls': [ + ( + RUN_CMD_LISTKEYS, + {'check_rc': False}, + ( + 2, + '', + GPG_NOKEY_OUTPUT, + ), + ), + ( + RUN_CMD_SHOW_KEYFILE, + {'check_rc': True}, + ( + 0, + GPG_SHOWKEY_OUTPUT.replace('61B7B526D98F0353', '61B7B526D98F0354'), + '', + ), + ), + ], + 'failed': True, + }, + ], + # state: present, key: absent, method: keyserver + [ + { + 'state': 'present', + 'id': TESTING_KEYID, + 'keyserver': 'pgp.mit.edu', + }, + { + 'id': 'state_present_key_absent_method_keyserver', + 'run_command.calls': [ + ( + RUN_CMD_LISTKEYS, + {'check_rc': False}, + ( + 2, + '', + GPG_NOKEY_OUTPUT, + ), + ), + ( + [ + MOCK_BIN_PATH, + '--gpgdir', + '/etc/pacman.d/gnupg', + '--keyserver', + 'pgp.mit.edu', + '--recv-keys', + TESTING_KEYID, + ], + {'check_rc': True}, + ( + 0, + ''' +gpg: key 0x61B7B526D98F0353: 32 signatures not checked due to missing keys +gpg: key 0x61B7B526D98F0353: public key "Mozilla Software Releases " imported +gpg: marginals needed: 3 completes needed: 1 trust model: pgp +gpg: depth: 0 valid: 1 signed: 0 trust: 0-, 0q, 0n, 0m, 0f, 1u +gpg: Total number processed: 1 +gpg: imported: 1 +''', + '', + ), + ), + ( + RUN_CMD_LSIGN_KEY, + {'check_rc': True}, + ( + 0, + PACMAN_KEY_SUCCESS, + '', + ), + ), + ], + 'changed': True, + }, + ], + # state: present, key: absent, method: data + [ + { + 'state': 'present', + 'id': TESTING_KEYID, + 'data': 'PGP_DATA', + }, + { + 'id': 'state_present_key_absent_method_data', + 'run_command.calls': [ + ( + RUN_CMD_LISTKEYS, + {'check_rc': False}, + ( + 2, + '', + GPG_NOKEY_OUTPUT, + ), + ), + ( + RUN_CMD_SHOW_KEYFILE, + {'check_rc': True}, + ( + 0, + GPG_SHOWKEY_OUTPUT, + '', + ), + ), + ( + [ + MOCK_BIN_PATH, + '--gpgdir', + '/etc/pacman.d/gnupg', + '--add', + '/tmp/pubkey.asc', + ], + {'check_rc': True}, + ( + 0, + PACMAN_KEY_SUCCESS, + '', + ), + ), + ( + RUN_CMD_LSIGN_KEY, + {'check_rc': True}, + ( + 0, + PACMAN_KEY_SUCCESS, + '', + ), + ), + ], + 'save_key_output': TESTING_KEYFILE_PATH, + 'changed': True, + }, + ], +] + + +@pytest.fixture +def patch_get_bin_path(mocker): + get_bin_path = mocker.patch.object( + AnsibleModule, + 'get_bin_path', + return_value=MOCK_BIN_PATH, + ) + + +@pytest.mark.parametrize( + 'patch_ansible_module, expected', + TESTCASES, + ids=[item[1]['id'] for item in TESTCASES], + indirect=['patch_ansible_module'] +) +@pytest.mark.usefixtures('patch_ansible_module') +def test_operation(mocker, capfd, patch_get_bin_path, expected): + # patch run_command invocations with mock data + if 'run_command.calls' in expected: + mock_run_command = mocker.patch.object( + AnsibleModule, + 'run_command', + side_effect=[item[2] for item in expected['run_command.calls']], + ) + + # patch save_key invocations with mock data + if 'save_key_output' in expected: + mock_save_key = mocker.patch.object( + pacman_key.PacmanKey, + 'save_key', + return_value=expected['save_key_output'], + ) + + # invoke module + with pytest.raises(SystemExit): + pacman_key.main() + + # capture std{out,err} + out, err = capfd.readouterr() + results = json.loads(out) + + # assertion time! + if 'msg' in expected: + assert results['msg'] == expected['msg'] + if 'changed' in expected: + assert results['changed'] == expected['changed'] + if 'failed' in expected: + assert results['failed'] == expected['failed'] + + if 'run_command.calls' in expected: + assert AnsibleModule.run_command.call_count == len(expected['run_command.calls']) + call_args_list = [(item[0][0], item[1]) for item in AnsibleModule.run_command.call_args_list] + expected_call_args_list = [(item[0], item[1]) for item in expected['run_command.calls']] + assert call_args_list == expected_call_args_list