diff --git a/changelogs/fragments/217-acme-exceptions.yml b/changelogs/fragments/217-acme-exceptions.yml new file mode 100644 index 00000000..3cd75f55 --- /dev/null +++ b/changelogs/fragments/217-acme-exceptions.yml @@ -0,0 +1,2 @@ +bugfixes: +- "acme_* modules - fix wrong usages of ``ACMEProtocolException`` (https://github.com/ansible-collections/community.crypto/pull/216, https://github.com/ansible-collections/community.crypto/pull/217)." diff --git a/plugins/module_utils/acme/acme.py b/plugins/module_utils/acme/acme.py index 1924de23..854f6552 100644 --- a/plugins/module_utils/acme/acme.py +++ b/plugins/module_utils/acme/acme.py @@ -286,12 +286,14 @@ class ACMEClient(object): content = info.pop('body', None) # Process result + parsed_json_result = False if parse_json_result: result = {} if content: if info['content-type'].startswith('application/json'): try: result = self.module.from_json(content.decode('utf8')) + parsed_json_result = True except ValueError: raise NetworkException("Failed to parse the ACME response: {0} {1}".format(uri, content)) else: @@ -301,7 +303,7 @@ class ACMEClient(object): if fail_on_error and _is_failed(info, expected_status_codes=expected_status_codes): raise ACMEProtocolException( - self.module, msg=error_msg, info=info, content=content, content_json=result if parse_json_result else None) + self.module, msg=error_msg, info=info, content=content, content_json=result if parsed_json_result else None) return result, info diff --git a/plugins/module_utils/acme/challenges.py b/plugins/module_utils/acme/challenges.py index 73db18ac..15aaf206 100644 --- a/plugins/module_utils/acme/challenges.py +++ b/plugins/module_utils/acme/challenges.py @@ -182,7 +182,7 @@ class Authorization(object): new_authz["resource"] = "new-authz" else: if 'newAuthz' not in client.directory.directory: - raise ACMEProtocolException('ACME endpoint does not support pre-authorization') + raise ACMEProtocolException(client.module, 'ACME endpoint does not support pre-authorization') url = client.directory['newAuthz'] result, info = client.send_signed_request( @@ -214,7 +214,7 @@ class Authorization(object): data[challenge.type] = validation_data return data - def raise_error(self, error_msg): + def raise_error(self, error_msg, module=None): ''' Aborts with a specific error for a challenge. ''' @@ -227,17 +227,20 @@ class Authorization(object): if 'error' in challenge.data: msg = '{msg}: {problem}'.format( msg=msg, - problem=format_error_problem(challenge.data['error'], subproblem_prefix='{0}.'.format(type)), + problem=format_error_problem(challenge.data['error'], subproblem_prefix='{0}.'.format(challenge.type)), ) error_details.append(msg) raise ACMEProtocolException( + module, 'Failed to validate challenge for {identifier}: {error}. {details}'.format( identifier=self.combined_identifier, error=error_msg, details='; '.join(error_details), ), - identifier=self.combined_identifier, - authorization=self.data, + extras=dict( + identifier=self.combined_identifier, + authorization=self.data, + ), ) def find_challenge(self, challenge_type): @@ -254,7 +257,7 @@ class Authorization(object): time.sleep(2) if self.status == 'invalid': - self.raise_error('Status is "invalid"') + self.raise_error('Status is "invalid"', module=client.module) return self.status == 'valid' diff --git a/plugins/module_utils/acme/errors.py b/plugins/module_utils/acme/errors.py index 1ee767c9..8e34e927 100644 --- a/plugins/module_utils/acme/errors.py +++ b/plugins/module_utils/acme/errors.py @@ -7,6 +7,9 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type +from ansible.module_utils.six import binary_type +from ansible.module_utils._text import to_text + def format_error_problem(problem, subproblem_prefix=''): if 'title' in problem: @@ -23,7 +26,7 @@ def format_error_problem(problem, subproblem_prefix=''): msg = '{msg} Subproblems:'.format(msg=msg) for index, problem in enumerate(subproblems): index_str = '{prefix}{index}'.format(prefix=subproblem_prefix, index=index) - msg = '{msg}\n({index}) {problem}.'.format( + msg = '{msg}\n({index}) {problem}'.format( msg=msg, index=index_str, problem=format_error_problem(problem, subproblem_prefix='{0}.'.format(index_str)), @@ -45,7 +48,7 @@ class ModuleFailException(Exception): class ACMEProtocolException(ModuleFailException): - def __init__(self, module, msg=None, info=None, response=None, content=None, content_json=None): + def __init__(self, module, msg=None, info=None, response=None, content=None, content_json=None, extras=None): # Try to get hold of content, if response is given and content is not provided if content is None and content_json is None and response is not None: try: @@ -53,50 +56,61 @@ class ACMEProtocolException(ModuleFailException): except AttributeError: content = info.pop('body', None) + # Make sure that content_json is None or a dictionary + if content_json is not None and not isinstance(content_json, dict): + if content is None and isinstance(content_json, binary_type): + content = content_json + content_json = None + # Try to get hold of JSON decoded content, when content is given and JSON not provided - if content_json is None and content is not None: + if content_json is None and content is not None and module is not None: try: - content_json = module.from_json(content.decode('utf8')) - except Exception: + content_json = module.from_json(to_text(content)) + except Exception as e: pass - extras = dict() - url = info['url'] if info else None - code = info['status'] if info else None - extras['http_url'] = url - extras['http_status'] = code + extras = extras or dict() if msg is None: msg = 'ACME request failed' add_msg = '' - if code >= 400 and content_json is not None and 'type' in content_json: - if 'status' in content_json and content_json['status'] != code: - code = 'status {problem_code} (HTTP status: {http_code})'.format(http_code=code, problem_code=content_json['status']) + if info is not None: + url = info['url'] + code = info['status'] + extras['http_url'] = url + extras['http_status'] = code + if code is not None and code >= 400 and content_json is not None and 'type' in content_json: + if 'status' in content_json and content_json['status'] != code: + code = 'status {problem_code} (HTTP status: {http_code})'.format(http_code=code, problem_code=content_json['status']) + else: + code = 'status {problem_code}'.format(problem_code=code) + subproblems = content_json.pop('subproblems', None) + add_msg = ' {problem}.'.format(problem=format_error_problem(content_json)) + extras['problem'] = content_json + extras['subproblems'] = subproblems or [] + if subproblems is not None: + add_msg = '{add_msg} Subproblems:'.format(add_msg=add_msg) + for index, problem in enumerate(subproblems): + add_msg = '{add_msg}\n({index}) {problem}.'.format( + add_msg=add_msg, + index=index, + problem=format_error_problem(problem, subproblem_prefix='{0}.'.format(index)), + ) else: - code = 'status {problem_code}'.format(problem_code=code) - add_msg = ' {problem}.'.format(problem=format_error_problem(content_json)) - - subproblems = content_json.pop('subproblems', None) - extras['problem'] = content_json - extras['subproblems'] = subproblems or [] - if subproblems is not None: - add_msg = '{add_msg} Subproblems:'.format(add_msg=add_msg) - for index, problem in enumerate(subproblems): - add_msg = '{add_msg}\n({index}) {problem}.'.format( - add_msg=add_msg, - index=index, - problem=format_error_problem(problem, subproblem_prefix='{0}.'.format(index)), - ) - else: - code = 'HTTP status {code}'.format(code=code) - if content_json is not None: - add_msg = ' The JSON error result: {content}'.format(content=content_json) - elif content is not None: - add_msg = ' The raw error result: {content}'.format(content=content.decode('utf-8')) + code = 'HTTP status {code}'.format(code=code) + if content_json is not None: + add_msg = ' The JSON error result: {content}'.format(content=content_json) + elif content is not None: + add_msg = ' The raw error result: {content}'.format(content=to_text(content)) + msg = '{msg} for {url} with {code}'.format(msg=msg, url=url, code=code) + elif content_json is not None: + add_msg = ' The JSON result: {content}'.format(content=content_json) + elif content is not None: + add_msg = ' The raw result: {content}'.format(content=to_text(content)) super(ACMEProtocolException, self).__init__( - '{msg} for {url} with {code}.{add_msg}'.format(msg=msg, url=url, code=code, add_msg=add_msg), + '{msg}.{add_msg}'.format(msg=msg, add_msg=add_msg), **extras ) self.problem = {} diff --git a/plugins/module_utils/acme/orders.py b/plugins/module_utils/acme/orders.py index 996ff18f..30b0a5e8 100644 --- a/plugins/module_utils/acme/orders.py +++ b/plugins/module_utils/acme/orders.py @@ -99,7 +99,9 @@ class Order(object): if self.status != 'valid': raise ACMEProtocolException( - 'Failed to wait for order to complete; got status "{status}"'.format(status=self.status), content_json=self.data) + client.module, + 'Failed to wait for order to complete; got status "{status}"'.format(status=self.status), + content_json=self.data) def finalize(self, client, csr_der, wait=True): ''' @@ -121,5 +123,7 @@ class Order(object): self.refresh(client) if self.status not in ['procesing', 'valid', 'invalid']: raise ACMEProtocolException( - 'Failed to finalize order; got status "{status}"'.format( - status=self.status), info=info, content_json=result) + client.module, + 'Failed to finalize order; got status "{status}"'.format(status=self.status), + info=info, + content_json=result) diff --git a/plugins/modules/acme_certificate.py b/plugins/modules/acme_certificate.py index 0eb16efb..fba85b53 100644 --- a/plugins/modules/acme_certificate.py +++ b/plugins/modules/acme_certificate.py @@ -739,7 +739,7 @@ class ACMECertificateClient(object): raise ModuleFailException('Found no authorization information for "{identifier}"!'.format( identifier=combine_identifier(identifier_type, identifier))) if authz.status != 'valid': - authz.raise_error('Status is "{status}" and not "valid"'.format(status=authz.status)) + authz.raise_error('Status is "{status}" and not "valid"'.format(status=authz.status), module=self.module) if self.version == 1: cert = retrieve_acme_v1_certificate(self.client, pem_to_der(self.csr, self.csr_content)) diff --git a/plugins/modules/acme_certificate_revoke.py b/plugins/modules/acme_certificate_revoke.py index b25bdfba..201b26d3 100644 --- a/plugins/modules/acme_certificate_revoke.py +++ b/plugins/modules/acme_certificate_revoke.py @@ -229,7 +229,7 @@ def main(): # but successfully terminate while indicating no change if already_revoked: module.exit_json(changed=False) - raise ACMEProtocolException('Failed to revoke certificate', info=info, content_json=result) + raise ACMEProtocolException(module, 'Failed to revoke certificate', info=info, content_json=result) module.exit_json(changed=True) except ModuleFailException as e: e.do_fail(module) diff --git a/plugins/modules/acme_inspect.py b/plugins/modules/acme_inspect.py index a2a4f067..00c653a8 100644 --- a/plugins/modules/acme_inspect.py +++ b/plugins/modules/acme_inspect.py @@ -307,7 +307,7 @@ def main(): pass # Fail if error was returned if fail_on_acme_error and info['status'] >= 400: - raise ACMEProtocolException(info=info, content_json=result) + raise ACMEProtocolException(module, info=info, content=data) # Done! module.exit_json(changed=changed, **result) except ModuleFailException as e: diff --git a/tests/unit/plugins/module_utils/acme/backend_data.py b/tests/unit/plugins/module_utils/acme/backend_data.py index 432d9afa..692d78a0 100644 --- a/tests/unit/plugins/module_utils/acme/backend_data.py +++ b/tests/unit/plugins/module_utils/acme/backend_data.py @@ -6,6 +6,14 @@ import base64 import datetime import os +from ansible_collections.community.crypto.plugins.module_utils.acme.backends import ( + CryptoBackend, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( + BackendException, +) + def load_fixture(name): with open(os.path.join(os.path.dirname(__file__), 'fixtures', name)) as f: @@ -74,3 +82,23 @@ TEST_CERT_DAYS = [ (datetime.datetime(2018, 11, 25, 15, 20, 0), 1), (datetime.datetime(2018, 11, 25, 15, 30, 0), 0), ] + + +class FakeBackend(CryptoBackend): + def parse_key(self, key_file=None, key_content=None, passphrase=None): + raise BackendException('Not implemented in fake backend') + + def sign(self, payload64, protected64, key_data): + raise BackendException('Not implemented in fake backend') + + def create_mac_key(self, alg, key): + raise BackendException('Not implemented in fake backend') + + def get_csr_identifiers(self, csr_filename=None, csr_content=None): + raise BackendException('Not implemented in fake backend') + + def get_cert_days(self, cert_filename=None, cert_content=None, now=None): + raise BackendException('Not implemented in fake backend') + + def create_chain_matcher(self, criterium): + raise BackendException('Not implemented in fake backend') diff --git a/tests/unit/plugins/module_utils/acme/test_challenges.py b/tests/unit/plugins/module_utils/acme/test_challenges.py new file mode 100644 index 00000000..eef385ad --- /dev/null +++ b/tests/unit/plugins/module_utils/acme/test_challenges.py @@ -0,0 +1,248 @@ +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +import pytest + +from mock import MagicMock + + +from ansible_collections.community.crypto.plugins.module_utils.acme.challenges import ( + combine_identifier, + split_identifier, + Challenge, + Authorization, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( + ACMEProtocolException, + ModuleFailException, +) + + +def test_combine_identifier(): + assert combine_identifier('', '') == ':' + assert combine_identifier('a', 'b') == 'a:b' + + +def test_split_identifier(): + assert split_identifier(':') == ['', ''] + assert split_identifier('a:b') == ['a', 'b'] + assert split_identifier('a:b:c') == ['a', 'b:c'] + with pytest.raises(ModuleFailException) as exc: + split_identifier('a') + assert exc.value.msg == 'Identifier "a" is not of the form :' + + +def test_challenge_from_to_json(): + client = MagicMock() + + data = { + 'url': 'xxx', + 'type': 'type', + 'status': 'valid', + } + client.version = 2 + challenge = Challenge.from_json(client, data) + assert challenge.data == data + assert challenge.type == 'type' + assert challenge.url == 'xxx' + assert challenge.status == 'valid' + assert challenge.token is None + assert challenge.to_json() == data + + data = { + 'type': 'type', + 'status': 'valid', + 'token': 'foo', + } + challenge = Challenge.from_json(None, data, url='xxx') + assert challenge.data == data + assert challenge.type == 'type' + assert challenge.url == 'xxx' + assert challenge.status == 'valid' + assert challenge.token == 'foo' + assert challenge.to_json() == data + + data = { + 'uri': 'xxx', + 'type': 'type', + 'status': 'valid', + } + client.version = 1 + challenge = Challenge.from_json(client, data) + assert challenge.data == data + assert challenge.type == 'type' + assert challenge.url == 'xxx' + assert challenge.status == 'valid' + assert challenge.token is None + assert challenge.to_json() == data + + +def test_authorization_from_to_json(): + client = MagicMock() + client.version = 2 + + data = { + 'challenges': [], + 'status': 'valid', + 'identifier': { + 'type': 'dns', + 'value': 'example.com', + }, + } + authz = Authorization.from_json(client, data, 'xxx') + assert authz.url == 'xxx' + assert authz.status == 'valid' + assert authz.identifier == 'example.com' + assert authz.identifier_type == 'dns' + assert authz.challenges == [] + assert authz.to_json() == { + 'uri': 'xxx', + 'challenges': [], + 'status': 'valid', + 'identifier': { + 'type': 'dns', + 'value': 'example.com', + }, + } + + data = { + 'challenges': [ + { + 'url': 'xxxyyy', + 'type': 'type', + 'status': 'valid', + } + ], + 'status': 'valid', + 'identifier': { + 'type': 'dns', + 'value': 'example.com', + }, + 'wildcard': True, + } + authz = Authorization.from_json(client, data, 'xxx') + assert authz.url == 'xxx' + assert authz.status == 'valid' + assert authz.identifier == '*.example.com' + assert authz.identifier_type == 'dns' + assert len(authz.challenges) == 1 + assert authz.challenges[0].data == { + 'url': 'xxxyyy', + 'type': 'type', + 'status': 'valid', + } + assert authz.to_json() == { + 'uri': 'xxx', + 'challenges': [ + { + 'url': 'xxxyyy', + 'type': 'type', + 'status': 'valid', + } + ], + 'status': 'valid', + 'identifier': { + 'type': 'dns', + 'value': 'example.com', + }, + 'wildcard': True, + } + + client.version = 1 + + data = { + 'challenges': [], + 'identifier': { + 'type': 'dns', + 'value': 'example.com', + }, + } + authz = Authorization.from_json(client, data, 'xxx') + assert authz.url == 'xxx' + assert authz.status == 'pending' + assert authz.identifier == 'example.com' + assert authz.identifier_type == 'dns' + assert authz.challenges == [] + assert authz.to_json() == { + 'uri': 'xxx', + 'challenges': [], + 'identifier': { + 'type': 'dns', + 'value': 'example.com', + }, + } + + +def test_authorization_create_error(): + client = MagicMock() + client.version = 2 + client.directory.directory = {} + with pytest.raises(ACMEProtocolException) as exc: + Authorization.create(client, 'dns', 'example.com') + + assert exc.value.msg == 'ACME endpoint does not support pre-authorization.' + + +def test_wait_for_validation_error(): + client = MagicMock() + client.version = 2 + data = { + 'challenges': [ + { + 'url': 'xxxyyy1', + 'type': 'dns-01', + 'status': 'invalid', + 'error': { + 'type': 'dns-failed', + 'subproblems': [ + { + 'type': 'subproblem', + 'detail': 'example.com DNS-01 validation failed', + }, + ] + }, + }, + { + 'url': 'xxxyyy2', + 'type': 'http-01', + 'status': 'invalid', + 'error': { + 'type': 'http-failed', + 'subproblems': [ + { + 'type': 'subproblem', + 'detail': 'example.com HTTP-01 validation failed', + }, + ] + }, + }, + { + 'url': 'xxxyyy3', + 'type': 'something-else', + 'status': 'valid', + }, + ], + 'status': 'invalid', + 'identifier': { + 'type': 'dns', + 'value': 'example.com', + }, + } + client.get_request = MagicMock(return_value=(data, {})) + authz = Authorization.from_json(client, data, 'xxx') + with pytest.raises(ACMEProtocolException) as exc: + authz.wait_for_validation(client, 'dns') + + assert exc.value.msg == ( + 'Failed to validate challenge for dns:example.com: Status is "invalid". Challenge dns-01: Error dns-failed Subproblems:\n' + '(dns-01.0) Error subproblem: "example.com DNS-01 validation failed"; Challenge http-01: Error http-failed Subproblems:\n' + '(http-01.0) Error subproblem: "example.com HTTP-01 validation failed".' + ) + data = data.copy() + data['uri'] = 'xxx' + assert exc.value.module_fail_args == { + 'identifier': 'dns:example.com', + 'authorization': data, + } diff --git a/tests/unit/plugins/module_utils/acme/test_errors.py b/tests/unit/plugins/module_utils/acme/test_errors.py new file mode 100644 index 00000000..317a9701 --- /dev/null +++ b/tests/unit/plugins/module_utils/acme/test_errors.py @@ -0,0 +1,374 @@ +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +import pytest + +from mock import MagicMock + + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( + format_error_problem, + ACMEProtocolException, +) + + +TEST_FORMAT_ERROR_PROBLEM = [ + ( + { + 'type': 'foo', + }, + '', + 'Error foo' + ), + ( + { + 'type': 'foo', + 'title': 'bar' + }, + '', + 'Error "bar" (foo)' + ), + ( + { + 'type': 'foo', + 'detail': 'bar baz' + }, + '', + 'Error foo: "bar baz"' + ), + ( + { + 'type': 'foo', + 'subproblems': [] + }, + '', + 'Error foo Subproblems:' + ), + ( + { + 'type': 'foo', + 'subproblems': [ + { + 'type': 'bar', + }, + ] + }, + '', + 'Error foo Subproblems:\n(0) Error bar' + ), + ( + { + 'type': 'foo', + 'subproblems': [ + { + 'type': 'bar', + 'subproblems': [ + { + 'type': 'baz', + }, + ] + }, + ] + }, + '', + 'Error foo Subproblems:\n(0) Error bar Subproblems:\n(0.0) Error baz' + ), + ( + { + 'type': 'foo', + 'title': 'Foo Error', + 'detail': 'Foo went wrong', + 'subproblems': [ + { + 'type': 'bar', + 'detail': 'Bar went wrong', + 'subproblems': [ + { + 'type': 'baz', + 'title': 'Baz Error', + }, + ] + }, + { + 'type': 'bar2', + 'title': 'Bar 2 Error', + 'detail': 'Bar really went wrong' + }, + ] + }, + 'X.', + 'Error "Foo Error" (foo): "Foo went wrong" Subproblems:\n' + '(X.0) Error bar: "Bar went wrong" Subproblems:\n' + '(X.0.0) Error "Baz Error" (baz)\n' + '(X.1) Error "Bar 2 Error" (bar2): "Bar really went wrong"' + ), +] + + +@pytest.mark.parametrize("problem, subproblem_prefix, result", TEST_FORMAT_ERROR_PROBLEM) +def test_format_error_problem(problem, subproblem_prefix, result): + res = format_error_problem(problem, subproblem_prefix) + assert res == result + + +def create_regular_response(response_text): + response = MagicMock() + response.read = MagicMock(return_value=response_text.encode('utf-8')) + return response + + +def create_error_response(): + response = MagicMock() + response.read = MagicMock(side_effect=AttributeError('read')) + return response + + +def create_decode_error(msg): + def f(content): + raise Exception(msg) + + return f + + +TEST_ACME_PROTOCOL_EXCEPTION = [ + ( + {}, + None, + 'ACME request failed.', + { + }, + ), + ( + { + 'msg': 'Foo', + 'extras': { + 'foo': 'bar', + }, + }, + None, + 'Foo.', + { + 'foo': 'bar', + }, + ), + ( + { + 'info': { + 'url': 'https://ca.example.com/foo', + 'status': 201, + }, + }, + None, + 'ACME request failed for https://ca.example.com/foo with HTTP status 201.', + { + 'http_url': 'https://ca.example.com/foo', + 'http_status': 201, + }, + ), + ( + { + 'info': { + 'url': 'https://ca.example.com/foo', + 'status': 201, + }, + 'response': create_regular_response('xxx'), + }, + None, + 'ACME request failed for https://ca.example.com/foo with HTTP status 201. The raw error result: xxx', + { + 'http_url': 'https://ca.example.com/foo', + 'http_status': 201, + }, + ), + ( + { + 'info': { + 'url': 'https://ca.example.com/foo', + 'status': 201, + }, + 'response': create_regular_response('xxx'), + }, + create_decode_error('yyy'), + 'ACME request failed for https://ca.example.com/foo with HTTP status 201. The raw error result: xxx', + { + 'http_url': 'https://ca.example.com/foo', + 'http_status': 201, + }, + ), + ( + { + 'info': { + 'url': 'https://ca.example.com/foo', + 'status': 201, + }, + 'response': create_regular_response('xxx'), + }, + lambda content: dict(foo='bar'), + "ACME request failed for https://ca.example.com/foo with HTTP status 201. The JSON error result: {'foo': 'bar'}", + { + 'http_url': 'https://ca.example.com/foo', + 'http_status': 201, + }, + ), + ( + { + 'info': { + 'url': 'https://ca.example.com/foo', + 'status': 201, + }, + 'response': create_error_response(), + }, + None, + 'ACME request failed for https://ca.example.com/foo with HTTP status 201.', + { + 'http_url': 'https://ca.example.com/foo', + 'http_status': 201, + }, + ), + ( + { + 'info': { + 'url': 'https://ca.example.com/foo', + 'status': 201, + 'body': 'xxx', + }, + 'response': create_error_response(), + }, + lambda content: dict(foo='bar'), + "ACME request failed for https://ca.example.com/foo with HTTP status 201. The JSON error result: {'foo': 'bar'}", + { + 'http_url': 'https://ca.example.com/foo', + 'http_status': 201, + }, + ), + ( + { + 'info': { + 'url': 'https://ca.example.com/foo', + 'status': 201, + }, + 'content': 'xxx', + }, + None, + "ACME request failed for https://ca.example.com/foo with HTTP status 201. The raw error result: xxx", + { + 'http_url': 'https://ca.example.com/foo', + 'http_status': 201, + }, + ), + ( + { + 'info': { + 'url': 'https://ca.example.com/foo', + 'status': 400, + }, + 'content_json': { + 'foo': 'bar', + }, + 'extras': { + 'bar': 'baz', + } + }, + None, + "ACME request failed for https://ca.example.com/foo with HTTP status 400. The JSON error result: {'foo': 'bar'}", + { + 'http_url': 'https://ca.example.com/foo', + 'http_status': 400, + 'bar': 'baz', + }, + ), + ( + { + 'info': { + 'url': 'https://ca.example.com/foo', + 'status': 201, + }, + 'content_json': { + 'type': 'foo', + }, + }, + None, + "ACME request failed for https://ca.example.com/foo with HTTP status 201. The JSON error result: {'type': 'foo'}", + { + 'http_url': 'https://ca.example.com/foo', + 'http_status': 201, + }, + ), + ( + { + 'info': { + 'url': 'https://ca.example.com/foo', + 'status': 400, + }, + 'content_json': { + 'type': 'foo', + }, + }, + None, + "ACME request failed for https://ca.example.com/foo with status 400. Error foo.", + { + 'http_url': 'https://ca.example.com/foo', + 'http_status': 400, + 'problem': { + 'type': 'foo', + }, + 'subproblems': [], + }, + ), + ( + { + 'info': { + 'url': 'https://ca.example.com/foo', + 'status': 400, + }, + 'content_json': { + 'type': 'foo', + 'title': 'Foo Error', + 'subproblems': [ + { + 'type': 'bar', + 'detail': 'This is a bar error', + 'details': 'Details.', + }, + ], + }, + }, + None, + "ACME request failed for https://ca.example.com/foo with status 400. Error \"Foo Error\" (foo). Subproblems:\n" + "(0) Error bar: \"This is a bar error\".", + { + 'http_url': 'https://ca.example.com/foo', + 'http_status': 400, + 'problem': { + 'type': 'foo', + 'title': 'Foo Error', + }, + 'subproblems': [ + { + 'type': 'bar', + 'detail': 'This is a bar error', + 'details': 'Details.', + }, + ], + }, + ), +] + + +@pytest.mark.parametrize("input, from_json, msg, args", TEST_ACME_PROTOCOL_EXCEPTION) +def test_acme_protocol_exception(input, from_json, msg, args): + if from_json is None: + module = None + else: + module = MagicMock() + module.from_json = from_json + with pytest.raises(ACMEProtocolException) as exc: + raise ACMEProtocolException(module, **input) + + print(exc.value.msg) + print(exc.value.module_fail_args) + print(msg) + print(args) + assert exc.value.msg == msg + assert exc.value.module_fail_args == args diff --git a/tests/unit/plugins/module_utils/acme/test_orders.py b/tests/unit/plugins/module_utils/acme/test_orders.py new file mode 100644 index 00000000..1b66e616 --- /dev/null +++ b/tests/unit/plugins/module_utils/acme/test_orders.py @@ -0,0 +1,56 @@ +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +import pytest + +from mock import MagicMock + + +from ansible_collections.community.crypto.plugins.module_utils.acme.orders import ( + Order, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( + ACMEProtocolException, + ModuleFailException, +) + + +def test_order_from_json(): + client = MagicMock() + + data = { + 'status': 'valid', + 'identifiers': [], + 'authorizations': [], + } + client.version = 2 + order = Order.from_json(client, data, 'xxx') + assert order.data == data + assert order.url == 'xxx' + assert order.status == 'valid' + assert order.identifiers == [] + assert order.finalize_uri is None + assert order.certificate_uri is None + assert order.authorization_uris == [] + assert order.authorizations == {} + + +def test_wait_for_finalization_error(): + client = MagicMock() + client.version = 2 + + data = { + 'status': 'invalid', + 'identifiers': [], + 'authorizations': [], + } + order = Order.from_json(client, data, 'xxx') + + client.get_request = MagicMock(return_value=(data, {})) + with pytest.raises(ACMEProtocolException) as exc: + order.wait_for_finalization(client) + + assert exc.value.msg.startswith('Failed to wait for order to complete; got status "invalid". The JSON result: ') + assert exc.value.module_fail_args == {}