diff --git a/meta/runtime.yml b/meta/runtime.yml index 94440a929..fb5b0b8fb 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -4,6 +4,7 @@ action_groups: # let's keep this in alphabetical order vault: - vault_kv1_get + - vault_kv2_delete - vault_kv2_get - vault_login - vault_pki_generate_certificate diff --git a/plugins/modules/vault_kv2_delete.py b/plugins/modules/vault_kv2_delete.py new file mode 100644 index 000000000..0408e72dd --- /dev/null +++ b/plugins/modules/vault_kv2_delete.py @@ -0,0 +1,172 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# (c) 2022, Isaac Wagner (@idwagner) +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' +module: vault_kv2_delete +author: + - Isaac Wagner (@idwagner) +short_description: Delete one or more versions of a secret from HashiCorp Vault's KV version 2 secret store +requirements: + - C(hvac) (L(Python library,https://hvac.readthedocs.io/en/stable/overview.html)) + - For detailed requirements, see R(the collection requirements page,ansible_collections.community.hashi_vault.docsite.user_guide.requirements). +description: + - Delete one or more versions of a secret from HashiCorp Vault's KV version 2 secret store. +notes: + - This module always reports C(changed) status because it cannot guarantee idempotence. + - Use C(changed_when) to control that in cases where the operation is known to not change state. + - In check mode, the module returns C(changed) status without contacting Vault. + Consider using M(community.hashi_vault.vault_kv2_get) to verify the existence of the secret first. +seealso: + - module: community.hashi_vault.vault_kv2_get + - name: KV2 Secrets Engine + description: Documentation for the Vault KV secrets engine, version 2. + link: https://www.vaultproject.io/docs/secrets/kv/kv-v2 +extends_documentation_fragment: + - community.hashi_vault.connection + - community.hashi_vault.auth + - community.hashi_vault.engine_mount +options: + engine_mount_point: + default: secret + path: + description: + - Vault KV path to be deleted. + - This is relative to the I(engine_mount_point), so the mount path should not be included. + - For kv2, do not include C(/data/) or C(/metadata/). + type: str + required: True + versions: + description: + - One or more versions of the secret to delete. + - When omitted, the latest version of the secret is deleted. + type: list + elements: int + required: False +''' + +EXAMPLES = """ +- name: Delete the latest version of the secret/mysecret secret. + community.hashi_vault.vault_kv2_delete: + url: https://vault:8201 + path: secret/mysecret + auth_method: userpass + username: user + password: '{{ passwd }}' + register: result + +- name: Delete versions 1 and 3 of the secret/mysecret secret. + community.hashi_vault.vault_kv2_delete: + url: https://vault:8201 + path: secret/mysecret + versions: [1, 3] + auth_method: userpass + username: user + password: '{{ passwd }}' +""" + +RETURN = """ +data: + description: + - The raw result of the delete against the given path. + - This is usually empty, but may contain warnings or other information. + returned: success + type: dict +""" + +import traceback + +from ansible.module_utils._text import to_native +from ansible.module_utils.basic import missing_required_lib + +from ansible_collections.community.hashi_vault.plugins.module_utils._hashi_vault_module import HashiVaultModule +from ansible_collections.community.hashi_vault.plugins.module_utils._hashi_vault_common import HashiVaultValueError + +try: + import hvac +except ImportError: + HAS_HVAC = False + HVAC_IMPORT_ERROR = traceback.format_exc() +else: + HVAC_IMPORT_ERROR = None + HAS_HVAC = True + + +def run_module(): + + argspec = HashiVaultModule.generate_argspec( + engine_mount_point=dict(type='str', default='secret'), + path=dict(type='str', required=True), + versions=dict(type='list', elements='int', required=False) + ) + + module = HashiVaultModule( + argument_spec=argspec, + supports_check_mode=True + ) + + if not HAS_HVAC: + module.fail_json( + msg=missing_required_lib('hvac'), + exception=HVAC_IMPORT_ERROR + ) + + engine_mount_point = module.params.get('engine_mount_point') + path = module.params.get('path') + versions = module.params.get('versions') + + module.connection_options.process_connection_options() + client_args = module.connection_options.get_hvac_connection_options() + client = module.helper.get_vault_client(**client_args) + + try: + module.authenticator.validate() + module.authenticator.authenticate(client) + except (NotImplementedError, HashiVaultValueError) as e: + module.fail_json(msg=to_native(e), exception=traceback.format_exc()) + + try: + # Vault has two separate methods, one for delete latest version, + # and delete specific versions. + if module.check_mode: + response = {} + elif not versions: + response = client.secrets.kv.v2.delete_latest_version_of_secret( + path=path, mount_point=engine_mount_point) + else: + response = client.secrets.kv.v2.delete_secret_versions( + path=path, versions=versions, mount_point=engine_mount_point) + + except hvac.exceptions.Forbidden as e: + module.fail_json(msg="Forbidden: Permission Denied to path ['%s']." % path, exception=traceback.format_exc()) + + # https://github.com/hvac/hvac/issues/797 + # HVAC returns a raw response object when the body is not JSON. + # That includes 204 responses, which are successful with no body. + # So we will try to detect that and a act accordingly. + # A better way may be to implement our own adapter for this + # collection, but it's a little premature to do that. + if hasattr(response, 'json') and callable(response.json): + if response.status_code == 204: + output = {} + else: + module.warn( + 'Vault returned status code %i and an unparsable body.' % response.status_code) + output = response.content + else: + output = response + + module.exit_json(changed=True, data=output) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/module_vault_kv2_delete/aliases b/tests/integration/targets/module_vault_kv2_delete/aliases new file mode 100644 index 000000000..7636a9a65 --- /dev/null +++ b/tests/integration/targets/module_vault_kv2_delete/aliases @@ -0,0 +1 @@ +context/target diff --git a/tests/integration/targets/module_vault_kv2_delete/meta/main.yml b/tests/integration/targets/module_vault_kv2_delete/meta/main.yml new file mode 100644 index 000000000..d3acb69e9 --- /dev/null +++ b/tests/integration/targets/module_vault_kv2_delete/meta/main.yml @@ -0,0 +1,4 @@ +--- +dependencies: + - setup_vault_test_plugins + - setup_vault_configure diff --git a/tests/integration/targets/module_vault_kv2_delete/tasks/main.yml b/tests/integration/targets/module_vault_kv2_delete/tasks/main.yml new file mode 100644 index 000000000..e222b14e6 --- /dev/null +++ b/tests/integration/targets/module_vault_kv2_delete/tasks/main.yml @@ -0,0 +1,3 @@ +--- +- import_tasks: module_vault_kv2_delete_setup.yml +- import_tasks: module_vault_kv2_delete_test.yml diff --git a/tests/integration/targets/module_vault_kv2_delete/tasks/module_vault_kv2_delete_setup.yml b/tests/integration/targets/module_vault_kv2_delete/tasks/module_vault_kv2_delete_setup.yml new file mode 100644 index 000000000..4b058c0ce --- /dev/null +++ b/tests/integration/targets/module_vault_kv2_delete/tasks/module_vault_kv2_delete_setup.yml @@ -0,0 +1,28 @@ +--- +- name: Configuration tasks + module_defaults: + vault_ci_token_create: '{{ vault_plugins_module_defaults_common }}' + block: + - name: Create a test non-root token + vault_ci_token_create: + policies: [test-policy] + register: user_token_cmd + +- name: Configuration tasks + module_defaults: + vault_ci_kv2_destroy_all: '{{ vault_plugins_module_defaults_common }}' + vault_ci_kv_put: '{{ vault_plugins_module_defaults_common }}' + block: + - name: Remove existing multi-version secret + vault_ci_kv2_destroy_all: + mount_point: '{{ vault_kv2_mount_point }}' + path: '{{ vault_kv2_versioned_path }}/secret6' + + - name: Set up a multi versioned secret for delete (v2) + vault_ci_kv_put: + version: 2 + mount_point: '{{ vault_kv2_mount_point }}' + path: '{{ vault_kv2_versioned_path }}/secret6' + secret: + v: value{{ item }} + loop: ["1", "2", "3", "4", "5"] diff --git a/tests/integration/targets/module_vault_kv2_delete/tasks/module_vault_kv2_delete_test.yml b/tests/integration/targets/module_vault_kv2_delete/tasks/module_vault_kv2_delete_test.yml new file mode 100644 index 000000000..62a7ccc4a --- /dev/null +++ b/tests/integration/targets/module_vault_kv2_delete/tasks/module_vault_kv2_delete_test.yml @@ -0,0 +1,231 @@ +--- +- name: Var block + vars: + user_token: '{{ user_token_cmd.result.auth.client_token }}' + regex_secret_version_is_deleted: "^[0-9]{4}-[0-9]{2}-[0-9]{2}T.*" + regex_secret_version_not_deleted: "^$" + + module_defaults: + community.hashi_vault.vault_kv2_delete: &defaults + url: '{{ vault_test_server_http }}' + auth_method: token + token: '{{ user_token }}' + timeout: 5 + vault_ci_kv2_metadata_read: '{{ vault_plugins_module_defaults_common }}' + + block: + - name: Test default path value + register: default_path + community.hashi_vault.vault_kv2_delete: + path: '{{ vault_kv2_path }}/secret2' + ignore_errors: true + + - assert: + that: + - default_path is failed + - default_path.msg is search('Permission Denied to path') + + - module_defaults: + community.hashi_vault.vault_kv2_delete: + <<: *defaults + engine_mount_point: '{{ vault_kv2_mount_point }}' + block: + + - name: Check kv2 existing versions + register: kv2_result + vault_ci_kv2_metadata_read: + path: "{{ vault_kv2_versioned_path }}/secret6" + mount_point: '{{ vault_kv2_mount_point }}' + + - assert: + that: + - "'result' in kv2_result" + - "'data' in kv2_result['result']" + - "'versions' in kv2_result['result']['data']" + - "kv2_result['result']['data']['versions']['1']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['2']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['3']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['4']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['5']['deletion_time'] is search(regex_secret_version_not_deleted)" + fail_msg: 'Test Seed value did not contain expected data.' + + + - name: Try kv2 delete latest version in check mode + register: kv2_result + community.hashi_vault.vault_kv2_delete: + path: "{{ vault_kv2_versioned_path }}/secret6" + check_mode: true + + - assert: + that: + - kv2_result is changed + - kv2_result.data == {} + + - name: Read resultant secret versions + register: kv2_result + vault_ci_kv2_metadata_read: + path: "{{ vault_kv2_versioned_path }}/secret6" + mount_point: '{{ vault_kv2_mount_point }}' + + - assert: + that: + - "'result' in kv2_result" + - "'data' in kv2_result['result']" + - "'versions' in kv2_result['result']['data']" + - "kv2_result['result']['data']['versions']['1']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['2']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['3']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['4']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['5']['deletion_time'] is search(regex_secret_version_not_deleted)" + fail_msg: 'Secret version was deleted while in check mode.' + + + - name: Try kv2 delete specific version in check mode + register: kv2_result + community.hashi_vault.vault_kv2_delete: + path: "{{ vault_kv2_versioned_path }}/secret6" + versions: [1, 3] + check_mode: true + + - name: Read resultant secret versions + register: kv2_result + vault_ci_kv2_metadata_read: + path: "{{ vault_kv2_versioned_path }}/secret6" + mount_point: '{{ vault_kv2_mount_point }}' + + - assert: + that: + - "'result' in kv2_result" + - "'data' in kv2_result['result']" + - "'versions' in kv2_result['result']['data']" + - "kv2_result['result']['data']['versions']['1']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['2']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['3']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['4']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['5']['deletion_time'] is search(regex_secret_version_not_deleted)" + fail_msg: 'Secret version was deleted while in check mode.' + + + - name: Try kv2 delete version 1 and 3 + register: kv2_result + community.hashi_vault.vault_kv2_delete: + path: "{{ vault_kv2_versioned_path }}/secret6" + versions: + - 1 + - 3 + + - name: Read resultant secret versions + register: kv2_result + vault_ci_kv2_metadata_read: + path: "{{ vault_kv2_versioned_path }}/secret6" + mount_point: '{{ vault_kv2_mount_point }}' + + - assert: + that: + - "'result' in kv2_result" + - "'data' in kv2_result['result']" + - "'versions' in kv2_result['result']['data']" + - "kv2_result['result']['data']['versions']['1']['deletion_time'] is search(regex_secret_version_is_deleted)" + - "kv2_result['result']['data']['versions']['2']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['3']['deletion_time'] is search(regex_secret_version_is_deleted)" + - "kv2_result['result']['data']['versions']['4']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['5']['deletion_time'] is search(regex_secret_version_not_deleted)" + fail_msg: 'Result value did not contain expected data.' + + + - name: Try kv2 delete latest version + register: kv2_result + community.hashi_vault.vault_kv2_delete: + path: "{{ vault_kv2_versioned_path }}/secret6" + + - name: Read resultant secret versions + register: kv2_result + vault_ci_kv2_metadata_read: + path: "{{ vault_kv2_versioned_path }}/secret6" + mount_point: '{{ vault_kv2_mount_point }}' + + - assert: + that: + - "'result' in kv2_result" + - "'data' in kv2_result['result']" + - "'versions' in kv2_result['result']['data']" + - "kv2_result['result']['data']['versions']['1']['deletion_time'] is search(regex_secret_version_is_deleted)" + - "kv2_result['result']['data']['versions']['2']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['3']['deletion_time'] is search(regex_secret_version_is_deleted)" + - "kv2_result['result']['data']['versions']['4']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['5']['deletion_time'] is search(regex_secret_version_is_deleted)" + fail_msg: 'Result value did not contain expected data.' + + + - name: Success expected when authorized delete on non-existent path (latest version) + register: test_nonexistant + community.hashi_vault.vault_kv2_delete: + path: "{{ vault_kv2_versioned_path }}/non_existent_secret" + + + - name: Success expected when authorized delete on non-existent path (specific version) + register: test_nonexistant + community.hashi_vault.vault_kv2_delete: + path: "{{ vault_kv2_versioned_path }}/non_existent_secret" + versions: + - 1 + + + ### failure tests + + - name: Failure expected when erroneous credentials are used (latest version) + register: test_wrong_cred + community.hashi_vault.vault_kv2_delete: + path: "{{ vault_kv2_versioned_path }}/secret6" + token: wrong_token + ignore_errors: true + + - assert: + that: + - test_wrong_cred is failed + - test_wrong_cred.msg is search('Invalid Vault Token') + fail_msg: "Expected failure but got success or wrong failure message." + + + - name: Failure expected when erroneous credentials are used (specific version) + register: test_wrong_cred + community.hashi_vault.vault_kv2_delete: + path: "{{ vault_kv2_versioned_path }}/secret6" + token: wrong_token + versions: + - 1 + ignore_errors: true + + - assert: + that: + - test_wrong_cred is failed + - test_wrong_cred.msg is search('Invalid Vault Token') + fail_msg: "Expected failure but got success or wrong failure message." + + + - name: Failure expected when unauthorized secret is deleted (latest version) + register: test_unauthorized + community.hashi_vault.vault_kv2_delete: + path: "{{ vault_kv2_path }}/secret3" + ignore_errors: true + + - assert: + that: + - test_unauthorized is failed + - test_unauthorized.msg is search('Permission Denied') + fail_msg: "Expected failure but got success or wrong failure message." + + + - name: Failure expected when unauthorized secret is deleted (specific version) + register: test_unauthorized + community.hashi_vault.vault_kv2_delete: + path: "{{ vault_kv2_path }}/secret3" + versions: + - 1 + ignore_errors: true + + - assert: + that: + - test_unauthorized is failed + - test_unauthorized.msg is search('Permission Denied') + fail_msg: "Expected failure but got success or wrong failure message." diff --git a/tests/integration/targets/setup_vault_configure/vars/main.yml b/tests/integration/targets/setup_vault_configure/vars/main.yml index b6d369379..29759d8b0 100644 --- a/tests/integration/targets/setup_vault_configure/vars/main.yml +++ b/tests/integration/targets/setup_vault_configure/vars/main.yml @@ -16,6 +16,8 @@ vault_kv2_versioned_path: versioned vault_kv2_api_path: '{{ vault_kv2_mount_point }}/data/{{ vault_kv2_path }}' vault_kv2_multi_api_path: '{{ vault_kv2_mount_point }}/data/{{ vault_kv2_multi_path }}' vault_kv2_versioned_api_path: '{{ vault_kv2_mount_point }}/data/{{ vault_kv2_versioned_path }}' +vault_kv2_delete_api_path: '{{ vault_kv2_mount_point }}/delete/{{ vault_kv2_versioned_path }}' +vault_kv2_metadata_api_path: '{{ vault_kv2_mount_point }}/metadata/{{ vault_kv2_versioned_path }}' vault_base_policy: | path "{{ vault_kv1_api_path }}/secret1" { @@ -52,6 +54,21 @@ vault_base_policy: | path "{{ vault_kv2_versioned_api_path }}/*" { capabilities = ["read"] } + path "{{ vault_kv2_versioned_api_path }}/secret6" { + capabilities = ["delete"] + } + path "{{ vault_kv2_versioned_api_path }}/non_existent_secret" { + capabilities = ["delete"] + } + path "{{ vault_kv2_delete_api_path }}/secret6" { + capabilities = ["create", "update"] + } + path "{{ vault_kv2_delete_api_path }}/non_existent_secret" { + capabilities = ["create", "update"] + } + path "{{ vault_kv2_metadata_api_path }}/secret6" { + capabilities = ["read"] + } vault_token_creator_policy: | path "auth/token/create" { diff --git a/tests/integration/targets/setup_vault_test_plugins/library/vault_ci_kv2_metadata_read.py b/tests/integration/targets/setup_vault_test_plugins/library/vault_ci_kv2_metadata_read.py new file mode 100644 index 000000000..0532124ed --- /dev/null +++ b/tests/integration/targets/setup_vault_test_plugins/library/vault_ci_kv2_metadata_read.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2022 Isaac Wagner (@idwagner) +# Simplified BSD License (see LICENSES/BSD-2-Clause.txt or https://opensource.org/licenses/BSD-2-Clause) +# SPDX-License-Identifier: BSD-2-Clause + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import traceback + +from ansible.module_utils.basic import AnsibleModule +import hvac + + +def main(): + module = AnsibleModule( + argument_spec=dict( + url=dict(type='str', required=True), + token=dict(type='str', required=True), + path=dict(type='str'), + mount_point=dict(type='str'), + ), + ) + + p = module.params + + client = hvac.Client(url=p['url'], token=p['token']) + + extra = {} + if p['mount_point'] is not None: + extra['mount_point'] = p['mount_point'] + + try: + result = client.secrets.kv.v2.read_secret_metadata(path=p['path'], **extra) + except Exception as e: + module.fail_json(msg=str(e), exception=traceback.format_exc()) + + module.exit_json(changed=True, result=result) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/setup_vault_test_plugins/vars/main.yml b/tests/integration/targets/setup_vault_test_plugins/vars/main.yml index 010e89505..3f891566e 100644 --- a/tests/integration/targets/setup_vault_test_plugins/vars/main.yml +++ b/tests/integration/targets/setup_vault_test_plugins/vars/main.yml @@ -11,6 +11,7 @@ vault_plugins_module_defaults: vault_ci_enable_engine: '{{ vault_plugins_module_defaults_common }}' vault_ci_kv_put: '{{ vault_plugins_module_defaults_common }}' vault_ci_kv2_destroy_all: '{{ vault_plugins_module_defaults_common }}' + vault_ci_kv2_metadata_read: '{{ vault_plugins_module_defaults_common }}' vault_ci_policy_put: '{{ vault_plugins_module_defaults_common }}' vault_ci_read: '{{ vault_plugins_module_defaults_common }}' vault_ci_token_create: '{{ vault_plugins_module_defaults_common }}' diff --git a/tests/unit/plugins/modules/test_vault_kv2_delete.py b/tests/unit/plugins/modules/test_vault_kv2_delete.py new file mode 100644 index 000000000..faca37d61 --- /dev/null +++ b/tests/unit/plugins/modules/test_vault_kv2_delete.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2022 Isaac Wagner (@idwagner) +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import pytest +import re +import json + +from ansible.module_utils.basic import missing_required_lib + +from ...compat import mock +from .....plugins.modules import vault_kv2_delete +from .....plugins.module_utils._hashi_vault_common import HashiVaultValueError + + +hvac = pytest.importorskip('hvac') + + +pytestmark = pytest.mark.usefixtures( + 'patch_ansible_module', + 'patch_authenticator', + 'patch_get_vault_client', +) + + +def _connection_options(): + return { + 'auth_method': 'token', + 'url': 'http://myvault', + 'token': 'beep-boop', + } + + +def _sample_options(): + return { + 'engine_mount_point': 'secret', + 'path': 'endpoint', + } + + +def _combined_options(**kwargs): + opt = _connection_options() + opt.update(_sample_options()) + opt.update(kwargs) + return opt + + +class TestModuleVaultKv2Delete(): + + @pytest.mark.parametrize('patch_ansible_module', [_combined_options()], indirect=True) + @pytest.mark.parametrize('exc', [HashiVaultValueError('throwaway msg'), NotImplementedError('throwaway msg')]) + def test_vault_kv2_delete_authentication_error(self, authenticator, exc, capfd): + authenticator.authenticate.side_effect = exc + + with pytest.raises(SystemExit) as e: + vault_kv2_delete.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + assert e.value.code != 0, "result: %r" % (result,) + assert result['msg'] == 'throwaway msg', "result: %r" % result + + @pytest.mark.parametrize('patch_ansible_module', [_combined_options()], indirect=True) + @pytest.mark.parametrize('exc', [HashiVaultValueError('throwaway msg'), NotImplementedError('throwaway msg')]) + def test_vault_kv2_delete_auth_validation_error(self, authenticator, exc, capfd): + authenticator.validate.side_effect = exc + + with pytest.raises(SystemExit) as e: + vault_kv2_delete.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + assert e.value.code != 0, "result: %r" % (result,) + assert result['msg'] == 'throwaway msg' + + @pytest.mark.parametrize('opt_versions', [None, [1, 3]]) + @pytest.mark.parametrize('patch_ansible_module', [[_combined_options(), 'versions']], indirect=True) + def test_vault_kv2_delete_empty_response(self, patch_ansible_module, opt_versions, requests_unparseable_response, vault_client, capfd): + client = vault_client + + requests_unparseable_response.status_code = 204 + + if opt_versions: + client.secrets.kv.v2.delete_secret_versions.return_value = requests_unparseable_response + else: + client.secrets.kv.v2.delete_latest_version_of_secret.return_value = requests_unparseable_response + + with pytest.raises(SystemExit) as e: + vault_kv2_delete.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + assert e.value.code == 0, "result: %r" % (result,) + + assert result['data'] == {} + + @pytest.mark.parametrize('opt_versions', [None, [1, 3]]) + @pytest.mark.parametrize('patch_ansible_module', [[_combined_options(), 'versions']], indirect=True) + def test_vault_kv2_delete_unparseable_response(self, vault_client, opt_versions, requests_unparseable_response, module_warn, capfd): + client = vault_client + + requests_unparseable_response.status_code = 200 + requests_unparseable_response.content = '(☞゚ヮ゚)☞ ┻━┻' + + if opt_versions: + client.secrets.kv.v2.delete_secret_versions.return_value = requests_unparseable_response + else: + client.secrets.kv.v2.delete_latest_version_of_secret.return_value = requests_unparseable_response + + with pytest.raises(SystemExit) as e: + vault_kv2_delete.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + assert e.value.code == 0, "result: %r" % (result,) + assert result['data'] == '(☞゚ヮ゚)☞ ┻━┻' + + module_warn.assert_called_once_with( + 'Vault returned status code 200 and an unparsable body.') + + @pytest.mark.parametrize('patch_ansible_module', [_combined_options()], indirect=True) + def test_vault_kv2_delete_no_hvac(self, capfd): + with mock.patch.multiple(vault_kv2_delete, HAS_HVAC=False, HVAC_IMPORT_ERROR=None, create=True): + with pytest.raises(SystemExit) as e: + vault_kv2_delete.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + assert e.value.code != 0, "result: %r" % (result,) + assert result['msg'] == missing_required_lib('hvac') + + @pytest.mark.parametrize( + 'exc', + [ + (hvac.exceptions.Forbidden, "", + r"^Forbidden: Permission Denied to path \['([^']+)'\]"), + ] + ) + @pytest.mark.parametrize('opt_versions', [None, [1, 3]]) + @pytest.mark.parametrize('opt_path', ['path/1', 'second/path']) + @pytest.mark.parametrize('patch_ansible_module', [[_combined_options(), 'path', 'versions']], indirect=True) + def test_vault_kv2_delete_vault_exception(self, vault_client, exc, opt_versions, opt_path, capfd): + + client = vault_client + + if opt_versions: + client.secrets.kv.v2.delete_secret_versions.side_effect = exc[0]( + exc[1]) + else: + client.secrets.kv.v2.delete_latest_version_of_secret.side_effect = exc[0]( + exc[1]) + + with pytest.raises(SystemExit) as e: + vault_kv2_delete.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + assert e.value.code != 0, "result: %r" % (result,) + match = re.search(exc[2], result['msg']) + assert match is not None, "result: %r\ndid not match: %s" % ( + result, exc[2]) + + assert opt_path == match.group(1) + + @pytest.mark.parametrize('opt__ansible_check_mode', [False, True]) + @pytest.mark.parametrize('opt_versions', [None]) + @pytest.mark.parametrize('patch_ansible_module', [[ + _combined_options(), + '_ansible_check_mode', + 'versions' + ]], indirect=True) + def test_vault_kv2_delete_latest_version_call(self, vault_client, opt__ansible_check_mode, opt_versions, capfd): + + client = vault_client + client.secrets.kv.v2.delete_latest_version_of_secret.return_value = {} + + with pytest.raises(SystemExit) as e: + vault_kv2_delete.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + if opt__ansible_check_mode: + client.secrets.kv.v2.delete_latest_version_of_secret.assert_not_called() + else: + client.secrets.kv.v2.delete_latest_version_of_secret.assert_called_once_with( + path='endpoint', mount_point='secret') + + @pytest.mark.parametrize('opt__ansible_check_mode', [False, True]) + @pytest.mark.parametrize('opt_versions', [[1, 3]]) + @pytest.mark.parametrize('patch_ansible_module', [[ + _combined_options(), + '_ansible_check_mode', + 'versions' + ]], indirect=True) + def test_vault_kv2_delete_specific_versions_call(self, vault_client, opt__ansible_check_mode, opt_versions, capfd): + + client = vault_client + client.secrets.kv.v2.delete_secret_versions.return_value = {} + + with pytest.raises(SystemExit) as e: + vault_kv2_delete.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + if opt__ansible_check_mode: + client.secrets.kv.v2.delete_secret_versions.assert_not_called() + else: + client.secrets.kv.v2.delete_secret_versions.assert_called_once_with( + path='endpoint', mount_point='secret', versions=[1, 3])